Monorepo for Tangled tangled.org

appview: replace PullComment to Comment #1273

open opened by boltless.me targeting master from sl/comment

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

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

Labels

None yet.

assignee

None yet.

Participants 2
AT URI
at://did:plc:xasnlahkri4ewmbuzly2rlc5/sh.tangled.repo.pull/3mimbhgiqc722
+740 -238
Diff #2
+250
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 != 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 + func DeleteComments(e Execer, filters ...orm.Filter) error { 109 + var conditions []string 110 + var args []any 111 + for _, filter := range filters { 112 + conditions = append(conditions, filter.Condition()) 113 + args = append(args, filter.Arg()...) 114 + } 115 + 116 + whereClause := "" 117 + if conditions != nil { 118 + whereClause = " where " + strings.Join(conditions, " and ") 119 + } 120 + 121 + query := fmt.Sprintf( 122 + `update comments 123 + set body_text = "", 124 + body_original = null, 125 + body_blobs = null, 126 + deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') 127 + %s`, 128 + whereClause, 129 + ) 130 + 131 + _, err := e.Exec(query, args...) 132 + return err 133 + } 134 + 135 + func GetComments(e Execer, filters ...orm.Filter) ([]models.Comment, error) { 136 + var comments []models.Comment 137 + 138 + var conditions []string 139 + var args []any 140 + for _, filter := range filters { 141 + conditions = append(conditions, filter.Condition()) 142 + args = append(args, filter.Arg()...) 143 + } 144 + 145 + whereClause := "" 146 + if conditions != nil { 147 + whereClause = " where " + strings.Join(conditions, " and ") 148 + } 149 + 150 + query := fmt.Sprintf(` 151 + select 152 + id, 153 + did, 154 + collection, 155 + rkey, 156 + cid, 157 + subject_uri, 158 + subject_cid, 159 + body_text, 160 + body_original, 161 + body_blobs, 162 + created, 163 + reply_to_uri, 164 + reply_to_cid, 165 + pull_round_idx, 166 + edited, 167 + deleted 168 + from 169 + comments 170 + %s 171 + `, whereClause) 172 + 173 + rows, err := e.Query(query, args...) 174 + if err != nil { 175 + return nil, err 176 + } 177 + 178 + for rows.Next() { 179 + var comment models.Comment 180 + var created string 181 + var cid, bodyBlobs, replyToUri, replyToCid, edited, deleted sql.Null[string] 182 + err := rows.Scan( 183 + &comment.Id, 184 + &comment.Did, 185 + &comment.Collection, 186 + &comment.Rkey, 187 + &cid, 188 + &comment.Subject.Uri, 189 + &comment.Subject.Cid, 190 + &comment.Body.Text, 191 + &comment.Body.Original, 192 + &bodyBlobs, 193 + &created, 194 + &replyToUri, 195 + &replyToCid, 196 + &comment.PullRoundIdx, 197 + &edited, 198 + &deleted, 199 + ) 200 + if err != nil { 201 + return nil, err 202 + } 203 + 204 + if cid.Valid && cid.V != "" { 205 + comment.Cid = syntax.CID(cid.V) 206 + } 207 + 208 + if bodyBlobs.Valid && bodyBlobs.V != "" { 209 + if err := json.Unmarshal([]byte(bodyBlobs.V), &comment.Body.Blobs); err != nil { 210 + return nil, fmt.Errorf("decoding blobs: %w", err) 211 + } 212 + } 213 + 214 + if t, err := time.Parse(time.RFC3339, created); err == nil { 215 + comment.Created = t 216 + } 217 + 218 + if replyToUri.Valid && replyToCid.Valid { 219 + comment.ReplyTo = &atproto.RepoStrongRef{ 220 + Uri: replyToUri.V, 221 + Cid: replyToCid.V, 222 + } 223 + } 224 + 225 + if edited.Valid { 226 + if t, err := time.Parse(time.RFC3339, edited.V); err == nil { 227 + comment.Edited = &t 228 + } 229 + } 230 + 231 + if deleted.Valid { 232 + if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 233 + comment.Deleted = &t 234 + } 235 + } 236 + 237 + comments = append(comments, comment) 238 + } 239 + 240 + if err := rows.Err(); err != nil { 241 + return nil, err 242 + } 243 + defer rows.Close() 244 + 245 + sort.Slice(comments, func(i, j int) bool { 246 + return comments[i].Created.Before(comments[j].Created) 247 + }) 248 + 249 + return comments, nil 250 + }
+96
appview/db/db.go
··· 1368 1368 return err 1369 1369 }) 1370 1370 1371 + orm.RunMigration(conn, logger, "add-comments-table", func(tx *sql.Tx) error { 1372 + _, err := tx.Exec(` 1373 + drop table if exists comments; 1374 + 1375 + create table comments ( 1376 + -- identifiers 1377 + id integer primary key autoincrement, 1378 + 1379 + did text not null, 1380 + collection text not null default 'sh.tangled.feed.comment', 1381 + rkey text not null, 1382 + at_uri text generated always as ('at://' || did || '/' || collection || '/' || rkey) stored, 1383 + cid text, 1384 + 1385 + -- content 1386 + subject_uri text not null, -- at_uri of subject (issue, pr, string) 1387 + subject_cid text not null, -- cid of subject 1388 + 1389 + body_text text not null, 1390 + body_original text, 1391 + body_blobs text, -- json 1392 + 1393 + created text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 1394 + 1395 + reply_to_uri text, -- at_uri of parent comment 1396 + reply_to_cid text, -- cid of parent comment 1397 + 1398 + pull_round_idx integer, -- pull round index. required when subject is sh.tangled.repo.pull 1399 + 1400 + -- appview-local information 1401 + edited text, 1402 + deleted text, 1403 + 1404 + unique(did, collection, rkey) 1405 + ); 1406 + 1407 + insert into comments ( 1408 + did, 1409 + collection, 1410 + rkey, 1411 + subject_uri, 1412 + subject_cid, -- we need to know cid 1413 + body_text, 1414 + created, 1415 + reply_to_uri, 1416 + reply_to_cid, -- we need to know cid 1417 + edited, 1418 + deleted 1419 + ) 1420 + select 1421 + did, 1422 + 'sh.tangled.repo.issue.comment', 1423 + rkey, 1424 + issue_at, 1425 + '', 1426 + body, 1427 + created, 1428 + reply_to, 1429 + '', 1430 + edited, 1431 + deleted 1432 + from issue_comments 1433 + where rkey is not null; 1434 + 1435 + insert into comments ( 1436 + did, 1437 + collection, 1438 + rkey, 1439 + subject_uri, 1440 + subject_cid, -- we need to know cid 1441 + body_text, 1442 + created, 1443 + pull_round_idx 1444 + ) 1445 + select 1446 + c.owner_did, 1447 + 'sh.tangled.repo.pull.comment', 1448 + substr( 1449 + substr(c.comment_at, 6 + instr(substr(c.comment_at, 6), '/')), -- nsid/rkey 1450 + instr( 1451 + substr(c.comment_at, 6 + instr(substr(c.comment_at, 6), '/')), -- nsid/rkey 1452 + '/' 1453 + ) + 1 1454 + ), -- rkey 1455 + p.at_uri, 1456 + '', 1457 + c.body, 1458 + c.created, 1459 + s.round_number 1460 + from pull_comments c 1461 + join pulls p on c.repo_at = p.repo_at and c.pull_id = p.pull_id 1462 + join pull_submissions s on s.id = c.submission_id; 1463 + `) 1464 + return err 1465 + }) 1466 + 1371 1467 return &DB{ 1372 1468 db, 1373 1469 logger,
+15 -132
appview/db/pulls.go
··· 353 353 } 354 354 defer rows.Close() 355 355 356 - submissionMap := make(map[int]*models.PullSubmission) 356 + pullMap := make(map[syntax.ATURI][]*models.PullSubmission) 357 357 358 358 for rows.Next() { 359 359 var submission models.PullSubmission ··· 384 384 submission.Combined = submissionCombined.String 385 385 } 386 386 387 - submissionMap[submission.ID] = &submission 387 + pullMap[submission.PullAt] = append(pullMap[submission.PullAt], &submission) 388 388 } 389 389 390 390 if err := rows.Err(); err != nil { 391 391 return nil, err 392 392 } 393 393 394 - // Get comments for all submissions using GetPullComments 395 - submissionIds := slices.Collect(maps.Keys(submissionMap)) 396 - comments, err := GetPullComments(e, orm.FilterIn("submission_id", submissionIds)) 394 + // Get comments for all submissions using GetComments 395 + pullAts := slices.Collect(maps.Keys(pullMap)) 396 + comments, err := GetComments(e, orm.FilterIn("subject_uri", pullAts)) 397 397 if err != nil { 398 398 return nil, fmt.Errorf("failed to get pull comments: %w", err) 399 399 } 400 400 for _, comment := range comments { 401 - if submission, ok := submissionMap[comment.SubmissionId]; ok { 402 - submission.Comments = append(submission.Comments, comment) 401 + if comment.PullRoundIdx != nil { 402 + roundIdx := *comment.PullRoundIdx 403 + if submissions, ok := pullMap[syntax.ATURI(comment.Subject.Uri)]; ok { 404 + if roundIdx < len(submissions) { 405 + submission := submissions[roundIdx] 406 + submission.Comments = append(submission.Comments, comment) 407 + } 408 + } 403 409 } 404 410 } 405 411 406 - // group the submissions by pull_at 407 - m := make(map[syntax.ATURI][]*models.PullSubmission) 408 - for _, s := range submissionMap { 409 - m[s.PullAt] = append(m[s.PullAt], s) 410 - } 411 - 412 412 // sort each one by round number 413 - for _, s := range m { 413 + for _, s := range pullMap { 414 414 slices.SortFunc(s, func(a, b *models.PullSubmission) int { 415 415 return cmp.Compare(a.RoundNumber, b.RoundNumber) 416 416 }) 417 417 } 418 418 419 - return m, nil 420 - } 421 - 422 - func GetPullComments(e Execer, filters ...orm.Filter) ([]models.PullComment, error) { 423 - var conditions []string 424 - var args []any 425 - for _, filter := range filters { 426 - conditions = append(conditions, filter.Condition()) 427 - args = append(args, filter.Arg()...) 428 - } 429 - 430 - whereClause := "" 431 - if conditions != nil { 432 - whereClause = " where " + strings.Join(conditions, " and ") 433 - } 434 - 435 - query := fmt.Sprintf(` 436 - select 437 - id, 438 - pull_id, 439 - submission_id, 440 - repo_at, 441 - owner_did, 442 - comment_at, 443 - body, 444 - created 445 - from 446 - pull_comments 447 - %s 448 - order by 449 - created asc 450 - `, whereClause) 451 - 452 - rows, err := e.Query(query, args...) 453 - if err != nil { 454 - return nil, err 455 - } 456 - defer rows.Close() 457 - 458 - commentMap := make(map[string]*models.PullComment) 459 - for rows.Next() { 460 - var comment models.PullComment 461 - var createdAt string 462 - err := rows.Scan( 463 - &comment.ID, 464 - &comment.PullId, 465 - &comment.SubmissionId, 466 - &comment.RepoAt, 467 - &comment.OwnerDid, 468 - &comment.CommentAt, 469 - &comment.Body, 470 - &createdAt, 471 - ) 472 - if err != nil { 473 - return nil, err 474 - } 475 - 476 - if t, err := time.Parse(time.RFC3339, createdAt); err == nil { 477 - comment.Created = t 478 - } 479 - 480 - atUri := comment.AtUri().String() 481 - commentMap[atUri] = &comment 482 - } 483 - 484 - if err := rows.Err(); err != nil { 485 - return nil, err 486 - } 487 - 488 - // collect references for each comments 489 - commentAts := slices.Collect(maps.Keys(commentMap)) 490 - allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts)) 491 - if err != nil { 492 - return nil, fmt.Errorf("failed to query reference_links: %w", err) 493 - } 494 - for commentAt, references := range allReferencs { 495 - if comment, ok := commentMap[commentAt.String()]; ok { 496 - comment.References = references 497 - } 498 - } 499 - 500 - var comments []models.PullComment 501 - for _, c := range commentMap { 502 - comments = append(comments, *c) 503 - } 504 - 505 - sort.Slice(comments, func(i, j int) bool { 506 - return comments[i].Created.Before(comments[j].Created) 507 - }) 508 - 509 - return comments, nil 419 + return pullMap, nil 510 420 } 511 421 512 422 // timeframe here is directly passed into the sql query filter, and any ··· 585 495 return pulls, nil 586 496 } 587 497 588 - func NewPullComment(tx *sql.Tx, comment *models.PullComment) (int64, error) { 589 - query := `insert into pull_comments (owner_did, repo_at, submission_id, comment_at, pull_id, body) values (?, ?, ?, ?, ?, ?)` 590 - res, err := tx.Exec( 591 - query, 592 - comment.OwnerDid, 593 - comment.RepoAt, 594 - comment.SubmissionId, 595 - comment.CommentAt, 596 - comment.PullId, 597 - comment.Body, 598 - ) 599 - if err != nil { 600 - return 0, err 601 - } 602 - 603 - i, err := res.LastInsertId() 604 - if err != nil { 605 - return 0, err 606 - } 607 - 608 - if err := putReferences(tx, comment.AtUri(), comment.References); err != nil { 609 - return 0, fmt.Errorf("put reference_links: %w", err) 610 - } 611 - 612 - return i, nil 613 - } 614 - 615 498 func SetPullState(e Execer, repoAt syntax.ATURI, pullId int, pullState models.PullState) error { 616 499 _, err := e.Exec( 617 500 `update pulls set state = ? where repo_at = ? and pull_id = ? and (state <> ? and state <> ?)`,
+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_at = r.at_uri 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_at = r.at_uri and c.pull_id = p.pull_id 137 + and c.subject_at = ('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.at_uri = p.repo_at 441 - join pull_comments c 442 - on r.at_uri = c.repo_at 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_at 443 442 where %s and %s`, 444 443 filter.Condition(), 445 444 exclude.Condition(),
+108 -6
appview/ingester.go
··· 19 19 "tangled.org/core/api/tangled" 20 20 "tangled.org/core/appview/config" 21 21 "tangled.org/core/appview/db" 22 + "tangled.org/core/appview/mentions" 22 23 "tangled.org/core/appview/models" 24 + "tangled.org/core/appview/notify" 23 25 "tangled.org/core/appview/serververify" 24 26 "tangled.org/core/appview/validator" 25 27 "tangled.org/core/idresolver" ··· 28 30 ) 29 31 30 32 type Ingester struct { 31 - Db db.DbWrapper 32 - Enforcer *rbac.Enforcer 33 - IdResolver *idresolver.Resolver 34 - Config *config.Config 35 - Logger *slog.Logger 36 - Validator *validator.Validator 33 + Db db.DbWrapper 34 + Enforcer *rbac.Enforcer 35 + IdResolver *idresolver.Resolver 36 + Config *config.Config 37 + Logger *slog.Logger 38 + Validator *validator.Validator 39 + MentionsResolver *mentions.Resolver 40 + Notifier notify.Notifier 37 41 } 38 42 39 43 type processFunc func(ctx context.Context, e *jmodels.Event) error ··· 81 85 err = i.ingestString(e) 82 86 case tangled.RepoIssueNSID: 83 87 err = i.ingestIssue(ctx, e) 88 + case tangled.FeedCommentNSID: 89 + err = i.ingestComment(e) 84 90 case tangled.RepoIssueCommentNSID: 85 91 err = i.ingestIssueComment(e) 92 + case tangled.RepoPullCommentNSID: 93 + err = i.ingestPullComment(e) 86 94 case tangled.LabelDefinitionNSID: 87 95 err = i.ingestLabelDefinition(e) 88 96 case tangled.LabelOpNSID: ··· 1002 1010 return nil 1003 1011 } 1004 1012 1013 + // ingestPullComment ingests legacy sh.tangled.repo.pull.comment deletions 1014 + func (i *Ingester) ingestPullComment(e *jmodels.Event) error { 1015 + l := i.Logger.With("handler", "ingestPullComment", "nsid", e.Commit.Collection, "did", e.Did, "rkey", e.Commit.RKey) 1016 + l.Info("ingesting record") 1017 + 1018 + switch e.Commit.Operation { 1019 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 1020 + // no-op. sh.tangled.repo.pull.comment is deprecated 1021 + 1022 + case jmodels.CommitOperationDelete: 1023 + if err := db.DeleteComments( 1024 + i.Db, 1025 + orm.FilterEq("did", e.Did), 1026 + orm.FilterEq("collection", e.Commit.Collection), 1027 + orm.FilterEq("rkey", e.Commit.RKey), 1028 + ); err != nil { 1029 + return fmt.Errorf("failed to delete comment record: %w", err) 1030 + } 1031 + } 1032 + 1033 + return nil 1034 + } 1035 + 1036 + func (i *Ingester) ingestComment(e *jmodels.Event) error { 1037 + did := e.Did 1038 + rkey := e.Commit.RKey 1039 + cid := e.Commit.CID 1040 + 1041 + var err error 1042 + 1043 + l := i.Logger.With("handler", "ingestComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 1044 + l.Info("ingesting record") 1045 + 1046 + ddb, ok := i.Db.Execer.(*db.DB) 1047 + if !ok { 1048 + return fmt.Errorf("failed to index issue comment record, invalid db cast") 1049 + } 1050 + 1051 + ctx := context.Background() 1052 + 1053 + switch e.Commit.Operation { 1054 + case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 1055 + raw := json.RawMessage(e.Commit.Record) 1056 + record := tangled.FeedComment{} 1057 + err = json.Unmarshal(raw, &record) 1058 + if err != nil { 1059 + return fmt.Errorf("invalid record: %w", err) 1060 + } 1061 + 1062 + comment, err := models.CommentFromRecord(syntax.DID(did), syntax.RecordKey(rkey), syntax.CID(cid), record) 1063 + if err != nil { 1064 + return fmt.Errorf("failed to parse comment from record: %w", err) 1065 + } 1066 + 1067 + if err := comment.Validate(); err != nil { 1068 + return fmt.Errorf("failed to validate comment: %w", err) 1069 + } 1070 + 1071 + var references []syntax.ATURI 1072 + if original := comment.Body.Original; original != nil { 1073 + _, references = i.MentionsResolver.Resolve(ctx, comment.Body.Text) 1074 + } 1075 + 1076 + tx, err := ddb.Begin() 1077 + if err != nil { 1078 + return fmt.Errorf("failed to start transaction: %w", err) 1079 + } 1080 + defer tx.Rollback() 1081 + 1082 + err = db.PutComment(tx, comment, references) 1083 + if err != nil { 1084 + return fmt.Errorf("failed to create comment: %w", err) 1085 + } 1086 + 1087 + if err := tx.Commit(); err != nil { 1088 + return err 1089 + } 1090 + 1091 + case jmodels.CommitOperationDelete: 1092 + if err := db.DeleteComments( 1093 + ddb, 1094 + orm.FilterEq("did", did), 1095 + orm.FilterEq("collection", e.Commit.Collection), 1096 + orm.FilterEq("rkey", rkey), 1097 + ); err != nil { 1098 + return fmt.Errorf("failed to delete comment record: %w", err) 1099 + } 1100 + 1101 + return nil 1102 + } 1103 + 1104 + return nil 1105 + } 1106 + 1005 1107 func (i *Ingester) ingestLabelDefinition(e *jmodels.Event) error { 1006 1108 did := e.Did 1007 1109 rkey := e.Commit.RKey
+146
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 = int64(*c.PullRoundIdx) 53 + } 54 + return &tangled.FeedComment{ 55 + Subject: &c.Subject, 56 + Body: &tangled.FeedComment_Body{MarkupMarkdown: &c.Body}, 57 + CreatedAt: c.Created.Format(time.RFC3339), 58 + ReplyTo: c.ReplyTo, 59 + PullRoundIdx: &pullRoundIdx, 60 + } 61 + } 62 + 63 + func (c *Comment) IsTopLevel() bool { 64 + return c.ReplyTo == nil 65 + } 66 + 67 + func (c *Comment) IsReply() bool { 68 + return c.ReplyTo != nil 69 + } 70 + 71 + func (c *Comment) Validate() error { 72 + // TODO: sanitize the body and then trim space 73 + if sb := strings.TrimSpace(c.Body.Text); sb == "" { 74 + return fmt.Errorf("body is empty after HTML sanitization") 75 + } 76 + 77 + // if it's for PR, PullSubmissionId should not be nil 78 + subjectAt, err := syntax.ParseATURI(c.Subject.Uri) 79 + if err != nil { 80 + return fmt.Errorf("subject.uri is not valid at-uri: %w", err) 81 + } 82 + if subjectAt.Collection().String() == tangled.RepoPullNSID { 83 + if c.PullRoundIdx == nil { 84 + return fmt.Errorf("pullSubmissionId should not be nil when subject is sh.tangled.repo.pull") 85 + } 86 + } 87 + return nil 88 + } 89 + 90 + func CommentFromRecord(did syntax.DID, rkey syntax.RecordKey, cid syntax.CID, record tangled.FeedComment) (*Comment, error) { 91 + created, err := time.Parse(time.RFC3339, record.CreatedAt) 92 + if err != nil { 93 + created = time.Now() 94 + } 95 + 96 + if record.Subject == nil { 97 + return nil, fmt.Errorf("subject can't be nil") 98 + } 99 + subjectAt, err := syntax.ParseATURI(record.Subject.Uri) 100 + if err != nil { 101 + return nil, fmt.Errorf("invalid subject uri: %w", err) 102 + } 103 + if _, err = syntax.ParseCID(record.Subject.Cid); err != nil { 104 + return nil, fmt.Errorf("invalid subject cid: %w", err) 105 + } 106 + 107 + if subjectAt.Collection() == tangled.RepoPullNSID { 108 + if record.PullRoundIdx == nil { 109 + return nil, fmt.Errorf("pullRoundIdx can't be nil when subject is sh.tangled.repo.pull") 110 + } 111 + } 112 + 113 + if record.Body == nil { 114 + return nil, fmt.Errorf("body can't be nil") 115 + } 116 + if record.Body.MarkupMarkdown == nil { 117 + return nil, fmt.Errorf("body should be markdown type") 118 + } 119 + 120 + if record.ReplyTo != nil { 121 + if _, err = syntax.ParseATURI(record.ReplyTo.Uri); err != nil { 122 + return nil, fmt.Errorf("invalid replyTo uri: %w", err) 123 + } 124 + if _, err = syntax.ParseCID(record.ReplyTo.Cid); err != nil { 125 + return nil, fmt.Errorf("invalid replyTo cid: %w", err) 126 + } 127 + } 128 + 129 + var pullRoundIdx int 130 + if record.PullRoundIdx != nil { 131 + pullRoundIdx = int(*record.PullRoundIdx) 132 + } 133 + 134 + return &Comment{ 135 + Did: did, 136 + Collection: tangled.FeedCommentNSID, 137 + Rkey: rkey, 138 + Cid: cid, 139 + 140 + Subject: *record.Subject, 141 + Body: *record.Body.MarkupMarkdown, 142 + Created: created, 143 + ReplyTo: record.ReplyTo, 144 + PullRoundIdx: &pullRoundIdx, 145 + }, nil 146 + }
+2 -28
appview/models/pull.go
··· 139 139 RoundNumber int 140 140 Patch string 141 141 Combined string 142 - Comments []PullComment 142 + Comments []Comment 143 143 SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs 144 144 145 145 // meta 146 146 Created time.Time 147 147 } 148 148 149 - type PullComment struct { 150 - // ids 151 - ID int 152 - PullId int 153 - SubmissionId int 154 - 155 - // at ids 156 - RepoAt string 157 - OwnerDid string 158 - CommentAt string 159 - 160 - // content 161 - Body string 162 - 163 - // meta 164 - Mentions []syntax.DID 165 - References []syntax.ATURI 166 - 167 - // meta 168 - Created time.Time 169 - } 170 - 171 - func (p *PullComment) AtUri() syntax.ATURI { 172 - return syntax.ATURI(p.CommentAt) 173 - } 174 - 175 149 func (p *Pull) TotalComments() int { 176 150 total := 0 177 151 for _, s := range p.Submissions { ··· 280 254 addParticipant(s.PullAt.Authority().String()) 281 255 282 256 for _, c := range s.Comments { 283 - addParticipant(c.OwnerDid) 257 + addParticipant(c.Did.String()) 284 258 } 285 259 286 260 return participants
+13 -7
appview/notify/db/db.go
··· 278 278 ) 279 279 } 280 280 281 - func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 281 + func (n *databaseNotifier) NewPullComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { 282 282 l := log.FromContext(ctx) 283 283 284 - pull, err := db.GetPull(n.db, 285 - syntax.ATURI(comment.RepoAt), 286 - comment.PullId, 284 + subjectAt := syntax.ATURI(comment.Subject.Uri) 285 + pulls, err := db.GetPulls(n.db, 286 + orm.FilterEq("owner_did", subjectAt.Authority()), 287 + orm.FilterEq("rkey", subjectAt.RecordKey()), 287 288 ) 288 289 if err != nil { 289 - l.Error("failed to get pulls", "err", err) 290 + l.Error("failed to get pull", "err", err) 290 291 return 291 292 } 293 + if len(pulls) == 0 { 294 + l.Error("NewPullComment: no pull found", "aturi", comment.Subject) 295 + return 296 + } 297 + pull := pulls[0] 292 298 293 - repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", comment.RepoAt)) 299 + repo, err := db.GetRepo(n.db, orm.FilterEq("at_uri", pull.RepoAt)) 294 300 if err != nil { 295 301 l.Error("failed to get repos", "err", err) 296 302 return ··· 308 314 recipients.Remove(m) 309 315 } 310 316 311 - actorDid := syntax.DID(comment.OwnerDid) 317 + actorDid := comment.Did 312 318 eventType := models.NotificationTypePullCommented 313 319 entityType := "pull" 314 320 entityId := pull.AtUri().String()
+1 -1
appview/notify/logging/notifier.go
··· 81 81 l.inner.NewPull(ctx, pull) 82 82 } 83 83 84 - func (l *loggingNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 84 + func (l *loggingNotifier) NewPullComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { 85 85 ctx = tlog.IntoContext(ctx, tlog.SubLogger(l.logger, "NewPullComment")) 86 86 l.inner.NewPullComment(ctx, comment, mentions) 87 87 }
+1 -1
appview/notify/merged_notifier.go
··· 78 78 m.fanout(func(n Notifier) { n.NewPull(ctx, pull) }) 79 79 } 80 80 81 - func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 81 + func (m *mergedNotifier) NewPullComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { 82 82 m.fanout(func(n Notifier) { n.NewPullComment(ctx, comment, mentions) }) 83 83 } 84 84
+2 -2
appview/notify/notifier.go
··· 22 22 DeleteFollow(ctx context.Context, follow *models.Follow) 23 23 24 24 NewPull(ctx context.Context, pull *models.Pull) 25 - NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) 25 + NewPullComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) 26 26 NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) 27 27 28 28 NewIssueLabelOp(ctx context.Context, issue *models.Issue) ··· 62 62 func (m *BaseNotifier) DeleteFollow(ctx context.Context, follow *models.Follow) {} 63 63 64 64 func (m *BaseNotifier) NewPull(ctx context.Context, pull *models.Pull) {} 65 - func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.PullComment, mentions []syntax.DID) { 65 + func (m *BaseNotifier) NewPullComment(ctx context.Context, models *models.Comment, mentions []syntax.DID) { 66 66 } 67 67 func (m *BaseNotifier) NewPullState(ctx context.Context, actor syntax.DID, pull *models.Pull) {} 68 68
+3 -4
appview/notify/posthog/notifier.go
··· 86 86 } 87 87 } 88 88 89 - func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.PullComment, mentions []syntax.DID) { 89 + func (n *posthogNotifier) NewPullComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { 90 90 err := n.client.Enqueue(posthog.Capture{ 91 - DistinctId: comment.OwnerDid, 91 + DistinctId: comment.Did.String(), 92 92 Event: "new_pull_comment", 93 93 Properties: posthog.Properties{ 94 - "repo_at": comment.RepoAt, 95 - "pull_id": comment.PullId, 94 + "pull_at": comment.Subject, 96 95 "mentions": mentions, 97 96 }, 98 97 })
+1
appview/oauth/scopes.go
··· 16 16 "repo:sh.tangled.spindle", 17 17 "repo:sh.tangled.spindle.member", 18 18 "repo:sh.tangled.graph.follow", 19 + "repo:sh.tangled.feed.comment", 19 20 "repo:sh.tangled.feed.star", 20 21 "repo:sh.tangled.feed.reaction", 21 22 "repo:sh.tangled.label.definition",
+5 -5
appview/pages/templates/repo/pulls/pull.html
··· 625 625 {{ end }} 626 626 627 627 {{ define "submissionComment" }} 628 - <div id="comment-{{.ID}}" class="flex gap-2 -ml-4 py-4 w-full mx-auto 628 + <div id="comment-{{.Id}}" class="flex gap-2 -ml-4 py-4 w-full mx-auto 629 629 target:ring-1 target:ring-gray-200 target:dark:ring-gray-700 630 630 target:px-2 target:-ml-6 631 631 first:target:translate-y-6 first:target:mb-6 ··· 634 634 "> 635 635 <!-- left column: profile picture --> 636 636 <div class="flex-shrink-0 h-fit relative"> 637 - {{ template "user/fragments/picLink" (list .OwnerDid "size-8") }} 637 + {{ template "user/fragments/picLink" (list .Did.String "size-8") }} 638 638 </div> 639 639 <!-- right column: name and body in two rows --> 640 640 <div class="flex-1 min-w-0"> 641 641 <!-- Row 1: Author and timestamp --> 642 642 <div class="text-sm text-gray-500 dark:text-gray-400 flex items-center gap-1"> 643 - {{ $handle := resolve .OwnerDid }} 643 + {{ $handle := resolve .Did.String }} 644 644 <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="/{{ $handle }}">{{ $handle }}</a> 645 645 <span class="before:content-['路']"></span> 646 - <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.ID}}"> 646 + <a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="#comment-{{.Id}}"> 647 647 {{ template "repo/fragments/shortTime" .Created }} 648 648 </a> 649 649 </div> 650 650 <!-- Row 2: Body text --> 651 651 <div class="prose dark:prose-invert mt-1"> 652 - {{ .Body | markdown }} 652 + {{ .Body.Text | markdown }} 653 653 </div> 654 654 </div> 655 655 </div>
+74 -31
appview/pulls/pulls.go
··· 791 791 case http.MethodPost: 792 792 body := r.FormValue("body") 793 793 if body == "" { 794 - s.pages.Notice(w, "pull", "Comment body is required") 794 + s.pages.Notice(w, "pull-comment", "Comment body is required") 795 795 return 796 796 } 797 797 798 + // TODO(boltless): normalize markdown body 799 + normalizedBody := body 798 800 mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 799 801 800 - // Start a transaction 801 - tx, err := s.db.BeginTx(r.Context(), nil) 802 + markdownBody := tangled.MarkupMarkdown{ 803 + Text: normalizedBody, 804 + Original: &body, 805 + Blobs: nil, 806 + } 807 + 808 + // ingest CID of PR record on-demand. 809 + // TODO(boltless): appview should ingest CID of atproto records 810 + cid, err := func() (syntax.CID, error) { 811 + ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 812 + if err != nil { 813 + return "", err 814 + } 815 + 816 + xrpcc := indigoxrpc.Client{Host: ident.PDSEndpoint()} 817 + out, err := comatproto.RepoGetRecord(r.Context(), &xrpcc, "", tangled.RepoPullNSID, pull.OwnerDid, pull.Rkey) 818 + if err != nil { 819 + return "", err 820 + } 821 + if out.Cid == nil { 822 + return "", fmt.Errorf("record CID is empty") 823 + } 824 + 825 + cid, err := syntax.ParseCID(*out.Cid) 826 + if err != nil { 827 + return "", err 828 + } 829 + 830 + return cid, nil 831 + }() 802 832 if err != nil { 803 - s.logger.Error("failed to start transaction", "err", err) 804 - s.pages.Notice(w, "pull-comment", "Failed to create comment.") 833 + s.logger.Error("failed to backfill subject PR record", "err", err) 834 + s.pages.Notice(w, "pull-comment", "failed to backfill subject record") 805 835 return 806 836 } 807 - defer tx.Rollback() 837 + pullStrongRef := comatproto.RepoStrongRef{ 838 + Uri: pull.AtUri().String(), 839 + Cid: cid.String(), 840 + } 841 + 842 + comment := models.Comment{ 843 + Did: syntax.DID(user.Active.Did), 844 + Collection: tangled.FeedCommentNSID, 845 + Rkey: syntax.RecordKey(tid.TID()), 808 846 809 - createdAt := time.Now().Format(time.RFC3339) 847 + Subject: pullStrongRef, 848 + Body: markdownBody, 849 + Created: time.Now(), 850 + ReplyTo: nil, 851 + PullRoundIdx: &roundNumber, 852 + } 853 + if err = comment.Validate(); err != nil { 854 + s.logger.Error("failed to validate comment", "err", err) 855 + s.pages.Notice(w, "pull-comment", "Failed to create comment.") 856 + return 857 + } 810 858 811 859 client, err := s.oauth.AuthorizedClient(r) 812 860 if err != nil { ··· 814 862 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 815 863 return 816 864 } 817 - atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 818 - Collection: tangled.RepoPullCommentNSID, 819 - Repo: user.Active.Did, 820 - Rkey: tid.TID(), 821 - Record: &lexutil.LexiconTypeDecoder{ 822 - Val: &tangled.RepoPullComment{ 823 - Pull: pull.AtUri().String(), 824 - Body: body, 825 - CreatedAt: createdAt, 826 - }, 827 - }, 865 + 866 + out, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 867 + Collection: comment.Collection.String(), 868 + Repo: comment.Did.String(), 869 + Rkey: comment.Rkey.String(), 870 + Record: &lexutil.LexiconTypeDecoder{Val: comment.AsRecord()}, 828 871 }) 829 872 if err != nil { 830 873 s.logger.Error("failed to create pull comment", "err", err) ··· 832 875 return 833 876 } 834 877 835 - comment := &models.PullComment{ 836 - OwnerDid: user.Active.Did, 837 - RepoAt: f.RepoAt().String(), 838 - PullId: pull.PullId, 839 - Body: body, 840 - CommentAt: atResp.Uri, 841 - SubmissionId: pull.Submissions[roundNumber].ID, 842 - Mentions: mentions, 843 - References: references, 878 + comment.Cid = syntax.CID(out.Cid) 879 + 880 + // Start a transaction 881 + tx, err := s.db.BeginTx(r.Context(), nil) 882 + if err != nil { 883 + s.logger.Error("failed to start transaction", "err", err) 884 + s.pages.Notice(w, "pull-comment", "Failed to create comment.") 885 + return 844 886 } 887 + defer tx.Rollback() 845 888 846 - // Create the pull comment in the database with the commentAt field 847 - commentId, err := db.NewPullComment(tx, comment) 889 + // Create the pull comment in the database 890 + err = db.PutComment(tx, &comment, references) 848 891 if err != nil { 849 892 s.logger.Error("failed to create pull comment", "err", err) 850 893 s.pages.Notice(w, "pull-comment", "Failed to create comment.") ··· 858 901 return 859 902 } 860 903 861 - s.notifier.NewPullComment(r.Context(), comment, mentions) 904 + s.notifier.NewPullComment(r.Context(), &comment, mentions) 862 905 863 906 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 864 - s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId)) 907 + s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, comment.Id)) 865 908 return 866 909 } 867 910 }
+16 -13
appview/state/state.go
··· 126 126 tangled.StringNSID, 127 127 tangled.RepoIssueNSID, 128 128 tangled.RepoIssueCommentNSID, 129 + tangled.FeedCommentNSID, 129 130 tangled.LabelDefinitionNSID, 130 131 tangled.LabelOpNSID, 131 132 }, ··· 146 147 return nil, fmt.Errorf("failed to backfill default label defs: %w", err) 147 148 } 148 149 149 - ingester := appview.Ingester{ 150 - Db: wrapper, 151 - Enforcer: enforcer, 152 - IdResolver: res, 153 - Config: config, 154 - Logger: log.SubLogger(logger, "ingester"), 155 - Validator: validator, 156 - } 157 - err = jc.StartJetstream(ctx, ingester.Ingest()) 158 - if err != nil { 159 - return nil, fmt.Errorf("failed to start jetstream watcher: %w", err) 160 - } 161 - 162 150 var notifiers []notify.Notifier 163 151 164 152 // Always add the database notifier ··· 175 163 notifier := notify.NewMergedNotifier(notifiers) 176 164 notifier = lognotify.NewLoggingNotifier(notifier, tlog.SubLogger(logger, "notify")) 177 165 166 + ingester := appview.Ingester{ 167 + Db: wrapper, 168 + Enforcer: enforcer, 169 + IdResolver: res, 170 + Config: config, 171 + Logger: log.SubLogger(logger, "ingester"), 172 + Validator: validator, 173 + MentionsResolver: mentionsResolver, 174 + Notifier: notifier, 175 + } 176 + err = jc.StartJetstream(ctx, ingester.Ingest()) 177 + if err != nil { 178 + return nil, fmt.Errorf("failed to start jetstream watcher: %w", err) 179 + } 180 + 178 181 var cfClient *cloudflare.Client 179 182 if config.Cloudflare.ApiToken != "" { 180 183 cfClient, err = cloudflare.New(config)

History

10 rounds 8 comments
sign up or login to add to the discussion
1 commit
expand
appview: replace PullComment to Comment
3/3 failed
expand
no conflicts, ready to merge
expand 0 comments
1 commit
expand
appview: replace PullComment to Comment
3/3 failed
expand
expand 0 comments
1 commit
expand
appview: replace PullComment to Comment
3/3 failed
expand
expand 0 comments
1 commit
expand
appview: replace PullComment to Comment
3/3 failed
expand
expand 0 comments
1 commit
expand
appview: replace PullComment to Comment
3/3 failed
expand
expand 0 comments
1 commit
expand
appview: replace PullComment to Comment
3/3 failed
expand
expand 0 comments
1 commit
expand
appview: replace PullComment to Comment
3/3 failed
expand
expand 2 comments

appview/models/comment.go:144 won't this always be non-nil, which means issue comments might get a pull round in the record?

appview/ingester.go:1073 maybe this should resolve mentions from the original instead of processed

1 commit
expand
appview: replace PullComment to Comment
3/3 failed
expand
expand 6 comments

appview/db/reference.go:137 is this supposed to be subject_uri as per the db?

Good spot! I completely missed it. Thanks

there's that one instance and a couple others I think

appview/ingester.go:1036 we could make a notification in this function right? :p

appview/db/comments.go:243 I think this should be before the rows loop, maybe

we could make a notification in this function right? :p

yeah, I was going to introduce that after #1275 and forgot to do that.

I think this should be before the rows loop, maybe

good spot. will fix

1 commit
expand
appview: replace PullComment to Comment
3/3 failed
expand
expand 0 comments
1 commit
expand
appview: replace PullComment to Comment
3/3 failed
expand
expand 0 comments