internal/control/notifications.go

a32f7f2001c1c0bf38ba8c3360e6bc7ca3982fee
gitbay/internal/control/notifications.go history · blame · raw

304 lines · 10463 bytes

  1package control
  2
  3import (
  4	"fmt"
  5	"io"
  6	"strconv"
  7	"strings"
  8
  9	"gitbay.org/gitbay/internal/autolink"
 10	"gitbay.org/gitbay/internal/policy"
 11	"gitbay.org/gitbay/internal/protocol"
 12	"gitbay.org/gitbay/internal/store"
 13)
 14
 15func init() {
 16	register(Command{Path: []string{"notifications", "list"},
 17		Summary:  "your notification inbox, newest first",
 18		Usage:    "notifications list [--all] [--limit <n>] [--cursor <c>]",
 19		ReadOnly: true, Run: runNotificationsList})
 20	register(Command{Path: []string{"notifications", "read"},
 21		Summary: "mark notifications read",
 22		Usage:   "notifications read <id>... | --all", Run: runNotificationsRead})
 23	register(Command{Path: []string{"notifications", "settings", "show"},
 24		Summary:  "your notification preferences",
 25		Usage:    "notifications settings show",
 26		ReadOnly: true, Run: runNotificationsSettingsShow})
 27	register(Command{Path: []string{"notifications", "settings", "mail"},
 28		Summary: "activity by mail as well as the inbox (login links are unaffected)",
 29		Usage:   "notifications settings mail on|off", Run: runNotificationsSettingsMail})
 30	register(Command{Path: []string{"notifications", "settings", "watch"},
 31		Summary: "every issue and merge request on repositories you can write to",
 32		Usage:   "notifications settings watch on|off", Run: runNotificationsSettingsWatch})
 33	register(Command{Path: []string{"repo", "watch"},
 34		Summary: "hear about all activity on a repository",
 35		Usage:   "repo watch <owner/name>", Run: runRepoWatch})
 36	register(Command{Path: []string{"repo", "unwatch"},
 37		Summary: "back to the default: only work you are part of",
 38		Usage:   "repo unwatch <owner/name>", Run: runRepoUnwatch})
 39	register(Command{Path: []string{"repo", "mute"},
 40		Summary: "mute a repository, including work you are part of",
 41		Usage:   "repo mute <owner/name>", Run: runRepoMute})
 42}
 43
 44// notice is one thing that happened, in the shape both delivery routes
 45// need: a mail subject and body, and an inbox row. The inbox is filed
 46// whether or not the instance has SMTP; mail is the optional half.
 47type notice struct {
 48	repo    store.Repo
 49	kind    string // issue, mr, or build
 50	subject string // mail subject
 51	action  string // "opened issue #12" — also the inbox summary
 52	excerpt string // quoted into the mail, not the inbox
 53	path    string // web path, no leading slash
 54	// body replaces the composed mail body outright, for a notice whose
 55	// mail is not prose — a failed build's log tail is not an excerpt of
 56	// something someone wrote, and is not cut to an excerpt's length.
 57	body string
 58	// direct keeps the notice to the given accounts: watchers of the
 59	// repository are not added. A mention is addressed to someone.
 60	direct bool
 61}
 62
 63// notify delivers a notice to the given user ids widened by the
 64// repository's watchers (unless direct), minus anyone who muted it and
 65// minus the acting user. A best-effort side channel: failures are
 66// ignored, the action itself already succeeded.
 67func notify(c *Ctx, userIDs []int64, n notice) {
 68	recipients, err := c.Store.NotifyRecipients(n.repo.ID, c.User.ID, userIDs, !n.direct)
 69	if err != nil {
 70		return
 71	}
 72	sendMail := c.Cfg.Mail.SMTPHost != ""
 73	body := noticeBody(c, n)
 74	for _, id := range recipients {
 75		c.Store.AddNotice(id, n.repo.ID, n.kind, c.User.Username, n.action, n.path)
 76		if !sendMail {
 77			continue
 78		}
 79		email, err := c.Store.ActivityMailAddress(id)
 80		if err != nil || email == "" {
 81			continue
 82		}
 83		c.Store.EnqueueMail(email, n.subject, body)
 84	}
 85}
 86
 87// notifyMentions files an inbox row for every account text mentions by
 88// @name that can read the repository, and records them as participants
 89// of the thread so they hear what follows (#202). Mute is honoured by
 90// notify; the actor mentioning themselves is dropped there too.
 91func notifyMentions(c *Ctx, repo store.Repo, t thread, itemID, number int64, title, text string) {
 92	var ids []int64
 93	for _, name := range autolink.Mentions(text) {
 94		u, err := c.Store.UserByUsername(name)
 95		if err != nil {
 96			trimmed := strings.TrimRight(name, "._-")
 97			if trimmed == "" || trimmed == name {
 98				continue
 99			}
100			if u, err = c.Store.UserByUsername(trimmed); err != nil {
101				continue
102			}
103		}
104		if u.ID == c.User.ID {
105			continue
106		}
107		grant, err := c.Store.AccessRole(repo.ID, u.ID)
108		if err != nil || !policy.CanRead(u, repo, grant) {
109			continue
110		}
111		ids = append(ids, u.ID)
112	}
113	if len(ids) == 0 {
114		return
115	}
116	c.Store.AddMentions(repo.ID, t.kind, itemID, ids)
117	notify(c, ids, notice{repo: repo, kind: t.kind, direct: true,
118		subject: fmt.Sprintf("[%s] %s%d: %s", repo.Path(), t.symbol, number, title),
119		action:  fmt.Sprintf("mentioned you in %s%d", t.symbol, number),
120		excerpt: text, path: fmt.Sprintf("%s/%s/%d", repo.Path(), t.segment, number)})
121}
122
123// noticeBody builds the standard mail body: who did what, an excerpt, and
124// the web link.
125func noticeBody(c *Ctx, n notice) string {
126	if n.body != "" {
127		return n.body
128	}
129	var b strings.Builder
130	fmt.Fprintf(&b, "%s %s\n", c.User.Username, n.action)
131	if e := strings.TrimSpace(n.excerpt); e != "" {
132		if len(e) > 500 {
133			e = e[:500] + "…"
134		}
135		fmt.Fprintf(&b, "\n%s\n", e)
136	}
137	fmt.Fprintf(&b, "\n%s/%s\n", strings.TrimSuffix(c.Cfg.Server.SiteURL, "/"), n.path)
138	return b.String()
139}
140
141func issueSubject(repo store.Repo, number int64, title string) string {
142	return fmt.Sprintf("[%s] #%d: %s", repo.Path(), number, title)
143}
144
145func mrSubject(repo store.Repo, number int64, title string) string {
146	return fmt.Sprintf("[%s] !%d: %s", repo.Path(), number, title)
147}
148
149func emitNotificationSettings(c *Ctx) int {
150	mail, err := c.Store.MailEnabled(c.User.ID)
151	if err != nil {
152		return c.fail(protocol.ExitFailure, "%v", err)
153	}
154	watch, err := c.Store.WatchEnabled(c.User.ID)
155	if err != nil {
156		return c.fail(protocol.ExitFailure, "%v", err)
157	}
158	return c.emit(map[string]bool{"mail": mail, "watch": watch}, func(w io.Writer) {
159		onOff := func(on bool) string {
160			if on {
161				return "on"
162			}
163			return "off"
164		}
165		fmt.Fprintf(w, "mail: %s\nwatch: %s\n", onOff(mail), onOff(watch))
166	})
167}
168
169func runNotificationsSettingsShow(c *Ctx, args []string) int {
170	if len(args) != 0 {
171		return c.usage()
172	}
173	return emitNotificationSettings(c)
174}
175
176// runNotificationsSettingsMail is the "inbox but no mail" switch: the
177// inbox is filed either way, the mail half consults it (#194).
178func runNotificationsSettingsMail(c *Ctx, args []string) int {
179	if len(args) != 1 || (args[0] != "on" && args[0] != "off") {
180		return c.usage()
181	}
182	if err := c.Store.SetMailEnabled(c.User.ID, args[0] == "on"); err != nil {
183		return c.fail(protocol.ExitFailure, "%v", err)
184	}
185	return emitNotificationSettings(c)
186}
187
188// runNotificationsSettingsWatch is the default watch state for
189// repositories the account can write to: consulted when a notice is
190// delivered, so a grant or a revoke needs no watch row of its own (#194).
191func runNotificationsSettingsWatch(c *Ctx, args []string) int {
192	if len(args) != 1 || (args[0] != "on" && args[0] != "off") {
193		return c.usage()
194	}
195	if err := c.Store.SetWatchEnabled(c.User.ID, args[0] == "on"); err != nil {
196		return c.fail(protocol.ExitFailure, "%v", err)
197	}
198	return emitNotificationSettings(c)
199}
200
201// noticesDefaultLimit caps a bare list; pagination reaches further back.
202const noticesDefaultLimit = 50
203
204func runNotificationsList(c *Ctx, args []string) int {
205	rest, p, code := parsePageFlags(c, args, "notifications", true)
206	if code >= 0 {
207		return code
208	}
209	fl, err := parseFlags(rest, flagSpec{Bools: []string{"--all"}, Usage: c.Cmd.Usage})
210	if err != nil {
211		return c.fail(protocol.ExitUsage, "%v", err)
212	}
213	all := fl.Has("--all")
214	if p.limit == 0 {
215		p.limit = noticesDefaultLimit
216	}
217	notices, err := c.Store.Inbox(c.User.ID, !all, p.queryLimit(), p.keyInt())
218	if err != nil {
219		return c.fail(protocol.ExitFailure, "%v", err)
220	}
221	type out struct {
222		ID        int64  `json:"id"`
223		Repo      string `json:"repo"`
224		Kind      string `json:"kind"`
225		Actor     string `json:"actor"`
226		Summary   string `json:"summary"`
227		Path      string `json:"path"`
228		CreatedAt string `json:"created_at"`
229		ReadAt    string `json:"read_at,omitempty"`
230	}
231	notices, next := trimPage(p, notices, "notifications", func(n store.Notice) string {
232		return strconv.FormatInt(n.ID, 10)
233	})
234	ds := make([]out, 0, len(notices))
235	for _, n := range notices {
236		ds = append(ds, out{n.ID, n.RepoPath, n.Kind, n.Actor, n.Summary, n.Path, n.CreatedAt, n.ReadAt})
237	}
238	return c.emitPage(p, ds, next, func(w io.Writer) {
239		for _, d := range ds {
240			mark := "*"
241			if d.ReadAt != "" {
242				mark = " "
243			}
244			fmt.Fprintf(w, "%s %d\t%s\t%s\t%s %s\t%s\n",
245				mark, d.ID, d.CreatedAt, d.Repo, d.Actor, d.Summary, d.Path)
246		}
247	})
248}
249
250func runNotificationsRead(c *Ctx, args []string) int {
251	fl, err := parseFlags(args, flagSpec{Bools: []string{"--all"}, MaxPos: -1, Usage: c.Cmd.Usage})
252	if err != nil {
253		return c.fail(protocol.ExitUsage, "%v", err)
254	}
255	// --all and a list of ids are two ways of saying which rows: taking
256	// both would leave which one won unstated.
257	if fl.Has("--all") == (len(fl.Pos) > 0) {
258		return c.usage()
259	}
260	var ids []int64
261	for _, a := range fl.Pos {
262		n, err := strconv.ParseInt(a, 10, 64)
263		if err != nil {
264			return c.fail(protocol.ExitUsage, "bad notification id %q", a)
265		}
266		ids = append(ids, n)
267	}
268	n, err := c.Store.MarkNoticesRead(c.User.ID, ids)
269	if err != nil {
270		return c.fail(protocol.ExitFailure, "%v", err)
271	}
272	return c.emit(map[string]int64{"read": n}, func(w io.Writer) {
273		fmt.Fprintf(w, "marked %d read\n", n)
274	})
275}
276
277func runRepoWatch(c *Ctx, args []string) int   { return setWatch(c, args, "watch", "watching") }
278func runRepoMute(c *Ctx, args []string) int    { return setWatch(c, args, "mute", "muted") }
279func runRepoUnwatch(c *Ctx, args []string) int { return setWatch(c, args, "unwatch", "default") }
280
281// setWatch records the caller's state on a repository. "default" deletes
282// the row: watch then unwatch leaves no trace, and a mute is undone the
283// same way.
284func setWatch(c *Ctx, args []string, verb, state string) int {
285	if len(args) != 1 {
286		return c.usage()
287	}
288	repo, code := resolveRepo(c, args[0], policy.CanRead)
289	if code >= 0 {
290		return code
291	}
292	var err error
293	if state == "default" {
294		err = c.Store.ClearRepoWatch(repo.ID, c.User.ID)
295	} else {
296		err = c.Store.SetRepoWatch(repo.ID, c.User.ID, state)
297	}
298	if err != nil {
299		return c.fail(protocol.ExitFailure, "%v", err)
300	}
301	return c.emit(map[string]string{"repo": repo.Path(), "state": state}, func(w io.Writer) {
302		fmt.Fprintf(w, "%s %s\n", state, repo.Path())
303	})
304}