mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
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>
219 lines
7.0 KiB
Go
219 lines
7.0 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package services
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"path"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
gitea "gitea.dev/sdk"
|
|
"gitea.dev/tea/internal/transport"
|
|
)
|
|
|
|
// Notification is the stable representation of an account notification thread.
|
|
type Notification struct {
|
|
ID int64 `json:"id"`
|
|
Repository string `json:"repository"`
|
|
// 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 {
|
|
result := Notification{ID: thread.ID, Unread: thread.Unread, Pinned: thread.Pinned, UpdatedAt: timestamp(thread.UpdatedAt)}
|
|
if thread.Repository != nil {
|
|
result.Repository = thread.Repository.FullName
|
|
}
|
|
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 {
|
|
result = append(result, gitea.NotificationStatus(status))
|
|
}
|
|
return result
|
|
}
|
|
|
|
// NotificationOptions supplies pagination and server-supported filters.
|
|
type NotificationOptions struct {
|
|
Limit, Page int
|
|
Statuses, Types []string
|
|
Since, Before *time.Time
|
|
}
|
|
|
|
func (o NotificationOptions) sdk() gitea.ListNotificationOptions {
|
|
result := gitea.ListNotificationOptions{ListOptions: listOptions(o.Limit, o.Page), Status: notificationStatuses(o.Statuses)}
|
|
for _, kind := range o.Types {
|
|
result.SubjectTypes = append(result.SubjectTypes, gitea.NotificationSubjectType(kind))
|
|
}
|
|
if o.Since != nil {
|
|
result.Since = *o.Since
|
|
}
|
|
if o.Before != nil {
|
|
result.Before = *o.Before
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Notifications manages account threads, optionally scoped to one repository.
|
|
type Notifications struct {
|
|
Client *transport.Client
|
|
Repository string
|
|
}
|
|
|
|
// 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 convertAll(threads, notificationFrom), headerOf(resp), nil
|
|
})
|
|
}
|
|
|
|
// View retrieves an account-owned notification thread.
|
|
func (s Notifications) View(ctx context.Context, id int64) (Notification, error) {
|
|
thread, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.NotificationThread, *gitea.Response, error) {
|
|
return c.Notifications.GetByID(ctx, id)
|
|
})
|
|
if err != nil {
|
|
return Notification{}, Classify(err, "notification", strconv.FormatInt(id, 10), "")
|
|
}
|
|
item := notificationFrom(thread)
|
|
if s.Repository != "" && !strings.EqualFold(item.Repository, s.Repository) {
|
|
return Notification{}, fmt.Errorf("notification %d belongs to %s, not selected repository %s", id, item.Repository, s.Repository)
|
|
}
|
|
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 != "" {
|
|
if _, err := s.View(ctx, id); err != nil {
|
|
return Notification{}, err
|
|
}
|
|
}
|
|
thread, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) (*gitea.NotificationThread, *gitea.Response, error) {
|
|
return c.Notifications.MarkReadByID(ctx, id, gitea.NotificationStatus(status))
|
|
})
|
|
if err != nil {
|
|
return Notification{}, err
|
|
}
|
|
return notificationFrom(thread), nil
|
|
}
|
|
|
|
// MarkAll applies a status to all matching notifications within the selected scope.
|
|
func (s Notifications) MarkAll(ctx context.Context, status string, filters []string, before time.Time) ([]Notification, error) {
|
|
opts := gitea.MarkNotificationOptions{LastReadAt: before, Status: notificationStatuses(filters), ToStatus: gitea.NotificationStatus(status)}
|
|
threads, _, err := call(ctx, s.Client, func(ctx context.Context, c *gitea.Client) ([]*gitea.NotificationThread, *gitea.Response, error) {
|
|
if s.Repository == "" {
|
|
return c.Notifications.MarkRead(ctx, opts)
|
|
}
|
|
owner, name, err := splitRepository(s.Repository)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return c.Notifications.MarkReadByRepo(ctx, owner, name, opts)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return convertAll(threads, notificationFrom), nil
|
|
}
|
|
|
|
// Watch reports new or changed threads and propagates cancellation and callback errors.
|
|
func (s Notifications) Watch(ctx context.Context, opts NotificationOptions, interval time.Duration, onChange func([]Notification) error) error {
|
|
if interval <= 0 {
|
|
return errors.New("notification polling interval must be positive")
|
|
}
|
|
seen := map[int64]Notification{}
|
|
for {
|
|
listed, err := s.List(ctx, opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
changes := []Notification{}
|
|
next := map[int64]Notification{}
|
|
for _, item := range listed.Items {
|
|
next[item.ID] = item
|
|
if previous, exists := seen[item.ID]; !exists || previous != item {
|
|
changes = append(changes, item)
|
|
}
|
|
}
|
|
seen = next
|
|
if len(changes) > 0 {
|
|
if err := onChange(changes); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if err := sleepCtx(ctx, interval); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|