forked from tangled.org/core
Monorepo for Tangled

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>

authored by boltless.me and committed by Tangled d2dcc711 4b0a917e

Changed files
+444 -54
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
··· 23 23 "tangled.org/core/appview/notify" 24 24 "tangled.org/core/appview/oauth" 25 25 "tangled.org/core/appview/pages" 26 - "tangled.org/core/appview/pages/markup" 27 26 "tangled.org/core/appview/pages/repoinfo" 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" ··· 39 39 enforcer *rbac.Enforcer 40 40 pages *pages.Pages 41 41 idResolver *idresolver.Resolver 42 + refResolver *refresolver.Resolver 42 43 db *db.DB 43 44 config *config.Config 44 45 notifier notify.Notifier ··· 53 54 enforcer *rbac.Enforcer, 54 55 pages *pages.Pages, 55 56 idResolver *idresolver.Resolver, 57 + refResolver *refresolver.Resolver, 56 58 db *db.DB, 57 59 config *config.Config, 58 60 notifier notify.Notifier, ··· 66 68 enforcer: enforcer, 67 69 pages: pages, 68 70 idResolver: idResolver, 71 + refResolver: refResolver, 69 72 db: db, 70 73 config: config, 71 74 notifier: notifier, ··· 391 394 replyTo = &replyToUri 392 395 } 393 396 397 + mentions, _ := rp.refResolver.Resolve(r.Context(), body) 398 + 394 399 comment := models.IssueComment{ 395 400 Did: user.Did, 396 401 Rkey: tid.TID(), ··· 447 452 // notify about the new comment 448 453 comment.Id = commentId 449 454 450 - rawMentions := markup.FindUserMentions(comment.Body) 451 - idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions) 452 - l.Debug("parsed mentions", "raw", rawMentions, "idents", idents) 453 - var mentions []syntax.DID 454 - for _, ident := range idents { 455 - if ident != nil && !ident.Handle.IsInvalidHandle() { 456 - mentions = append(mentions, ident.DID) 457 - } 458 - } 459 455 rp.notifier.NewIssueComment(r.Context(), &comment, mentions) 460 456 461 457 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) ··· 870 866 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 871 867 }) 872 868 case http.MethodPost: 869 + body := r.FormValue("body") 870 + mentions, _ := rp.refResolver.Resolve(r.Context(), body) 871 + 873 872 issue := &models.Issue{ 874 873 RepoAt: f.RepoAt(), 875 874 Rkey: tid.TID(), 876 875 Title: r.FormValue("title"), 877 - Body: r.FormValue("body"), 876 + Body: body, 878 877 Open: true, 879 878 Did: user.Did, 880 879 Created: time.Now(), ··· 946 945 // everything is successful, do not rollback the atproto record 947 946 atUri = "" 948 947 949 - rawMentions := markup.FindUserMentions(issue.Body) 950 - idents := rp.idResolver.ResolveIdents(r.Context(), rawMentions) 951 - l.Debug("parsed mentions", "raw", rawMentions, "idents", idents) 952 - var mentions []syntax.DID 953 - for _, ident := range idents { 954 - if ident != nil && !ident.Handle.IsInvalidHandle() { 955 - mentions = append(mentions, ident.DID) 956 - } 957 - } 958 948 rp.notifier.NewIssue(r.Context(), issue, mentions) 959 949 960 950 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
+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 + }
-24
appview/pages/markup/markdown.go
··· 304 304 return path.Join(rctx.CurrentDir, dst) 305 305 } 306 306 307 - // FindUserMentions returns Set of user handles from given markup soruce. 308 - // It doesn't guarntee unique DIDs 309 - func FindUserMentions(source string) []string { 310 - var ( 311 - mentions []string 312 - mentionsSet = make(map[string]struct{}) 313 - md = NewMarkdown() 314 - sourceBytes = []byte(source) 315 - root = md.Parser().Parse(text.NewReader(sourceBytes)) 316 - ) 317 - ast.Walk(root, func(n ast.Node, entering bool) (ast.WalkStatus, error) { 318 - if entering && n.Kind() == textension.KindAt { 319 - handle := n.(*textension.AtNode).Handle 320 - mentionsSet[handle] = struct{}{} 321 - return ast.WalkSkipChildren, nil 322 - } 323 - return ast.WalkContinue, nil 324 - }) 325 - for handle := range mentionsSet { 326 - mentions = append(mentions, handle) 327 - } 328 - return mentions 329 - } 330 - 331 307 func isAbsoluteUrl(link string) bool { 332 308 parsed, err := url.Parse(link) 333 309 if err != nil {
+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
··· 24 24 "tangled.org/core/appview/pages" 25 25 "tangled.org/core/appview/pages/markup" 26 26 "tangled.org/core/appview/pages/repoinfo" 27 + "tangled.org/core/appview/refresolver" 27 28 "tangled.org/core/appview/reporesolver" 28 29 "tangled.org/core/appview/validator" 29 30 "tangled.org/core/appview/xrpcclient" ··· 46 47 repoResolver *reporesolver.RepoResolver 47 48 pages *pages.Pages 48 49 idResolver *idresolver.Resolver 50 + refResolver *refresolver.Resolver 49 51 db *db.DB 50 52 config *config.Config 51 53 notifier notify.Notifier ··· 60 62 repoResolver *reporesolver.RepoResolver, 61 63 pages *pages.Pages, 62 64 resolver *idresolver.Resolver, 65 + refResolver *refresolver.Resolver, 63 66 db *db.DB, 64 67 config *config.Config, 65 68 notifier notify.Notifier, ··· 73 76 repoResolver: repoResolver, 74 77 pages: pages, 75 78 idResolver: resolver, 79 + refResolver: refResolver, 76 80 db: db, 77 81 config: config, 78 82 notifier: notifier, ··· 678 682 } 679 683 680 684 func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { 681 - l := s.logger.With("handler", "PullComment") 682 685 user := s.oauth.GetUser(r) 683 686 f, err := s.repoResolver.Resolve(r) 684 687 if err != nil { ··· 716 719 s.pages.Notice(w, "pull", "Comment body is required") 717 720 return 718 721 } 722 + 723 + mentions, _ := s.refResolver.Resolve(r.Context(), body) 719 724 720 725 // Start a transaction 721 726 tx, err := s.db.BeginTx(r.Context(), nil) ··· 776 781 return 777 782 } 778 783 779 - rawMentions := markup.FindUserMentions(comment.Body) 780 - idents := s.idResolver.ResolveIdents(r.Context(), rawMentions) 781 - l.Debug("parsed mentions", "raw", rawMentions, "idents", idents) 782 - var mentions []syntax.DID 783 - for _, ident := range idents { 784 - if ident != nil && !ident.Handle.IsInvalidHandle() { 785 - mentions = append(mentions, ident.DID) 786 - } 787 - } 788 784 s.notifier.NewPullComment(r.Context(), comment, mentions) 789 785 790 786 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
+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
··· 266 266 s.enforcer, 267 267 s.pages, 268 268 s.idResolver, 269 + s.refResolver, 269 270 s.db, 270 271 s.config, 271 272 s.notifier, ··· 282 283 s.repoResolver, 283 284 s.pages, 284 285 s.idResolver, 286 + s.refResolver, 285 287 s.db, 286 288 s.config, 287 289 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, 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,