Commit ada2c022c4
Verified · cmc
cmd/gitbayd/main.go +4
| @@ -21,6 +21,7 @@ import ( | ||
| 21 | 21 | "gitbay.org/gitbay/internal/config" |
| 22 | 22 | "gitbay.org/gitbay/internal/control" |
| 23 | 23 | "gitbay.org/gitbay/internal/mail" |
| 24 | "gitbay.org/gitbay/internal/notify" | |
| 24 | 25 | "gitbay.org/gitbay/internal/gitd" |
| 25 | 26 | "gitbay.org/gitbay/internal/hookd" |
| 26 | 27 | "gitbay.org/gitbay/internal/httpd" |
| @@ -133,6 +134,9 @@ func serveCmd() *cobra.Command { | ||
| 133 | 134 | whCtx, whCancel := context.WithCancel(context.Background()) |
| 134 | 135 | defer whCancel() |
| 135 | 136 | go webhook.New(st, cfg.Webhooks.AllowLocal, retryBase).Run(whCtx) |
| 137 | if cfg.Mail.SMTPHost != "" { | |
| 138 | go notify.New(st, cfg, retryBase).Run(whCtx) | |
| 139 | } | |
| 136 | 140 | |
| 137 | 141 | errCh := make(chan error, 3) |
| 138 | 142 | if cfg.SSH.Mode == "embedded" { |
docs/users.org +8
| @@ -166,6 +166,14 @@ Semantics worth knowing: | ||
| 166 | 166 | verified commits merge; everything server-created is refused with |
| 167 | 167 | instructions to rebase locally. |
| 168 | 168 | |
| 169 | * Notifications | |
| 170 | ||
| 171 | When the instance has SMTP configured, activity mails you: someone | |
| 172 | opens an issue or MR on your repository, comments where you are a | |
| 173 | participant (author, commenter, reviewer), reviews, closes, or merges. | |
| 174 | You are never mailed about your own actions, and only verified primary | |
| 175 | addresses receive anything. Delivery retries on relay failure. | |
| 176 | ||
| 169 | 177 | * Scripting |
| 170 | 178 | |
| 171 | 179 | Every read command takes =--json= and emits one envelope: |
e2e/notify_test.go added +165
| @@ -0,0 +1,165 @@ | ||
| 1 | package e2e | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "net" | |
| 6 | "os" | |
| 7 | "os/exec" | |
| 8 | "strings" | |
| 9 | "testing" | |
| 10 | "time" | |
| 11 | ) | |
| 12 | ||
| 13 | // mailTo returns captured messages addressed to one recipient. | |
| 14 | func (f *fakeSMTP) mailTo(recipient string) []string { | |
| 15 | f.mu.Lock() | |
| 16 | defer f.mu.Unlock() | |
| 17 | var out []string | |
| 18 | for _, m := range f.mail { | |
| 19 | if strings.Contains(m, "To: "+recipient) { | |
| 20 | out = append(out, m) | |
| 21 | } | |
| 22 | } | |
| 23 | return out | |
| 24 | } | |
| 25 | ||
| 26 | func (f *fakeSMTP) waitFor(t *testing.T, recipient, substr string) string { | |
| 27 | t.Helper() | |
| 28 | deadline := time.Now().Add(15 * time.Second) | |
| 29 | for time.Now().Before(deadline) { | |
| 30 | for _, m := range f.mailTo(recipient) { | |
| 31 | if strings.Contains(m, substr) { | |
| 32 | return m | |
| 33 | } | |
| 34 | } | |
| 35 | time.Sleep(100 * time.Millisecond) | |
| 36 | } | |
| 37 | t.Fatalf("no mail to %s containing %q", recipient, substr) | |
| 38 | return "" | |
| 39 | } | |
| 40 | ||
| 41 | func TestActivityNotifications(t *testing.T) { | |
| 42 | smtp := startFakeSMTP(t) | |
| 43 | inst := startInstanceWith(t, fmt.Sprintf( | |
| 44 | "[mail]\nsmtp_host = %q\nfrom = \"noreply@gitbay.test\"\n", smtp.addr)) | |
| 45 | // Fast retries for the mailer loop. | |
| 46 | inst.proc.Process.Kill() | |
| 47 | inst.proc.Wait() | |
| 48 | inst.proc = exec.Command(inst.gitbayd, "--config", inst.config, "serve") | |
| 49 | inst.proc.Env = append(os.Environ(), "GITBAY_WEBHOOK_RETRY_BASE=500ms") | |
| 50 | inst.proc.Stderr = os.Stderr | |
| 51 | if err := inst.proc.Start(); err != nil { | |
| 52 | t.Fatal(err) | |
| 53 | } | |
| 54 | t.Cleanup(func() { inst.proc.Process.Kill(); inst.proc.Wait() }) | |
| 55 | deadline := time.Now().Add(10 * time.Second) | |
| 56 | for { | |
| 57 | conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", inst.port), 200*time.Millisecond) | |
| 58 | if err == nil { | |
| 59 | conn.Close() | |
| 60 | break | |
| 61 | } | |
| 62 | if time.Now().After(deadline) { | |
| 63 | t.Fatal("daemon did not restart") | |
| 64 | } | |
| 65 | time.Sleep(50 * time.Millisecond) | |
| 66 | } | |
| 67 | ||
| 68 | aliceKey := inst.newKey(t, "alice") | |
| 69 | bobKey := inst.newKey(t, "bob") | |
| 70 | eveKey := inst.newKey(t, "eve") | |
| 71 | inst.admin(t, "admin", "user", "create", "alice", | |
| 72 | "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified") | |
| 73 | inst.admin(t, "admin", "user", "create", "bob", | |
| 74 | "--key", bobKey+".pub", "--email", "bob@example.test", "--verified") | |
| 75 | // eve has NO verified email: she must be skipped silently. | |
| 76 | inst.admin(t, "admin", "user", "create", "eve", | |
| 77 | "--key", eveKey+".pub", "--email", "eve@example.test") | |
| 78 | ||
| 79 | if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 { | |
| 80 | t.Fatalf("repo create: %s", errOut) | |
| 81 | } | |
| 82 | ||
| 83 | // Bob opens an issue: the repo owner (alice) is notified with subject, | |
| 84 | // excerpt, and link; bob (the actor) is not. | |
| 85 | if _, errOut, code := inst.ssh(t, bobKey, "", | |
| 86 | "issue", "create", "alice/app", "--title", "'it leaks'", "--body", "'memory climbs forever'"); code != 0 { | |
| 87 | t.Fatalf("issue create: %s", errOut) | |
| 88 | } | |
| 89 | m := smtp.waitFor(t, "alice@example.test", "#1") | |
| 90 | if !strings.Contains(m, "[alice/app] #1: it leaks") || | |
| 91 | !strings.Contains(m, "bob opened issue #1") || | |
| 92 | !strings.Contains(m, "memory climbs forever") || | |
| 93 | !strings.Contains(m, "/alice/app/issues/1") { | |
| 94 | t.Fatalf("issue-open mail:\n%s", m) | |
| 95 | } | |
| 96 | if n := len(smtp.mailTo("bob@example.test")); n != 0 { | |
| 97 | t.Fatalf("actor notified about own action (%d mails)", n) | |
| 98 | } | |
| 99 | ||
| 100 | // Alice comments: bob (author/participant) is notified; alice is not. | |
| 101 | if _, _, code := inst.ssh(t, aliceKey, "", "issue", "comment", "alice/app", "1", "--message", "'on it'"); code != 0 { | |
| 102 | t.Fatal("comment failed") | |
| 103 | } | |
| 104 | m = smtp.waitFor(t, "bob@example.test", "commented") | |
| 105 | if !strings.Contains(m, "alice commented on #1") || !strings.Contains(m, "on it") { | |
| 106 | t.Fatalf("comment mail:\n%s", m) | |
| 107 | } | |
| 108 | ||
| 109 | // Eve comments (unverified email, still allowed to act): both alice | |
| 110 | // and bob get mail; eve never receives any. | |
| 111 | if _, _, code := inst.ssh(t, eveKey, "", "issue", "comment", "alice/app", "1", "--message", "'same here'"); code != 0 { | |
| 112 | t.Fatal("eve comment failed") | |
| 113 | } | |
| 114 | smtp.waitFor(t, "alice@example.test", "same here") | |
| 115 | smtp.waitFor(t, "bob@example.test", "same here") | |
| 116 | ||
| 117 | // Close notifies participants. | |
| 118 | if _, _, code := inst.ssh(t, aliceKey, "", "issue", "close", "alice/app", "1"); code != 0 { | |
| 119 | t.Fatal("close failed") | |
| 120 | } | |
| 121 | m = smtp.waitFor(t, "bob@example.test", "closed #1") | |
| 122 | if !strings.Contains(m, "alice closed #1") { | |
| 123 | t.Fatalf("close mail:\n%s", m) | |
| 124 | } | |
| 125 | if n := len(smtp.mailTo("eve@example.test")); n != 0 { | |
| 126 | t.Fatalf("unverified recipient got mail (%d)", n) | |
| 127 | } | |
| 128 | ||
| 129 | // MR flow: bob opens (alice notified), alice reviews (bob notified), | |
| 130 | // alice merges (bob notified). | |
| 131 | if _, _, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/app", "bob", "write"); code != 0 { | |
| 132 | t.Fatal("grant failed") | |
| 133 | } | |
| 134 | work := t.TempDir() | |
| 135 | bobEnv := inst.gitEnv(bobKey) | |
| 136 | mustGit(t, work, bobEnv, "clone", inst.sshURL("alice/app"), "w") | |
| 137 | dir := work + "/w" | |
| 138 | os.WriteFile(dir+"/f.txt", []byte("x\n"), 0o644) | |
| 139 | mustGit(t, dir, bobEnv, "checkout", "-q", "-b", "main") | |
| 140 | mustGit(t, dir, bobEnv, "add", ".") | |
| 141 | mustGit(t, dir, bobEnv, "commit", "-q", "-m", "base") | |
| 142 | mustGit(t, dir, bobEnv, "push", "-q", "origin", "main") | |
| 143 | mustGit(t, dir, bobEnv, "checkout", "-q", "-b", "feat") | |
| 144 | mustGit(t, dir, bobEnv, "commit", "-q", "--allow-empty", "-m", "work") | |
| 145 | mustGit(t, dir, bobEnv, "push", "-q", "origin", "feat") | |
| 146 | if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/app", | |
| 147 | "--source", "feat", "--target", "main", "--title", "'ship it'"); code != 0 { | |
| 148 | t.Fatalf("mr create: %s", errOut) | |
| 149 | } | |
| 150 | m = smtp.waitFor(t, "alice@example.test", "!1") | |
| 151 | if !strings.Contains(m, "bob opened merge request !1") { | |
| 152 | t.Fatalf("mr-open mail:\n%s", m) | |
| 153 | } | |
| 154 | if _, _, code := inst.ssh(t, aliceKey, "", "mr", "review", "alice/app", "1", "--approve"); code != 0 { | |
| 155 | t.Fatal("review failed") | |
| 156 | } | |
| 157 | smtp.waitFor(t, "bob@example.test", "reviewed !1: approve") | |
| 158 | if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "merge", "alice/app", "1"); code != 0 { | |
| 159 | t.Fatalf("merge: %s", errOut) | |
| 160 | } | |
| 161 | m = smtp.waitFor(t, "bob@example.test", "merged !1") | |
| 162 | if !strings.Contains(m, "alice merged !1 into main") { | |
| 163 | t.Fatalf("merge mail:\n%s", m) | |
| 164 | } | |
| 165 | } | |
internal/control/issue.go +13
| @@ -139,6 +139,10 @@ func runIssueCreate(c *Ctx, args []string) int { | ||
| 139 | 139 | return c.fail(protocol.ExitFailure, "%v", err) |
| 140 | 140 | } |
| 141 | 141 | c.Store.RecordEvent(repo.ID, c.User.ID, "issue.created", fmt.Sprintf(`{"number":%d}`, n)) |
| 142 | if targets, err := c.Store.RepoNotifyTargets(repo); err == nil { | |
| 143 | notifyUsers(c, targets, issueSubject(repo, n, title), | |
| 144 | notifyBody(c, fmt.Sprintf("opened issue #%d", n), b, fmt.Sprintf("%s/issues/%d", repo.Path(), n))) | |
| 145 | } | |
| 142 | 146 | return c.emit(map[string]any{"number": n}, func(w io.Writer) { |
| 143 | 147 | fmt.Fprintf(w, "created %s#%d\n", repo.Path(), n) |
| 144 | 148 | }) |
| @@ -263,6 +267,10 @@ func runIssueComment(c *Ctx, args []string) int { | ||
| 263 | 267 | return c.fail(protocol.ExitFailure, "%v", err) |
| 264 | 268 | } |
| 265 | 269 | c.Store.RecordEvent(repo.ID, c.User.ID, "issue.commented", fmt.Sprintf(`{"number":%d}`, issue.Number)) |
| 270 | if parts, err := c.Store.IssueParticipants(issue.ID); err == nil { | |
| 271 | notifyUsers(c, parts, issueSubject(repo, issue.Number, issue.Title), | |
| 272 | notifyBody(c, fmt.Sprintf("commented on #%d", issue.Number), body, fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number))) | |
| 273 | } | |
| 266 | 274 | return c.emit(map[string]any{"number": issue.Number}, func(w io.Writer) { |
| 267 | 275 | fmt.Fprintf(w, "commented on %s#%d\n", repo.Path(), issue.Number) |
| 268 | 276 | }) |
| @@ -292,6 +300,11 @@ func setIssueState(c *Ctx, args []string, state string) int { | ||
| 292 | 300 | return c.fail(protocol.ExitFailure, "%v", err) |
| 293 | 301 | } |
| 294 | 302 | c.Store.RecordEvent(repo.ID, c.User.ID, "issue."+state, fmt.Sprintf(`{"number":%d}`, issue.Number)) |
| 303 | if parts, err := c.Store.IssueParticipants(issue.ID); err == nil { | |
| 304 | verb := map[string]string{"open": "reopened", "closed": "closed"}[state] | |
| 305 | notifyUsers(c, parts, issueSubject(repo, issue.Number, issue.Title), | |
| 306 | notifyBody(c, fmt.Sprintf("%s #%d", verb, issue.Number), "", fmt.Sprintf("%s/issues/%d", repo.Path(), issue.Number))) | |
| 307 | } | |
| 295 | 308 | return c.emit(map[string]any{"number": issue.Number, "state": state}, func(w io.Writer) { |
| 296 | 309 | fmt.Fprintf(w, "%s#%d is now %s\n", repo.Path(), issue.Number, state) |
| 297 | 310 | }) |
internal/control/mr.go +16
| @@ -233,6 +233,10 @@ func runMRCreate(c *Ctx, args []string) int { | ||
| 233 | 233 | return c.fail(protocol.ExitFailure, "recording MR head: %v", err) |
| 234 | 234 | } |
| 235 | 235 | c.Store.RecordEvent(repo.ID, c.User.ID, "mr.created", fmt.Sprintf(`{"number":%d}`, n)) |
| 236 | if targets, err := c.Store.RepoNotifyTargets(repo); err == nil { | |
| 237 | notifyUsers(c, targets, mrSubject(repo, n, title), | |
| 238 | notifyBody(c, fmt.Sprintf("opened merge request !%d (%s -> %s)", n, source, target), b, fmt.Sprintf("%s/mrs/%d", repo.Path(), n))) | |
| 239 | } | |
| 236 | 240 | return c.emit(map[string]any{"number": n, "head_sha": headSHA}, func(w io.Writer) { |
| 237 | 241 | fmt.Fprintf(w, "created %s!%d (%s -> %s)\n", repo.Path(), n, source, target) |
| 238 | 242 | }) |
| @@ -441,6 +445,10 @@ func runMRComment(c *Ctx, args []string) int { | ||
| 441 | 445 | if err := c.Store.AddMRComment(mr.ID, c.User.ID, body); err != nil { |
| 442 | 446 | return c.fail(protocol.ExitFailure, "%v", err) |
| 443 | 447 | } |
| 448 | if parts, err := c.Store.MRParticipants(mr.ID); err == nil { | |
| 449 | notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title), | |
| 450 | notifyBody(c, fmt.Sprintf("commented on !%d", mr.Number), body, fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number))) | |
| 451 | } | |
| 444 | 452 | return c.emit(map[string]any{"number": mr.Number}, func(w io.Writer) { |
| 445 | 453 | fmt.Fprintf(w, "commented on %s!%d\n", repo.Path(), mr.Number) |
| 446 | 454 | }) |
| @@ -474,6 +482,10 @@ func runMRReview(c *Ctx, args []string) int { | ||
| 474 | 482 | if err := c.Store.AddMRReview(mr.ID, c.User.ID, verdict, mr.HeadSHA); err != nil { |
| 475 | 483 | return c.fail(protocol.ExitFailure, "%v", err) |
| 476 | 484 | } |
| 485 | if parts, err := c.Store.MRParticipants(mr.ID); err == nil { | |
| 486 | notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title), | |
| 487 | notifyBody(c, fmt.Sprintf("reviewed !%d: %s", mr.Number, verdict), "", fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number))) | |
| 488 | } | |
| 477 | 489 | return c.emit(map[string]any{"number": mr.Number, "verdict": verdict}, func(w io.Writer) { |
| 478 | 490 | fmt.Fprintf(w, "reviewed %s!%d: %s\n", repo.Path(), mr.Number, verdict) |
| 479 | 491 | }) |
| @@ -735,6 +747,10 @@ func runMRMerge(c *Ctx, args []string) int { | ||
| 735 | 747 | return c.fail(protocol.ExitFailure, "%v", err) |
| 736 | 748 | } |
| 737 | 749 | c.Store.RecordEvent(repo.ID, c.User.ID, "mr.merged", fmt.Sprintf(`{"number":%d,"sha":%q}`, mr.Number, newSHA)) |
| 750 | if parts, err := c.Store.MRParticipants(mr.ID); err == nil { | |
| 751 | notifyUsers(c, parts, mrSubject(repo, mr.Number, mr.Title), | |
| 752 | notifyBody(c, fmt.Sprintf("merged !%d into %s (%s)", mr.Number, mr.TargetRef, strategy), "", fmt.Sprintf("%s/mrs/%d", repo.Path(), mr.Number))) | |
| 753 | } | |
| 738 | 754 | return c.emit(map[string]any{"number": mr.Number, "strategy": strategy, "sha": newSHA}, func(w io.Writer) { |
| 739 | 755 | fmt.Fprintf(w, "merged %s!%d into %s (%s) at %.10s\n", repo.Path(), mr.Number, mr.TargetRef, strategy, newSHA) |
| 740 | 756 | }) |
internal/control/notifications.go added +53
| @@ -0,0 +1,53 @@ | ||
| 1 | package control | |
| 2 | ||
| 3 | import ( | |
| 4 | "fmt" | |
| 5 | "strings" | |
| 6 | ||
| 7 | "gitbay.org/gitbay/internal/store" | |
| 8 | ) | |
| 9 | ||
| 10 | // notifyUsers enqueues activity mail for the given user ids, excluding the | |
| 11 | // acting user and anyone without a verified primary email. A best-effort | |
| 12 | // side channel: failures are ignored, the action itself already succeeded. | |
| 13 | // No-op when the instance has no SMTP. | |
| 14 | func notifyUsers(c *Ctx, userIDs []int64, subject, body string) { | |
| 15 | if c.Cfg.Mail.SMTPHost == "" { | |
| 16 | return | |
| 17 | } | |
| 18 | seen := map[int64]bool{c.User.ID: true} | |
| 19 | for _, id := range userIDs { | |
| 20 | if seen[id] { | |
| 21 | continue | |
| 22 | } | |
| 23 | seen[id] = true | |
| 24 | email, err := c.Store.PrimaryVerifiedEmail(id) | |
| 25 | if err != nil || email == "" { | |
| 26 | continue | |
| 27 | } | |
| 28 | c.Store.EnqueueMail(email, subject, body) | |
| 29 | } | |
| 30 | } | |
| 31 | ||
| 32 | // notifyBody builds the standard notification body: who did what, an | |
| 33 | // excerpt, and the web link. | |
| 34 | func notifyBody(c *Ctx, action, excerpt, path string) string { | |
| 35 | var b strings.Builder | |
| 36 | fmt.Fprintf(&b, "%s %s\n", c.User.Username, action) | |
| 37 | if e := strings.TrimSpace(excerpt); e != "" { | |
| 38 | if len(e) > 500 { | |
| 39 | e = e[:500] + "…" | |
| 40 | } | |
| 41 | fmt.Fprintf(&b, "\n%s\n", e) | |
| 42 | } | |
| 43 | fmt.Fprintf(&b, "\n%s/%s\n", strings.TrimSuffix(c.Cfg.Server.SiteURL, "/"), path) | |
| 44 | return b.String() | |
| 45 | } | |
| 46 | ||
| 47 | func issueSubject(repo store.Repo, number int64, title string) string { | |
| 48 | return fmt.Sprintf("[%s] #%d: %s", repo.Path(), number, title) | |
| 49 | } | |
| 50 | ||
| 51 | func mrSubject(repo store.Repo, number int64, title string) string { | |
| 52 | return fmt.Sprintf("[%s] !%d: %s", repo.Path(), number, title) | |
| 53 | } | |
internal/notify/notify.go added +57
| @@ -0,0 +1,57 @@ | ||
| 1 | // Package notify drains the notification queue: activity mail with the same | |
| 2 | // bounded-retry discipline as webhook delivery, so a flaky relay delays | |
| 3 | // feedback instead of losing it. | |
| 4 | package notify | |
| 5 | ||
| 6 | import ( | |
| 7 | "context" | |
| 8 | "log/slog" | |
| 9 | "time" | |
| 10 | ||
| 11 | "gitbay.org/gitbay/internal/config" | |
| 12 | "gitbay.org/gitbay/internal/mail" | |
| 13 | "gitbay.org/gitbay/internal/store" | |
| 14 | ) | |
| 15 | ||
| 16 | type Mailer struct { | |
| 17 | St *store.Store | |
| 18 | Cfg config.Config | |
| 19 | RetryBase time.Duration | |
| 20 | MaxAttempts int | |
| 21 | } | |
| 22 | ||
| 23 | func New(st *store.Store, cfg config.Config, retryBase time.Duration) *Mailer { | |
| 24 | return &Mailer{St: st, Cfg: cfg, RetryBase: retryBase, MaxAttempts: 5} | |
| 25 | } | |
| 26 | ||
| 27 | // Run polls for due mail until ctx is done. | |
| 28 | func (m *Mailer) Run(ctx context.Context) { | |
| 29 | tick := time.NewTicker(2 * time.Second) | |
| 30 | defer tick.Stop() | |
| 31 | for { | |
| 32 | select { | |
| 33 | case <-ctx.Done(): | |
| 34 | return | |
| 35 | case <-tick.C: | |
| 36 | due, err := m.St.DueMail(20) | |
| 37 | if err != nil { | |
| 38 | slog.Error("notify: listing due mail", "err", err) | |
| 39 | continue | |
| 40 | } | |
| 41 | for _, q := range due { | |
| 42 | if err := mail.Send(m.Cfg, q.Recipient, q.Subject, q.Body); err != nil { | |
| 43 | attempt := q.Attempts + 1 | |
| 44 | if attempt >= m.MaxAttempts { | |
| 45 | m.St.MarkMailFailed(q.ID, err.Error(), nil) | |
| 46 | slog.Warn("notification dead-lettered", "recipient", q.Recipient, "err", err) | |
| 47 | } else { | |
| 48 | next := time.Now().Add(m.RetryBase << (attempt - 1)) | |
| 49 | m.St.MarkMailFailed(q.ID, err.Error(), &next) | |
| 50 | } | |
| 51 | continue | |
| 52 | } | |
| 53 | m.St.MarkMailSent(q.ID) | |
| 54 | } | |
| 55 | } | |
| 56 | } | |
| 57 | } | |
internal/store/migrations/0009_notifications.down.sql added +1
| @@ -0,0 +1 @@ | ||
| 1 | DROP TABLE notifications; | |
internal/store/migrations/0009_notifications.up.sql added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | CREATE TABLE notifications ( | |
| 2 | id INTEGER PRIMARY KEY, | |
| 3 | recipient TEXT NOT NULL, | |
| 4 | subject TEXT NOT NULL, | |
| 5 | body TEXT NOT NULL, | |
| 6 | attempts INTEGER NOT NULL DEFAULT 0, | |
| 7 | next_attempt_at TEXT, | |
| 8 | sent_at TEXT, | |
| 9 | failed_at TEXT, | |
| 10 | last_error TEXT, | |
| 11 | created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) | |
| 12 | ); | |
| 13 | CREATE INDEX notifications_due ON notifications(next_attempt_at) | |
| 14 | WHERE sent_at IS NULL AND failed_at IS NULL; | |
internal/store/notify.go added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | package store | |
| 2 | ||
| 3 | import "time" | |
| 4 | ||
| 5 | type QueuedMail struct { | |
| 6 | ID int64 | |
| 7 | Recipient string | |
| 8 | Subject string | |
| 9 | Body string | |
| 10 | Attempts int | |
| 11 | } | |
| 12 | ||
| 13 | func (s *Store) EnqueueMail(recipient, subject, body string) error { | |
| 14 | _, err := s.DB.Exec( | |
| 15 | "INSERT INTO notifications (recipient, subject, body) VALUES (?, ?, ?)", | |
| 16 | recipient, subject, body) | |
| 17 | return err | |
| 18 | } | |
| 19 | ||
| 20 | func (s *Store) DueMail(limit int) ([]QueuedMail, error) { | |
| 21 | rows, err := s.DB.Query(` | |
| 22 | SELECT id, recipient, subject, body, attempts FROM notifications | |
| 23 | WHERE sent_at IS NULL AND failed_at IS NULL | |
| 24 | AND (next_attempt_at IS NULL OR next_attempt_at <= ?) | |
| 25 | ORDER BY id LIMIT ?`, fmtTime(time.Now()), limit) | |
| 26 | if err != nil { | |
| 27 | return nil, err | |
| 28 | } | |
| 29 | defer rows.Close() | |
| 30 | var out []QueuedMail | |
| 31 | for rows.Next() { | |
| 32 | var m QueuedMail | |
| 33 | if err := rows.Scan(&m.ID, &m.Recipient, &m.Subject, &m.Body, &m.Attempts); err != nil { | |
| 34 | return nil, err | |
| 35 | } | |
| 36 | out = append(out, m) | |
| 37 | } | |
| 38 | return out, rows.Err() | |
| 39 | } | |
| 40 | ||
| 41 | func (s *Store) MarkMailSent(id int64) error { | |
| 42 | _, err := s.DB.Exec( | |
| 43 | "UPDATE notifications SET sent_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), attempts = attempts + 1 WHERE id = ?", id) | |
| 44 | return err | |
| 45 | } | |
| 46 | ||
| 47 | func (s *Store) MarkMailFailed(id int64, errMsg string, nextAt *time.Time) error { | |
| 48 | if nextAt == nil { | |
| 49 | _, err := s.DB.Exec( | |
| 50 | "UPDATE notifications SET failed_at = strftime('%Y-%m-%dT%H:%M:%fZ','now'), attempts = attempts + 1, last_error = ? WHERE id = ?", | |
| 51 | errMsg, id) | |
| 52 | return err | |
| 53 | } | |
| 54 | _, err := s.DB.Exec( | |
| 55 | "UPDATE notifications SET attempts = attempts + 1, last_error = ?, next_attempt_at = ? WHERE id = ?", | |
| 56 | errMsg, fmtTime(*nextAt), id) | |
| 57 | return err | |
| 58 | } | |
| 59 | ||
| 60 | // IssueParticipants returns distinct user ids involved in an issue: the | |
| 61 | // author and every commenter. | |
| 62 | func (s *Store) IssueParticipants(issueID int64) ([]int64, error) { | |
| 63 | return s.idQuery(` | |
| 64 | SELECT author_id FROM issues WHERE id = ? | |
| 65 | UNION SELECT author_id FROM issue_comments WHERE issue_id = ?`, issueID, issueID) | |
| 66 | } | |
| 67 | ||
| 68 | // MRParticipants returns distinct user ids involved in an MR: author, | |
| 69 | // commenters, reviewers. | |
| 70 | func (s *Store) MRParticipants(mrID int64) ([]int64, error) { | |
| 71 | return s.idQuery(` | |
| 72 | SELECT author_id FROM merge_requests WHERE id = ? | |
| 73 | UNION SELECT author_id FROM mr_comments WHERE mr_id = ? | |
| 74 | UNION SELECT reviewer_id FROM mr_reviews WHERE mr_id = ?`, mrID, mrID, mrID) | |
| 75 | } | |
| 76 | ||
| 77 | // RepoNotifyTargets returns who should hear about new activity on a repo: | |
| 78 | // the owning user, or every admin of the owning org. | |
| 79 | func (s *Store) RepoNotifyTargets(repo Repo) ([]int64, error) { | |
| 80 | if repo.OwnerKind == "user" { | |
| 81 | return []int64{repo.OwnerID}, nil | |
| 82 | } | |
| 83 | return s.idQuery( | |
| 84 | "SELECT user_id FROM org_members WHERE org_id = ? AND role = 'admin'", repo.OwnerID) | |
| 85 | } | |
| 86 | ||
| 87 | func (s *Store) idQuery(q string, args ...any) ([]int64, error) { | |
| 88 | rows, err := s.DB.Query(q, args...) | |
| 89 | if err != nil { | |
| 90 | return nil, err | |
| 91 | } | |
| 92 | defer rows.Close() | |
| 93 | var out []int64 | |
| 94 | for rows.Next() { | |
| 95 | var id int64 | |
| 96 | if err := rows.Scan(&id); err != nil { | |
| 97 | return nil, err | |
| 98 | } | |
| 99 | out = append(out, id) | |
| 100 | } | |
| 101 | return out, rows.Err() | |
| 102 | } | |