appview: introduce email notifications for @ mentions on issue/pr comments #393

closed
opened by boltless.me targeting master from boltless.me/core: feat/mentions

Stacked on top of #392

Yes, I know we have stacked PRs, but I want to explicitly separate two sets of commits and review both on different places

This is MVC implementation of email notification.

Still lot of parts are missing, but this is a PR with most basic features.

Changed files
+295 -42
appview
+7 -1
appview/pages/markup/markdown.go
··· 45 Sanitizer Sanitizer 46 } 47 48 - func (rctx *RenderContext) RenderMarkdown(source string) string { 49 md := goldmark.New( 50 goldmark.WithExtensions( 51 extension.GFM, ··· 59 extension.NewFootnote( 60 extension.WithFootnoteIDPrefix([]byte("footnote")), 61 ), 62 ), 63 goldmark.WithParserOptions( 64 parser.WithAutoHeadingID(), 65 ), 66 goldmark.WithRendererOptions(html.WithUnsafe()), 67 ) 68 69 if rctx != nil { 70 var transformers []util.PrioritizedValue
··· 45 Sanitizer Sanitizer 46 } 47 48 + func NewMarkdown() goldmark.Markdown { 49 md := goldmark.New( 50 goldmark.WithExtensions( 51 extension.GFM, ··· 59 extension.NewFootnote( 60 extension.WithFootnoteIDPrefix([]byte("footnote")), 61 ), 62 + AtExt, 63 ), 64 goldmark.WithParserOptions( 65 parser.WithAutoHeadingID(), 66 ), 67 goldmark.WithRendererOptions(html.WithUnsafe()), 68 ) 69 + return md 70 + } 71 + 72 + func (rctx *RenderContext) RenderMarkdown(source string) string { 73 + md := NewMarkdown() 74 75 if rctx != nil { 76 var transformers []util.PrioritizedValue
+134
appview/pages/markup/markdown_at_extension.go
···
··· 1 + // heavily inspired by: https://github.com/kaleocheng/goldmark-extensions 2 + 3 + package markup 4 + 5 + import ( 6 + "regexp" 7 + 8 + "github.com/yuin/goldmark" 9 + "github.com/yuin/goldmark/ast" 10 + "github.com/yuin/goldmark/parser" 11 + "github.com/yuin/goldmark/renderer" 12 + "github.com/yuin/goldmark/renderer/html" 13 + "github.com/yuin/goldmark/text" 14 + "github.com/yuin/goldmark/util" 15 + ) 16 + 17 + // An AtNode struct represents an AtNode 18 + type AtNode struct { 19 + handle string 20 + ast.BaseInline 21 + } 22 + 23 + var _ ast.Node = &AtNode{} 24 + 25 + // Dump implements Node.Dump. 26 + func (n *AtNode) Dump(source []byte, level int) { 27 + ast.DumpHelper(n, source, level, nil, nil) 28 + } 29 + 30 + // KindAt is a NodeKind of the At node. 31 + var KindAt = ast.NewNodeKind("At") 32 + 33 + // Kind implements Node.Kind. 34 + func (n *AtNode) Kind() ast.NodeKind { 35 + return KindAt 36 + } 37 + 38 + var atRegexp = regexp.MustCompile(`(^|\s|\()(@)([a-zA-Z0-9.-]+)(\b)`) 39 + 40 + type atParser struct{} 41 + 42 + // NewAtParser return a new InlineParser that parses 43 + // at expressions. 44 + func NewAtParser() parser.InlineParser { 45 + return &atParser{} 46 + } 47 + 48 + func (s *atParser) Trigger() []byte { 49 + return []byte{'@'} 50 + } 51 + 52 + func (s *atParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node { 53 + line, segment := block.PeekLine() 54 + m := atRegexp.FindSubmatchIndex(line) 55 + if m == nil { 56 + return nil 57 + } 58 + block.Advance(m[1]) 59 + node := &AtNode{} 60 + node.AppendChild(node, ast.NewTextSegment(text.NewSegment(segment.Start, segment.Start+m[1]))) 61 + node.handle = string(node.Text(block.Source())[1:]) 62 + return node 63 + } 64 + 65 + // atHtmlRenderer is a renderer.NodeRenderer implementation that 66 + // renders At nodes. 67 + type atHtmlRenderer struct { 68 + html.Config 69 + } 70 + 71 + // NewAtHTMLRenderer returns a new AtHTMLRenderer. 72 + func NewAtHTMLRenderer(opts ...html.Option) renderer.NodeRenderer { 73 + r := &atHtmlRenderer{ 74 + Config: html.NewConfig(), 75 + } 76 + for _, opt := range opts { 77 + opt.SetHTMLOption(&r.Config) 78 + } 79 + return r 80 + } 81 + 82 + // RegisterFuncs implements renderer.NodeRenderer.RegisterFuncs. 83 + func (r *atHtmlRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) { 84 + reg.Register(KindAt, r.renderAt) 85 + } 86 + 87 + func (r *atHtmlRenderer) renderAt(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { 88 + if entering { 89 + w.WriteString(`<a href="/@`) 90 + w.WriteString(n.(*AtNode).handle) 91 + w.WriteString(`" class="text-red-500">`) 92 + } else { 93 + w.WriteString("</a>") 94 + } 95 + return ast.WalkContinue, nil 96 + } 97 + 98 + type atExt struct{} 99 + 100 + // At is an extension that allow you to use at expression like '@user.bsky.social' . 101 + var AtExt = &atExt{} 102 + 103 + func (e *atExt) Extend(m goldmark.Markdown) { 104 + m.Parser().AddOptions(parser.WithInlineParsers( 105 + util.Prioritized(NewAtParser(), 500), 106 + )) 107 + m.Renderer().AddOptions(renderer.WithNodeRenderers( 108 + util.Prioritized(NewAtHTMLRenderer(), 500), 109 + )) 110 + } 111 + 112 + // FindUserMentions returns Set of user handles from given markup soruce. 113 + // It doesn't guarntee unique DIDs 114 + func FindUserMentions(source string) []string { 115 + var ( 116 + mentions []string 117 + mentionsSet = make(map[string]struct{}) 118 + md = NewMarkdown() 119 + sourceBytes = []byte(source) 120 + root = md.Parser().Parse(text.NewReader(sourceBytes)) 121 + ) 122 + ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 123 + if entering && n.Kind() == KindAt { 124 + handle := n.(*AtNode).handle 125 + mentionsSet[handle] = struct{}{} 126 + return ast.WalkSkipChildren, nil 127 + } 128 + return ast.WalkContinue, nil 129 + }) 130 + for handle := range mentionsSet { 131 + mentions = append(mentions, handle) 132 + } 133 + return mentions 134 + }
+18 -16
appview/issues/issues.go
··· 260 return 261 } 262 263 - commentId := mathrand.IntN(1000000) 264 - rkey := tid.TID() 265 - 266 - err := db.NewIssueComment(rp.db, &db.Comment{ 267 OwnerDid: user.Did, 268 RepoAt: f.RepoAt(), 269 Issue: issueIdInt, 270 - CommentId: commentId, 271 Body: body, 272 - Rkey: rkey, 273 - }) 274 if err != nil { 275 log.Println("failed to create comment", err) 276 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") ··· 278 } 279 280 createdAt := time.Now().Format(time.RFC3339) 281 - commentIdInt64 := int64(commentId) 282 - ownerDid := user.Did 283 - issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt) 284 if err != nil { 285 - log.Println("failed to get issue at", err) 286 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 287 return 288 } 289 290 - atUri := f.RepoAt().String() 291 client, err := rp.oauth.AuthorizedClient(r) 292 if err != nil { 293 log.Println("failed to get authorized client", err) ··· 297 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 298 Collection: tangled.RepoIssueCommentNSID, 299 Repo: user.Did, 300 - Rkey: rkey, 301 Record: &lexutil.LexiconTypeDecoder{ 302 Val: &tangled.RepoIssueComment{ 303 Repo: &atUri, 304 - Issue: issueAt, 305 CommentId: &commentIdInt64, 306 - Owner: &ownerDid, 307 Body: body, 308 CreatedAt: createdAt, 309 }, ··· 315 return 316 } 317 318 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId)) 319 return 320 } 321 }
··· 260 return 261 } 262 263 + comment := &db.Comment{ 264 OwnerDid: user.Did, 265 RepoAt: f.RepoAt(), 266 Issue: issueIdInt, 267 + CommentId: mathrand.IntN(1000000), 268 Body: body, 269 + Rkey: tid.TID(), 270 + } 271 + 272 + err := db.NewIssueComment(rp.db, comment) 273 if err != nil { 274 log.Println("failed to create comment", err) 275 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") ··· 277 } 278 279 createdAt := time.Now().Format(time.RFC3339) 280 + commentIdInt64 := int64(comment.CommentId) 281 + issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 282 if err != nil { 283 + log.Println("failed to get issue", err) 284 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 285 return 286 } 287 288 + atUri := comment.RepoAt.String() 289 client, err := rp.oauth.AuthorizedClient(r) 290 if err != nil { 291 log.Println("failed to get authorized client", err) ··· 295 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{ 296 Collection: tangled.RepoIssueCommentNSID, 297 Repo: user.Did, 298 + Rkey: comment.Rkey, 299 Record: &lexutil.LexiconTypeDecoder{ 300 Val: &tangled.RepoIssueComment{ 301 Repo: &atUri, 302 + Issue: issue.AtUri().String(), 303 CommentId: &commentIdInt64, 304 + Owner: &comment.OwnerDid, 305 Body: body, 306 CreatedAt: createdAt, 307 }, ··· 313 return 314 } 315 316 + mentions := markup.FindUserMentions(comment.Body) 317 + 318 + rp.notifier.NewIssueComment(r.Context(), &f.Repo, issue, comment, mentions) 319 + 320 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), comment.Issue, comment.CommentId)) 321 return 322 } 323 }
+3 -8
appview/email/email.go
··· 1 package email 2 3 import ( 4 - "fmt" 5 "net" 6 "regexp" 7 "strings" ··· 11 12 type Email struct { 13 From string 14 - To string 15 Subject string 16 Text string 17 Html string 18 APIKey string 19 } 20 21 - func SendEmail(email Email) error { 22 client := resend.NewClient(email.APIKey) 23 _, err := client.Emails.Send(&resend.SendEmailRequest{ 24 From: email.From, 25 - To: []string{email.To}, 26 Subject: email.Subject, 27 Text: email.Text, 28 Html: email.Html, 29 }) 30 - if err != nil { 31 - return fmt.Errorf("error sending email: %w", err) 32 - } 33 - return nil 34 } 35 36 func IsValidEmail(email string) bool {
··· 1 package email 2 3 import ( 4 "net" 5 "regexp" 6 "strings" ··· 10 11 type Email struct { 12 From string 13 Subject string 14 Text string 15 Html string 16 APIKey string 17 } 18 19 + func SendEmail(email Email, recipients ...string) error { 20 client := resend.NewClient(email.APIKey) 21 _, err := client.Emails.Send(&resend.SendEmailRequest{ 22 From: email.From, 23 + To: recipients, 24 Subject: email.Subject, 25 Text: email.Text, 26 Html: email.Html, 27 }) 28 + return err 29 } 30 31 func IsValidEmail(email string) bool {
+1 -2
appview/signup/signup.go
··· 149 em := email.Email{ 150 APIKey: s.config.Resend.ApiKey, 151 From: s.config.Resend.SentFrom, 152 - To: emailId, 153 Subject: "Verify your Tangled account", 154 Text: `Copy and paste this code below to verify your account on Tangled. 155 ` + code, ··· 157 <p><code>` + code + `</code></p>`, 158 } 159 160 - err = email.SendEmail(em) 161 if err != nil { 162 s.l.Error("failed to send email", "error", err) 163 s.pages.Notice(w, noticeId, "Failed to send email.")
··· 149 em := email.Email{ 150 APIKey: s.config.Resend.ApiKey, 151 From: s.config.Resend.SentFrom, 152 Subject: "Verify your Tangled account", 153 Text: `Copy and paste this code below to verify your account on Tangled. 154 ` + code, ··· 156 <p><code>` + code + `</code></p>`, 157 } 158 159 + err = email.SendEmail(em, emailId) 160 if err != nil { 161 s.l.Error("failed to send email", "error", err) 162 s.pages.Notice(w, noticeId, "Failed to send email.")
+2
appview/state/state.go
··· 23 "tangled.sh/tangled.sh/core/appview/cache/session" 24 "tangled.sh/tangled.sh/core/appview/config" 25 "tangled.sh/tangled.sh/core/appview/db" 26 "tangled.sh/tangled.sh/core/appview/notify" 27 "tangled.sh/tangled.sh/core/appview/oauth" 28 "tangled.sh/tangled.sh/core/appview/pages" ··· 138 spindlestream.Start(ctx) 139 140 var notifiers []notify.Notifier 141 if !config.Core.Dev { 142 notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) 143 }
··· 23 "tangled.sh/tangled.sh/core/appview/cache/session" 24 "tangled.sh/tangled.sh/core/appview/config" 25 "tangled.sh/tangled.sh/core/appview/db" 26 + "tangled.sh/tangled.sh/core/appview/email" 27 "tangled.sh/tangled.sh/core/appview/notify" 28 "tangled.sh/tangled.sh/core/appview/oauth" 29 "tangled.sh/tangled.sh/core/appview/pages" ··· 139 spindlestream.Start(ctx) 140 141 var notifiers []notify.Notifier 142 + notifiers = append(notifiers, email.NewEmailNotifier(d, res, config)) 143 if !config.Core.Dev { 144 notifiers = append(notifiers, posthogService.NewPosthogNotifier(posthog)) 145 }
+7
appview/db/db.go
··· 703 return err 704 }) 705 706 return &DB{db}, nil 707 } 708
··· 703 return err 704 }) 705 706 + runMigration(conn, "add-email-notif-preference-to-profile", func(tx *sql.Tx) error { 707 + _, err := tx.Exec(` 708 + alter table profile add column email_notif_preference integer not null default 0 check (email_notif_preference in (0, 1, 2)); -- disable, metion, enable 709 + `) 710 + return err 711 + }) 712 + 713 return &DB{db}, nil 714 } 715
+22
appview/db/email.go
··· 299 _, err := e.Exec(query, code, did, email) 300 return err 301 }
··· 299 _, err := e.Exec(query, code, did, email) 300 return err 301 } 302 + 303 + func GetUserEmailPreference(e Execer, did string) (EmailPreference, error) { 304 + var preference EmailPreference 305 + err := e.QueryRow(` 306 + select email_notif_preference 307 + from profile 308 + where did = ? 309 + `, did).Scan(&preference) 310 + if err != nil { 311 + return preference, err 312 + } 313 + return preference, nil 314 + } 315 + 316 + func UpdateSettingsEmailPreference(e Execer, did string, preference EmailPreference) error { 317 + _, err := e.Exec(` 318 + update profile 319 + set email_notif_preference = ? 320 + where did = ? 321 + `, preference, did) 322 + return err 323 + }
+32 -6
appview/db/profile.go
··· 183 Links [5]string 184 Stats [2]VanityStat 185 PinnedRepos [6]syntax.ATURI 186 } 187 188 func (p Profile) IsLinksEmpty() bool { ··· 280 did, 281 description, 282 include_bluesky, 283 - location 284 ) 285 - values (?, ?, ?, ?)`, 286 profile.Did, 287 profile.Description, 288 includeBskyValue, 289 profile.Location, 290 ) 291 292 if err != nil { ··· 367 did, 368 description, 369 include_bluesky, 370 - location 371 from 372 profile 373 %s`, ··· 383 var profile Profile 384 var includeBluesky int 385 386 - err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location) 387 if err != nil { 388 return nil, err 389 } ··· 457 458 includeBluesky := 0 459 err := e.QueryRow( 460 - `select description, include_bluesky, location from profile where did = ?`, 461 did, 462 - ).Scan(&profile.Description, &includeBluesky, &profile.Location) 463 if err == sql.ErrNoRows { 464 profile := Profile{} 465 profile.Did = did
··· 183 Links [5]string 184 Stats [2]VanityStat 185 PinnedRepos [6]syntax.ATURI 186 + 187 + // settings 188 + EmailNotifPreference EmailPreference 189 + } 190 + 191 + type EmailPreference int 192 + 193 + const ( 194 + EmailNotifDisabled EmailPreference = iota 195 + EmailNotifMention 196 + EmailNotifEnabled 197 + ) 198 + 199 + func (p EmailPreference) IsDisabled() bool { 200 + return p == EmailNotifDisabled 201 + } 202 + 203 + func (p EmailPreference) IsMention() bool { 204 + return p == EmailNotifMention 205 + } 206 + 207 + func (p EmailPreference) IsEnabled() bool { 208 + return p == EmailNotifEnabled 209 } 210 211 func (p Profile) IsLinksEmpty() bool { ··· 303 did, 304 description, 305 include_bluesky, 306 + location, 307 + email_notif_preference 308 ) 309 + values (?, ?, ?, ?, ?)`, 310 profile.Did, 311 profile.Description, 312 includeBskyValue, 313 profile.Location, 314 + profile.EmailNotifPreference, 315 ) 316 317 if err != nil { ··· 392 did, 393 description, 394 include_bluesky, 395 + location, 396 + email_notif_preference 397 from 398 profile 399 %s`, ··· 409 var profile Profile 410 var includeBluesky int 411 412 + err = rows.Scan(&profile.ID, &profile.Did, &profile.Description, &includeBluesky, &profile.Location, &profile.EmailNotifPreference) 413 if err != nil { 414 return nil, err 415 } ··· 483 484 includeBluesky := 0 485 err := e.QueryRow( 486 + `select description, include_bluesky, location, email_notif_preference from profile where did = ?`, 487 did, 488 + ).Scan(&profile.Description, &includeBluesky, &profile.Location, &profile.EmailNotifPreference) 489 if err == sql.ErrNoRows { 490 profile := Profile{} 491 profile.Did = did
+5 -4
appview/pages/pages.go
··· 328 } 329 330 type UserEmailsSettingsParams struct { 331 - LoggedInUser *oauth.User 332 - Emails []db.Email 333 - Tabs []map[string]any 334 - Tab string 335 } 336 337 func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error {
··· 328 } 329 330 type UserEmailsSettingsParams struct { 331 + LoggedInUser *oauth.User 332 + Emails []db.Email 333 + NotifPreference db.EmailPreference 334 + Tabs []map[string]any 335 + Tab string 336 } 337 338 func (p *Pages) UserEmailsSettings(w io.Writer, params UserEmailsSettingsParams) error {
+31 -1
appview/pages/templates/user/settings/emails.html
··· 11 </div> 12 <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 {{ template "emailSettings" . }} 14 </div> 15 </section> 16 </div> 17 {{ end }} 18 19 {{ define "emailSettings" }} 20 <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 21 <div class="col-span-1 md:col-span-2"> 22 <h2 class="text-sm pb-2 uppercase font-bold">Email Addresses</h2> ··· 91 <div id="settings-emails-error" class="text-red-500 dark:text-red-400"></div> 92 <div id="settings-emails-success" class="text-green-500 dark:text-green-400"></div> 93 </form> 94 - {{ end }}
··· 11 </div> 12 <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 {{ template "emailSettings" . }} 14 + {{ template "emailList" . }} 15 </div> 16 </section> 17 </div> 18 {{ end }} 19 20 {{ define "emailSettings" }} 21 + <form 22 + hx-post="/settings/email/preference" 23 + hx-swap="none" 24 + hx-indicator="#email-preference-spinner" 25 + class="grid grid-cols-1 md:grid-cols-3 gap-4" 26 + > 27 + <div class="col-span-1 md:col-span-2"> 28 + <h2 class="text-sm pb-2 uppercase font-bold">Email Notifications</h2> 29 + </div> 30 + <select 31 + name="preference" 32 + class="p-1 border border-gray-200 bg-white dark:bg-gray-700 dark:text-white dark:border-gray-600" 33 + > 34 + <option value="enable" {{ if .NotifPreference.IsEnabled }}selected{{ end }}>Enable</option> 35 + <option value="mention" {{ if .NotifPreference.IsMention }}selected{{ end }}>Only on Mentions</option> 36 + <option value="disable" {{ if .NotifPreference.IsDisabled }}selected{{ end }}>Disable</option> 37 + </select> 38 + <div class="md:col-start-2 col-span-2 flex justify-end"> 39 + <button type="submit" class="btn text-base"> 40 + <span>Save Preference</span> 41 + <span id="email-preference-spinner" class="group"> 42 + {{ i "loader-circle" "w-4 h-4 animate-spin hidden group-[.htmx-request]:inline" }} 43 + </span> 44 + </button> 45 + </div> 46 + </form> 47 + {{ end }} 48 + 49 + {{ define "emailList" }} 50 <div class="grid grid-cols-1 md:grid-cols-3 gap-4 items-center"> 51 <div class="col-span-1 md:col-span-2"> 52 <h2 class="text-sm pb-2 uppercase font-bold">Email Addresses</h2> ··· 121 <div id="settings-emails-error" class="text-red-500 dark:text-red-400"></div> 122 <div id="settings-emails-success" class="text-green-500 dark:text-green-400"></div> 123 </form> 124 + {{ end }}
+30 -3
appview/email/notifier.go
··· 69 }, nil 70 } 71 72 func (n *EmailNotifier) gatherRecipientEmails(ctx context.Context, handles []string) []string { 73 recipients := []string{} 74 for _, handle := range handles { ··· 109 } 110 } 111 112 - // func (n *EmailNotifier) NewPullComment(ctx context.Context, comment *db.PullComment, []string) { 113 - // n.usersMentioned(ctx, mentions) 114 - // }
··· 69 }, nil 70 } 71 72 + func (n *EmailNotifier) buildPullEmail(ctx context.Context, repo *db.Repo, pull *db.Pull, comment *db.PullComment) (Email, error) { 73 + commentOwner, err := n.idResolver.ResolveIdent(ctx, comment.OwnerDid) 74 + if err != nil || commentOwner.Handle.IsInvalidHandle() { 75 + return Email{}, fmt.Errorf("resolve comment owner did: %w", err) 76 + } 77 + repoOwnerSlashName, err := n.repoOwnerSlashName(ctx, repo) 78 + if err != nil { 79 + return Email{}, nil 80 + } 81 + baseUrl := n.Config.Core.AppviewHost 82 + url := fmt.Sprintf("%s/%s/pulls/%d#comment-%d", baseUrl, repoOwnerSlashName, comment.PullId, comment.ID) 83 + return Email{ 84 + APIKey: n.Config.Resend.ApiKey, 85 + From: n.Config.Resend.SentFrom, 86 + Subject: fmt.Sprintf("[%s] %s (pr#%d)", repoOwnerSlashName, pull.Title, pull.PullId), 87 + Html: fmt.Sprintf(`<p><b>@%s</b> mentioned you</p><a href="%s">View it on tangled.sh</a>.`, commentOwner.Handle.String(), url), 88 + }, nil 89 + } 90 + 91 func (n *EmailNotifier) gatherRecipientEmails(ctx context.Context, handles []string) []string { 92 recipients := []string{} 93 for _, handle := range handles { ··· 128 } 129 } 130 131 + func (n *EmailNotifier) NewPullComment(ctx context.Context, repo *db.Repo, pull *db.Pull, comment *db.PullComment, mentions []string) { 132 + email, err := n.buildPullEmail(ctx, repo, pull, comment) 133 + if err != nil { 134 + log.Println("failed to create pull-email:", err) 135 + } 136 + recipients := n.gatherRecipientEmails(ctx, mentions) 137 + log.Println("sending email to:", recipients) 138 + if err = SendEmail(email); err != nil { 139 + log.Println("error sending email:", err) 140 + } 141 + }
+3 -1
appview/pulls/pulls.go
··· 665 return 666 } 667 668 - s.notifier.NewPullComment(r.Context(), comment) 669 670 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 671 return
··· 665 return 666 } 667 668 + mentions := markup.FindUserMentions(comment.Body) 669 + 670 + s.notifier.NewPullComment(r.Context(), &f.Repo, pull, comment, mentions) 671 672 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 673 return