mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11: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.
|
// have yet to complete; see TestHelpTextsAreComplete.
|
||||||
const (
|
const (
|
||||||
shortsToFix = `
|
shortsToFix = `
|
||||||
notification mark-read
|
|
||||||
notification mark-unread
|
|
||||||
pr review cancel-request
|
pr review cancel-request
|
||||||
`
|
`
|
||||||
examplesToWrite = `
|
examplesToWrite = `
|
||||||
@@ -459,13 +457,6 @@ const (
|
|||||||
milestone list
|
milestone list
|
||||||
milestone reopen
|
milestone reopen
|
||||||
milestone view
|
milestone view
|
||||||
notification list
|
|
||||||
notification mark-read
|
|
||||||
notification mark-unread
|
|
||||||
notification pin
|
|
||||||
notification unpin
|
|
||||||
notification view
|
|
||||||
notification watch
|
|
||||||
pr attachment delete
|
pr attachment delete
|
||||||
pr attachment download
|
pr attachment download
|
||||||
pr attachment edit
|
pr attachment edit
|
||||||
@@ -547,7 +538,6 @@ const (
|
|||||||
label
|
label
|
||||||
milestone
|
milestone
|
||||||
milestone issue
|
milestone issue
|
||||||
notification
|
|
||||||
pr
|
pr
|
||||||
pr attachment
|
pr attachment
|
||||||
pr comment
|
pr comment
|
||||||
|
|||||||
+383
-153
@@ -7,7 +7,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -19,173 +18,404 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"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 {
|
func (a *application) notificationCommand() *cobra.Command {
|
||||||
group := &cobra.Command{Use: "notification", Short: "Manage account notifications"}
|
group := &cobra.Command{Use: "notification", Short: "Read and triage your notifications", Long: `Read and triage the notifications of your account.
|
||||||
var mine bool
|
|
||||||
group.PersistentFlags().BoolVar(&mine, "mine", false, "Use all your notifications instead of repository notifications")
|
Commands act on your whole inbox. -R OWNER/REPO limits them to one repository, and
|
||||||
service := func(cmd *cobra.Command, requireRepo bool) (services.Notifications, error) {
|
--this-repo to the repository of the current directory. The IDs that tea notification
|
||||||
if mine && a.options.repo != "" {
|
list shows identify notification threads; they are not issue numbers.`}
|
||||||
return services.Notifications{}, clierr.Wrap(clierr.Usage, errors.New("--mine and --repo are mutually exclusive"))
|
var mine, thisRepo bool
|
||||||
}
|
group.PersistentFlags().BoolVar(&mine, "mine", false, "Read your whole inbox (the default)")
|
||||||
target, err := a.resolve(cmd.Context(), requireRepo && !mine)
|
_ = 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 {
|
if err != nil {
|
||||||
return services.Notifications{}, err
|
return notificationScope{}, err
|
||||||
}
|
}
|
||||||
repository := ""
|
where := "in your inbox"
|
||||||
if (requireRepo || a.options.repo != "") && !mine {
|
if t.Repo != "" {
|
||||||
repository = target.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"} {
|
group.AddCommand(a.notificationListCommand(scope), a.notificationWatchCommand(scope), a.notificationViewCommand(scope))
|
||||||
opts := services.NotificationOptions{}
|
for _, mark := range []struct {
|
||||||
var updated timeRange
|
verb, status, filter, short, done, example string
|
||||||
var interval, timeout time.Duration
|
}{
|
||||||
command := &cobra.Command{Use: verb, Short: titleCase(verb) + " notifications", Args: argsExact(0), RunE: func(cmd *cobra.Command, _ []string) error {
|
{"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"},
|
||||||
if err := validateNotificationStatuses(opts.Statuses); err != nil {
|
{"mark-unread", "unread", "read", "Mark notifications as unread", "Marked %s as unread", "$ tea notification mark-unread 28 26"},
|
||||||
return err
|
{"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"},
|
||||||
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"},
|
|
||||||
} {
|
} {
|
||||||
verb := mark.verb
|
|
||||||
var all bool
|
var all bool
|
||||||
var filters []string
|
var filters []string
|
||||||
var before 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 {
|
command := &cobra.Command{
|
||||||
if len(args) == 0 && !all {
|
Use: mark.verb + " [<id>...]", Short: mark.short, Example: mark.example, ValidArgsFunction: a.completeNotifications(),
|
||||||
return clierr.Wrap(clierr.Usage, errors.New("supply notification IDs or --all"))
|
Annotations: map[string]string{"tea.confirm": "true"},
|
||||||
}
|
Args: func(_ *cobra.Command, args []string) error {
|
||||||
if len(args) > 0 && all {
|
if len(args) == 0 && !all {
|
||||||
return clierr.Wrap(clierr.Usage, errors.New("notification IDs and --all are mutually exclusive"))
|
return clierr.Wrap(clierr.Usage, errors.New("supply notification IDs or --all"))
|
||||||
}
|
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
until = *parsed
|
if len(args) > 0 && all {
|
||||||
}
|
return clierr.Wrap(clierr.Usage, errors.New("notification IDs and --all are mutually exclusive"))
|
||||||
if !all && (cmd.Flags().Changed("status") || cmd.Flags().Changed("before")) {
|
}
|
||||||
return clierr.Wrap(clierr.Usage, errors.New("--status and --before require --all"))
|
return nil
|
||||||
}
|
},
|
||||||
s, err := service(cmd, all)
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if err != nil {
|
ids, err := resourceNumbers(args)
|
||||||
return err
|
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) {
|
if !all && (cmd.Flags().Changed("status") || cmd.Flags().Changed("before")) {
|
||||||
return s.Mark(cmd.Context(), id, mark.status)
|
return clierr.Wrap(clierr.Usage, errors.New("--status and --before require --all"))
|
||||||
}, notificationRow)
|
}
|
||||||
}
|
if !cmd.Flags().Changed("status") {
|
||||||
results, err := s.MarkAll(cmd.Context(), mark.status, filters, until)
|
filters = []string{mark.filter}
|
||||||
if err != nil {
|
}
|
||||||
return err
|
var until time.Time
|
||||||
}
|
if cmd.Flags().Changed("before") {
|
||||||
return writeList(a, cmd, results, notificationRow)
|
parsed, err := parseRunTime(before, a.deps.Now())
|
||||||
}}
|
if err != nil {
|
||||||
command.Flags().BoolVar(&all, "all", false, "Apply to all notifications matching filters in the selected scope")
|
return clierr.Wrap(clierr.Usage, fmt.Errorf("invalid --before: %w", err))
|
||||||
command.Flags().StringSliceVar(&filters, "status", nil, "Filter statuses for --all")
|
}
|
||||||
command.Flags().StringVar(&before, "before", "", "Updated before a date, timestamp, or duration (requires --all)")
|
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)
|
group.AddCommand(command)
|
||||||
}
|
}
|
||||||
return group
|
return group
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateNotificationStatuses(statuses []string) error {
|
// notificationFilters registers the list filters of list and watch.
|
||||||
for _, status := range statuses {
|
func notificationFilters(cmd *cobra.Command, opts *services.NotificationOptions, updated *timeRange) {
|
||||||
if !slices.Contains([]string{"unread", "read", "pinned"}, status) {
|
pageFlags(cmd, &opts.Limit, &opts.Page)
|
||||||
return clierr.Wrap(clierr.Usage, errors.New("--status must contain unread, read, or pinned"))
|
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")
|
||||||
return nil
|
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 {
|
// notificationOptions completes the list filters for the API.
|
||||||
return terminal.Row(strconv.FormatInt(item.ID, 10), item.Repository, item.Title, item.URL)
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -33,3 +34,107 @@ func TestNotificationBoundsShareClockAndReachServer(t *testing.T) {
|
|||||||
require.Equal(t, 1, calls)
|
require.Equal(t, 1, calls)
|
||||||
require.JSONEq(t, `[]`, out.String())
|
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", "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", "--limit", "1001"}, `"-L, --limit" flag: must be 1..1000`},
|
||||||
{[]string{"milestone", "list", "--state", "merged"}, "--state must be open, closed, or all"},
|
{[]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", "--status", "invalid"}, "--status must be unread, read, or pinned"},
|
||||||
{[]string{"notification", "list", "--type", "invalid"}, "--type must contain issue, pull, commit, or repository"},
|
{[]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", "--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="}, "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", "list", "--since=1h", "--before=2h"}, "--since must not be later than --before"},
|
||||||
{[]string{"notification", "mark-read"}, "supply notification IDs or --all"},
|
{[]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", "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", "--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", "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", "--interval", "0s"}, "--interval must be positive and --timeout not negative"},
|
||||||
{[]string{"notification", "watch", "--timeout", "0s"}, "--interval and --timeout must be positive"},
|
{[]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{"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", "create", "team", "--visibility", "unknown"}, "--visibility must be public, limited, or private"},
|
||||||
{[]string{"org", "edit", "team"}, "supply organization fields to edit"},
|
{[]string{"org", "edit", "team"}, "supply organization fields to edit"},
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -18,13 +22,17 @@ import (
|
|||||||
type Notification struct {
|
type Notification struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Repository string `json:"repository"`
|
Repository string `json:"repository"`
|
||||||
Title string `json:"title"`
|
// Number is the issue or pull request number of the subject, when it is one.
|
||||||
URL string `json:"url"`
|
Number int64 `json:"number,omitempty"`
|
||||||
Type string `json:"type"`
|
Title string `json:"title"`
|
||||||
State string `json:"state"`
|
URL string `json:"url"`
|
||||||
Unread bool `json:"unread"`
|
Type string `json:"type"`
|
||||||
Pinned bool `json:"pinned"`
|
State string `json:"state"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
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 {
|
func notificationFrom(thread *gitea.NotificationThread) Notification {
|
||||||
@@ -32,15 +40,32 @@ func notificationFrom(thread *gitea.NotificationThread) Notification {
|
|||||||
if thread.Repository != nil {
|
if thread.Repository != nil {
|
||||||
result.Repository = thread.Repository.FullName
|
result.Repository = thread.Repository.FullName
|
||||||
}
|
}
|
||||||
if thread.Subject != nil {
|
if subject := thread.Subject; subject != nil {
|
||||||
result.Title = thread.Subject.Title
|
result.Title = subject.Title
|
||||||
result.URL = thread.Subject.HTMLURL
|
result.URL = subject.HTMLURL
|
||||||
result.Type = string(thread.Subject.Type)
|
result.Type = string(subject.Type)
|
||||||
result.State = string(thread.Subject.State)
|
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
|
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 {
|
func notificationStatuses(statuses []string) []gitea.NotificationStatus {
|
||||||
result := make([]gitea.NotificationStatus, 0, len(statuses))
|
result := make([]gitea.NotificationStatus, 0, len(statuses))
|
||||||
for _, status := range statuses {
|
for _, status := range statuses {
|
||||||
@@ -76,22 +101,26 @@ type Notifications struct {
|
|||||||
Repository string
|
Repository string
|
||||||
}
|
}
|
||||||
|
|
||||||
// List returns a non-nil page of notifications.
|
// 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) ([]Notification, error) {
|
func (s Notifications) List(ctx context.Context, opts NotificationOptions) (Page[Notification], error) {
|
||||||
threads, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.NotificationThread, *gitea.Response, error) {
|
return listPage(opts.Limit, opts.Page, func(limit, page int) ([]Notification, http.Header, error) {
|
||||||
if s.Repository == "" {
|
window := opts
|
||||||
return c.Notifications.List(ctx, opts.sdk())
|
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) {
|
||||||
owner, name, err := splitRepository(s.Repository)
|
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 {
|
if err != nil {
|
||||||
return nil, nil, err
|
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.
|
// 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)
|
return c.Notifications.GetByID(ctx, id)
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Notification{}, err
|
return Notification{}, Classify(err, "notification", strconv.FormatInt(id, 10), "")
|
||||||
}
|
}
|
||||||
item := notificationFrom(thread)
|
item := notificationFrom(thread)
|
||||||
if s.Repository != "" && !strings.EqualFold(item.Repository, s.Repository) {
|
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
|
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.
|
// Mark changes the selected notification status.
|
||||||
func (s Notifications) Mark(ctx context.Context, id int64, status string) (Notification, error) {
|
func (s Notifications) Mark(ctx context.Context, id int64, status string) (Notification, error) {
|
||||||
if s.Repository != "" {
|
if s.Repository != "" {
|
||||||
@@ -151,13 +193,13 @@ func (s Notifications) Watch(ctx context.Context, opts NotificationOptions, inte
|
|||||||
}
|
}
|
||||||
seen := map[int64]Notification{}
|
seen := map[int64]Notification{}
|
||||||
for {
|
for {
|
||||||
items, err := s.List(ctx, opts)
|
listed, err := s.List(ctx, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
changes := []Notification{}
|
changes := []Notification{}
|
||||||
next := map[int64]Notification{}
|
next := map[int64]Notification{}
|
||||||
for _, item := range items {
|
for _, item := range listed.Items {
|
||||||
next[item.ID] = item
|
next[item.ID] = item
|
||||||
if previous, exists := seen[item.ID]; !exists || previous != item {
|
if previous, exists := seen[item.ID]; !exists || previous != item {
|
||||||
changes = append(changes, item)
|
changes = append(changes, item)
|
||||||
|
|||||||
@@ -105,17 +105,20 @@ func TestNotificationListSendsFilters(t *testing.T) {
|
|||||||
require.Empty(t, query.Get("before"))
|
require.Empty(t, query.Get("before"))
|
||||||
_, _ = w.Write([]byte(`[]`))
|
_, _ = 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.NoError(t, err)
|
||||||
require.Equal(t, []Notification{}, items)
|
require.Equal(t, []Notification{}, page.Items)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNotificationFromMapsServerFields(t *testing.T) {
|
func TestNotificationFromMapsServerFields(t *testing.T) {
|
||||||
thread := decode[gitea.NotificationThread](t, `{"id":5,"repository":{"full_name":"alice/project"},"unread":true,"pinned":true,
|
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{
|
require.Equal(t, Notification{
|
||||||
ID: 5, Repository: "alice/project", Title: "Fix", URL: "https://gitea.test/alice/project/pulls/1", Type: "Pull", State: "merged",
|
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",
|
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))
|
}, 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}`)))
|
require.Equal(t, Notification{ID: 6}, notificationFrom(decode[gitea.NotificationThread](t, `{"id":6,"repository":null,"subject":null}`)))
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user