mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
The application caches the settings snapshot and reloads it only after an update through the store, so output and prompt helpers no longer read and parse config.yml and hosts.yml several times per command. canPrompt and chooser replace the inline prompt-policy checks. Checks are tallied by one services.Tally, terminal.Plural replaces the two app copies, refKind.article/noun replace the comment group's own words, list commands use servicePage, and web URLs are built by repositoryURL and webURL in browser.go. Signed-off-by: bircni <bircni@icloud.com>
423 lines
16 KiB
Go
423 lines
16 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.dev/tea/internal/clierr"
|
|
"gitea.dev/tea/internal/services"
|
|
"gitea.dev/tea/internal/terminal"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
// notificationScope is the notifications a command acts on: the account's inbox, or one repository's.
|
|
type notificationScope struct {
|
|
services.Notifications
|
|
// where names the scope in messages: "in your inbox" or "in alice/project".
|
|
where string
|
|
}
|
|
|
|
func (a *application) notificationCommand() *cobra.Command {
|
|
group := &cobra.Command{Use: "notification", Short: "Read and triage your notifications", Long: `Read and triage the notifications of your account.
|
|
|
|
Commands act on your whole inbox. -R OWNER/REPO limits them to one repository, and
|
|
--this-repo to the repository of the current directory. The IDs that tea notification
|
|
list shows identify notification threads; they are not issue numbers.`}
|
|
var mine, thisRepo bool
|
|
group.PersistentFlags().BoolVar(&mine, "mine", false, "Read your whole inbox (the default)")
|
|
_ = group.PersistentFlags().MarkHidden("mine")
|
|
group.PersistentFlags().BoolVar(&thisRepo, "this-repo", false, "Only notifications of the repository in the current directory")
|
|
scope := func(cmd *cobra.Command) (notificationScope, error) {
|
|
t, err := a.resolve(cmd.Context(), thisRepo)
|
|
if err != nil {
|
|
return notificationScope{}, err
|
|
}
|
|
where := "in your inbox"
|
|
if t.Repo != "" {
|
|
where = "in " + t.Repo
|
|
}
|
|
return notificationScope{services.Notifications{Client: t.client, Repository: t.Repo}, where}, nil
|
|
}
|
|
group.AddCommand(a.notificationListCommand(scope), a.notificationWatchCommand(scope), a.notificationViewCommand(scope))
|
|
for _, mark := range []struct {
|
|
verb, status, filter, short, ask, done, example string
|
|
}{
|
|
{"mark-read", "read", "unread", "Mark notifications as read", "Mark all %s 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", "Mark all %s as unread?", "Marked %s as unread", "$ tea notification mark-unread 28 26"},
|
|
{"pin", "pinned", "unread", "Pin notifications", "Pin all %s?", "Pinned %s", "$ tea notification pin 28"},
|
|
{"unpin", "read", "pinned", "Unpin notifications", "Unpin all %s?", "Unpinned %s", "$ tea notification unpin 28\n# Unpin everything\n$ tea notification unpin --all"},
|
|
} {
|
|
var all bool
|
|
var filters []string
|
|
var before string
|
|
command := &cobra.Command{
|
|
Use: mark.verb + " [<id>...]", Short: mark.short, Example: mark.example, ValidArgsFunction: a.completeNotifications(),
|
|
Annotations: map[string]string{"tea.confirm": "true"},
|
|
Args: func(_ *cobra.Command, args []string) error {
|
|
if len(args) == 0 && !all {
|
|
return clierr.Wrap(clierr.Usage, errors.New("supply notification IDs or --all"))
|
|
}
|
|
if len(args) > 0 && all {
|
|
return clierr.Wrap(clierr.Usage, errors.New("notification IDs and --all are mutually exclusive"))
|
|
}
|
|
return nil
|
|
},
|
|
RunE: func(cmd *cobra.Command, args []string) error {
|
|
ids, err := resourceNumbers(args)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !all && (cmd.Flags().Changed("status") || cmd.Flags().Changed("before")) {
|
|
return clierr.Wrap(clierr.Usage, errors.New("--status and --before require --all"))
|
|
}
|
|
if !cmd.Flags().Changed("status") {
|
|
filters = []string{mark.filter}
|
|
}
|
|
var until time.Time
|
|
if cmd.Flags().Changed("before") {
|
|
parsed, err := parseRunTime(before, a.deps.Now())
|
|
if err != nil {
|
|
return clierr.Wrap(clierr.Usage, fmt.Errorf("invalid --before: %w", err))
|
|
}
|
|
until = *parsed
|
|
}
|
|
s, err := scope(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !all {
|
|
return batchDone(a, cmd, ids, func(id int64) string { return fmt.Sprintf("notification %d", id) }, func(id int64) (services.Notification, error) {
|
|
return s.Mark(cmd.Context(), id, mark.status)
|
|
}, func(item services.Notification) string { return fmt.Sprintf(mark.done, notificationLabel(item)) })
|
|
}
|
|
what := notificationsWith(filters) + " " + s.where
|
|
if a.canPrompt() {
|
|
if err := a.confirmAction(cmd, terminal.Confirmation{Prompt: fmt.Sprintf(mark.ask, 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, terminal.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: unread, read, pinned")
|
|
enumValues(command, "status", "unread", "read", "pinned")
|
|
command.Flags().StringVar(&before, "before", "", "With --all, only notifications updated before a date, timestamp, or duration such as 1h")
|
|
group.AddCommand(command)
|
|
}
|
|
return group
|
|
}
|
|
|
|
// notificationFilters registers the list filters of list and watch.
|
|
func notificationFilters(cmd *cobra.Command, opts *services.NotificationOptions, updated *timeRange) {
|
|
pageFlags(cmd, &opts.Limit, &opts.Page)
|
|
cmd.Flags().StringSliceVar(&opts.Statuses, "status", []string{"unread", "pinned"}, "Only notifications with these statuses: unread, read, pinned")
|
|
enumValues(cmd, "status", "unread", "read", "pinned")
|
|
cmd.Flags().StringSliceVar(&opts.Types, "type", nil, "Only notifications about these subjects: issue, pull, commit, repository")
|
|
enumValues(cmd, "type", "issue", "pull", "commit", "repository")
|
|
updated.register(cmd, "since", "Only notifications updated since a date, timestamp, or duration such as 24h", "before", "Only notifications updated before a date, timestamp, or duration such as 1h")
|
|
}
|
|
|
|
// notificationOptions completes the list filters for the API.
|
|
func (a *application) notificationOptions(opts services.NotificationOptions, updated timeRange) (services.NotificationOptions, error) {
|
|
types := make([]string, len(opts.Types))
|
|
for i, kind := range opts.Types {
|
|
types[i] = titleCase(kind)
|
|
}
|
|
opts.Types = types
|
|
var err error
|
|
opts.Since, opts.Before, err = updated.parse(a.deps.Now())
|
|
return opts, err
|
|
}
|
|
|
|
func (a *application) notificationListCommand(scope func(*cobra.Command) (notificationScope, error)) *cobra.Command {
|
|
opts := services.NotificationOptions{}
|
|
var updated timeRange
|
|
command := &cobra.Command{
|
|
Use: "list", Short: "List notifications", Args: argsExact(0),
|
|
Example: `$ tea notification list
|
|
# Including the ones you read, of one repository
|
|
$ tea notification list --status unread,read,pinned -R alice/project
|
|
# Pull requests updated today
|
|
$ tea notification list --type pull --since 24h`,
|
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
|
listed, err := a.notificationOptions(opts, updated)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s, err := scope(cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
page, err := s.List(cmd.Context(), listed)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
what := notificationsWith(opts.Statuses) + " " + s.where
|
|
return writeTable(a, cmd, page.Items, listSpec[services.Notification]{
|
|
Columns: notificationColumns, Row: notificationRow, Empty: "no " + what, Context: what,
|
|
}, servicePage(page, opts.Page))
|
|
},
|
|
}
|
|
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 {
|
|
return fmt.Sprintf("notification %d (%s)", item.ID, subjectRef(item))
|
|
}
|
|
|
|
// subjectRef names the subject of a notification by its repository and number: "alice/project#17".
|
|
func subjectRef(item services.Notification) string {
|
|
if item.Number == 0 {
|
|
return item.Repository
|
|
}
|
|
return item.Repository + "#" + strconv.FormatInt(item.Number, 10)
|
|
}
|
|
|
|
// 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 := subjectRef(item)
|
|
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), subjectRef(item) + " " + item.Title
|
|
}), err
|
|
})
|
|
}
|