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