diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 0828fa31..cfe5935b 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -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 diff --git a/internal/app/notifications.go b/internal/app/notifications.go index 787646e3..f22e39d0 100644 --- a/internal/app/notifications.go +++ b/internal/app/notifications.go @@ -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 ", 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 + " [...]", 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 ", 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 + }) } diff --git a/internal/app/notifications_test.go b/internal/app/notifications_test.go index ab8818b7..04f6cffc 100644 --- a/internal/app/notifications_test.go +++ b/internal/app/notifications_test.go @@ -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") +} diff --git a/internal/app/validation_test.go b/internal/app/validation_test.go index 4d4b0ee9..96a1f4c9 100644 --- a/internal/app/validation_test.go +++ b/internal/app/validation_test.go @@ -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"}, diff --git a/internal/services/notifications.go b/internal/services/notifications.go index 140cc66b..58bf4be4 100644 --- a/internal/services/notifications.go +++ b/internal/services/notifications.go @@ -7,6 +7,10 @@ import ( "context" "errors" "fmt" + "net/http" + "net/url" + "path" + "strconv" "strings" "time" @@ -18,13 +22,17 @@ import ( type Notification struct { ID int64 `json:"id"` Repository string `json:"repository"` - Title string `json:"title"` - URL string `json:"url"` - Type string `json:"type"` - State string `json:"state"` - Unread bool `json:"unread"` - Pinned bool `json:"pinned"` - UpdatedAt string `json:"updated_at"` + // Number is the issue or pull request number of the subject, when it is one. + Number int64 `json:"number,omitempty"` + Title string `json:"title"` + URL string `json:"url"` + Type string `json:"type"` + State string `json:"state"` + Unread bool `json:"unread"` + Pinned bool `json:"pinned"` + UpdatedAt string `json:"updated_at"` + // latestComment is the API URL of the latest comment on the subject. + latestComment string } func notificationFrom(thread *gitea.NotificationThread) Notification { @@ -32,15 +40,32 @@ func notificationFrom(thread *gitea.NotificationThread) Notification { if thread.Repository != nil { result.Repository = thread.Repository.FullName } - if thread.Subject != nil { - result.Title = thread.Subject.Title - result.URL = thread.Subject.HTMLURL - result.Type = string(thread.Subject.Type) - result.State = string(thread.Subject.State) + if subject := thread.Subject; subject != nil { + result.Title = subject.Title + result.URL = subject.HTMLURL + result.Type = string(subject.Type) + result.State = string(subject.State) + result.latestComment = subject.LatestCommentURL + if subject.Type == gitea.NotificationSubjectIssue || subject.Type == gitea.NotificationSubjectPull { + result.Number = lastPathNumber(subject.URL) + } } return result } +// lastPathNumber returns the number that ends the path of a URL, such as 17 of …/issues/17, or 0. +func lastPathNumber(raw string) int64 { + u, err := url.Parse(raw) + if err != nil { + return 0 + } + number, err := strconv.ParseInt(path.Base(u.Path), 10, 64) + if err != nil { + return 0 + } + return number +} + func notificationStatuses(statuses []string) []gitea.NotificationStatus { result := make([]gitea.NotificationStatus, 0, len(statuses)) for _, status := range statuses { @@ -76,22 +101,26 @@ type Notifications struct { Repository string } -// List returns a non-nil page of notifications. -func (s Notifications) List(ctx context.Context, opts NotificationOptions) ([]Notification, error) { - threads, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.NotificationThread, *gitea.Response, error) { - if s.Repository == "" { - return c.Notifications.List(ctx, opts.sdk()) - } - owner, name, err := splitRepository(s.Repository) +// List returns a non-nil page of notifications and what the server reported about the rest. +func (s Notifications) List(ctx context.Context, opts NotificationOptions) (Page[Notification], error) { + return listPage(opts.Limit, opts.Page, func(limit, page int) ([]Notification, http.Header, error) { + window := opts + window.Limit, window.Page = limit, page + threads, resp, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.NotificationThread, *gitea.Response, error) { + if s.Repository == "" { + return c.Notifications.List(ctx, window.sdk()) + } + owner, name, err := splitRepository(s.Repository) + if err != nil { + return nil, nil, err + } + return c.Notifications.ListByRepo(ctx, owner, name, window.sdk()) + }) if err != nil { return nil, nil, err } - return c.Notifications.ListByRepo(ctx, owner, name, opts.sdk()) + return convertAll(threads, notificationFrom), headerOf(resp), nil }) - if err != nil { - return nil, err - } - return convertAll(threads, notificationFrom), nil } // View retrieves an account-owned notification thread. @@ -100,7 +129,7 @@ func (s Notifications) View(ctx context.Context, id int64) (Notification, error) return c.Notifications.GetByID(ctx, id) }) if err != nil { - return Notification{}, err + return Notification{}, Classify(err, "notification", strconv.FormatInt(id, 10), "") } item := notificationFrom(thread) if s.Repository != "" && !strings.EqualFold(item.Repository, s.Repository) { @@ -109,6 +138,19 @@ func (s Notifications) View(ctx context.Context, id int64) (Notification, error) return item, nil } +// LatestComment returns the latest comment on the subject of a notification, or nil without one. +func (s Notifications) LatestComment(ctx context.Context, item Notification) (*Comment, error) { + id := lastPathNumber(item.latestComment) + if id == 0 || item.Repository == "" { + return nil, nil + } + comment, err := (Comments{Client: s.Client, Repository: item.Repository}).View(ctx, id) + if err != nil { + return nil, err + } + return &comment, nil +} + // Mark changes the selected notification status. func (s Notifications) Mark(ctx context.Context, id int64, status string) (Notification, error) { if s.Repository != "" { @@ -151,13 +193,13 @@ func (s Notifications) Watch(ctx context.Context, opts NotificationOptions, inte } seen := map[int64]Notification{} for { - items, err := s.List(ctx, opts) + listed, err := s.List(ctx, opts) if err != nil { return err } changes := []Notification{} next := map[int64]Notification{} - for _, item := range items { + for _, item := range listed.Items { next[item.ID] = item if previous, exists := seen[item.ID]; !exists || previous != item { changes = append(changes, item) diff --git a/internal/services/notifications_test.go b/internal/services/notifications_test.go index 09ff92e6..2bff96f6 100644 --- a/internal/services/notifications_test.go +++ b/internal/services/notifications_test.go @@ -105,17 +105,20 @@ func TestNotificationListSendsFilters(t *testing.T) { require.Empty(t, query.Get("before")) _, _ = w.Write([]byte(`[]`)) })) - items, err := (Notifications{Client: client}).List(context.Background(), NotificationOptions{Limit: 20, Page: 2, Types: []string{"Issue", "Pull"}, Since: &since}) + page, err := (Notifications{Client: client}).List(context.Background(), NotificationOptions{Limit: 20, Page: 2, Types: []string{"Issue", "Pull"}, Since: &since}) require.NoError(t, err) - require.Equal(t, []Notification{}, items) + require.Equal(t, []Notification{}, page.Items) } func TestNotificationFromMapsServerFields(t *testing.T) { thread := decode[gitea.NotificationThread](t, `{"id":5,"repository":{"full_name":"alice/project"},"unread":true,"pinned":true, - "updated_at":"2026-10-02T12:00:00+02:00","subject":{"title":"Fix","html_url":"https://gitea.test/alice/project/pulls/1","type":"Pull","state":"merged"}}`) + "updated_at":"2026-10-02T12:00:00+02:00","subject":{"title":"Fix","html_url":"https://gitea.test/alice/project/pulls/1","type":"Pull","state":"merged", + "url":"https://gitea.test/api/v1/repos/alice/project/issues/1","latest_comment_url":"https://gitea.test/api/v1/repos/alice/project/issues/comments/9"}}`) require.Equal(t, Notification{ - ID: 5, Repository: "alice/project", Title: "Fix", URL: "https://gitea.test/alice/project/pulls/1", Type: "Pull", State: "merged", - Unread: true, Pinned: true, UpdatedAt: "2026-10-02T12:00:00+02:00", + ID: 5, Repository: "alice/project", Number: 1, Title: "Fix", URL: "https://gitea.test/alice/project/pulls/1", Type: "Pull", State: "merged", + Unread: true, Pinned: true, UpdatedAt: "2026-10-02T12:00:00+02:00", latestComment: "https://gitea.test/api/v1/repos/alice/project/issues/comments/9", }, notificationFrom(thread)) + commit := decode[gitea.NotificationThread](t, `{"id":7,"subject":{"type":"Commit","url":"https://gitea.test/api/v1/repos/alice/project/commits/123"}}`) + require.Zero(t, notificationFrom(commit).Number) require.Equal(t, Notification{ID: 6}, notificationFrom(decode[gitea.NotificationThread](t, `{"id":6,"repository":null,"subject":null}`))) }