internal/control/notifications.go
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}