From c1a08815c72aaf3fb3308fc86b6438f72ce0c7f7 Mon Sep 17 00:00:00 2001 From: Seongmin Lee Date: Thu, 6 Nov 2025 17:59:13 +0900 Subject: [PATCH] draft: render backlinks Change-Id: strluxumtqkotpptxzxxrvkxuvmpyqws Signed-off-by: Seongmin Lee --- appview/db/reference.go | 212 ++++++++++++++++++ appview/issues/issues.go | 8 + 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 | 8 + 8 files changed, 316 insertions(+) create mode 100644 appview/pages/templates/repo/fragments/backlinks.html diff --git a/appview/db/reference.go b/appview/db/reference.go index 6018a3b0..d7c3c5c0 100644 --- a/appview/db/reference.go +++ b/appview/db/reference.go @@ -249,3 +249,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 + join repos r + join pulls p + on r.at_uri = i.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.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 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 0a3b2d1d..cf200764 100644 --- a/appview/issues/issues.go +++ b/appview/issues/issues.go @@ -100,6 +100,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.Repo.Labels), @@ -121,6 +128,7 @@ func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) { RepoInfo: f.RepoInfo(user), Issue: issue, CommentList: issue.CommentList(), + Backlinks: backlinks, OrderedReactionKinds: models.OrderedReactionKinds, Reactions: reactionMap, UserReacted: userReactions, 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 7771121d..034713c9 100644 --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -975,6 +975,7 @@ type RepoSingleIssueParams struct { Active string Issue *models.Issue CommentList []models.CommentListItem + Backlinks []models.RichReferenceLink LabelDefs map[string]*models.LabelDefinition OrderedReactionKinds []models.ReactionKind @@ -1128,6 +1129,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 c2f02fef..b9b88283 100644 --- a/appview/pulls/pulls.go +++ b/appview/pulls/pulls.go @@ -154,6 +154,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 +236,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, -- 2.43.0