Monorepo for Tangled tangled.org
7

Configure Feed

Select the types of activity you want to include in your feed.

appview: replace `PullComment` to `Comment`

Including db migration to migrate `issue_comments` and `pull_comments`
to unified `comments` table.

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

author
Seongmin Lee
date (May 22, 2026, 7:55 PM +0900) commit 29575536 parent 5276029b change-id koxrvyzn
+752 -240
+268
appview/db/comments.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + "encoding/json" 6 + "fmt" 7 + "sort" 8 + "strings" 9 + "time" 10 + 11 + "github.com/bluesky-social/indigo/api/atproto" 12 + "github.com/bluesky-social/indigo/atproto/syntax" 13 + "tangled.org/core/api/tangled" 14 + "tangled.org/core/appview/models" 15 + "tangled.org/core/orm" 16 + ) 17 + 18 + func PutComment(tx *sql.Tx, c *models.Comment, references []syntax.ATURI) error { 19 + if c.Collection == "" { 20 + c.Collection = tangled.FeedCommentNSID 21 + } 22 + 23 + var bodyBlobs, replyToUri, replyToCid *string 24 + if len(c.Body.Blobs) > 0 { 25 + encoded, err := json.Marshal(c.Body.Blobs) 26 + if err != nil { 27 + return fmt.Errorf("encoding blobs to json: %w", err) 28 + } 29 + encodedStr := string(encoded) 30 + bodyBlobs = &encodedStr 31 + } 32 + if c.ReplyTo != nil { 33 + replyToUri = &c.ReplyTo.Uri 34 + replyToCid = &c.ReplyTo.Cid 35 + } 36 + result, err := tx.Exec( 37 + // users can change the 'created' date. 38 + // skip update entirely if cid is unchanged. 39 + `insert into comments ( 40 + did, 41 + collection, 42 + rkey, 43 + cid, 44 + subject_uri, 45 + subject_cid, 46 + body_text, 47 + body_original, 48 + body_blobs, 49 + created, 50 + reply_to_uri, 51 + reply_to_cid, 52 + pull_round_idx 53 + ) 54 + values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 55 + on conflict(did, collection, rkey) 56 + do update set 57 + cid = excluded.cid, 58 + subject_uri = excluded.subject_uri, 59 + subject_cid = excluded.subject_cid, 60 + body_text = excluded.body_text, 61 + body_original = excluded.body_original, 62 + body_blobs = excluded.body_blobs, 63 + created = excluded.created, 64 + reply_to_uri = excluded.reply_to_uri, 65 + reply_to_cid = excluded.reply_to_cid, 66 + pull_round_idx = excluded.pull_round_idx, 67 + edited = ? 68 + where comments.cid is not excluded.cid`, 69 + c.Did, 70 + c.Collection, 71 + c.Rkey, 72 + c.Cid, 73 + c.Subject.Uri, 74 + c.Subject.Cid, 75 + c.Body.Text, 76 + c.Body.Original, 77 + bodyBlobs, 78 + c.Created.Format(time.RFC3339), 79 + replyToUri, 80 + replyToCid, 81 + c.PullRoundIdx, 82 + time.Now().Format(time.RFC3339), 83 + ) 84 + if err != nil { 85 + return err 86 + } 87 + 88 + c.Id, err = result.LastInsertId() 89 + if err != nil { 90 + return err 91 + } 92 + 93 + affected, err := result.RowsAffected() 94 + if err != nil { 95 + return err 96 + } 97 + 98 + if affected > 0 { 99 + // update references when comment is updated 100 + if err := putReferences(tx, c.AtUri(), references); err != nil { 101 + return fmt.Errorf("put reference_links: %w", err) 102 + } 103 + } 104 + 105 + return nil 106 + } 107 + 108 + // PurgeComments actually purges a comment row from db instead of marking it as "deleted" 109 + func PurgeComments(e Execer, filters ...orm.Filter) error { 110 + var conditions []string 111 + var args []any 112 + for _, filter := range filters { 113 + conditions = append(conditions, filter.Condition()) 114 + args = append(args, filter.Arg()...) 115 + } 116 + 117 + whereClause := "" 118 + if conditions != nil { 119 + whereClause = " where " + strings.Join(conditions, " and ") 120 + } 121 + 122 + _, err := e.Exec(fmt.Sprintf(`delete from comments %s`, whereClause), args...) 123 + return err 124 + } 125 + 126 + func DeleteComments(e Execer, filters ...orm.Filter) error { 127 + var conditions []string 128 + var args []any 129 + for _, filter := range filters { 130 + conditions = append(conditions, filter.Condition()) 131 + args = append(args, filter.Arg()...) 132 + } 133 + 134 + whereClause := "" 135 + if conditions != nil { 136 + whereClause = " where " + strings.Join(conditions, " and ") 137 + } 138 + 139 + query := fmt.Sprintf( 140 + `update comments 141 + set body_text = "", 142 + body_original = null, 143 + body_blobs = null, 144 + deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') 145 + %s`, 146 + whereClause, 147 + ) 148 + 149 + _, err := e.Exec(query, args...) 150 + return err 151 + } 152 + 153 + func GetComments(e Execer, filters ...orm.Filter) ([]models.Comment, error) { 154 + var comments []models.Comment 155 + 156 + var conditions []string 157 + var args []any 158 + for _, filter := range filters { 159 + conditions = append(conditions, filter.Condition()) 160 + args = append(args, filter.Arg()...) 161 + } 162 + 163 + whereClause := "" 164 + if conditions != nil { 165 + whereClause = " where " + strings.Join(conditions, " and ") 166 + } 167 + 168 + query := fmt.Sprintf(` 169 + select 170 + id, 171 + did, 172 + collection, 173 + rkey, 174 + cid, 175 + subject_uri, 176 + subject_cid, 177 + body_text, 178 + body_original, 179 + body_blobs, 180 + created, 181 + reply_to_uri, 182 + reply_to_cid, 183 + pull_round_idx, 184 + edited, 185 + deleted 186 + from 187 + comments 188 + %s 189 + `, whereClause) 190 + 191 + rows, err := e.Query(query, args...) 192 + if err != nil { 193 + return nil, err 194 + } 195 + defer rows.Close() 196 + 197 + for rows.Next() { 198 + var comment models.Comment 199 + var created string 200 + var cid, bodyBlobs, replyToUri, replyToCid, edited, deleted sql.Null[string] 201 + err := rows.Scan( 202 + &comment.Id, 203 + &comment.Did, 204 + &comment.Collection, 205 + &comment.Rkey, 206 + &cid, 207 + &comment.Subject.Uri, 208 + &comment.Subject.Cid, 209 + &comment.Body.Text, 210 + &comment.Body.Original, 211 + &bodyBlobs, 212 + &created, 213 + &replyToUri, 214 + &replyToCid, 215 + &comment.PullRoundIdx, 216 + &edited, 217 + &deleted, 218 + ) 219 + if err != nil { 220 + return nil, err 221 + } 222 + 223 + if cid.Valid && cid.V != "" { 224 + comment.Cid = syntax.CID(cid.V) 225 + } 226 + 227 + if bodyBlobs.Valid && bodyBlobs.V != "" { 228 + if err := json.Unmarshal([]byte(bodyBlobs.V), &comment.Body.Blobs); err != nil { 229 + return nil, fmt.Errorf("decoding blobs: %w", err) 230 + } 231 + } 232 + 233 + if t, err := time.Parse(time.RFC3339, created); err == nil { 234 + comment.Created = t 235 + } 236 + 237 + if replyToUri.Valid && replyToCid.Valid { 238 + comment.ReplyTo = &atproto.RepoStrongRef{ 239 + Uri: replyToUri.V, 240 + Cid: replyToCid.V, 241 + } 242 + } 243 + 244 + if edited.Valid { 245 + if t, err := time.Parse(time.RFC3339, edited.V); err == nil { 246 + comment.Edited = &t 247 + } 248 + } 249 + 250 + if deleted.Valid { 251 + if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 252 + comment.Deleted = &t 253 + } 254 + } 255 + 256 + comments = append(comments, comment) 257 + } 258 + 259 + if err := rows.Err(); err != nil { 260 + return nil, err 261 + } 262 + 263 + sort.Slice(comments, func(i, j int) bool { 264 + return comments[i].Created.Before(comments[j].Created) 265 + }) 266 + 267 + return comments, nil 268 + }
+96
appview/db/db.go
··· 1977 1977 return err 1978 1978 }) 1979 1979 1980 + orm.RunMigration(conn, logger, "add-comments-table", func(tx *sql.Tx) error { 1981 + _, err := tx.Exec(` 1982 + drop table if exists comments; 1983 + 1984 + create table comments ( 1985 + -- identifiers 1986 + id integer primary key autoincrement, 1987 + 1988 + did text not null, 1989 + collection text not null default 'sh.tangled.feed.comment', 1990 + rkey text not null, 1991 + at_uri text generated always as ('at://' || did || '/' || collection || '/' || rkey) stored, 1992 + cid text, 1993 + 1994 + -- content 1995 + subject_uri text not null, -- at_uri of subject (issue, pr, string) 1996 + subject_cid text not null, -- cid of subject 1997 + 1998 + body_text text not null, 1999 + body_original text, 2000 + body_blobs text, -- json 2001 + 2002 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 2003 + 2004 + reply_to_uri text, -- at_uri of parent comment 2005 + reply_to_cid text, -- cid of parent comment 2006 + 2007 + pull_round_idx integer, -- pull round index. required when subject is sh.tangled.repo.pull 2008 + 2009 + -- appview-local information 2010 + edited text, 2011 + deleted text, 2012 + 2013 + unique(did, collection, rkey) 2014 + ); 2015 + 2016 + insert into comments ( 2017 + did, 2018 + collection, 2019 + rkey, 2020 + subject_uri, 2021 + subject_cid, -- we need to know cid 2022 + body_text, 2023 + created, 2024 + reply_to_uri, 2025 + reply_to_cid, -- we need to know cid 2026 + edited, 2027 + deleted 2028 + ) 2029 + select 2030 + did, 2031 + 'sh.tangled.repo.issue.comment', 2032 + rkey, 2033 + issue_at, 2034 + '', 2035 + body, 2036 + created, 2037 + reply_to, 2038 + '', 2039 + edited, 2040 + deleted 2041 + from issue_comments 2042 + where rkey is not null; 2043 + 2044 + insert into comments ( 2045 + did, 2046 + collection, 2047 + rkey, 2048 + subject_uri, 2049 + subject_cid, -- we need to know cid 2050 + body_text, 2051 + created, 2052 + pull_round_idx 2053 + ) 2054 + select 2055 + c.owner_did, 2056 + 'sh.tangled.repo.pull.comment', 2057 + substr( 2058 + substr(c.comment_at, 6 + instr(substr(c.comment_at, 6), '/')), -- nsid/rkey 2059 + instr( 2060 + substr(c.comment_at, 6 + instr(substr(c.comment_at, 6), '/')), -- nsid/rkey 2061 + '/' 2062 + ) + 1 2063 + ), -- rkey 2064 + p.at_uri, 2065 + '', 2066 + c.body, 2067 + c.created, 2068 + s.round_number 2069 + from pull_comments c 2070 + join pulls p on c.repo_did = p.repo_did and c.pull_id = p.pull_id 2071 + join pull_submissions s on s.id = c.submission_id; 2072 + `) 2073 + return err 2074 + }) 2075 + 1980 2076 return &DB{ 1981 2077 db, 1982 2078 logger,
+15 -132
appview/db/pulls.go
··· 523 523 } 524 524 defer rows.Close() 525 525 526 - submissionMap := make(map[int]*models.PullSubmission) 526 + pullMap := make(map[syntax.ATURI][]*models.PullSubmission) 527 527 528 528 for rows.Next() { 529 529 var submission models.PullSubmission ··· 571 571 submission.Blob.Size = patchBlobSize.V 572 572 } 573 573 574 - submissionMap[submission.ID] = &submission 574 + pullMap[submission.PullAt] = append(pullMap[submission.PullAt], &submission) 575 575 } 576 576 577 577 if err := rows.Err(); err != nil { 578 578 return nil, err 579 579 } 580 580 581 - // Get comments for all submissions using GetPullComments 582 - submissionIds := slices.Collect(maps.Keys(submissionMap)) 583 - comments, err := GetPullComments(e, orm.FilterIn("submission_id", submissionIds)) 581 + // Get comments for all submissions using GetComments 582 + pullAts := slices.Collect(maps.Keys(pullMap)) 583 + comments, err := GetComments(e, orm.FilterIn("subject_uri", pullAts)) 584 584 if err != nil { 585 585 return nil, fmt.Errorf("failed to get pull comments: %w", err) 586 586 } 587 587 for _, comment := range comments { 588 - if submission, ok := submissionMap[comment.SubmissionId]; ok { 589 - submission.Comments = append(submission.Comments, comment) 588 + if comment.PullRoundIdx != nil { 589 + roundIdx := *comment.PullRoundIdx 590 + if submissions, ok := pullMap[syntax.ATURI(comment.Subject.Uri)]; ok { 591 + if roundIdx < len(submissions) { 592 + submission := submissions[roundIdx] 593 + submission.Comments = append(submission.Comments, comment) 594 + } 595 + } 590 596 } 591 597 } 592 598 593 - // group the submissions by pull_at 594 - m := make(map[syntax.ATURI][]*models.PullSubmission) 595 - for _, s := range submissionMap { 596 - m[s.PullAt] = append(m[s.PullAt], s) 597 - } 598 - 599 599 // sort each one by round number 600 - for _, s := range m { 600 + for _, s := range pullMap { 601 601 slices.SortFunc(s, func(a, b *models.PullSubmission) int { 602 602 return cmp.Compare(a.RoundNumber, b.RoundNumber) 603 603 }) 604 604 } 605 605 606 - return m, nil 607 - } 608 - 609 - func GetPullComments(e Execer, filters ...orm.Filter) ([]models.PullComment, error) { 610 - var conditions []string 611 - var args []any 612 - for _, filter := range filters { 613 - conditions = append(conditions, filter.Condition()) 614 - args = append(args, filter.Arg()...) 615 - } 616 - 617 - whereClause := "" 618 - if conditions != nil { 619 - whereClause = " where " + strings.Join(conditions, " and ") 620 - } 621 - 622 - query := fmt.Sprintf(` 623 - select 624 - id, 625 - pull_id, 626 - submission_id, 627 - repo_did, 628 - owner_did, 629 - comment_at, 630 - body, 631 - created 632 - from 633 - pull_comments 634 - %s 635 - order by 636 - created asc 637 - `, whereClause) 638 - 639 - rows, err := e.Query(query, args...) 640 - if err != nil { 641 - return nil, err 642 - } 643 - defer rows.Close() 644 - 645 - commentMap := make(map[string]*models.PullComment) 646 - for rows.Next() { 647 - var comment models.PullComment 648 - var createdAt string 649 - err := rows.Scan( 650 - &comment.ID, 651 - &comment.PullId, 652 - &comment.SubmissionId, 653 - &comment.RepoDid, 654 - &comment.OwnerDid, 655 - &comment.CommentAt, 656 - &comment.Body, 657 - &createdAt, 658 - ) 659 - if err != nil { 660 - return nil, err 661 - } 662 - 663 - if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 664 - comment.Created = t 665 - } 666 - 667 - atUri := comment.AtUri().String() 668 - commentMap[atUri] = &comment 669 - } 670 - 671 - if err := rows.Err(); err != nil { 672 - return nil, err 673 - } 674 - 675 - // collect references for each comments 676 - commentAts := slices.Collect(maps.Keys(commentMap)) 677 - allReferences, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) 678 - if err != nil { 679 - return nil, fmt.Errorf("failed to query reference_links: %w", err) 680 - } 681 - for commentAt, references := range allReferences { 682 - if comment, ok := commentMap[commentAt.String()]; ok { 683 - comment.References = references 684 - } 685 - } 686 - 687 - var comments []models.PullComment 688 - for _, c := range commentMap { 689 - comments = append(comments, *c) 690 - } 691 - 692 - sort.Slice(comments, func(i, j int) bool { 693 - return comments[i].Created.Before(comments[j].Created) 694 - }) 695 - 696 - return comments, nil 606 + return pullMap, nil 697 607 } 698 608 699 609 // timeframe here is directly passed into the sql query filter, and any ··· 770 680 } 771 681 772 682 return pulls, nil 773 - } 774 - 775 - func NewPullComment(tx *sql.Tx, comment *models.PullComment) (int64, error) { 776 - query := `insert into pull_comments (owner_did, repo_did, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` 777 - res, err := tx.Exec( 778 - query, 779 - comment.OwnerDid, 780 - comment.RepoDid, 781 - comment.SubmissionId, 782 - comment.CommentAt, 783 - comment.PullId, 784 - comment.Body, 785 - ) 786 - if err != nil { 787 - return 0, err 788 - } 789 - 790 - i, err := res.LastInsertId() 791 - if err != nil { 792 - return 0, err 793 - } 794 - 795 - if err := putReferences(tx, comment.AtUri(), comment.References); err != nil { 796 - return 0, fmt.Errorf("put reference_links: %w", err) 797 - } 798 - 799 - return i, nil 800 683 } 801 684 802 685 // use with transaction
+7 -8
appview/db/reference.go
··· 124 124 values %s 125 125 ) 126 126 select 127 - p.owner_did, p.rkey, 128 - c.comment_at 127 + p.owner_did, p.rkey, c.at_uri 129 128 from input inp 130 129 join repos r 131 130 on r.did = inp.owner_did ··· 133 132 join pulls p 134 133 on p.repo_did = r.repo_did 135 134 and p.pull_id = inp.pull_id 136 - left join pull_comments c 135 + left join comments c 137 136 on inp.comment_id is not null 138 - and c.repo_did = p.repo_did and c.pull_id = p.pull_id 137 + and c.subject_uri = ('at://' || p.owner_did || '/' || 'sh.tangled.repo.pull' || '/' || p.rkey) 139 138 and c.id = inp.comment_id 140 139 `, 141 140 strings.Join(vals, ","), ··· 293 292 return nil, fmt.Errorf("get pull backlinks: %w", err) 294 293 } 295 294 backlinks = append(backlinks, ls...) 296 - ls, err = getPullCommentBacklinks(e, target, backlinksMap[tangled.RepoPullCommentNSID]) 295 + ls, err = getPullCommentBacklinks(e, target, backlinksMap[tangled.FeedCommentNSID]) 297 296 if err != nil { 298 297 return nil, fmt.Errorf("get pull_comment backlinks: %w", err) 299 298 } ··· 430 429 if len(aturis) == 0 { 431 430 return nil, nil 432 431 } 433 - filter := orm.FilterIn("c.comment_at", aturis) 432 + filter := orm.FilterIn("c.at_uri", aturis) 434 433 exclude := orm.FilterNotEq("p.at_uri", target) 435 434 rows, err := e.Query( 436 435 fmt.Sprintf( ··· 438 437 from repos r 439 438 join pulls p 440 439 on r.repo_did = p.repo_did 441 - join pull_comments c 442 - on p.repo_did = c.repo_did and p.pull_id = c.pull_id 440 + join comments c 441 + on ('at://' || p.owner_did || '/' || 'sh.tangled.repo.pull' || '/' || p.rkey) = c.subject_uri 443 442 where %s and %s`, 444 443 filter.Condition(), 445 444 exclude.Condition(),
+105 -10
appview/ingester.go
··· 27 27 "tangled.org/core/appview/cache" 28 28 "tangled.org/core/appview/config" 29 29 "tangled.org/core/appview/db" 30 + "tangled.org/core/appview/mentions" 30 31 "tangled.org/core/appview/models" 31 32 "tangled.org/core/appview/notify" 32 33 "tangled.org/core/appview/repoverify" ··· 38 39 ) 39 40 40 41 type Ingester struct { 41 - Ctx context.Context 42 - Db *db.DB 43 - Enforcer *rbac.Enforcer 44 - IdResolver *idresolver.Resolver 45 - Cache *cache.Cache 46 - Config *config.Config 47 - Logger *slog.Logger 48 - Validator *validator.Validator 49 - Notifier notify.Notifier 50 - Verifier repoverify.Verifier 42 + Ctx context.Context 43 + Db *db.DB 44 + Enforcer *rbac.Enforcer 45 + IdResolver *idresolver.Resolver 46 + Cache *cache.Cache 47 + Config *config.Config 48 + Logger *slog.Logger 49 + Validator *validator.Validator 50 + MentionsResolver *mentions.Resolver 51 + Notifier notify.Notifier 52 + Verifier repoverify.Verifier 51 53 } 52 54 53 55 type processFunc func(ctx context.Context, e *jmodels.Event) error ··· 97 99 err = i.ingestIssue(ctx, e) 98 100 case tangled.RepoPullNSID: 99 101 err = i.ingestPull(ctx, e) 102 + case tangled.FeedCommentNSID: 103 + err = i.ingestComment(e) 100 104 case tangled.RepoIssueCommentNSID: 101 105 err = i.ingestIssueComment(e) 106 + case tangled.RepoPullCommentNSID: 107 + err = i.ingestPullComment(e) 102 108 case tangled.LabelDefinitionNSID: 103 109 err = i.ingestLabelDefinition(e) 104 110 case tangled.LabelOpNSID: ··· 1586 1592 orm.FilterEq("rkey", rkey), 1587 1593 ); err != nil { 1588 1594 return fmt.Errorf("failed to delete issue comment record: %w", err) 1595 + } 1596 + 1597 + return nil 1598 + } 1599 + 1600 + return nil 1601 + } 1602 + 1603 + // ingestPullComment ingests legacy sh.tangled.repo.pull.comment deletions 1604 + func (i *Ingester) ingestPullComment(e *jmodels.Event) error { 1605 + l := i.Logger.With("handler", "ingestPullComment", "nsid", e.Commit.Collection, "did", e.Did, "rkey", e.Commit.RKey) 1606 + l.Info("ingesting record") 1607 + 1608 + switch e.Commit.Operation { 1609 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 1610 + // no-op. sh.tangled.repo.pull.comment is deprecated 1611 + 1612 + case jmodels.CommitOperationDelete: 1613 + if err := db.PurgeComments( 1614 + i.Db, 1615 + orm.FilterEq("did", e.Did), 1616 + orm.FilterEq("collection", e.Commit.Collection), 1617 + orm.FilterEq("rkey", e.Commit.RKey), 1618 + ); err != nil { 1619 + return fmt.Errorf("failed to delete comment record: %w", err) 1620 + } 1621 + } 1622 + 1623 + return nil 1624 + } 1625 + 1626 + func (i *Ingester) ingestComment(e *jmodels.Event) error { 1627 + did := e.Did 1628 + rkey := e.Commit.RKey 1629 + cid := e.Commit.CID 1630 + 1631 + var err error 1632 + 1633 + l := i.Logger.With("handler", "ingestComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 1634 + l.Info("ingesting record") 1635 + 1636 + ctx := context.Background() 1637 + 1638 + switch e.Commit.Operation { 1639 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 1640 + raw := json.RawMessage(e.Commit.Record) 1641 + record := tangled.FeedComment{} 1642 + err = json.Unmarshal(raw, &record) 1643 + if err != nil { 1644 + return fmt.Errorf("invalid record: %w", err) 1645 + } 1646 + 1647 + comment, err := models.CommentFromRecord(syntax.DID(did), syntax.RecordKey(rkey), syntax.CID(cid), record) 1648 + if err != nil { 1649 + return fmt.Errorf("failed to parse comment from record: %w", err) 1650 + } 1651 + 1652 + if err := comment.Validate(); err != nil { 1653 + return fmt.Errorf("failed to validate comment: %w", err) 1654 + } 1655 + 1656 + var references []syntax.ATURI 1657 + if comment.Body.Original != nil { 1658 + _, references = i.MentionsResolver.Resolve(ctx, *comment.Body.Original) 1659 + } 1660 + 1661 + tx, err := i.Db.Begin() 1662 + if err != nil { 1663 + return fmt.Errorf("failed to start transaction: %w", err) 1664 + } 1665 + defer tx.Rollback() 1666 + 1667 + err = db.PutComment(tx, comment, references) 1668 + if err != nil { 1669 + return fmt.Errorf("failed to create comment: %w", err) 1670 + } 1671 + 1672 + if err := tx.Commit(); err != nil { 1673 + return err 1674 + } 1675 + 1676 + case jmodels.CommitOperationDelete: 1677 + if err := db.DeleteComments( 1678 + i.Db, 1679 + orm.FilterEq("did", did), 1680 + orm.FilterEq("collection", e.Commit.Collection), 1681 + orm.FilterEq("rkey", rkey), 1682 + ); err != nil { 1683 + return fmt.Errorf("failed to delete comment record: %w", err) 1589 1684 } 1590 1685 1591 1686 return nil
+148
appview/models/comment.go
··· 1 + package models 2 + 3 + import ( 4 + "fmt" 5 + "strings" 6 + "time" 7 + 8 + comatproto "github.com/bluesky-social/indigo/api/atproto" 9 + "github.com/bluesky-social/indigo/atproto/syntax" 10 + typegen "github.com/whyrusleeping/cbor-gen" 11 + "tangled.org/core/api/tangled" 12 + ) 13 + 14 + type Comment struct { 15 + Id int64 16 + 17 + Did syntax.DID 18 + Collection syntax.NSID 19 + Rkey syntax.RecordKey 20 + Cid syntax.CID 21 + 22 + // record content 23 + Subject comatproto.RepoStrongRef 24 + Body tangled.MarkupMarkdown // markup body type. only markdown is supported right now 25 + Created time.Time 26 + ReplyTo *comatproto.RepoStrongRef // (optional) parent comment 27 + PullRoundIdx *int // (optional) pull round number used when subject is sh.tangled.repo.pull 28 + 29 + // store on db, but not on PDS 30 + Edited *time.Time 31 + Deleted *time.Time 32 + } 33 + 34 + func (c *Comment) AtUri() syntax.ATURI { 35 + return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", c.Did, c.Collection, c.Rkey)) 36 + } 37 + 38 + func (c *Comment) StrongRef() comatproto.RepoStrongRef { 39 + return comatproto.RepoStrongRef{ 40 + Uri: c.AtUri().String(), 41 + Cid: c.Cid.String(), 42 + } 43 + } 44 + 45 + func (c *Comment) AsRecord() typegen.CBORMarshaler { 46 + // can't convert to record for legacy types 47 + if c.Collection != tangled.FeedCommentNSID { 48 + return nil 49 + } 50 + var pullRoundIdx *int64 51 + if c.PullRoundIdx != nil { 52 + pullRoundIdx = new(int64) 53 + *pullRoundIdx = int64(*c.PullRoundIdx) 54 + } 55 + return &tangled.FeedComment{ 56 + Subject: &c.Subject, 57 + Body: &tangled.FeedComment_Body{MarkupMarkdown: &c.Body}, 58 + CreatedAt: c.Created.Format(time.RFC3339), 59 + ReplyTo: c.ReplyTo, 60 + PullRoundIdx: pullRoundIdx, 61 + } 62 + } 63 + 64 + func (c *Comment) IsTopLevel() bool { 65 + return c.ReplyTo == nil 66 + } 67 + 68 + func (c *Comment) IsReply() bool { 69 + return c.ReplyTo != nil 70 + } 71 + 72 + func (c *Comment) Validate() error { 73 + // TODO: sanitize the body and then trim space 74 + if sb := strings.TrimSpace(c.Body.Text); sb == "" { 75 + return fmt.Errorf("body is empty after HTML sanitization") 76 + } 77 + 78 + // if it's for PR, PullSubmissionId should not be nil 79 + subjectAt, err := syntax.ParseATURI(c.Subject.Uri) 80 + if err != nil { 81 + return fmt.Errorf("subject.uri is not valid at-uri: %w", err) 82 + } 83 + if subjectAt.Collection().String() == tangled.RepoPullNSID { 84 + if c.PullRoundIdx == nil { 85 + return fmt.Errorf("pullSubmissionId should not be nil when subject is sh.tangled.repo.pull") 86 + } 87 + } 88 + return nil 89 + } 90 + 91 + func CommentFromRecord(did syntax.DID, rkey syntax.RecordKey, cid syntax.CID, record tangled.FeedComment) (*Comment, error) { 92 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 93 + if err != nil { 94 + created = time.Now() 95 + } 96 + 97 + if record.Subject == nil { 98 + return nil, fmt.Errorf("subject can't be nil") 99 + } 100 + subjectAt, err := syntax.ParseATURI(record.Subject.Uri) 101 + if err != nil { 102 + return nil, fmt.Errorf("invalid subject uri: %w", err) 103 + } 104 + if _, err = syntax.ParseCID(record.Subject.Cid); err != nil { 105 + return nil, fmt.Errorf("invalid subject cid: %w", err) 106 + } 107 + 108 + if subjectAt.Collection() == tangled.RepoPullNSID { 109 + if record.PullRoundIdx == nil { 110 + return nil, fmt.Errorf("pullRoundIdx can't be nil when subject is sh.tangled.repo.pull") 111 + } 112 + } 113 + 114 + if record.Body == nil { 115 + return nil, fmt.Errorf("body can't be nil") 116 + } 117 + if record.Body.MarkupMarkdown == nil { 118 + return nil, fmt.Errorf("body should be markdown type") 119 + } 120 + 121 + if record.ReplyTo != nil { 122 + if _, err = syntax.ParseATURI(record.ReplyTo.Uri); err != nil { 123 + return nil, fmt.Errorf("invalid replyTo uri: %w", err) 124 + } 125 + if _, err = syntax.ParseCID(record.ReplyTo.Cid); err != nil { 126 + return nil, fmt.Errorf("invalid replyTo cid: %w", err) 127 + } 128 + } 129 + 130 + var pullRoundIdx *int 131 + if record.PullRoundIdx != nil { 132 + pullRoundIdx = new(int) 133 + *pullRoundIdx = int(*record.PullRoundIdx) 134 + } 135 + 136 + return &Comment{ 137 + Did: did, 138 + Collection: tangled.FeedCommentNSID, 139 + Rkey: rkey, 140 + Cid: cid, 141 + 142 + Subject: *record.Subject, 143 + Body: *record.Body.MarkupMarkdown, 144 + Created: created, 145 + ReplyTo: record.ReplyTo, 146 + PullRoundIdx: pullRoundIdx, 147 + }, nil 148 + }
+2 -28
appview/models/pull.go
··· 275 275 Blob lexutil.LexBlob 276 276 Patch string 277 277 Combined string 278 - Comments []PullComment 278 + Comments []Comment 279 279 SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs 280 280 281 281 // meta 282 282 Created time.Time 283 - } 284 - 285 - type PullComment struct { 286 - // ids 287 - ID int 288 - PullId int 289 - SubmissionId int 290 - 291 - // at ids 292 - RepoDid string 293 - OwnerDid string 294 - CommentAt string 295 - 296 - // content 297 - Body string 298 - 299 - // meta 300 - Mentions []syntax.DID 301 - References []syntax.ATURI 302 - 303 - // meta 304 - Created time.Time 305 - } 306 - 307 - func (p *PullComment) AtUri() syntax.ATURI { 308 - return syntax.ATURI(p.CommentAt) 309 283 } 310 284 311 285 func (p *Pull) TotalComments() int { ··· 426 400 addParticipant(s.PullAt.Authority().String()) 427 401 428 402 for _, c := range s.Comments { 429 - addParticipant(c.OwnerDid) 403 + addParticipant(c.Did.String()) 430 404 } 431 405 432 406 return participants
+7 -6
appview/notify/db/db.go
··· 282 282 ) 283 283 } 284 284 285 - func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 285 + func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { 286 286 l := log.FromContext(ctx) 287 287 288 + subjectAt := syntax.ATURI(comment.Subject.Uri) 288 289 pull, err := db.GetPull(n.db, 289 - orm.FilterEq("repo_did", comment.RepoDid), 290 - orm.FilterEq("pull_id", comment.PullId), 290 + orm.FilterEq("owner_did", subjectAt.Authority()), 291 + orm.FilterEq("rkey", subjectAt.RecordKey()), 291 292 ) 292 293 if err != nil { 293 - l.Error("failed to get pulls", "err", err) 294 + l.Error("failed to get pull", "err", err) 294 295 return 295 296 } 296 297 297 - repo, err := db.GetRepo(n.db, orm.FilterEq("repo_did", comment.RepoDid)) 298 + repo, err := db.GetRepo(n.db, orm.FilterEq("repo_did", pull.RepoDid)) 298 299 if err != nil { 299 300 l.Error("failed to get repos", "err", err) 300 301 return ··· 312 313 recipients.Remove(m) 313 314 } 314 315 315 - actorDid := syntax.DID(comment.OwnerDid) 316 + actorDid := comment.Did 316 317 eventType := models.NotificationTypePullCommented 317 318 entityType := "pull" 318 319 entityId := pull.AtUri().String()
+1 -1
appview/notify/logging/notifier.go
··· 91 91 l.inner.NewPull(ctx, pull) 92 92 } 93 93 94 - func (l *loggingNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 94 + func (l *loggingNotifier) NewPullComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { 95 95 ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewPullComment")) 96 96 l.inner.NewPullComment(ctx, comment, mentions) 97 97 }
+1 -1
appview/notify/merged_notifier.go
··· 86 86 m.fanout(func(n Notifier) { n.NewPull(ctx, pull) }) 87 87 } 88 88 89 - func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 89 + func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { 90 90 m.fanout(func(n Notifier) { n.NewPullComment(ctx, comment, mentions) }) 91 91 } 92 92
+2 -2
appview/notify/notifier.go
··· 24 24 DeleteFollow(ctx context.Context, follow *models.Follow) 25 25 26 26 NewPull(ctx context.Context, pull *models.Pull) 27 - NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) 27 + NewPullComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) 28 28 NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) 29 29 30 30 NewIssueLabelOp(ctx context.Context, issue *models.Issue) ··· 67 67 func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 68 68 69 69 func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 70 - func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment, mentions []syntax.DID) { 70 + func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.Comment, mentions []syntax.DID) { 71 71 } 72 72 func (m *BaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {} 73 73
+3 -4
appview/notify/posthog/notifier.go
··· 108 108 } 109 109 } 110 110 111 - func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 111 + func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { 112 112 err := n.client.Enqueue(posthog.Capture{ 113 - DistinctId: comment.OwnerDid, 113 + DistinctId: comment.Did.String(), 114 114 Event: "new_pull_comment", 115 115 Properties: posthog.Properties{ 116 - "repo_did": comment.RepoDid, 117 - "pull_id": comment.PullId, 116 + "pull_at": comment.Subject, 118 117 "mentions": mentions, 119 118 }, 120 119 })
+1
appview/oauth/scopes.go
··· 4 4 "atproto", 5 5 6 6 "repo:sh.tangled.actor.profile", 7 + "repo:sh.tangled.feed.comment", 7 8 "repo:sh.tangled.feed.reaction", 8 9 "repo:sh.tangled.feed.star", 9 10 "repo:sh.tangled.graph.follow",
+5 -5
appview/pages/templates/repo/pulls/pull.html
··· 630 630 {{ define "submissionComment" }} 631 631 {{ $comment := index . 0 }} 632 632 {{ $root := index . 1 }} 633 - <div id="comment-{{$comment.ID}}" class="flex gap-2 -ml-4 py-4 w-full mx-auto group/comment"> 633 + <div id="comment-{{$comment.Id}}" class="flex gap-2 -ml-4 py-4 w-full mx-auto group/comment"> 634 634 <!-- left column: profile picture --> 635 635 <div class="flex-shrink-0 h-fit relative"> 636 - {{ template "user/fragments/picLink" (list $comment.OwnerDid "size-8" (index $root.VouchRelationships (did $comment.OwnerDid))) }} 636 + {{ template "user/fragments/picLink" (list $comment.Did.String "size-8" (index $root.VouchRelationships (did $comment.OwnerDid))) }} 637 637 </div> 638 638 <!-- right column: name and body in two rows --> 639 639 <div class="flex-1 min-w-0"> 640 640 <!-- Row 1: Author and timestamp --> 641 641 <div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1 group-target/comment:bg-yellow-200/30 group-target/comment:dark:bg-yellow-600/30"> 642 - {{ $handle := resolve $comment.OwnerDid }} 642 + {{ $handle := resolve $comment.Did.String }} 643 643 <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="/{{ $handle }}">{{ $handle }}</a> 644 644 <span class="before:content-['·']"></span> 645 - <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{$comment.ID}}"> 645 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{$comment.Id}}"> 646 646 {{ template "repo/fragments/shortTime" $comment.Created }} 647 647 </a> 648 648 </div> 649 649 <!-- Row 2: Body text --> 650 650 <div class="prose dark:prose-invert mt-1"> 651 - {{ $comment.Body | markdown }} 651 + {{ $comment.Body.Text | markdown }} 652 652 </div> 653 653 </div> 654 654 </div>
+78 -33
appview/pulls/comment.go
··· 14 14 "tangled.org/core/tid" 15 15 16 16 comatproto "github.com/bluesky-social/indigo/api/atproto" 17 + "github.com/bluesky-social/indigo/atproto/syntax" 17 18 lexutil "github.com/bluesky-social/indigo/lex/util" 19 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 18 20 "github.com/go-chi/chi/v5" 19 21 ) 20 22 ··· 60 62 case http.MethodPost: 61 63 body := r.FormValue("body") 62 64 if body == "" { 63 - s.pages.Notice(w, "pull", "Comment body is required") 65 + s.pages.Notice(w, "pull-comment", "Comment body is required") 64 66 return 65 67 } 66 68 69 + // TODO(boltless): normalize markdown body 70 + normalizedBody := body 67 71 mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 68 72 69 - // Start a transaction 70 - tx, err := s.db.BeginTx(r.Context(), nil) 73 + markdownBody := tangled.MarkupMarkdown{ 74 + Text: normalizedBody, 75 + Original: &body, 76 + Blobs: nil, 77 + } 78 + 79 + // ingest CID of PR record on-demand. 80 + // TODO(boltless): appview should ingest CID of atproto records 81 + cid, err := func() (syntax.CID, error) { 82 + ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 83 + if err != nil { 84 + return "", err 85 + } 86 + 87 + xrpcc := indigoxrpc.Client{Host: ident.PDSEndpoint()} 88 + out, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoPullNSID, pull.OwnerDid, pull.Rkey) 89 + if err != nil { 90 + return "", err 91 + } 92 + if out.Cid == nil { 93 + return "", fmt.Errorf("record CID is empty") 94 + } 95 + 96 + cid, err := syntax.ParseCID(*out.Cid) 97 + if err != nil { 98 + return "", err 99 + } 100 + 101 + return cid, nil 102 + }() 71 103 if err != nil { 72 - l.Error("failed to start transaction", "err", err) 104 + s.logger.Error("failed to backfill subject PR record", "err", err) 105 + s.pages.Notice(w, "pull-comment", "failed to backfill subject record") 106 + return 107 + } 108 + pullStrongRef := comatproto.RepoStrongRef{ 109 + Uri: pull.AtUri().String(), 110 + Cid: cid.String(), 111 + } 112 + 113 + comment := models.Comment{ 114 + Did: syntax.DID(user.Did), 115 + Collection: tangled.FeedCommentNSID, 116 + Rkey: syntax.RecordKey(tid.TID()), 117 + 118 + Subject: pullStrongRef, 119 + Body: markdownBody, 120 + Created: time.Now(), 121 + ReplyTo: nil, 122 + PullRoundIdx: &roundNumber, 123 + } 124 + if err = comment.Validate(); err != nil { 125 + s.logger.Error("failed to validate comment", "err", err) 73 126 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 74 127 return 75 128 } 76 - defer tx.Rollback() 77 - 78 - createdAt := time.Now().Format(time.RFC3339) 79 129 80 130 client, err := s.oauth.AuthorizedClient(r) 81 131 if err != nil { 82 - l.Error("failed to get authorized client", "err", err) 132 + s.logger.Error("failed to get authorized client", "err", err) 83 133 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 84 134 return 85 135 } 86 - atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 87 - Collection: tangled.RepoPullCommentNSID, 88 - Repo: user.Did, 89 - Rkey: tid.TID(), 90 - Record: &lexutil.LexiconTypeDecoder{ 91 - Val: &tangled.RepoPullComment{ 92 - Pull: pull.AtUri().String(), 93 - Body: body, 94 - CreatedAt: createdAt, 95 - }, 96 - }, 136 + 137 + out, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 138 + Collection: comment.Collection.String(), 139 + Repo: comment.Did.String(), 140 + Rkey: comment.Rkey.String(), 141 + Record: &lexutil.LexiconTypeDecoder{Val: comment.AsRecord()}, 97 142 }) 98 143 if err != nil { 99 - l.Error("failed to create pull comment", "err", err) 144 + s.logger.Error("failed to create pull comment", "err", err) 100 145 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 101 146 return 102 147 } 103 148 104 - comment := &models.PullComment{ 105 - OwnerDid: user.Did, 106 - RepoDid: string(f.RepoDid), 107 - PullId: pull.PullId, 108 - Body: body, 109 - CommentAt: atResp.Uri, 110 - SubmissionId: pull.Submissions[roundNumber].ID, 111 - Mentions: mentions, 112 - References: references, 149 + comment.Cid = syntax.CID(out.Cid) 150 + 151 + // Start a transaction 152 + tx, err := s.db.BeginTx(r.Context(), nil) 153 + if err != nil { 154 + l.Error("failed to start transaction", "err", err) 155 + s.pages.Notice(w, "pull-comment", "Failed to create comment.") 156 + return 113 157 } 158 + defer tx.Rollback() 114 159 115 - // Create the pull comment in the database with the commentAt field 116 - commentId, err := db.NewPullComment(tx, comment) 160 + // Create the pull comment in the database 161 + err = db.PutComment(tx, &comment, references) 117 162 if err != nil { 118 163 l.Error("failed to create pull comment in database", "err", err) 119 164 s.pages.Notice(w, "pull-comment", "Failed to create comment.") ··· 127 172 return 128 173 } 129 174 130 - s.notifier.NewPullComment(r.Context(), comment, mentions) 175 + s.notifier.NewPullComment(r.Context(), &comment, mentions) 131 176 132 177 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 133 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId)) 178 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, comment.Id)) 134 179 return 135 180 } 136 181 }
+13 -10
appview/state/state.go
··· 124 124 []string{ 125 125 tangled.ActorProfileNSID, 126 126 tangled.FeedStarNSID, 127 + tangled.FeedCommentNSID, 127 128 tangled.GraphFollowNSID, 128 129 tangled.GraphVouchNSID, 129 130 tangled.KnotMemberNSID, ··· 136 137 tangled.RepoIssueNSID, 137 138 tangled.RepoNSID, 138 139 tangled.RepoPullNSID, 140 + tangled.RepoPullCommentNSID, 139 141 tangled.SpindleMemberNSID, 140 142 tangled.SpindleNSID, 141 143 tangled.StringNSID, ··· 174 176 notifier = lognotify.NewLoggingNotifier(notifier, tlog.SubLogger(logger, "notify")) 175 177 176 178 ingester := appview.Ingester{ 177 - Ctx: ctx, 178 - Db: d, 179 - Enforcer: enforcer, 180 - IdResolver: res, 181 - Cache: rdb, 182 - Config: config, 183 - Logger: log.SubLogger(logger, "ingester"), 184 - Validator: validator, 185 - Notifier: notifier, 186 - Verifier: repoverify.New(res, config.Core.Dev), 179 + Ctx: ctx, 180 + Db: d, 181 + Enforcer: enforcer, 182 + IdResolver: res, 183 + Cache: rdb, 184 + Config: config, 185 + Logger: log.SubLogger(logger, "ingester"), 186 + Validator: validator, 187 + MentionsResolver: mentionsResolver, 188 + Notifier: notifier, 189 + Verifier: repoverify.New(res, config.Core.Dev), 187 190 } 188 191 err = jc.StartJetstream(ctx, ingester.Ingest()) 189 192 if err != nil {