From c7ab0f78cb7a4f8bb7880105e3117e5fa1d2235a Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Thu, 6 Nov 2025 17:59:13 +0900 Subject: [PATCH] appview: backlinks Change-Id: strluxumtqkotpptxzxxrvkxuvmpyqws currently all backlinks are parsed with markdown parser. So only explict urls like below are supported: - - [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 --- appview/db/pulls.go | 20 +- appview/db/reference.go | 212 ++++++++++++++++++ appview/issues/issues.go | 11 + appview/models/pull.go | 18 +- appview/models/reference.go | 31 +++ appview/pages/pages.go | 2 + .../templates/repo/fragments/backlinks.html | 49 ++++ .../pages/templates/repo/issues/issue.html | 3 + appview/pages/templates/repo/pulls/pull.html | 3 + appview/pulls/pulls.go | 23 +- 10 files changed, 365 insertions(+), 7 deletions(-) create mode 100644 appview/pages/templates/repo/fragments/backlinks.html diff --git a/appview/db/pulls.go b/appview/db/pulls.go index 25045425..e765a340 100644 --- a/appview/db/pulls.go +++ b/appview/db/pulls.go @@ -93,7 +93,15 @@ func NewPull(tx *sql.Tx, pull *models.Pull) error { insert into pull_submissions (pull_at, round_number, patch, combined, source_rev) values (?, ?, ?, ?, ?) `, pull.AtUri(), 0, pull.Submissions[0].Patch, pull.Submissions[0].Combined, pull.Submissions[0].SourceRev) - return err + if err != nil { + return err + } + + if err := putReferences(tx, pull.AtUri(), pull.References); err != nil { + return fmt.Errorf("put reference_links: %w", err) + } + + return nil } func GetPullAt(e Execer, repoAt syntax.ATURI, pullId int) (syntax.ATURI, error) { @@ -266,6 +274,16 @@ func GetPullsWithLimit(e Execer, limit int, filters ...filter) ([]*models.Pull, } } + allReferences, err := GetReferencesAll(e, FilterIn("from_at", pullAts)) + if err != nil { + return nil, fmt.Errorf("failed to query reference_links: %w", err) + } + for pullAt, references := range allReferences { + if pull, ok := pulls[pullAt]; ok { + pull.References = references + } + } + orderedByPullId := []*models.Pull{} for _, p := range pulls { orderedByPullId = append(orderedByPullId, p) diff --git a/appview/db/reference.go b/appview/db/reference.go index 4dabedb5..ca56d865 100644 --- a/appview/db/reference.go +++ b/appview/db/reference.go @@ -248,3 +248,215 @@ func GetReferencesAll(e Execer, filters ...filter) (map[syntax.ATURI][]syntax.AT return result, nil } + +func GetBacklinks(e Execer, target syntax.ATURI) ([]models.RichReferenceLink, error) { + rows, err := e.Query( + `select from_at from reference_links + where to_at = ?`, + target, + ) + if err != nil { + return nil, fmt.Errorf("query backlinks: %w", err) + } + defer rows.Close() + + var ( + backlinks []models.RichReferenceLink + backlinksMap = make(map[string][]syntax.ATURI) + ) + for rows.Next() { + var from syntax.ATURI + if err := rows.Scan(&from); err != nil { + return nil, fmt.Errorf("scan row: %w", err) + } + nsid := from.Collection().String() + backlinksMap[nsid] = append(backlinksMap[nsid], from) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate rows: %w", err) + } + + var ls []models.RichReferenceLink + ls, err = getIssueBacklinks(e, backlinksMap[tangled.RepoIssueNSID]) + if err != nil { + return nil, fmt.Errorf("get issue backlinks: %w", err) + } + backlinks = append(backlinks, ls...) + ls, err = getIssueCommentBacklinks(e, backlinksMap[tangled.RepoIssueCommentNSID]) + if err != nil { + return nil, fmt.Errorf("get issue_comment backlinks: %w", err) + } + backlinks = append(backlinks, ls...) + ls, err = getPullBacklinks(e, backlinksMap[tangled.RepoPullNSID]) + if err != nil { + return nil, fmt.Errorf("get pull backlinks: %w", err) + } + backlinks = append(backlinks, ls...) + ls, err = getPullCommentBacklinks(e, backlinksMap[tangled.RepoPullCommentNSID]) + if err != nil { + return nil, fmt.Errorf("get pull_comment backlinks: %w", err) + } + backlinks = append(backlinks, ls...) + + return backlinks, nil +} + +func getIssueBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) { + if len(aturis) == 0 { + return nil, nil + } + vals := make([]string, len(aturis)) + args := make([]any, 0, len(aturis)*2) + for i, aturi := range aturis { + vals[i] = "(?, ?)" + did := aturi.Authority().String() + rkey := aturi.RecordKey().String() + args = append(args, did, rkey) + } + rows, err := e.Query( + fmt.Sprintf( + `select r.did, r.name, i.issue_id, i.title, i.open + from issues i + join repos r + on r.at_uri = i.repo_at + where (i.did, i.rkey) in (%s)`, + strings.Join(vals, ","), + ), + args..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + var refLinks []models.RichReferenceLink + for rows.Next() { + var l models.RichReferenceLink + l.Kind = models.RefKindIssue + if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, &l.Title, &l.State); err != nil { + return nil, err + } + refLinks = append(refLinks, l) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate rows: %w", err) + } + return refLinks, nil +} + +func getIssueCommentBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) { + if len(aturis) == 0 { + return nil, nil + } + filter := FilterIn("c.at_uri", aturis) + rows, err := e.Query( + fmt.Sprintf( + `select r.did, r.name, i.issue_id, c.id, i.title, i.open + from issue_comments c + join issues i + on i.at_uri = c.issue_at + join repos r + on r.at_uri = i.repo_at + where %s`, + filter.Condition(), + ), + filter.Arg()..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + var refLinks []models.RichReferenceLink + for rows.Next() { + var l models.RichReferenceLink + l.Kind = models.RefKindIssue + l.CommentId = new(int) + if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, l.CommentId, &l.Title, &l.State); err != nil { + return nil, err + } + refLinks = append(refLinks, l) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate rows: %w", err) + } + return refLinks, nil +} + +func getPullBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) { + if len(aturis) == 0 { + return nil, nil + } + vals := make([]string, len(aturis)) + args := make([]any, 0, len(aturis)*2) + for i, aturi := range aturis { + vals[i] = "(?, ?)" + did := aturi.Authority().String() + rkey := aturi.RecordKey().String() + args = append(args, did, rkey) + } + rows, err := e.Query( + fmt.Sprintf( + `select r.did, r.name, p.pull_id, p.title, p.state + from pulls p + join repos r + on r.at_uri = p.repo_at + where (p.owner_did, p.rkey) in (%s)`, + strings.Join(vals, ","), + ), + args..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + var refLinks []models.RichReferenceLink + for rows.Next() { + var l models.RichReferenceLink + l.Kind = models.RefKindPull + if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, &l.Title, &l.State); err != nil { + return nil, err + } + refLinks = append(refLinks, l) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate rows: %w", err) + } + return refLinks, nil +} + +func getPullCommentBacklinks(e Execer, aturis []syntax.ATURI) ([]models.RichReferenceLink, error) { + if len(aturis) == 0 { + return nil, nil + } + filter := FilterIn("c.comment_at", aturis) + rows, err := e.Query( + fmt.Sprintf( + `select r.did, r.name, p.pull_id, c.id, p.title, p.state + from repos r + join pulls p + on r.at_uri = p.repo_at + join pull_comments c + on r.at_uri = c.repo_at and p.pull_id = c.pull_id + where %s`, + filter.Condition(), + ), + filter.Arg()..., + ) + if err != nil { + return nil, err + } + defer rows.Close() + var refLinks []models.RichReferenceLink + for rows.Next() { + var l models.RichReferenceLink + l.Kind = models.RefKindPull + l.CommentId = new(int) + if err := rows.Scan(&l.Handle, &l.Repo, &l.SubjectId, l.CommentId, &l.Title, &l.State); err != nil { + return nil, err + } + refLinks = append(refLinks, l) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate rows: %w", err) + } + return refLinks, nil +} diff --git a/appview/issues/issues.go b/appview/issues/issues.go index e860795e..3f5dc796 100644 --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -104,6 +104,13 @@ func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { userReactions = db.GetReactionStatusMap(rp.db, user.Did, issue.AtUri()) } + backlinks, err := db.GetBacklinks(rp.db, issue.AtUri()) + if err != nil { + l.Error("failed to fetch backlinks", "err", err) + rp.pages.Error503(w) + return + } + labelDefs, err := db.GetLabelDefinitions( rp.db, db.FilterIn("at_uri", f.Labels), @@ -125,6 +132,7 @@ func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { RepoInfo: rp.repoResolver.GetRepoInfo(r, user), Issue: issue, CommentList: issue.CommentList(), + Backlinks: backlinks, OrderedReactionKinds: models.OrderedReactionKinds, Reactions: reactionMap, UserReacted: userReactions, @@ -155,6 +163,7 @@ func (rp *Issues) EditIssue(w http.ResponseWriter, r *http.Request) { newIssue := issue newIssue.Title = r.FormValue("title") newIssue.Body = r.FormValue("body") + newIssue.Mentions, newIssue.References = rp.refResolver.Resolve(r.Context(), newIssue.Body) if err := rp.validator.ValidateIssue(newIssue); err != nil { l.Error("validation error", "err", err) @@ -575,6 +584,8 @@ func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) { newComment := comment newComment.Body = newBody newComment.Edited = &now + newComment.Mentions, newComment.References = rp.refResolver.Resolve(r.Context(), newBody) + record := newComment.AsRecord() tx, err := rp.db.Begin() diff --git a/appview/models/pull.go b/appview/models/pull.go index be5af82d..af34e9ea 100644 --- a/appview/models/pull.go +++ b/appview/models/pull.go @@ -66,6 +66,8 @@ type Pull struct { TargetBranch string State PullState Submissions []*PullSubmission + Mentions []syntax.DID + References []syntax.ATURI // stacking StackId string // nullable string @@ -92,11 +94,21 @@ func (p Pull) AsRecord() tangled.RepoPull { source.Repo = &s } } + mentions := make([]string, len(p.Mentions)) + for i, did := range p.Mentions { + mentions[i] = string(did) + } + references := make([]string, len(p.References)) + for i, uri := range p.References { + references[i] = string(uri) + } record := tangled.RepoPull{ - Title: p.Title, - Body: &p.Body, - CreatedAt: p.Created.Format(time.RFC3339), + Title: p.Title, + Body: &p.Body, + Mentions: mentions, + References: references, + CreatedAt: p.Created.Format(time.RFC3339), Target: &tangled.RepoPull_Target{ Repo: p.RepoAt.String(), Branch: p.TargetBranch, diff --git a/appview/models/reference.go b/appview/models/reference.go index 4c871825..732419f5 100644 --- a/appview/models/reference.go +++ b/appview/models/reference.go @@ -1,5 +1,7 @@ package models +import "fmt" + type RefKind int const ( @@ -7,6 +9,14 @@ const ( RefKindPull ) +func (k RefKind) String() string { + if k == RefKindIssue { + return "issues" + } else { + return "pulls" + } +} + // /@alice.com/cool-proj/issues/123 // /@alice.com/cool-proj/issues/123#comment-321 type ReferenceLink struct { @@ -16,3 +26,24 @@ type ReferenceLink struct { SubjectId int CommentId *int } + +func (l ReferenceLink) String() string { + comment := "" + if l.CommentId != nil { + comment = fmt.Sprintf("#comment-%d", *l.CommentId) + } + return fmt.Sprintf("/%s/%s/%s/%d%s", + l.Handle, + l.Repo, + l.Kind.String(), + l.SubjectId, + comment, + ) +} + +type RichReferenceLink struct { + ReferenceLink + Title string + // reusing PullState for both issue & PR + State PullState +} diff --git a/appview/pages/pages.go b/appview/pages/pages.go index 1965ecae..16c4f87f 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -934,6 +934,7 @@ type RepoSingleIssueParams struct { Active string Issue *models.Issue CommentList []models.CommentListItem + Backlinks []models.RichReferenceLink LabelDefs map[string]*models.LabelDefinition OrderedReactionKinds []models.ReactionKind @@ -1087,6 +1088,7 @@ type RepoSinglePullParams struct { Pull *models.Pull Stack models.Stack AbandonedPulls []*models.Pull + Backlinks []models.RichReferenceLink BranchDeleteStatus *models.BranchDeleteStatus MergeCheck types.MergeCheckResponse ResubmitCheck ResubmitResult diff --git a/appview/pages/templates/repo/fragments/backlinks.html b/appview/pages/templates/repo/fragments/backlinks.html new file mode 100644 index 00000000..9f10a887 --- /dev/null +++ b/appview/pages/templates/repo/fragments/backlinks.html @@ -0,0 +1,49 @@ +{{ define "repo/fragments/backlinks" }} + {{ if .Backlinks }} +
+
+ Referenced by +
+
    + {{ range .Backlinks }} +
  • + {{ $repoOwner := resolve .Handle }} + {{ $repoName := .Repo }} + {{ $repoUrl := printf "%s/%s" $repoOwner $repoName }} +
    +
    + {{ if .State.IsClosed }} + + {{ i "ban" "w-4 h-4" }} + + {{ else if eq .Kind.String "issues" }} + + {{ i "circle-dot" "w-4 h-4" }} + + {{ else if .State.IsOpen }} + + {{ i "git-pull-request" "w-4 h-4" }} + + {{ else if .State.IsMerged }} + + {{ i "git-merge" "w-4 h-4" }} + + {{ else }} + + {{ i "git-pull-request-closed" "w-4 h-4" }} + + {{ end }} + #{{ .SubjectId }} {{ .Title }} +
    + {{ if not (eq $.RepoInfo.FullName $repoUrl) }} + + {{ end }} +
    +
  • + {{ end }} +
+
+ {{ end }} +{{ end }} diff --git a/appview/pages/templates/repo/issues/issue.html b/appview/pages/templates/repo/issues/issue.html index 16821eff..8beb1fe5 100644 --- a/appview/pages/templates/repo/issues/issue.html +++ b/appview/pages/templates/repo/issues/issue.html @@ -20,6 +20,9 @@ "Subject" $.Issue.AtUri "State" $.Issue.Labels) }} {{ template "repo/fragments/participants" $.Issue.Participants }} + {{ template "repo/fragments/backlinks" + (dict "RepoInfo" $.RepoInfo + "Backlinks" $.Backlinks) }} {{ template "repo/fragments/externalLinkPanel" $.Issue.AtUri }} diff --git a/appview/pages/templates/repo/pulls/pull.html b/appview/pages/templates/repo/pulls/pull.html index 42160efc..0dca0b7e 100644 --- a/appview/pages/templates/repo/pulls/pull.html +++ b/appview/pages/templates/repo/pulls/pull.html @@ -21,6 +21,9 @@ "Subject" $.Pull.AtUri "State" $.Pull.Labels) }} {{ template "repo/fragments/participants" $.Pull.Participants }} + {{ template "repo/fragments/backlinks" + (dict "RepoInfo" $.RepoInfo + "Backlinks" $.Backlinks) }} {{ template "repo/fragments/externalLinkPanel" $.Pull.AtUri }} diff --git a/appview/pulls/pulls.go b/appview/pulls/pulls.go index b5b277b0..40864603 100644 --- a/appview/pulls/pulls.go +++ b/appview/pulls/pulls.go @@ -1,6 +1,7 @@ package pulls import ( + "context" "database/sql" "encoding/json" "errors" @@ -155,6 +156,13 @@ func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) { return } + backlinks, err := db.GetBacklinks(s.db, pull.AtUri()) + if err != nil { + log.Println("failed to get pull backlinks", err) + s.pages.Notice(w, "pull-error", "Failed to get pull. Try again later.") + return + } + // can be nil if this pull is not stacked stack, _ := r.Context().Value("stack").(models.Stack) abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) @@ -229,6 +237,7 @@ func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) { Pull: pull, Stack: stack, AbandonedPulls: abandonedPulls, + Backlinks: backlinks, BranchDeleteStatus: branchDeleteStatus, MergeCheck: mergeCheckResponse, ResubmitCheck: resubmitResult, @@ -1196,6 +1205,8 @@ func (s *Pulls) createPullRequest( } } + mentions, references := s.refResolver.Resolve(r.Context(), body) + rkey := tid.TID() initialSubmission := models.PullSubmission{ Patch: patch, @@ -1209,6 +1220,8 @@ func (s *Pulls) createPullRequest( OwnerDid: user.Did, RepoAt: repo.RepoAt(), Rkey: rkey, + Mentions: mentions, + References: references, Submissions: []*models.PullSubmission{ &initialSubmission, }, @@ -1297,7 +1310,7 @@ func (s *Pulls) createStackedPullRequest( // build a stack out of this patch stackId := uuid.New() - stack, err := newStack(repo, user, targetBranch, patch, pullSource, stackId.String()) + stack, err := s.newStack(r.Context(), repo, user, targetBranch, patch, pullSource, stackId.String()) if err != nil { log.Println("failed to create stack", err) s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err)) @@ -1911,7 +1924,7 @@ func (s *Pulls) resubmitStackedPullHelper( targetBranch := pull.TargetBranch origStack, _ := r.Context().Value("stack").(models.Stack) - newStack, err := newStack(repo, user, targetBranch, patch, pull.PullSource, stackId) + newStack, err := s.newStack(r.Context(), repo, user, targetBranch, patch, pull.PullSource, stackId) if err != nil { log.Println("failed to create resubmitted stack", err) s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") @@ -2359,7 +2372,7 @@ func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) } -func newStack(repo *models.Repo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { +func (s *Pulls) newStack(ctx context.Context, repo *models.Repo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { formatPatches, err := patchutil.ExtractPatches(patch) if err != nil { return nil, fmt.Errorf("Failed to extract patches: %v", err) @@ -2384,6 +2397,8 @@ func newStack(repo *models.Repo, user *oauth.User, targetBranch, patch string, p body := fp.Body rkey := tid.TID() + mentions, references := s.refResolver.Resolve(ctx, body) + initialSubmission := models.PullSubmission{ Patch: fp.Raw, SourceRev: fp.SHA, @@ -2396,6 +2411,8 @@ func newStack(repo *models.Repo, user *oauth.User, targetBranch, patch string, p OwnerDid: user.Did, RepoAt: repo.RepoAt(), Rkey: rkey, + Mentions: mentions, + References: references, Submissions: []*models.PullSubmission{ &initialSubmission, }, -- 2.43.0