internal/control/notifications.go

v1.43.0
gitbay/internal/control/notifications.go history · blame · raw

607 lines · 21776 bytes

  1package control
  2
  3import (
  4	"errors"
  5	"fmt"
  6	"io"
  7	"strconv"
  8	"strings"
  9	"time"
 10
 11	"gitbay.org/gitbay/internal/autolink"
 12	"gitbay.org/gitbay/internal/mailreply"
 13	"gitbay.org/gitbay/internal/policy"
 14	"gitbay.org/gitbay/internal/protocol"
 15	"gitbay.org/gitbay/internal/store"
 16)
 17
 18func init() {
 19	register(Command{Path: []string{"notifications", "list"},
 20		Summary: "your notification inbox, newest first",
 21		Usage:   "notifications list [--all] [--limit <n>] [--cursor <c>]",
 22		Flags: []Flag{
 23			{"--all", "", "include already-read notifications", ""},
 24			{"--limit", "<n>", "rows per page", ""},
 25			{"--cursor", "<c>", "continue from the previous page", ""},
 26		},
 27		Examples: []string{"notifications list", "notifications list --all --limit 50"},
 28		ReadOnly: true, Run: runNotificationsList})
 29	register(Command{Path: []string{"notifications", "read"},
 30		Summary: "mark notifications read",
 31		Usage:   "notifications read <id>... | --all",
 32		Flags: []Flag{
 33			{"--all", "", "mark every unread notification read", ""},
 34		},
 35		Examples: []string{"notifications read 12 13", "notifications read --all"},
 36		Run:      runNotificationsRead})
 37	register(Command{Path: []string{"notifications", "settings", "show"},
 38		Summary:  "your notification preferences",
 39		Usage:    "notifications settings show",
 40		Examples: []string{"notifications settings show"},
 41		ReadOnly: true, Run: runNotificationsSettingsShow})
 42	register(Command{Path: []string{"notifications", "settings", "mail"},
 43		Summary:  "activity by mail as well as the inbox (login links are unaffected)",
 44		Usage:    "notifications settings mail on|off",
 45		Examples: []string{"notifications settings mail on"},
 46		Run:      runNotificationsSettingsMail})
 47	register(Command{Path: []string{"notifications", "settings", "reply"},
 48		Summary:  "reply to issue and merge request mail to comment",
 49		Usage:    "notifications settings reply on|off",
 50		Examples: []string{"notifications settings reply on"},
 51		Run:      runNotificationsSettingsReply})
 52	register(Command{Path: []string{"notifications", "settings", "watch"},
 53		Summary:  "every issue and merge request on repositories you can write to",
 54		Usage:    "notifications settings watch on|off",
 55		Examples: []string{"notifications settings watch on"},
 56		Run:      runNotificationsSettingsWatch})
 57	register(Command{Path: []string{"notifications", "device", "add"},
 58		NeedsRecentSignIn: true,
 59		Summary:           "register an Apple device for push, token on stdin",
 60		Usage:             "notifications device add [--label <name>] < token",
 61		Flags: []Flag{
 62			{"--label", "<name>", "a name for the device", ""},
 63		},
 64		Examples: []string{"notifications device add --label iphone < token"},
 65		// Mandatory: without it control.go swaps in an empty reader and
 66		// this command stores an empty token without erroring.
 67		ReadsStdin: true, Run: runNotificationsDeviceAdd})
 68	register(Command{Path: []string{"notifications", "device", "list"},
 69		Summary:  "your registered devices",
 70		Usage:    "notifications device list",
 71		Examples: []string{"notifications device list"},
 72		ReadOnly: true, Run: runNotificationsDeviceList})
 73	register(Command{Path: []string{"notifications", "device", "remove"},
 74		Summary:  "deregister a device",
 75		Usage:    "notifications device remove <id>",
 76		Examples: []string{"notifications device remove 4"},
 77		Run:      runNotificationsDeviceRemove})
 78	register(Command{Path: []string{"notifications", "settings", "push"},
 79		Summary:  "activity on your registered devices as well as the inbox",
 80		Usage:    "notifications settings push on|off",
 81		Examples: []string{"notifications settings push on"},
 82		Run:      runNotificationsSettingsPush})
 83	register(Command{Path: []string{"repo", "watch"},
 84		Summary:  "hear about all activity on a repository",
 85		Usage:    "repo watch <owner/name>",
 86		Examples: []string{"repo watch krz/gitbay"},
 87		Run:      runRepoWatch})
 88	register(Command{Path: []string{"repo", "unwatch"},
 89		Summary:  "back to the default: only work you are part of",
 90		Usage:    "repo unwatch <owner/name>",
 91		Examples: []string{"repo unwatch krz/gitbay"},
 92		Run:      runRepoUnwatch})
 93	register(Command{Path: []string{"repo", "mute"},
 94		Summary:  "mute a repository, including work you are part of",
 95		Usage:    "repo mute <owner/name>",
 96		Examples: []string{"repo mute krz/gitbay"},
 97		Run:      runRepoMute})
 98}
 99
100// notice is one thing that happened, in the shape both delivery routes
101// need: a mail subject and body, and an inbox row. The inbox is filed
102// whether or not the instance has SMTP; mail is the optional half.
103type notice struct {
104	repo    store.Repo
105	kind    string // issue, mr, or build
106	number  int64  // the issue or merge request; 0 for a build
107	subject string // mail subject
108	action  string // "opened issue #12" — also the inbox summary
109	excerpt string // quoted into the mail, not the inbox
110	path    string // web path, no leading slash
111	// body replaces the composed mail body outright, for a notice whose
112	// mail is not prose — a failed build's log tail is not an excerpt of
113	// something someone wrote, and is not cut to an excerpt's length.
114	body string
115	// direct keeps the notice to the given accounts: watchers of the
116	// repository are not added. A mention is addressed to someone.
117	direct bool
118}
119
120// notify delivers a notice to the given user ids widened by the
121// repository's watchers (unless direct), minus anyone who muted it and
122// minus the acting user. A best-effort side channel: failures are
123// ignored, the action itself already succeeded.
124func notify(c *Ctx, userIDs []int64, n notice) {
125	recipients, err := c.Store.NotifyRecipients(n.repo.ID, c.User.ID, userIDs, !n.direct)
126	if err != nil {
127		return
128	}
129	sendMail := c.Cfg.Mail.SMTPHost != ""
130	// Nothing drains push_queue unless the daemon started the deliverer,
131	// and the retention sweep only collects rows that were sent or
132	// dead-lettered, so a row written here would sit there forever.
133	sendPush := c.Cfg.Push.Enabled
134	body := noticeBody(c, n)
135	for _, id := range recipients {
136		c.Store.AddNotice(id, n.repo.ID, n.kind, c.User.Username, n.action, n.path)
137		if sendPush {
138			c.Store.EnqueuePush(id, pushTitle(n), pushBody(c.User.Username, n), n.path)
139		}
140		if !sendMail {
141			continue
142		}
143		email, err := c.Store.ActivityMailAddress(id)
144		if err != nil || email == "" {
145			continue
146		}
147		if replyTo := replyAddress(c, id, n); replyTo != "" {
148			c.Store.EnqueueMailReplyTo(email, replyTo, n.subject, body+replyFooter)
149			continue
150		}
151		c.Store.EnqueueMail(email, n.subject, body)
152	}
153}
154
155// replyFooter ends mail that carries a reply address.
156const replyFooter = "\nReply to this mail to comment. Replies are accepted from your verified addresses.\n"
157
158// replyAddress is the Reply-To for recipient's mail about n (#295): an
159// address carrying a token for the recipient and the thread, when the
160// instance polls for replies and the recipient turned replies on. ""
161// otherwise, or when no token can be minted.
162func replyAddress(c *Ctx, recipient int64, n notice) string {
163	in := c.Cfg.Mail.Inbound
164	keys := c.Store.Keyring()
165	if !in.Enabled || keys == nil || n.number == 0 || (n.kind != "issue" && n.kind != "mr") {
166		return ""
167	}
168	if on, err := c.Store.ReplyEnabled(recipient); err != nil || !on {
169		return ""
170	}
171	secrets, err := keys.Derive(mailreply.Purpose)
172	if err != nil {
173		return ""
174	}
175	tok, err := mailreply.Mint(secrets, mailreply.Target{
176		UserID: recipient, RepoID: n.repo.ID, Kind: n.kind, Number: n.number,
177	}, time.Now().Add(mailreply.Lifetime))
178	if err != nil {
179		return ""
180	}
181	return mailreply.Address(in.ReplyAddress, tok)
182}
183
184// notifyMentions files an inbox row for every account text mentions by
185// @name that can read the repository, and records them as participants
186// of the thread so they hear what follows (#202). Mute is honoured by
187// notify; the actor mentioning themselves is dropped there too.
188func notifyMentions(c *Ctx, repo store.Repo, t thread, itemID, number int64, title, text string) {
189	var ids []int64
190	for _, name := range autolink.Mentions(text) {
191		u, err := c.Store.UserByUsername(name)
192		if err != nil {
193			trimmed := strings.TrimRight(name, "._-")
194			if trimmed == "" || trimmed == name {
195				continue
196			}
197			if u, err = c.Store.UserByUsername(trimmed); err != nil {
198				continue
199			}
200		}
201		if u.ID == c.User.ID {
202			continue
203		}
204		grant, err := c.Store.AccessRole(repo.ID, u.ID)
205		if err != nil || !policy.CanRead(u, repo, grant) {
206			continue
207		}
208		ids = append(ids, u.ID)
209	}
210	if len(ids) == 0 {
211		return
212	}
213	c.Store.AddMentions(repo.ID, t.kind, itemID, ids)
214	notify(c, ids, notice{repo: repo, kind: t.kind, number: number, direct: true,
215		subject: fmt.Sprintf("[%s] %s%d: %s", repo.Path(), t.symbol, number, title),
216		action:  fmt.Sprintf("mentioned you in %s%d", t.symbol, number),
217		excerpt: text, path: fmt.Sprintf("%s/%s/%d", repo.Path(), t.segment, number)})
218}
219
220// noticeBody builds the standard mail body: who did what, an excerpt, and
221// the web link.
222func noticeBody(c *Ctx, n notice) string {
223	if n.body != "" {
224		return n.body
225	}
226	var b strings.Builder
227	fmt.Fprintf(&b, "%s %s\n", c.User.Username, n.action)
228	if e := strings.TrimSpace(n.excerpt); e != "" {
229		if len(e) > 500 {
230			e = e[:500] + "…"
231		}
232		fmt.Fprintf(&b, "\n%s\n", e)
233	}
234	fmt.Fprintf(&b, "\n%s/%s\n", strings.TrimSuffix(c.Cfg.Server.SiteURL, "/"), n.path)
235	return b.String()
236}
237
238// pushTitle and pushBody are the alert's two lines. The body is built
239// from the same two values AddNotice files, so the alert and the inbox
240// row cannot disagree about what happened. The title is the repository,
241// which also groups a repository's notices in Notification Center.
242func pushTitle(n notice) string { return n.repo.Path() }
243
244func pushBody(actor string, n notice) string { return actor + " " + n.action }
245
246func issueSubject(repo store.Repo, number int64, title string) string {
247	return fmt.Sprintf("[%s] #%d: %s", repo.Path(), number, title)
248}
249
250func mrSubject(repo store.Repo, number int64, title string) string {
251	return fmt.Sprintf("[%s] !%d: %s", repo.Path(), number, title)
252}
253
254func emitNotificationSettings(c *Ctx) int {
255	mail, err := c.Store.MailEnabled(c.User.ID)
256	if err != nil {
257		return c.fail(protocol.ExitFailure, "%v", err)
258	}
259	watch, err := c.Store.WatchEnabled(c.User.ID)
260	if err != nil {
261		return c.fail(protocol.ExitFailure, "%v", err)
262	}
263	push, err := c.Store.PushEnabled(c.User.ID)
264	if err != nil {
265		return c.fail(protocol.ExitFailure, "%v", err)
266	}
267	reply, err := c.Store.ReplyEnabled(c.User.ID)
268	if err != nil {
269		return c.fail(protocol.ExitFailure, "%v", err)
270	}
271	return c.emitView(map[string]bool{"mail": mail, "watch": watch, "push": push, "reply": reply}, func(w io.Writer) {
272		onOff := func(on bool) string {
273			if on {
274				return "on"
275			}
276			return "off"
277		}
278		v := c.view(w)
279		v.fields(
280			"mail", onOff(mail),
281			"reply", onOff(reply),
282			"watch", onOff(watch),
283			"push", onOff(push),
284		)
285	}, func() screen {
286		var s screen
287		for _, x := range []struct {
288			label, name string
289			on          bool
290		}{{"Mail", "mail", mail}, {"Reply", "reply", reply}, {"Watch", "watch", watch}, {"Push", "push", push}} {
291			state, flip := cText("on"), "off"
292			if !x.on {
293				state, flip = cMeta("off"), "on"
294			}
295			s.fields = append(s.fields, field{x.label, []cell{state}})
296			s.actions = append(s.actions, action{"Settings", []string{"notifications", "settings", x.name, flip}})
297		}
298		s.actions = append(s.actions, action{"Devices", []string{"notifications", "device", "list"}})
299		return s
300	})
301}
302
303func runNotificationsSettingsShow(c *Ctx, args []string) int {
304	if len(args) != 0 {
305		return c.usage()
306	}
307	return emitNotificationSettings(c)
308}
309
310// runNotificationsSettingsMail is the "inbox but no mail" switch: the
311// inbox is filed either way, the mail half consults it (#194).
312func runNotificationsSettingsMail(c *Ctx, args []string) int {
313	if len(args) != 1 || (args[0] != "on" && args[0] != "off") {
314		return c.usage()
315	}
316	if err := c.Store.SetMailEnabled(c.User.ID, args[0] == "on"); err != nil {
317		return c.fail(protocol.ExitFailure, "%v", err)
318	}
319	return emitNotificationSettings(c)
320}
321
322// runNotificationsSettingsReply turns on a Reply-To on the account's
323// issue and merge request mail (#295). Turning it on is refused where
324// nothing reads the replies.
325func runNotificationsSettingsReply(c *Ctx, args []string) int {
326	if len(args) != 1 || (args[0] != "on" && args[0] != "off") {
327		return c.usage()
328	}
329	on := args[0] == "on"
330	if on && !c.Cfg.Mail.Inbound.Enabled {
331		return c.fail(protocol.ExitFailure,
332			"this instance does not read replies to its mail ([mail.inbound] enabled = false); ask an admin")
333	}
334	if err := c.Store.SetReplyEnabled(c.User.ID, on); err != nil {
335		return c.fail(protocol.ExitFailure, "%v", err)
336	}
337	return emitNotificationSettings(c)
338}
339
340// runNotificationsSettingsWatch is the default watch state for
341// repositories the account can write to: consulted when a notice is
342// delivered, so a grant or a revoke needs no watch row of its own (#194).
343func runNotificationsSettingsWatch(c *Ctx, args []string) int {
344	if len(args) != 1 || (args[0] != "on" && args[0] != "off") {
345		return c.usage()
346	}
347	if err := c.Store.SetWatchEnabled(c.User.ID, args[0] == "on"); err != nil {
348		return c.fail(protocol.ExitFailure, "%v", err)
349	}
350	return emitNotificationSettings(c)
351}
352
353func runNotificationsSettingsPush(c *Ctx, args []string) int {
354	if len(args) != 1 || (args[0] != "on" && args[0] != "off") {
355		return c.usage()
356	}
357	if err := c.Store.SetPushEnabled(c.User.ID, args[0] == "on"); err != nil {
358		return c.fail(protocol.ExitFailure, "%v", err)
359	}
360	return emitNotificationSettings(c)
361}
362
363// maxDeviceTokenBytes is well past APNs' 32-byte token rendered as 64 hex
364// characters, and stops a stdin that is not a token from becoming a row.
365const maxDeviceTokenBytes = 512
366
367func runNotificationsDeviceAdd(c *Ctx, args []string) int {
368	f, err := c.parseArgs(args, flagSpec{Values: []string{"--label"}, Usage: c.Cmd.Usage})
369	if err != nil {
370		return c.fail(protocol.ExitUsage, "%v", err)
371	}
372	if len(f.Pos) != 0 {
373		return c.usage()
374	}
375	// The registration itself would succeed and then deliver nothing,
376	// while notifications settings show still reported push on. Say what
377	// is actually wrong instead.
378	if !c.Cfg.Push.Enabled {
379		return c.fail(protocol.ExitFailure,
380			"this instance does not send push notifications ([push] enabled = false); ask an admin")
381	}
382	raw, err := io.ReadAll(io.LimitReader(c.Stdin, maxDeviceTokenBytes+1))
383	if err != nil {
384		return c.fail(protocol.ExitFailure, "reading stdin: %v", err)
385	}
386	token := strings.TrimSpace(string(raw))
387	if token == "" {
388		return c.usageWith("no device token on stdin")
389	}
390	if len(token) > maxDeviceTokenBytes {
391		return c.fail(protocol.ExitUsage, "device token is too long")
392	}
393	id, err := c.Store.AddPushDevice(c.User.ID, token, f.Value("--label"))
394	if err != nil {
395		return c.fail(protocol.ExitFailure, "%v", err)
396	}
397	return c.emit(map[string]any{"id": id, "status": "registered"}, func(w io.Writer) {
398		fmt.Fprintf(w, "registered device %d\n", id)
399	})
400}
401
402func runNotificationsDeviceList(c *Ctx, args []string) int {
403	if len(args) != 0 {
404		return c.usage()
405	}
406	devices, err := c.Store.PushDevices(c.User.ID)
407	if err != nil {
408		return c.fail(protocol.ExitFailure, "%v", err)
409	}
410	type deviceRow struct {
411		ID    int64  `json:"id"`
412		Label string `json:"label"`
413		Token string `json:"token"` // truncated; a token is not echoed in full
414		Added string `json:"added"`
415	}
416	rows := make([]deviceRow, 0, len(devices))
417	for _, d := range devices {
418		rows = append(rows, deviceRow{ID: d.ID, Label: d.Label,
419			Token: ShortToken(d.Token), Added: d.CreatedAt})
420	}
421	return c.emitView(rows, func(w io.Writer) {
422		tb := c.table(w, "ID", "LABEL", "TOKEN", "ADDED")
423		for _, r := range rows {
424			tb.row(cRef(fmt.Sprintf("%d", r.ID)), cText(r.Label), cText(r.Token), cAge(r.Added))
425		}
426		tb.flush()
427	}, func() screen {
428		rs := make([]row, len(rows))
429		for i, r := range rows {
430			rs[i] = rowOf(cRef(strconv.FormatInt(r.ID, 10)), cFlex(r.Label), cMeta(r.Token, "added "+relAge(r.Added, termNow())))
431		}
432		return listScreen("Push devices", rs,
433			action{"Devices", []string{"notifications", "device", "remove", "<id>"}},
434		)
435	})
436}
437
438// ShortToken renders a device token as its first eight characters. Enough
439// to tell two devices apart in a list, not enough to push to one. A real
440// APNs token is 64 hex characters, so anything at or under the cut length
441// is not a token worth showing part of — it is masked outright rather
442// than echoed whole, which "abc…" would imply is a truncation.
443//
444// Exported because the account page lists the same devices: one renderer,
445// so the two surfaces cannot come to disagree about what they print.
446func ShortToken(t string) string {
447	if len(t) > 8 {
448		return t[:8] + "…"
449	}
450	return "(short token)"
451}
452
453func runNotificationsDeviceRemove(c *Ctx, args []string) int {
454	if len(args) != 1 {
455		return c.usage()
456	}
457	id, err := strconv.ParseInt(args[0], 10, 64)
458	if err != nil {
459		return c.usageWith("device id must be a number")
460	}
461	if err := c.Store.RemovePushDevice(c.User.ID, id); err != nil {
462		if errors.Is(err, store.ErrNotFound) {
463			return c.fail(protocol.ExitNotFound, "no such device; notifications device list shows yours")
464		}
465		return c.fail(protocol.ExitFailure, "%v", err)
466	}
467	return c.emit(map[string]string{"status": "removed"}, func(w io.Writer) {
468		fmt.Fprintln(w, "device removed")
469	})
470}
471
472// noticesDefaultLimit caps a bare list; pagination reaches further back.
473const noticesDefaultLimit = 50
474
475func runNotificationsList(c *Ctx, args []string) int {
476	rest, p, code := parsePageFlags(c, args, "notifications", true)
477	if code >= 0 {
478		return code
479	}
480	fl, err := c.parseArgs(rest, flagSpec{Bools: []string{"--all"}, Usage: c.Cmd.Usage})
481	if err != nil {
482		return c.fail(protocol.ExitUsage, "%v", err)
483	}
484	all := fl.Has("--all")
485	if p.limit == 0 {
486		p.limit = noticesDefaultLimit
487	}
488	notices, err := c.Store.Inbox(c.User.ID, !all, p.queryLimit(), p.keyInt())
489	if err != nil {
490		return c.fail(protocol.ExitFailure, "%v", err)
491	}
492	type out struct {
493		ID        int64  `json:"id"`
494		Repo      string `json:"repo"`
495		Kind      string `json:"kind"`
496		Actor     string `json:"actor"`
497		Summary   string `json:"summary"`
498		Path      string `json:"path"`
499		CreatedAt string `json:"created_at"`
500		ReadAt    string `json:"read_at,omitempty"`
501	}
502	notices, next := trimPage(p, notices, "notifications", func(n store.Notice) string {
503		return strconv.FormatInt(n.ID, 10)
504	})
505	ds := make([]out, 0, len(notices))
506	for _, n := range notices {
507		ds = append(ds, out{n.ID, n.RepoPath, n.Kind, n.Actor, n.Summary, n.Path, n.CreatedAt, n.ReadAt})
508	}
509	if !c.JSON && !p.active && len(ds) == 0 {
510		msg := "nothing to list"
511		if !all {
512			if read, err := c.Store.Inbox(c.User.ID, false, 1, 0); err == nil && len(read) > 0 {
513				msg = "no unread notifications (--all for read ones)"
514			}
515		}
516		fmt.Fprintln(c.Stderr, msg)
517		return protocol.ExitOK
518	}
519	return c.emitPageView(p, ds, next, func(w io.Writer) {
520		tb := c.table(w, "ID", "WHEN", "REPO", "EVENT", "PATH")
521		for _, d := range ds {
522			mark := "*"
523			if d.ReadAt != "" {
524				mark = " "
525			}
526			tb.row(cRef(fmt.Sprintf("%s %d", mark, d.ID)), cAge(d.CreatedAt), cRef(d.Repo),
527				cFlex(fmt.Sprintf("%s %s", d.Actor, d.Summary)), cText(d.Path))
528		}
529		tb.flush()
530	}, func() screen {
531		rows := make([]row, len(ds))
532		var read []string
533		for i, d := range ds {
534			lead := cGlyph("")
535			if d.ReadAt == "" {
536				lead = cYou()
537			}
538			rows[i] = rowOf(cRef(d.Repo), lead, cFlex(d.Actor+" "+d.Summary), cMeta(d.Kind, relAge(d.CreatedAt, termNow())))
539			if read == nil && (d.Kind == "issue" || d.Kind == "mr") {
540				if j := strings.LastIndex(d.Path, "/"); j >= 0 {
541					read = []string{d.Kind, "show", d.Repo, d.Path[j+1:]}
542				}
543			}
544		}
545		s := listScreen("Notifications", rows, action{"Inbox", []string{"notifications", "read", "--all"}})
546		if read != nil {
547			s.actions = append(s.actions, action{"Read", read})
548		}
549		return s
550	})
551}
552
553func runNotificationsRead(c *Ctx, args []string) int {
554	fl, err := c.parseArgs(args, flagSpec{Bools: []string{"--all"}, MaxPos: -1, Usage: c.Cmd.Usage})
555	if err != nil {
556		return c.fail(protocol.ExitUsage, "%v", err)
557	}
558	// --all and a list of ids are two ways of saying which rows: taking
559	// both would leave which one won unstated.
560	if fl.Has("--all") == (len(fl.Pos) > 0) {
561		return c.usage()
562	}
563	var ids []int64
564	for _, a := range fl.Pos {
565		n, err := strconv.ParseInt(a, 10, 64)
566		if err != nil {
567			return c.fail(protocol.ExitUsage, "bad notification id %q", a)
568		}
569		ids = append(ids, n)
570	}
571	n, err := c.Store.MarkNoticesRead(c.User.ID, ids)
572	if err != nil {
573		return c.fail(protocol.ExitFailure, "%v", err)
574	}
575	return c.emit(map[string]int64{"read": n}, func(w io.Writer) {
576		fmt.Fprintf(w, "marked %d read\n", n)
577	})
578}
579
580func runRepoWatch(c *Ctx, args []string) int   { return setWatch(c, args, "watch", "watching") }
581func runRepoMute(c *Ctx, args []string) int    { return setWatch(c, args, "mute", "muted") }
582func runRepoUnwatch(c *Ctx, args []string) int { return setWatch(c, args, "unwatch", "default") }
583
584// setWatch records the caller's state on a repository. "default" deletes
585// the row: watch then unwatch leaves no trace, and a mute is undone the
586// same way.
587func setWatch(c *Ctx, args []string, verb, state string) int {
588	if len(args) != 1 {
589		return c.usage()
590	}
591	repo, code := resolveRepo(c, args[0], policy.CanRead)
592	if code >= 0 {
593		return code
594	}
595	var err error
596	if state == "default" {
597		err = c.Store.ClearRepoWatch(repo.ID, c.User.ID)
598	} else {
599		err = c.Store.SetRepoWatch(repo.ID, c.User.ID, state)
600	}
601	if err != nil {
602		return c.fail(protocol.ExitFailure, "%v", err)
603	}
604	return c.emit(map[string]string{"repo": repo.Path(), "state": state}, func(w io.Writer) {
605		fmt.Fprintf(w, "%s %s\n", state, repo.Path())
606	})
607}