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
+266 -107
appview
+3 -8
appview/middleware/middleware.go
··· 8 8 "slices" 9 9 "strconv" 10 10 "strings" 11 - "time" 12 11 13 12 "github.com/bluesky-social/indigo/atproto/identity" 14 13 "github.com/go-chi/chi/v5" ··· 222 221 return 223 222 } 224 223 225 - ctx := context.WithValue(req.Context(), "knot", repo.Knot) 226 - ctx = context.WithValue(ctx, "repoAt", repo.AtUri) 227 - ctx = context.WithValue(ctx, "repoDescription", repo.Description) 228 - ctx = context.WithValue(ctx, "repoSpindle", repo.Spindle) 229 - ctx = context.WithValue(ctx, "repoAddedAt", repo.Created.Format(time.RFC3339)) 224 + ctx := context.WithValue(req.Context(), "repo", repo) 230 225 next.ServeHTTP(w, req.WithContext(ctx)) 231 226 }) 232 227 } ··· 251 246 return 252 247 } 253 248 254 - pr, err := db.GetPull(mw.db, f.RepoAt, prIdInt) 249 + pr, err := db.GetPull(mw.db, f.RepoAt(), prIdInt) 255 250 if err != nil { 256 251 log.Println("failed to get pull and comments", err) 257 252 return ··· 292 287 return 293 288 } 294 289 295 - fullName := f.OwnerHandle() + "/" + f.RepoName 290 + fullName := f.OwnerHandle() + "/" + f.Name 296 291 297 292 if r.Header.Get("User-Agent") == "Go-http-client/1.1" { 298 293 if r.URL.Query().Get("go-get") == "1" {
+6 -6
appview/repo/artifact.go
··· 76 76 Artifact: uploadBlobResp.Blob, 77 77 CreatedAt: createdAt.Format(time.RFC3339), 78 78 Name: handler.Filename, 79 - Repo: f.RepoAt.String(), 79 + Repo: f.RepoAt().String(), 80 80 Tag: tag.Tag.Hash[:], 81 81 }, 82 82 }, ··· 100 100 artifact := db.Artifact{ 101 101 Did: user.Did, 102 102 Rkey: rkey, 103 - RepoAt: f.RepoAt, 103 + RepoAt: f.RepoAt(), 104 104 Tag: tag.Tag.Hash, 105 105 CreatedAt: createdAt, 106 106 BlobCid: cid.Cid(uploadBlobResp.Blob.Ref), ··· 155 155 156 156 artifacts, err := db.GetArtifact( 157 157 rp.db, 158 - db.FilterEq("repo_at", f.RepoAt), 158 + db.FilterEq("repo_at", f.RepoAt()), 159 159 db.FilterEq("tag", tag.Tag.Hash[:]), 160 160 db.FilterEq("name", filename), 161 161 ) ··· 197 197 198 198 artifacts, err := db.GetArtifact( 199 199 rp.db, 200 - db.FilterEq("repo_at", f.RepoAt), 200 + db.FilterEq("repo_at", f.RepoAt()), 201 201 db.FilterEq("tag", tag[:]), 202 202 db.FilterEq("name", filename), 203 203 ) ··· 239 239 defer tx.Rollback() 240 240 241 241 err = db.DeleteArtifact(tx, 242 - db.FilterEq("repo_at", f.RepoAt), 242 + db.FilterEq("repo_at", f.RepoAt()), 243 243 db.FilterEq("tag", artifact.Tag[:]), 244 244 db.FilterEq("name", filename), 245 245 ) ··· 270 270 return nil, err 271 271 } 272 272 273 - result, err := us.Tags(f.OwnerDid(), f.RepoName) 273 + result, err := us.Tags(f.OwnerDid(), f.Name) 274 274 if err != nil { 275 275 log.Println("failed to reach knotserver", err) 276 276 return nil, err
+5 -5
appview/repo/index.go
··· 37 37 return 38 38 } 39 39 40 - result, err := us.Index(f.OwnerDid(), f.RepoName, ref) 40 + result, err := us.Index(f.OwnerDid(), f.Name, ref) 41 41 if err != nil { 42 42 rp.pages.Error503(w) 43 43 log.Println("failed to reach knotserver", err) ··· 166 166 // first attempt to fetch from db 167 167 langs, err := db.GetRepoLanguages( 168 168 rp.db, 169 - db.FilterEq("repo_at", f.RepoAt), 169 + db.FilterEq("repo_at", f.RepoAt()), 170 170 db.FilterEq("ref", f.Ref), 171 171 ) 172 172 173 173 if err != nil || langs == nil { 174 174 // non-fatal, fetch langs from ks 175 - ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, f.Ref) 175 + ls, err := signedClient.RepoLanguages(f.OwnerDid(), f.Name, f.Ref) 176 176 if err != nil { 177 177 return nil, err 178 178 } ··· 182 182 183 183 for l, s := range ls.Languages { 184 184 langs = append(langs, db.RepoLanguage{ 185 - RepoAt: f.RepoAt, 185 + RepoAt: f.RepoAt(), 186 186 Ref: f.Ref, 187 187 IsDefaultRef: isDefaultRef, 188 188 Language: l, ··· 279 279 hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref) 280 280 281 281 var status types.AncestorCheckResponse 282 - forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt), repoInfo.Name, f.Ref, hiddenRef) 282 + forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt()), repoInfo.Name, f.Ref, hiddenRef) 283 283 if err != nil { 284 284 log.Printf("failed to check if fork is ahead/behind: %s", err) 285 285 return nil, err
+25 -58
appview/reporesolver/resolver.go
··· 12 12 "strings" 13 13 14 14 "github.com/bluesky-social/indigo/atproto/identity" 15 - "github.com/bluesky-social/indigo/atproto/syntax" 16 15 securejoin "github.com/cyphar/filepath-securejoin" 17 16 "github.com/go-chi/chi/v5" 18 17 "tangled.sh/tangled.sh/core/appview/config" ··· 26 25 ) 27 26 28 27 type ResolvedRepo struct { 29 - Knot string 28 + db.Repo 30 29 OwnerId identity.Identity 31 - RepoName string 32 - RepoAt syntax.ATURI 33 - Description string 34 - Spindle string 35 - CreatedAt string 36 30 Ref string 37 31 CurrentDir string 38 32 ··· 51 45 } 52 46 53 47 func (rr *RepoResolver) Resolve(r *http.Request) (*ResolvedRepo, error) { 54 - repoName := chi.URLParam(r, "repo") 55 - knot, ok := r.Context().Value("knot").(string) 48 + repo, ok := r.Context().Value("repo").(*db.Repo) 56 49 if !ok { 57 - log.Println("malformed middleware") 50 + log.Println("malformed middleware: `repo` not exist in context") 58 51 return nil, fmt.Errorf("malformed middleware") 59 52 } 60 53 id, ok := r.Context().Value("resolvedId").(identity.Identity) ··· 63 56 return nil, fmt.Errorf("malformed middleware") 64 57 } 65 58 66 - repoAt, ok := r.Context().Value("repoAt").(string) 67 - if !ok { 68 - log.Println("malformed middleware") 69 - return nil, fmt.Errorf("malformed middleware") 70 - } 71 - 72 - parsedRepoAt, err := syntax.ParseATURI(repoAt) 73 - if err != nil { 74 - log.Println("malformed repo at-uri") 75 - return nil, fmt.Errorf("malformed middleware") 76 - } 77 - 78 59 ref := chi.URLParam(r, "ref") 79 60 80 61 if ref == "" { 81 - us, err := knotclient.NewUnsignedClient(knot, rr.config.Core.Dev) 62 + us, err := knotclient.NewUnsignedClient(repo.Knot, rr.config.Core.Dev) 82 63 if err != nil { 83 64 return nil, err 84 65 } 85 66 86 - defaultBranch, err := us.DefaultBranch(id.DID.String(), repoName) 67 + defaultBranch, err := us.DefaultBranch(id.DID.String(), repo.Name) 87 68 if err != nil { 88 69 return nil, err 89 70 } ··· 93 74 94 75 currentDir := path.Dir(extractPathAfterRef(r.URL.EscapedPath(), ref)) 95 76 96 - // pass through values from the middleware 97 - description, ok := r.Context().Value("repoDescription").(string) 98 - addedAt, ok := r.Context().Value("repoAddedAt").(string) 99 - spindle, ok := r.Context().Value("repoSpindle").(string) 100 - 101 77 return &ResolvedRepo{ 102 - Knot: knot, 103 - OwnerId: id, 104 - RepoName: repoName, 105 - RepoAt: parsedRepoAt, 106 - Description: description, 107 - CreatedAt: addedAt, 108 - Ref: ref, 109 - CurrentDir: currentDir, 110 - Spindle: spindle, 78 + Repo: *repo, 79 + OwnerId: id, 80 + Ref: ref, 81 + CurrentDir: currentDir, 111 82 112 83 rr: rr, 113 84 }, nil ··· 126 97 127 98 var p string 128 99 if handle != "" && !handle.IsInvalidHandle() { 129 - p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName) 100 + p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.Name) 130 101 } else { 131 - p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 102 + p, _ = securejoin.SecureJoin(f.OwnerDid(), f.Name) 132 103 } 133 104 134 105 return p 135 106 } 136 107 137 - func (f *ResolvedRepo) DidSlashRepo() string { 138 - p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName) 139 - return p 140 - } 141 - 142 108 func (f *ResolvedRepo) Collaborators(ctx context.Context) ([]pages.Collaborator, error) { 143 109 repoCollaborators, err := f.rr.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot) 144 110 if err != nil { ··· 186 152 // this function is a bit weird since it now returns RepoInfo from an entirely different 187 153 // package. we should refactor this or get rid of RepoInfo entirely. 188 154 func (f *ResolvedRepo) RepoInfo(user *oauth.User) repoinfo.RepoInfo { 155 + repoAt := f.RepoAt() 189 156 isStarred := false 190 157 if user != nil { 191 - isStarred = db.GetStarStatus(f.rr.execer, user.Did, syntax.ATURI(f.RepoAt)) 158 + isStarred = db.GetStarStatus(f.rr.execer, user.Did, repoAt) 192 159 } 193 160 194 - starCount, err := db.GetStarCount(f.rr.execer, f.RepoAt) 161 + starCount, err := db.GetStarCount(f.rr.execer, repoAt) 195 162 if err != nil { 196 - log.Println("failed to get star count for ", f.RepoAt) 163 + log.Println("failed to get star count for ", repoAt) 197 164 } 198 - issueCount, err := db.GetIssueCount(f.rr.execer, f.RepoAt) 165 + issueCount, err := db.GetIssueCount(f.rr.execer, repoAt) 199 166 if err != nil { 200 - log.Println("failed to get issue count for ", f.RepoAt) 167 + log.Println("failed to get issue count for ", repoAt) 201 168 } 202 - pullCount, err := db.GetPullCount(f.rr.execer, f.RepoAt) 169 + pullCount, err := db.GetPullCount(f.rr.execer, repoAt) 203 170 if err != nil { 204 - log.Println("failed to get issue count for ", f.RepoAt) 171 + log.Println("failed to get issue count for ", repoAt) 205 172 } 206 - source, err := db.GetRepoSource(f.rr.execer, f.RepoAt) 173 + source, err := db.GetRepoSource(f.rr.execer, repoAt) 207 174 if errors.Is(err, sql.ErrNoRows) { 208 175 source = "" 209 176 } else if err != nil { 210 - log.Println("failed to get repo source for ", f.RepoAt, err) 177 + log.Println("failed to get repo source for ", repoAt, err) 211 178 } 212 179 213 180 var sourceRepo *db.Repo ··· 232 199 if err != nil { 233 200 log.Printf("failed to create unsigned client for %s: %v", knot, err) 234 201 } else { 235 - result, err := us.Branches(f.OwnerDid(), f.RepoName) 202 + result, err := us.Branches(f.OwnerDid(), f.Name) 236 203 if err != nil { 237 - log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err) 204 + log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.Name, err) 238 205 } 239 206 240 207 if len(result.Branches) == 0 { ··· 245 212 repoInfo := repoinfo.RepoInfo{ 246 213 OwnerDid: f.OwnerDid(), 247 214 OwnerHandle: f.OwnerHandle(), 248 - Name: f.RepoName, 249 - RepoAt: f.RepoAt, 215 + Name: f.Name, 216 + RepoAt: repoAt, 250 217 Description: f.Description, 251 218 Ref: f.Ref, 252 219 IsStarred: isStarred,
+7 -4
appview/db/repos.go
··· 391 391 var description, spindle sql.NullString 392 392 393 393 row := e.QueryRow(` 394 - select did, name, knot, created, at_uri, description, spindle 394 + select did, name, knot, created, at_uri, description, spindle, rkey 395 395 from repos 396 396 where did = ? and name = ? 397 397 `, ··· 400 400 ) 401 401 402 402 var createdAt string 403 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &description, &spindle); err != nil { 403 + if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &description, &spindle, &repo.Rkey); err != nil { 404 404 return nil, err 405 405 } 406 406 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 421 421 var repo Repo 422 422 var nullableDescription sql.NullString 423 423 424 - row := e.QueryRow(`select did, name, knot, created, at_uri, description from repos where at_uri = ?`, atUri) 424 + row := e.QueryRow(`select did, name, knot, created, at_uri, rkey, description from repos where at_uri = ?`, atUri) 425 425 426 426 var createdAt string 427 - if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &nullableDescription); err != nil { 427 + if err := row.Scan(&repo.Did, &repo.Name, &repo.Knot, &createdAt, &repo.AtUri, &repo.Rkey, &nullableDescription); err != nil { 428 428 return nil, err 429 429 } 430 430 createdAtTime, _ := time.Parse(time.RFC3339, createdAt) ··· 440 440 } 441 441 442 442 func AddRepo(e Execer, repo *Repo) error { 443 + if repo.AtUri == "" { 444 + repo.AtUri = repo.RepoAt().String() 445 + } 443 446 _, err := e.Exec( 444 447 `insert into repos 445 448 (did, name, knot, rkey, at_uri, description, source)
+10 -2
appview/pages/markup/markdown.go
··· 42 42 RendererType RendererType 43 43 } 44 44 45 - func (rctx *RenderContext) RenderMarkdown(source string) string { 45 + func NewMarkdown() goldmark.Markdown { 46 46 md := goldmark.New( 47 - goldmark.WithExtensions(extension.GFM), 47 + goldmark.WithExtensions( 48 + extension.GFM, 49 + AtExt, 50 + ), 48 51 goldmark.WithParserOptions( 49 52 parser.WithAutoHeadingID(), 50 53 ), 51 54 goldmark.WithRendererOptions(html.WithUnsafe()), 52 55 ) 56 + return md 57 + } 58 + 59 + func (rctx *RenderContext) RenderMarkdown(source string) string { 60 + md := NewMarkdown() 53 61 54 62 if rctx != nil { 55 63 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 + }
+4 -4
appview/issues/issues.go
··· 293 293 294 294 createdAt := time.Now().Format(time.RFC3339) 295 295 commentIdInt64 := int64(comment.CommentId) 296 - issueAt, err := db.GetIssueAt(rp.db, f.RepoAt(), issueIdInt) 296 + issue, err := db.GetIssue(rp.db, f.RepoAt(), issueIdInt) 297 297 if err != nil { 298 - log.Println("failed to get issue at", err) 298 + log.Println("failed to get issue", err) 299 299 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 300 300 return 301 301 } ··· 314 314 Record: &lexutil.LexiconTypeDecoder{ 315 315 Val: &tangled.RepoIssueComment{ 316 316 Repo: &atUri, 317 - Issue: issueAt, 317 + Issue: issue.IssueAt, 318 318 CommentId: &commentIdInt64, 319 319 Owner: &comment.OwnerDid, 320 320 Body: body, ··· 330 330 331 331 mentions := markup.FindUserMentions(comment.Body) 332 332 333 - rp.notifier.NewIssueComment(r.Context(), comment, mentions) 333 + rp.notifier.NewIssueComment(r.Context(), &f.Repo, issue, comment, mentions) 334 334 335 335 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), comment.Issue, comment.CommentId)) 336 336 return
+63 -14
appview/email/notifier.go
··· 34 34 35 35 var _ notify.Notifier = &EmailNotifier{} 36 36 37 - func (n *EmailNotifier) buildIssueEmail(ctx context.Context, repo *db.Repo, issue *db.Issue, comment *db.Comment, did string) (Email, error) { 38 - // TODO: check email preferences 39 - email, err := db.GetPrimaryEmail(n.db, did) 40 - if err != nil { 41 - return Email{}, fmt.Errorf("db.GetPrimaryEmail: %w", err) 42 - } 43 - commentOwner, err := n.idResolver.ResolveIdent(ctx, comment.OwnerDid) 44 - if err != nil || commentOwner.Handle.IsInvalidHandle() { 45 - return Email{}, fmt.Errorf("resolve comment owner did: %w", err) 46 - } 37 + // TODO: yeah this is just bad design. should be moved under idResolver ore include repoResolver at first place 38 + func (n *EmailNotifier) repoOwnerSlashName(ctx context.Context, repo *db.Repo) (string, error) { 47 39 repoOwnerID, err := n.idResolver.ResolveIdent(ctx, repo.Did) 48 40 if err != nil || repoOwnerID.Handle.IsInvalidHandle() { 49 - return Email{}, fmt.Errorf("resolve repo owner did: %w", err) 41 + return "", fmt.Errorf("resolve comment owner did: %w", err) 50 42 } 51 43 repoOwnerHandle := repoOwnerID.Handle 52 44 var repoOwnerSlashName string ··· 55 47 } else { 56 48 repoOwnerSlashName = repo.DidSlashRepo() 57 49 } 50 + return repoOwnerSlashName, nil 51 + } 52 + 53 + func (n *EmailNotifier) buildIssueEmail(ctx context.Context, repo *db.Repo, issue *db.Issue, comment *db.Comment, did string) (Email, error) { 54 + // TODO: check email preferences 55 + email, err := db.GetPrimaryEmail(n.db, did) 56 + if err != nil { 57 + return Email{}, fmt.Errorf("db.GetPrimaryEmail: %w", err) 58 + } 59 + commentOwner, err := n.idResolver.ResolveIdent(ctx, comment.OwnerDid) 60 + if err != nil || commentOwner.Handle.IsInvalidHandle() { 61 + return Email{}, fmt.Errorf("resolve comment owner did: %w", err) 62 + } 58 63 // TODO: make this configurable 59 64 baseUrl := "https://tangled.sh" 65 + repoOwnerSlashName, err := n.repoOwnerSlashName(ctx, repo) 66 + if err != nil { 67 + return Email{}, nil 68 + } 60 69 url := fmt.Sprintf("%s/%s/issues/%d#comment-%d", baseUrl, repoOwnerSlashName, comment.Issue, comment.CommentId) 61 70 return Email{ 62 71 APIKey: n.Config.Resend.ApiKey, ··· 67 76 }, nil 68 77 } 69 78 79 + func (n *EmailNotifier) buildPullEmail(ctx context.Context, repo *db.Repo, pull *db.Pull, comment *db.PullComment, did string) (Email, error) { 80 + // TODO: check email preferences 81 + email, err := db.GetPrimaryEmail(n.db, did) 82 + if err != nil { 83 + return Email{}, fmt.Errorf("db.GetPrimaryEmail: %w", err) 84 + } 85 + commentOwner, err := n.idResolver.ResolveIdent(ctx, comment.OwnerDid) 86 + if err != nil || commentOwner.Handle.IsInvalidHandle() { 87 + return Email{}, fmt.Errorf("resolve comment owner did: %w", err) 88 + } 89 + repoOwnerSlashName, err := n.repoOwnerSlashName(ctx, repo) 90 + if err != nil { 91 + return Email{}, nil 92 + } 93 + // TODO: make this configurable 94 + baseUrl := "https://tangled.sh" 95 + url := fmt.Sprintf("%s/%s/pulls/%d#comment-%d", baseUrl, repoOwnerSlashName, comment.PullId, comment.ID) 96 + return Email{ 97 + APIKey: n.Config.Resend.ApiKey, 98 + From: n.Config.Resend.SentFrom, 99 + To: email.Address, 100 + Subject: fmt.Sprintf("[%s] %s (pr#%d)", repoOwnerSlashName, pull.Title, pull.PullId), 101 + Html: fmt.Sprintf(`<p><b>@%s</b> mentioned you:</p><a href="%s">View it on tangled.sh</a>.`, commentOwner.Handle.String(), url), 102 + }, nil 103 + } 104 + 70 105 func (n *EmailNotifier) NewIssueComment(ctx context.Context, repo *db.Repo, issue *db.Issue, comment *db.Comment, mentions []string) { 71 106 resolvedIds := n.idResolver.ResolveIdents(ctx, mentions) 72 107 handleDidMap := make(map[string]string) ··· 85 120 } 86 121 } 87 122 88 - // func (n *EmailNotifier) NewPullComment(ctx context.Context, comment *db.PullComment, []string) { 89 - // n.usersMentioned(ctx, mentions) 90 - // } 123 + func (n *EmailNotifier) NewPullComment(ctx context.Context, repo *db.Repo, pull *db.Pull, comment *db.PullComment, mentions []string) { 124 + resolvedIds := n.idResolver.ResolveIdents(ctx, mentions) 125 + handleDidMap := make(map[string]string) 126 + for _, identity := range resolvedIds { 127 + if !identity.Handle.IsInvalidHandle() { 128 + handleDidMap[identity.Handle.String()] = identity.DID.String() 129 + } 130 + } 131 + for _, handle := range mentions { 132 + id, err := n.idResolver.ResolveIdent(ctx, handle) 133 + email, err := n.buildPullEmail(ctx, repo, pull, comment, id.DID.String()) 134 + if err != nil { 135 + log.Println("failed to create issue-email:", err) 136 + } 137 + SendEmail(email) 138 + } 139 + }
+2 -2
appview/notify/merged_notifier.go
··· 61 61 notifier.NewPull(ctx, pull) 62 62 } 63 63 } 64 - func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) { 64 + func (m *mergedNotifier) NewPullComment(ctx context.Context, repo *db.Repo, pull *db.Pull, comment *db.PullComment, mentions []string) { 65 65 for _, notifier := range m.notifiers { 66 - notifier.NewPullComment(ctx, comment) 66 + notifier.NewPullComment(ctx, repo, pull, comment, mentions) 67 67 } 68 68 } 69 69
+2 -2
appview/notify/notifier.go
··· 19 19 DeleteFollow(ctx context.Context, follow *db.Follow) 20 20 21 21 NewPull(ctx context.Context, pull *db.Pull) 22 - NewPullComment(ctx context.Context, comment *db.PullComment) 22 + NewPullComment(ctx context.Context, repo *db.Repo, pull *db.Pull, comment *db.PullComment, mentions []string) 23 23 24 24 UpdateProfile(ctx context.Context, profile *db.Profile) 25 25 } ··· 41 41 func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *db.Follow) {} 42 42 43 43 func (m *BaseNotifier) NewPull(ctx context.Context, pull *db.Pull) {} 44 - func (m *BaseNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) {} 44 + func (m *BaseNotifier) NewPullComment(ctx context.Context, repo *db.Repo, pull *db.Pull, comment *db.PullComment, mentions []string) {} 45 45 46 46 func (m *BaseNotifier) UpdateProfile(ctx context.Context, profile *db.Profile) {}
+1 -1
appview/posthog/notifier.go
··· 98 98 } 99 99 } 100 100 101 - func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *db.PullComment) { 101 + func (n *posthogNotifier) NewPullComment(ctx context.Context, repo *db.Repo, pull *db.Pull, comment *db.PullComment, mentions []string) { 102 102 err := n.client.Enqueue(posthog.Capture{ 103 103 DistinctId: comment.OwnerDid, 104 104 Event: "new_pull_comment",
+4 -1
appview/pulls/pulls.go
··· 19 19 "tangled.sh/tangled.sh/core/appview/notify" 20 20 "tangled.sh/tangled.sh/core/appview/oauth" 21 21 "tangled.sh/tangled.sh/core/appview/pages" 22 + "tangled.sh/tangled.sh/core/appview/pages/markup" 22 23 "tangled.sh/tangled.sh/core/appview/reporesolver" 23 24 "tangled.sh/tangled.sh/core/idresolver" 24 25 "tangled.sh/tangled.sh/core/knotclient" ··· 707 708 return 708 709 } 709 710 710 - s.notifier.NewPullComment(r.Context(), comment) 711 + mentions := markup.FindUserMentions(comment.Body) 712 + 713 + s.notifier.NewPullComment(r.Context(), &f.Repo, pull, comment, mentions) 711 714 712 715 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 713 716 return