forked from tangled.org/core
Monorepo for Tangled

appview: backlinks

currently all backlinks are parsed with markdown parser.
So only explict urls like below are supported:

- <https://tangled.org/owner.com/repo-name/issues/123>
- [full url](https://tangled.org/owner.com/repo-name/issues/123)
- [absolute path](/owner.com/repo-name/issues/123)

Also `#comment-123` fragment is supported too.

All references in issue/pull/comment records are stored in at-uri
format. we do have a `mentions` field, but it isn't used yet. mentions
are parsed on rendering and aren't stored anywhere for now.

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

authored by boltless.me and committed by Tangled 442e2ab1 290b2ac5

Changed files
+365 -7
appview
db
issues
models
pages
templates
repo
fragments
issues
pulls
pulls
+19 -1
appview/db/pulls.go
··· 93 93 insert into pull_submissions (pull_at, round_number, patch, combined, source_rev) 94 94 values (?, ?, ?, ?, ?) 95 95 `, pull.AtUri(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev) 96 - return err 96 + if err != nil { 97 + return err 98 + } 99 + 100 + if err := putReferences(tx, pull.AtUri(), pull.References); err != nil { 101 + return fmt.Errorf("put reference_links: %w", err) 102 + } 103 + 104 + return nil 97 105 } 98 106 99 107 func GetPullAt(e Execer, repoAt syntax.ATURI, pullId int) (syntax.ATURI, error) { ··· 263 271 if sourceRepo, ok := sourceRepoMap[*p.PullSource.RepoAt]; ok { 264 272 p.PullSource.Repo = sourceRepo 265 273 } 274 + } 275 + } 276 + 277 + allReferences, err := GetReferencesAll(e, FilterIn("from_at", pullAts)) 278 + if err != nil { 279 + return nil, fmt.Errorf("failed to query reference_links: %w", err) 280 + } 281 + for pullAt, references := range allReferences { 282 + if pull, ok := pulls[pullAt]; ok { 283 + pull.References = references 266 284 } 267 285 } 268 286
+212
appview/db/reference.go
··· 248 248 249 249 return result, nil 250 250 } 251 + 252 + func GetBacklinks(e Execer, target syntax.ATURI) ([]models.RichReferenceLink, error) { 253 + rows, err := e.Query( 254 + `select from_at from reference_links 255 + where to_at = ?`, 256 + target, 257 + ) 258 + if err != nil { 259 + return nil, fmt.Errorf("query backlinks: %w", err) 260 + } 261 + defer rows.Close() 262 + 263 + var ( 264 + backlinks []models.RichReferenceLink 265 + backlinksMap = make(map[string][]syntax.ATURI) 266 + ) 267 + for rows.Next() { 268 + var from syntax.ATURI 269 + if err := rows.Scan(&from); err != nil { 270 + return nil, fmt.Errorf("scan row: %w", err) 271 + } 272 + nsid := from.Collection().String() 273 + backlinksMap[nsid] = append(backlinksMap[nsid], from) 274 + } 275 + if err := rows.Err(); err != nil { 276 + return nil, fmt.Errorf("iterate rows: %w", err) 277 + } 278 + 279 + var ls []models.RichReferenceLink 280 + ls, err = getIssueBacklinks(e, backlinksMap[tangled.RepoIssueNSID]) 281 + if err != nil { 282 + return nil, fmt.Errorf("get issue backlinks: %w", err) 283 + } 284 + backlinks = append(backlinks, ls...) 285 + ls, err = getIssueCommentBacklinks(e, backlinksMap[tangled.RepoIssueCommentNSID]) 286 + if err != nil { 287 + return nil, fmt.Errorf("get issue_comment backlinks: %w", err) 288 + } 289 + backlinks = append(backlinks, ls...) 290 + ls, err = getPullBacklinks(e, backlinksMap[tangled.RepoPullNSID]) 291 + if err != nil { 292 + return nil, fmt.Errorf("get pull backlinks: %w", err) 293 + } 294 + backlinks = append(backlinks, ls...) 295 + ls, err = getPullCommentBacklinks(e, backlinksMap[tangled.RepoPullCommentNSID]) 296 + if err != nil { 297 + return nil, fmt.Errorf("get pull_comment backlinks: %w", err) 298 + } 299 + backlinks = append(backlinks, ls...) 300 + 301 + return backlinks, nil 302 + } 303 + 304 + func getIssueBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) { 305 + if len(aturis) == 0 { 306 + return nil, nil 307 + } 308 + vals := make([]string, len(aturis)) 309 + args := make([]any, 0, len(aturis)*2) 310 + for i, aturi := range aturis { 311 + vals[i] = "(?, ?)" 312 + did := aturi.Authority().String() 313 + rkey := aturi.RecordKey().String() 314 + args = append(args, did, rkey) 315 + } 316 + rows, err := e.Query( 317 + fmt.Sprintf( 318 + `select r.did, r.name, i.issue_id, i.title, i.open 319 + from issues i 320 + join repos r 321 + on r.at_uri = i.repo_at 322 + where (i.did, i.rkey) in (%s)`, 323 + strings.Join(vals, ","), 324 + ), 325 + args..., 326 + ) 327 + if err != nil { 328 + return nil, err 329 + } 330 + defer rows.Close() 331 + var refLinks []models.RichReferenceLink 332 + for rows.Next() { 333 + var l models.RichReferenceLink 334 + l.Kind = models.RefKindIssue 335 + if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, &l.Title, &l.State); err != nil { 336 + return nil, err 337 + } 338 + refLinks = append(refLinks, l) 339 + } 340 + if err := rows.Err(); err != nil { 341 + return nil, fmt.Errorf("iterate rows: %w", err) 342 + } 343 + return refLinks, nil 344 + } 345 + 346 + func getIssueCommentBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) { 347 + if len(aturis) == 0 { 348 + return nil, nil 349 + } 350 + filter := FilterIn("c.at_uri", aturis) 351 + rows, err := e.Query( 352 + fmt.Sprintf( 353 + `select r.did, r.name, i.issue_id, c.id, i.title, i.open 354 + from issue_comments c 355 + join issues i 356 + on i.at_uri = c.issue_at 357 + join repos r 358 + on r.at_uri = i.repo_at 359 + where %s`, 360 + filter.Condition(), 361 + ), 362 + filter.Arg()..., 363 + ) 364 + if err != nil { 365 + return nil, err 366 + } 367 + defer rows.Close() 368 + var refLinks []models.RichReferenceLink 369 + for rows.Next() { 370 + var l models.RichReferenceLink 371 + l.Kind = models.RefKindIssue 372 + l.CommentId = new(int) 373 + if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, l.CommentId, &l.Title, &l.State); err != nil { 374 + return nil, err 375 + } 376 + refLinks = append(refLinks, l) 377 + } 378 + if err := rows.Err(); err != nil { 379 + return nil, fmt.Errorf("iterate rows: %w", err) 380 + } 381 + return refLinks, nil 382 + } 383 + 384 + func getPullBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) { 385 + if len(aturis) == 0 { 386 + return nil, nil 387 + } 388 + vals := make([]string, len(aturis)) 389 + args := make([]any, 0, len(aturis)*2) 390 + for i, aturi := range aturis { 391 + vals[i] = "(?, ?)" 392 + did := aturi.Authority().String() 393 + rkey := aturi.RecordKey().String() 394 + args = append(args, did, rkey) 395 + } 396 + rows, err := e.Query( 397 + fmt.Sprintf( 398 + `select r.did, r.name, p.pull_id, p.title, p.state 399 + from pulls p 400 + join repos r 401 + on r.at_uri = p.repo_at 402 + where (p.owner_did, p.rkey) in (%s)`, 403 + strings.Join(vals, ","), 404 + ), 405 + args..., 406 + ) 407 + if err != nil { 408 + return nil, err 409 + } 410 + defer rows.Close() 411 + var refLinks []models.RichReferenceLink 412 + for rows.Next() { 413 + var l models.RichReferenceLink 414 + l.Kind = models.RefKindPull 415 + if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, &l.Title, &l.State); err != nil { 416 + return nil, err 417 + } 418 + refLinks = append(refLinks, l) 419 + } 420 + if err := rows.Err(); err != nil { 421 + return nil, fmt.Errorf("iterate rows: %w", err) 422 + } 423 + return refLinks, nil 424 + } 425 + 426 + func getPullCommentBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) { 427 + if len(aturis) == 0 { 428 + return nil, nil 429 + } 430 + filter := FilterIn("c.comment_at", aturis) 431 + rows, err := e.Query( 432 + fmt.Sprintf( 433 + `select r.did, r.name, p.pull_id, c.id, p.title, p.state 434 + from repos r 435 + join pulls p 436 + on r.at_uri = p.repo_at 437 + join pull_comments c 438 + on r.at_uri = c.repo_at and p.pull_id = c.pull_id 439 + where %s`, 440 + filter.Condition(), 441 + ), 442 + filter.Arg()..., 443 + ) 444 + if err != nil { 445 + return nil, err 446 + } 447 + defer rows.Close() 448 + var refLinks []models.RichReferenceLink 449 + for rows.Next() { 450 + var l models.RichReferenceLink 451 + l.Kind = models.RefKindPull 452 + l.CommentId = new(int) 453 + if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, l.CommentId, &l.Title, &l.State); err != nil { 454 + return nil, err 455 + } 456 + refLinks = append(refLinks, l) 457 + } 458 + if err := rows.Err(); err != nil { 459 + return nil, fmt.Errorf("iterate rows: %w", err) 460 + } 461 + return refLinks, nil 462 + }
+11
appview/issues/issues.go
··· 104 104 userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) 105 105 } 106 106 107 + backlinks, err := db.GetBacklinks(rp.db, issue.AtUri()) 108 + if err != nil { 109 + l.Error("failed to fetch backlinks", "err", err) 110 + rp.pages.Error503(w) 111 + return 112 + } 113 + 107 114 labelDefs, err := db.GetLabelDefinitions( 108 115 rp.db, 109 116 db.FilterIn("at_uri", f.Labels), ··· 125 132 RepoInfo: rp.repoResolver.GetRepoInfo(r, user), 126 133 Issue: issue, 127 134 CommentList: issue.CommentList(), 135 + Backlinks: backlinks, 128 136 OrderedReactionKinds: models.OrderedReactionKinds, 129 137 Reactions: reactionMap, 130 138 UserReacted: userReactions, ··· 155 163 newIssue := issue 156 164 newIssue.Title = r.FormValue("title") 157 165 newIssue.Body = r.FormValue("body") 166 + newIssue.Mentions, newIssue.References = rp.refResolver.Resolve(r.Context(), newIssue.Body) 158 167 159 168 if err := rp.validator.ValidateIssue(newIssue); err != nil { 160 169 l.Error("validation error", "err", err) ··· 575 584 newComment := comment 576 585 newComment.Body = newBody 577 586 newComment.Edited = &now 587 + newComment.Mentions, newComment.References = rp.refResolver.Resolve(r.Context(), newBody) 588 + 578 589 record := newComment.AsRecord() 579 590 580 591 tx, err := rp.db.Begin()
+15 -3
appview/models/pull.go
··· 66 66 TargetBranch string 67 67 State PullState 68 68 Submissions []*PullSubmission 69 + Mentions []syntax.DID 70 + References []syntax.ATURI 69 71 70 72 // stacking 71 73 StackId string // nullable string ··· 92 94 source.Repo = &s 93 95 } 94 96 } 97 + mentions := make([]string, len(p.Mentions)) 98 + for i, did := range p.Mentions { 99 + mentions[i] = string(did) 100 + } 101 + references := make([]string, len(p.References)) 102 + for i, uri := range p.References { 103 + references[i] = string(uri) 104 + } 95 105 96 106 record := tangled.RepoPull{ 97 - Title: p.Title, 98 - Body: &p.Body, 99 - CreatedAt: p.Created.Format(time.RFC3339), 107 + Title: p.Title, 108 + Body: &p.Body, 109 + Mentions: mentions, 110 + References: references, 111 + CreatedAt: p.Created.Format(time.RFC3339), 100 112 Target: &tangled.RepoPull_Target{ 101 113 Repo: p.RepoAt.String(), 102 114 Branch: p.TargetBranch,
+31
appview/models/reference.go
··· 1 1 package models 2 2 3 + import "fmt" 4 + 3 5 type RefKind int 4 6 5 7 const ( ··· 7 9 RefKindPull 8 10 ) 9 11 12 + func (k RefKind) String() string { 13 + if k == RefKindIssue { 14 + return "issues" 15 + } else { 16 + return "pulls" 17 + } 18 + } 19 + 10 20 // /@alice.com/cool-proj/issues/123 11 21 // /@alice.com/cool-proj/issues/123#comment-321 12 22 type ReferenceLink struct { ··· 16 26 SubjectId int 17 27 CommentId *int 18 28 } 29 + 30 + func (l ReferenceLink) String() string { 31 + comment := "" 32 + if l.CommentId != nil { 33 + comment = fmt.Sprintf("#comment-%d", *l.CommentId) 34 + } 35 + return fmt.Sprintf("/%s/%s/%s/%d%s", 36 + l.Handle, 37 + l.Repo, 38 + l.Kind.String(), 39 + l.SubjectId, 40 + comment, 41 + ) 42 + } 43 + 44 + type RichReferenceLink struct { 45 + ReferenceLink 46 + Title string 47 + // reusing PullState for both issue & PR 48 + State PullState 49 + }
+2
appview/pages/pages.go
··· 934 934 Active string 935 935 Issue *models.Issue 936 936 CommentList []models.CommentListItem 937 + Backlinks []models.RichReferenceLink 937 938 LabelDefs map[string]*models.LabelDefinition 938 939 939 940 OrderedReactionKinds []models.ReactionKind ··· 1087 1088 Pull *models.Pull 1088 1089 Stack models.Stack 1089 1090 AbandonedPulls []*models.Pull 1091 + Backlinks []models.RichReferenceLink 1090 1092 BranchDeleteStatus *models.BranchDeleteStatus 1091 1093 MergeCheck types.MergeCheckResponse 1092 1094 ResubmitCheck ResubmitResult
+49
appview/pages/templates/repo/fragments/backlinks.html
··· 1 + {{ define "repo/fragments/backlinks" }} 2 + {{ if .Backlinks }} 3 + <div id="at-uri-panel" class="px-2 md:px-0"> 4 + <div> 5 + <span class="text-sm py-1 font-bold text-gray-500 dark:text-gray-400">Referenced by</span> 6 + </div> 7 + <ul> 8 + {{ range .Backlinks }} 9 + <li> 10 + {{ $repoOwner := resolve .Handle }} 11 + {{ $repoName := .Repo }} 12 + {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} 13 + <div class="flex flex-col"> 14 + <div class="flex gap-2 items-center"> 15 + {{ if .State.IsClosed }} 16 + <span class="text-gray-500 dark:text-gray-400"> 17 + {{ i "ban" "w-4 h-4" }} 18 + </span> 19 + {{ else if eq .Kind.String "issues" }} 20 + <span class="text-green-600 dark:text-green-500"> 21 + {{ i "circle-dot" "w-4 h-4" }} 22 + </span> 23 + {{ else if .State.IsOpen }} 24 + <span class="text-green-600 dark:text-green-500"> 25 + {{ i "git-pull-request" "w-4 h-4" }} 26 + </span> 27 + {{ else if .State.IsMerged }} 28 + <span class="text-purple-600 dark:text-purple-500"> 29 + {{ i "git-merge" "w-4 h-4" }} 30 + </span> 31 + {{ else }} 32 + <span class="text-gray-600 dark:text-gray-300"> 33 + {{ i "git-pull-request-closed" "w-4 h-4" }} 34 + </span> 35 + {{ end }} 36 + <a href="{{ . }}"><span class="text-gray-500 dark:text-gray-400">#{{ .SubjectId }}</span> {{ .Title }}</a> 37 + </div> 38 + {{ if not (eq $.RepoInfo.FullName $repoUrl) }} 39 + <div> 40 + <span>on <a href="/{{ $repoUrl }}">{{ $repoUrl }}</a></span> 41 + </div> 42 + {{ end }} 43 + </div> 44 + </li> 45 + {{ end }} 46 + </ul> 47 + </div> 48 + {{ end }} 49 + {{ end }}
+3
appview/pages/templates/repo/issues/issue.html
··· 20 20 "Subject" $.Issue.AtUri 21 21 "State" $.Issue.Labels) }} 22 22 {{ template "repo/fragments/participants" $.Issue.Participants }} 23 + {{ template "repo/fragments/backlinks" 24 + (dict "RepoInfo" $.RepoInfo 25 + "Backlinks" $.Backlinks) }} 23 26 {{ template "repo/fragments/externalLinkPanel" $.Issue.AtUri }} 24 27 </div> 25 28 </div>
+3
appview/pages/templates/repo/pulls/pull.html
··· 21 21 "Subject" $.Pull.AtUri 22 22 "State" $.Pull.Labels) }} 23 23 {{ template "repo/fragments/participants" $.Pull.Participants }} 24 + {{ template "repo/fragments/backlinks" 25 + (dict "RepoInfo" $.RepoInfo 26 + "Backlinks" $.Backlinks) }} 24 27 {{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }} 25 28 </div> 26 29 </div>
+20 -3
appview/pulls/pulls.go
··· 1 1 package pulls 2 2 3 3 import ( 4 + "context" 4 5 "database/sql" 5 6 "encoding/json" 6 7 "errors" ··· 155 156 return 156 157 } 157 158 159 + backlinks, err := db.GetBacklinks(s.db, pull.AtUri()) 160 + if err != nil { 161 + log.Println("failed to get pull backlinks", err) 162 + s.pages.Notice(w, "pull-error", "Failed to get pull. Try again later.") 163 + return 164 + } 165 + 158 166 // can be nil if this pull is not stacked 159 167 stack, _ := r.Context().Value("stack").(models.Stack) 160 168 abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) ··· 229 237 Pull: pull, 230 238 Stack: stack, 231 239 AbandonedPulls: abandonedPulls, 240 + Backlinks: backlinks, 232 241 BranchDeleteStatus: branchDeleteStatus, 233 242 MergeCheck: mergeCheckResponse, 234 243 ResubmitCheck: resubmitResult, ··· 1196 1205 } 1197 1206 } 1198 1207 1208 + mentions, references := s.refResolver.Resolve(r.Context(), body) 1209 + 1199 1210 rkey := tid.TID() 1200 1211 initialSubmission := models.PullSubmission{ 1201 1212 Patch: patch, ··· 1209 1220 OwnerDid: user.Did, 1210 1221 RepoAt: repo.RepoAt(), 1211 1222 Rkey: rkey, 1223 + Mentions: mentions, 1224 + References: references, 1212 1225 Submissions: []*models.PullSubmission{ 1213 1226 &initialSubmission, 1214 1227 }, ··· 1297 1310 1298 1311 // build a stack out of this patch 1299 1312 stackId := uuid.New() 1300 - stack, err := newStack(repo, user, targetBranch, patch, pullSource, stackId.String()) 1313 + stack, err := s.newStack(r.Context(), repo, user, targetBranch, patch, pullSource, stackId.String()) 1301 1314 if err != nil { 1302 1315 log.Println("failed to create stack", err) 1303 1316 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err)) ··· 1911 1924 targetBranch := pull.TargetBranch 1912 1925 1913 1926 origStack, _ := r.Context().Value("stack").(models.Stack) 1914 - newStack, err := newStack(repo, user, targetBranch, patch, pull.PullSource, stackId) 1927 + newStack, err := s.newStack(r.Context(), repo, user, targetBranch, patch, pull.PullSource, stackId) 1915 1928 if err != nil { 1916 1929 log.Println("failed to create resubmitted stack", err) 1917 1930 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") ··· 2359 2372 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2360 2373 } 2361 2374 2362 - func newStack(repo *models.Repo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { 2375 + func (s *Pulls) newStack(ctx context.Context, repo *models.Repo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { 2363 2376 formatPatches, err := patchutil.ExtractPatches(patch) 2364 2377 if err != nil { 2365 2378 return nil, fmt.Errorf("Failed to extract patches: %v", err) ··· 2384 2397 body := fp.Body 2385 2398 rkey := tid.TID() 2386 2399 2400 + mentions, references := s.refResolver.Resolve(ctx, body) 2401 + 2387 2402 initialSubmission := models.PullSubmission{ 2388 2403 Patch: fp.Raw, 2389 2404 SourceRev: fp.SHA, ··· 2396 2411 OwnerDid: user.Did, 2397 2412 RepoAt: repo.RepoAt(), 2398 2413 Rkey: rkey, 2414 + Mentions: mentions, 2415 + References: references, 2399 2416 Submissions: []*models.PullSubmission{ 2400 2417 &initialSubmission, 2401 2418 },