internal/control/notifications.go

6f16ad5df0845d8f44ec6530f1d1d6993d8dcb70
gitbay/internal/control/notifications.go history · blame · raw

204 lines · 6880 bytes

  1package control
  2
  3import (
  4	"fmt"
  5	"io"
  6	"strconv"
  7	"strings"
  8
  9	"gitbay.org/gitbay/internal/policy"
 10	"gitbay.org/gitbay/internal/protocol"
 11	"gitbay.org/gitbay/internal/store"
 12)
 13
 14func init() {
 15	register(Command{Path: []string{"notifications", "list"},
 16		Summary:  "your notification inbox, newest first",
 17		Usage:    "notifications list [--all] [--limit <n>] [--cursor <c>]",
 18		ReadOnly: true, Run: runNotificationsList})
 19	register(Command{Path: []string{"notifications", "read"},
 20		Summary: "mark notifications read",
 21		Usage:   "notifications read <id>... | --all", Run: runNotificationsRead})
 22	register(Command{Path: []string{"repo", "watch"},
 23		Summary: "hear about all activity on a repository",
 24		Usage:   "repo watch <owner/name>", Run: runRepoWatch})
 25	register(Command{Path: []string{"repo", "unwatch"},
 26		Summary: "back to the default: only work you are part of",
 27		Usage:   "repo unwatch <owner/name>", Run: runRepoUnwatch})
 28	register(Command{Path: []string{"repo", "mute"},
 29		Summary: "mute a repository, including work you are part of",
 30		Usage:   "repo mute <owner/name>", Run: runRepoMute})
 31}
 32
 33// notice is one thing that happened, in the shape both delivery routes
 34// need: a mail subject and body, and an inbox row. The inbox is filed
 35// whether or not the instance has SMTP; mail is the optional half.
 36type notice struct {
 37	repo    store.Repo
 38	kind    string // issue, mr, or build
 39	subject string // mail subject
 40	action  string // "opened issue #12" — also the inbox summary
 41	excerpt string // quoted into the mail, not the inbox
 42	path    string // web path, no leading slash
 43	// body replaces the composed mail body outright, for a notice whose
 44	// mail is not prose — a failed build's log tail is not an excerpt of
 45	// something someone wrote, and is not cut to an excerpt's length.
 46	body string
 47}
 48
 49// notify delivers a notice to the given user ids widened by the
 50// repository's watchers, minus anyone who muted it and minus the acting
 51// user. A best-effort side channel: failures are ignored, the action
 52// itself already succeeded.
 53func notify(c *Ctx, userIDs []int64, n notice) {
 54	recipients, err := c.Store.NotifyRecipients(n.repo.ID, c.User.ID, userIDs)
 55	if err != nil {
 56		return
 57	}
 58	sendMail := c.Cfg.Mail.SMTPHost != ""
 59	body := noticeBody(c, n)
 60	for _, id := range recipients {
 61		c.Store.AddNotice(id, n.repo.ID, n.kind, c.User.Username, n.action, n.path)
 62		if !sendMail {
 63			continue
 64		}
 65		email, err := c.Store.PrimaryVerifiedEmail(id)
 66		if err != nil || email == "" {
 67			continue
 68		}
 69		c.Store.EnqueueMail(email, n.subject, body)
 70	}
 71}
 72
 73// noticeBody builds the standard mail body: who did what, an excerpt, and
 74// the web link.
 75func noticeBody(c *Ctx, n notice) string {
 76	if n.body != "" {
 77		return n.body
 78	}
 79	var b strings.Builder
 80	fmt.Fprintf(&b, "%s %s\n", c.User.Username, n.action)
 81	if e := strings.TrimSpace(n.excerpt); e != "" {
 82		if len(e) > 500 {
 83			e = e[:500] + "…"
 84		}
 85		fmt.Fprintf(&b, "\n%s\n", e)
 86	}
 87	fmt.Fprintf(&b, "\n%s/%s\n", strings.TrimSuffix(c.Cfg.Server.SiteURL, "/"), n.path)
 88	return b.String()
 89}
 90
 91func issueSubject(repo store.Repo, number int64, title string) string {
 92	return fmt.Sprintf("[%s] #%d: %s", repo.Path(), number, title)
 93}
 94
 95func mrSubject(repo store.Repo, number int64, title string) string {
 96	return fmt.Sprintf("[%s] !%d: %s", repo.Path(), number, title)
 97}
 98
 99// noticesDefaultLimit caps a bare list; pagination reaches further back.
100const noticesDefaultLimit = 50
101
102func runNotificationsList(c *Ctx, args []string) int {
103	const usage = "notifications list [--all] [--limit <n>] [--cursor <c>]"
104	rest, p, code := parsePageFlags(c, args, "notifications", true)
105	if code >= 0 {
106		return code
107	}
108	fl, err := parseFlags(rest, flagSpec{Bools: []string{"--all"}, Usage: usage})
109	if err != nil {
110		return c.fail(protocol.ExitUsage, "%v", err)
111	}
112	all := fl.Has("--all")
113	if p.limit == 0 {
114		p.limit = noticesDefaultLimit
115	}
116	notices, err := c.Store.Inbox(c.User.ID, !all, p.queryLimit(), p.keyInt())
117	if err != nil {
118		return c.fail(protocol.ExitFailure, "%v", err)
119	}
120	type out struct {
121		ID        int64  `json:"id"`
122		Repo      string `json:"repo"`
123		Kind      string `json:"kind"`
124		Actor     string `json:"actor"`
125		Summary   string `json:"summary"`
126		Path      string `json:"path"`
127		CreatedAt string `json:"created_at"`
128		ReadAt    string `json:"read_at,omitempty"`
129	}
130	notices, next := trimPage(p, notices, "notifications", func(n store.Notice) string {
131		return strconv.FormatInt(n.ID, 10)
132	})
133	ds := make([]out, 0, len(notices))
134	for _, n := range notices {
135		ds = append(ds, out{n.ID, n.RepoPath, n.Kind, n.Actor, n.Summary, n.Path, n.CreatedAt, n.ReadAt})
136	}
137	return c.emitPage(p, ds, next, func(w io.Writer) {
138		for _, d := range ds {
139			mark := "*"
140			if d.ReadAt != "" {
141				mark = " "
142			}
143			fmt.Fprintf(w, "%s %d\t%s\t%s\t%s %s\t%s\n",
144				mark, d.ID, d.CreatedAt, d.Repo, d.Actor, d.Summary, d.Path)
145		}
146	})
147}
148
149func runNotificationsRead(c *Ctx, args []string) int {
150	const usage = "notifications read <id>... | --all"
151	fl, err := parseFlags(args, flagSpec{Bools: []string{"--all"}, MaxPos: -1, Usage: usage})
152	if err != nil {
153		return c.fail(protocol.ExitUsage, "%v", err)
154	}
155	// --all and a list of ids are two ways of saying which rows: taking
156	// both would leave which one won unstated.
157	if fl.Has("--all") == (len(fl.Pos) > 0) {
158		return c.fail(protocol.ExitUsage, "usage: %s", usage)
159	}
160	var ids []int64
161	for _, a := range fl.Pos {
162		n, err := strconv.ParseInt(a, 10, 64)
163		if err != nil {
164			return c.fail(protocol.ExitUsage, "bad notification id %q", a)
165		}
166		ids = append(ids, n)
167	}
168	n, err := c.Store.MarkNoticesRead(c.User.ID, ids)
169	if err != nil {
170		return c.fail(protocol.ExitFailure, "%v", err)
171	}
172	return c.emit(map[string]int64{"read": n}, func(w io.Writer) {
173		fmt.Fprintf(w, "marked %d read\n", n)
174	})
175}
176
177func runRepoWatch(c *Ctx, args []string) int   { return setWatch(c, args, "watch", "watching") }
178func runRepoMute(c *Ctx, args []string) int    { return setWatch(c, args, "mute", "muted") }
179func runRepoUnwatch(c *Ctx, args []string) int { return setWatch(c, args, "unwatch", "default") }
180
181// setWatch records the caller's state on a repository. "default" deletes
182// the row: watch then unwatch leaves no trace, and a mute is undone the
183// same way.
184func setWatch(c *Ctx, args []string, verb, state string) int {
185	if len(args) != 1 {
186		return c.fail(protocol.ExitUsage, "usage: repo %s <owner/name>", verb)
187	}
188	repo, code := resolveRepo(c, args[0], policy.CanRead)
189	if code >= 0 {
190		return code
191	}
192	var err error
193	if state == "default" {
194		err = c.Store.ClearRepoWatch(repo.ID, c.User.ID)
195	} else {
196		err = c.Store.SetRepoWatch(repo.ID, c.User.ID, state)
197	}
198	if err != nil {
199		return c.fail(protocol.ExitFailure, "%v", err)
200	}
201	return c.emit(map[string]string{"repo": repo.Path(), "state": state}, func(w io.Writer) {
202		fmt.Fprintf(w, "%s %s\n", state, repo.Path())
203	})
204}