Monorepo for Tangled tangled.org

appview: parse reference links from markdown body

Defined `refResolver` which will parse useful data from markdown body
like @-mentions or issue/pr/comment mentions

Signed-off-by: Seongmin Lee <git@boltless.me>

boltless.me 0b18c302 2414e4b3

verified
Changed files
+471 -33
appview
+172
appview/db/reference.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "fmt" 6 + "strings" 7 + 8 + "github.com/bluesky-social/indigo/atproto/syntax" 9 + "tangled.org/core/api/tangled" 10 + "tangled.org/core/appview/models" 11 + ) 12 + 13 + // FindReferences resolves refLinks to Issue/PR/IssueComment/PullComment ATURIs. 14 + // It will ignore missing refLinks. 15 + func FindReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) { 16 + var ( 17 + issueRefs []models.ReferenceLink 18 + pullRefs []models.ReferenceLink 19 + ) 20 + for _, ref := range refLinks { 21 + switch ref.Kind { 22 + case models.RefKindIssue: 23 + issueRefs = append(issueRefs, ref) 24 + case models.RefKindPull: 25 + pullRefs = append(pullRefs, ref) 26 + } 27 + } 28 + issueUris, err := findIssueReferences(e, issueRefs) 29 + if err != nil { 30 + return nil, err 31 + } 32 + pullUris, err := findPullReferences(e, pullRefs) 33 + if err != nil { 34 + return nil, err 35 + } 36 + 37 + return append(issueUris, pullUris...), nil 38 + } 39 + 40 + func findIssueReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) { 41 + if len(refLinks) == 0 { 42 + return nil, nil 43 + } 44 + vals := make([]string, len(refLinks)) 45 + args := make([]any, 0, len(refLinks)*4) 46 + for i, ref := range refLinks { 47 + vals[i] = "(?, ?, ?, ?)" 48 + args = append(args, ref.Handle, ref.Repo, ref.SubjectId, ref.CommentId) 49 + } 50 + query := fmt.Sprintf( 51 + `with input(owner_did, name, issue_id, comment_id) as ( 52 + values %s 53 + ) 54 + select 55 + i.did, i.rkey, 56 + c.did, c.rkey 57 + from input inp 58 + join repos r 59 + on r.did = inp.owner_did 60 + and r.name = inp.name 61 + join issues i 62 + on i.repo_at = r.at_uri 63 + and i.issue_id = inp.issue_id 64 + left join issue_comments c 65 + on inp.comment_id is not null 66 + and c.issue_at = i.at_uri 67 + and c.id = inp.comment_id 68 + `, 69 + strings.Join(vals, ","), 70 + ) 71 + rows, err := e.Query(query, args...) 72 + if err != nil { 73 + return nil, err 74 + } 75 + defer rows.Close() 76 + 77 + var uris []syntax.ATURI 78 + 79 + for rows.Next() { 80 + // Scan rows 81 + var issueOwner, issueRkey string 82 + var commentOwner, commentRkey sql.NullString 83 + var uri syntax.ATURI 84 + if err := rows.Scan(&issueOwner, &issueRkey, &commentOwner, &commentRkey); err != nil { 85 + return nil, err 86 + } 87 + if commentOwner.Valid && commentRkey.Valid { 88 + uri = syntax.ATURI(fmt.Sprintf( 89 + "at://%s/%s/%s", 90 + commentOwner.String, 91 + tangled.RepoIssueCommentNSID, 92 + commentRkey.String, 93 + )) 94 + } else { 95 + uri = syntax.ATURI(fmt.Sprintf( 96 + "at://%s/%s/%s", 97 + issueOwner, 98 + tangled.RepoIssueNSID, 99 + issueRkey, 100 + )) 101 + } 102 + uris = append(uris, uri) 103 + } 104 + return uris, nil 105 + } 106 + 107 + func findPullReferences(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) { 108 + if len(refLinks) == 0 { 109 + return nil, nil 110 + } 111 + vals := make([]string, len(refLinks)) 112 + args := make([]any, 0, len(refLinks)*4) 113 + for i, ref := range refLinks { 114 + vals[i] = "(?, ?, ?, ?)" 115 + args = append(args, ref.Handle, ref.Repo, ref.SubjectId, ref.CommentId) 116 + } 117 + query := fmt.Sprintf( 118 + `with input(owner_did, name, pull_id, comment_id) as ( 119 + values %s 120 + ) 121 + select 122 + p.owner_did, p.rkey, 123 + c.owner_did, c.rkey 124 + from input inp 125 + join repos r 126 + on r.did = inp.owner_did 127 + and r.name = inp.name 128 + join pulls p 129 + on p.repo_at = r.at_uri 130 + and p.pull_id = inp.pull_id 131 + left join pull_comments c 132 + on inp.comment_id is not null 133 + and c.repo_at = r.at_uri and c.pull_id = p.pull_id 134 + and c.id = inp.comment_id 135 + `, 136 + strings.Join(vals, ","), 137 + ) 138 + rows, err := e.Query(query, args...) 139 + if err != nil { 140 + return nil, err 141 + } 142 + defer rows.Close() 143 + 144 + var uris []syntax.ATURI 145 + 146 + for rows.Next() { 147 + // Scan rows 148 + var pullOwner, pullRkey string 149 + var commentOwner, commentRkey sql.NullString 150 + var uri syntax.ATURI 151 + if err := rows.Scan(&pullOwner, &pullRkey, &commentOwner, &commentRkey); err != nil { 152 + return nil, err 153 + } 154 + if commentOwner.Valid && commentRkey.Valid { 155 + uri = syntax.ATURI(fmt.Sprintf( 156 + "at://%s/%s/%s", 157 + commentOwner.String, 158 + tangled.RepoPullCommentNSID, 159 + commentRkey.String, 160 + )) 161 + } else { 162 + uri = syntax.ATURI(fmt.Sprintf( 163 + "at://%s/%s/%s", 164 + pullOwner, 165 + tangled.RepoPullNSID, 166 + pullRkey, 167 + )) 168 + } 169 + uris = append(uris, uri) 170 + } 171 + return uris, nil 172 + }
+10 -20
appview/issues/issues.go
··· 24 24 "tangled.org/core/appview/notify" 25 25 "tangled.org/core/appview/oauth" 26 26 "tangled.org/core/appview/pages" 27 - "tangled.org/core/appview/pages/markup" 28 27 "tangled.org/core/appview/pagination" 28 + "tangled.org/core/appview/refresolver" 29 29 "tangled.org/core/appview/reporesolver" 30 30 "tangled.org/core/appview/validator" 31 31 "tangled.org/core/idresolver" ··· 37 37 repoResolver *reporesolver.RepoResolver 38 38 pages *pages.Pages 39 39 idResolver *idresolver.Resolver 40 + refResolver *refresolver.Resolver 40 41 db *db.DB 41 42 config *config.Config 42 43 notifier notify.Notifier ··· 50 51 repoResolver *reporesolver.RepoResolver, 51 52 pages *pages.Pages, 52 53 idResolver *idresolver.Resolver, 54 + refResolver *refresolver.Resolver, 53 55 db *db.DB, 54 56 config *config.Config, 55 57 notifier notify.Notifier, ··· 62 64 repoResolver: repoResolver, 63 65 pages: pages, 64 66 idResolver: idResolver, 67 + refResolver: refResolver, 65 68 db: db, 66 69 config: config, 67 70 notifier: notifier, ··· 399 402 replyTo = &replyToUri 400 403 } 401 404 405 + mentions, _ := rp.refResolver.Resolve(r.Context(), body) 406 + 402 407 comment := models.IssueComment{ 403 408 Did: user.Did, 404 409 Rkey: tid.TID(), ··· 455 460 // notify about the new comment 456 461 comment.Id = commentId 457 462 458 - rawMentions := markup.FindUserMentions(comment.Body) 459 - idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions) 460 - l.Debug("parsed mentions", "raw", rawMentions, "idents", idents) 461 - var mentions []syntax.DID 462 - for _, ident := range idents { 463 - if ident != nil && !ident.Handle.IsInvalidHandle() { 464 - mentions = append(mentions, ident.DID) 465 - } 466 - } 467 463 rp.notifier.NewIssueComment(r.Context(), &comment, mentions) 468 464 469 465 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issue.IssueId, commentId)) ··· 884 880 RepoInfo: f.RepoInfo(user), 885 881 }) 886 882 case http.MethodPost: 883 + body := r.FormValue("body") 884 + mentions, _ := rp.refResolver.Resolve(r.Context(), body) 885 + 887 886 issue := &models.Issue{ 888 887 RepoAt: f.RepoAt(), 889 888 Rkey: tid.TID(), 890 889 Title: r.FormValue("title"), 891 - Body: r.FormValue("body"), 890 + Body: body, 892 891 Open: true, 893 892 Did: user.Did, 894 893 Created: time.Now(), ··· 960 959 // everything is successful, do not rollback the atproto record 961 960 atUri = "" 962 961 963 - rawMentions := markup.FindUserMentions(issue.Body) 964 - idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions) 965 - l.Debug("parsed mentions", "raw", rawMentions, "idents", idents) 966 - var mentions []syntax.DID 967 - for _, ident := range idents { 968 - if ident != nil && !ident.Handle.IsInvalidHandle() { 969 - mentions = append(mentions, ident.DID) 970 - } 971 - } 972 962 rp.notifier.NewIssue(r.Context(), issue, mentions) 973 963 rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issue.IssueId)) 974 964 return
+18
appview/models/reference.go
··· 1 + package models 2 + 3 + type RefKind int 4 + 5 + const ( 6 + RefKindIssue RefKind = iota 7 + RefKindPull 8 + ) 9 + 10 + // /@alice.com/cool-proj/issues/123 11 + // /@alice.com/cool-proj/issues/123#comment-321 12 + type ReferenceLink struct { 13 + Handle string 14 + Repo string 15 + Kind RefKind 16 + SubjectId int 17 + CommentId *int 18 + }
+3 -3
appview/pages/markup/extension/atlink.go
··· 16 16 17 17 // An AtNode struct represents an AtNode 18 18 type AtNode struct { 19 - handle string 19 + Handle string 20 20 ast.BaseInline 21 21 } 22 22 ··· 59 59 block.Advance(m[1]) 60 60 node := &AtNode{} 61 61 node.AppendChild(node, ast.NewTextSegment(atSegment)) 62 - node.handle = string(atSegment.Value(block.Source())[1:]) 62 + node.Handle = string(atSegment.Value(block.Source())[1:]) 63 63 return node 64 64 } 65 65 ··· 88 88 func (r *atHtmlRenderer) renderAt(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { 89 89 if entering { 90 90 w.WriteString(`<a href="/@`) 91 - w.WriteString(n.(*AtNode).handle) 91 + w.WriteString(n.(*AtNode).Handle) 92 92 w.WriteString(`" class="mention">`) 93 93 } else { 94 94 w.WriteString("</a>")
+24
appview/pages/markup/markdown.go
··· 77 77 return md 78 78 } 79 79 80 + // FindUserMentions returns Set of user handles from given markup soruce. 81 + // It doesn't guarntee unique DIDs 82 + func FindUserMentions(source string) []string { 83 + var ( 84 + mentions []string 85 + mentionsSet = make(map[string]struct{}) 86 + md = NewMarkdown() 87 + sourceBytes = []byte(source) 88 + root = md.Parser().Parse(text.NewReader(sourceBytes)) 89 + ) 90 + ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 91 + if entering && n.Kind() == textension.KindAt { 92 + handle := n.(*textension.AtNode).Handle 93 + mentionsSet[handle] = struct{}{} 94 + return ast.WalkSkipChildren, nil 95 + } 96 + return ast.WalkContinue, nil 97 + }) 98 + for handle := range mentionsSet { 99 + mentions = append(mentions, handle) 100 + } 101 + return mentions 102 + } 103 + 80 104 func (rctx *RenderContext) RenderMarkdown(source string) string { 81 105 md := NewMarkdown() 82 106
+124
appview/pages/markup/reference_link.go
··· 1 + package markup 2 + 3 + import ( 4 + "maps" 5 + "net/url" 6 + "path" 7 + "slices" 8 + "strconv" 9 + "strings" 10 + 11 + "github.com/yuin/goldmark/ast" 12 + "github.com/yuin/goldmark/text" 13 + "tangled.org/core/appview/models" 14 + textension "tangled.org/core/appview/pages/markup/extension" 15 + ) 16 + 17 + // FindReferences collects all links referencing tangled-related objects 18 + // like issues, PRs, comments or even @-mentions 19 + // This funciton doesn't actually check for the existence of records in the DB 20 + // or the PDS; it merely returns a list of what are presumed to be references. 21 + func FindReferences(baseUrl string, source string) ([]string, []models.ReferenceLink) { 22 + var ( 23 + refLinkSet = make(map[models.ReferenceLink]struct{}) 24 + mentionsSet = make(map[string]struct{}) 25 + md = NewMarkdown() 26 + sourceBytes = []byte(source) 27 + root = md.Parser().Parse(text.NewReader(sourceBytes)) 28 + ) 29 + // trim url scheme. the SSL shouldn't matter 30 + baseUrl = strings.TrimPrefix(baseUrl, "https://") 31 + baseUrl = strings.TrimPrefix(baseUrl, "http://") 32 + 33 + ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 34 + if !entering { 35 + return ast.WalkContinue, nil 36 + } 37 + switch n.Kind() { 38 + case textension.KindAt: 39 + handle := n.(*textension.AtNode).Handle 40 + mentionsSet[handle] = struct{}{} 41 + return ast.WalkSkipChildren, nil 42 + case ast.KindLink: 43 + dest := string(n.(*ast.Link).Destination) 44 + ref := parseTangledLink(baseUrl, dest) 45 + if ref != nil { 46 + refLinkSet[*ref] = struct{}{} 47 + } 48 + return ast.WalkSkipChildren, nil 49 + case ast.KindAutoLink: 50 + an := n.(*ast.AutoLink) 51 + if an.AutoLinkType == ast.AutoLinkURL { 52 + dest := string(an.URL(sourceBytes)) 53 + ref := parseTangledLink(baseUrl, dest) 54 + if ref != nil { 55 + refLinkSet[*ref] = struct{}{} 56 + } 57 + } 58 + return ast.WalkSkipChildren, nil 59 + } 60 + return ast.WalkContinue, nil 61 + }) 62 + mentions := slices.Collect(maps.Keys(mentionsSet)) 63 + references := slices.Collect(maps.Keys(refLinkSet)) 64 + return mentions, references 65 + } 66 + 67 + func parseTangledLink(baseHost string, urlStr string) *models.ReferenceLink { 68 + u, err := url.Parse(urlStr) 69 + if err != nil { 70 + return nil 71 + } 72 + 73 + if u.Host != "" && !strings.EqualFold(u.Host, baseHost) { 74 + return nil 75 + } 76 + 77 + p := path.Clean(u.Path) 78 + parts := strings.FieldsFunc(p, func(r rune) bool { return r == '/' }) 79 + if len(parts) < 4 { 80 + // need at least: handle / repo / kind / id 81 + return nil 82 + } 83 + 84 + var ( 85 + handle = parts[0] 86 + repo = parts[1] 87 + kindSeg = parts[2] 88 + subjectSeg = parts[3] 89 + ) 90 + 91 + handle = strings.TrimPrefix(handle, "@") 92 + 93 + var kind models.RefKind 94 + switch kindSeg { 95 + case "issues": 96 + kind = models.RefKindIssue 97 + case "pulls": 98 + kind = models.RefKindPull 99 + default: 100 + return nil 101 + } 102 + 103 + subjectId, err := strconv.Atoi(subjectSeg) 104 + if err != nil { 105 + return nil 106 + } 107 + var commentId *int 108 + if u.Fragment != "" { 109 + if strings.HasPrefix(u.Fragment, "comment-") { 110 + commentIdStr := u.Fragment[len("comment-"):] 111 + if id, err := strconv.Atoi(commentIdStr); err == nil { 112 + commentId = &id 113 + } 114 + } 115 + } 116 + 117 + return &models.ReferenceLink{ 118 + Handle: handle, 119 + Repo: repo, 120 + Kind: kind, 121 + SubjectId: subjectId, 122 + CommentId: commentId, 123 + } 124 + }
+6 -10
appview/pulls/pulls.go
··· 23 23 "tangled.org/core/appview/oauth" 24 24 "tangled.org/core/appview/pages" 25 25 "tangled.org/core/appview/pages/markup" 26 + "tangled.org/core/appview/refresolver" 26 27 "tangled.org/core/appview/reporesolver" 27 28 "tangled.org/core/appview/validator" 28 29 "tangled.org/core/appview/xrpcclient" ··· 45 46 repoResolver *reporesolver.RepoResolver 46 47 pages *pages.Pages 47 48 idResolver *idresolver.Resolver 49 + refResolver *refresolver.Resolver 48 50 db *db.DB 49 51 config *config.Config 50 52 notifier notify.Notifier ··· 59 61 repoResolver *reporesolver.RepoResolver, 60 62 pages *pages.Pages, 61 63 resolver *idresolver.Resolver, 64 + refResolver *refresolver.Resolver, 62 65 db *db.DB, 63 66 config *config.Config, 64 67 notifier notify.Notifier, ··· 72 75 repoResolver: repoResolver, 73 76 pages: pages, 74 77 idResolver: resolver, 78 + refResolver: refResolver, 75 79 db: db, 76 80 config: config, 77 81 notifier: notifier, ··· 691 695 } 692 696 693 697 func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { 694 - l := s.logger.With("handler", "PullComment") 695 698 user := s.oauth.GetUser(r) 696 699 f, err := s.repoResolver.Resolve(r) 697 700 if err != nil { ··· 729 732 s.pages.Notice(w, "pull", "Comment body is required") 730 733 return 731 734 } 735 + 736 + mentions, _ := s.refResolver.Resolve(r.Context(), body) 732 737 733 738 // Start a transaction 734 739 tx, err := s.db.BeginTx(r.Context(), nil) ··· 789 794 return 790 795 } 791 796 792 - rawMentions := markup.FindUserMentions(comment.Body) 793 - idents := s.idResolver.ResolveIdents(r.Context(), rawMentions) 794 - l.Debug("parsed mentions", "raw", rawMentions, "idents", idents) 795 - var mentions []syntax.DID 796 - for _, ident := range idents { 797 - if ident != nil && !ident.Handle.IsInvalidHandle() { 798 - mentions = append(mentions, ident.DID) 799 - } 800 - } 801 797 s.notifier.NewPullComment(r.Context(), comment, mentions) 802 798 803 799 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
+65
appview/refresolver/resolver.go
··· 1 + package refresolver 2 + 3 + import ( 4 + "context" 5 + "log/slog" 6 + 7 + "github.com/bluesky-social/indigo/atproto/syntax" 8 + "tangled.org/core/appview/config" 9 + "tangled.org/core/appview/db" 10 + "tangled.org/core/appview/models" 11 + "tangled.org/core/appview/pages/markup" 12 + "tangled.org/core/idresolver" 13 + ) 14 + 15 + type Resolver struct { 16 + config *config.Config 17 + idResolver *idresolver.Resolver 18 + execer db.Execer 19 + logger *slog.Logger 20 + } 21 + 22 + func New( 23 + config *config.Config, 24 + idResolver *idresolver.Resolver, 25 + execer db.Execer, 26 + logger *slog.Logger, 27 + ) *Resolver { 28 + return &Resolver{ 29 + config, 30 + idResolver, 31 + execer, 32 + logger, 33 + } 34 + } 35 + 36 + func (r *Resolver) Resolve(ctx context.Context, source string) ([]syntax.DID, []syntax.ATURI) { 37 + l := r.logger.With("method", "find_references") 38 + rawMentions, rawRefs := markup.FindReferences(r.config.Core.AppviewHost, source) 39 + l.Debug("found possible references", "mentions", rawMentions, "refs", rawRefs) 40 + idents := r.idResolver.ResolveIdents(ctx, rawMentions) 41 + var mentions []syntax.DID 42 + for _, ident := range idents { 43 + if ident != nil && !ident.Handle.IsInvalidHandle() { 44 + mentions = append(mentions, ident.DID) 45 + } 46 + } 47 + l.Debug("found mentions", "mentions", mentions) 48 + 49 + var resolvedRefs []models.ReferenceLink 50 + for _, rawRef := range rawRefs { 51 + ident, err := r.idResolver.ResolveIdent(ctx, rawRef.Handle) 52 + if err != nil || ident == nil || ident.Handle.IsInvalidHandle() { 53 + continue 54 + } 55 + rawRef.Handle = string(ident.DID) 56 + resolvedRefs = append(resolvedRefs, rawRef) 57 + } 58 + aturiRefs, err := db.FindReferences(r.execer, resolvedRefs) 59 + if err != nil { 60 + l.Error("failed running query", "err", err) 61 + } 62 + l.Debug("found references", "refs", aturiRefs) 63 + 64 + return mentions, aturiRefs 65 + }
+2
appview/state/router.go
··· 260 260 s.repoResolver, 261 261 s.pages, 262 262 s.idResolver, 263 + s.refResolver, 263 264 s.db, 264 265 s.config, 265 266 s.notifier, ··· 276 277 s.repoResolver, 277 278 s.pages, 278 279 s.idResolver, 280 + s.refResolver, 279 281 s.db, 280 282 s.config, 281 283 s.notifier,
+5
appview/state/state.go
··· 21 21 phnotify "tangled.org/core/appview/notify/posthog" 22 22 "tangled.org/core/appview/oauth" 23 23 "tangled.org/core/appview/pages" 24 + "tangled.org/core/appview/refresolver" 24 25 "tangled.org/core/appview/reporesolver" 25 26 "tangled.org/core/appview/validator" 26 27 xrpcclient "tangled.org/core/appview/xrpcclient" ··· 49 50 enforcer *rbac.Enforcer 50 51 pages *pages.Pages 51 52 idResolver *idresolver.Resolver 53 + refResolver *refresolver.Resolver 52 54 posthog posthog.Client 53 55 jc *jetstream.JetstreamClient 54 56 config *config.Config ··· 97 99 validator := validator.New(d, res, enforcer) 98 100 99 101 repoResolver := reporesolver.New(config, enforcer, res, d) 102 + 103 + refResolver := refresolver.New(config, res, d, log.SubLogger(logger, "refResolver")) 100 104 101 105 wrapper := db.DbWrapper{Execer: d} 102 106 jc, err := jetstream.NewJetstreamClient( ··· 178 182 enforcer, 179 183 pages, 180 184 res, 185 + refResolver, 181 186 posthog, 182 187 jc, 183 188 config,