appview: replace IssueComment to Comment #866

open
opened by boltless.me targeting master from sl/wnrvrwyvrlzo
Changed files
+93 -380
appview
db
issues
models
notify
pages
templates
repo
issues
state
validator
+6 -185
appview/db/issues.go
··· 99 } 100 101 func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]models.Issue, error) { 102 - issueMap := make(map[string]*models.Issue) // at-uri -> issue 103 104 var conditions []string 105 var args []any ··· 195 } 196 } 197 198 - atUri := issue.AtUri().String() 199 - issueMap[atUri] = &issue 200 } 201 202 // collect reverse repos ··· 228 // collect comments 229 issueAts := slices.Collect(maps.Keys(issueMap)) 230 231 - comments, err := GetIssueComments(e, FilterIn("issue_at", issueAts)) 232 if err != nil { 233 return nil, fmt.Errorf("failed to query comments: %w", err) 234 } 235 for i := range comments { 236 - issueAt := comments[i].IssueAt 237 if issue, ok := issueMap[issueAt]; ok { 238 issue.Comments = append(issue.Comments, comments[i]) 239 } ··· 245 return nil, fmt.Errorf("failed to query labels: %w", err) 246 } 247 for issueAt, labels := range allLabels { 248 - if issue, ok := issueMap[issueAt.String()]; ok { 249 issue.Labels = labels 250 } 251 } ··· 256 return nil, fmt.Errorf("failed to query reference_links: %w", err) 257 } 258 for issueAt, references := range allReferencs { 259 - if issue, ok := issueMap[issueAt.String()]; ok { 260 issue.References = references 261 } 262 } ··· 350 return ids, nil 351 } 352 353 - func AddIssueComment(tx *sql.Tx, c models.IssueComment) (int64, error) { 354 - result, err := tx.Exec( 355 - `insert into issue_comments ( 356 - did, 357 - rkey, 358 - issue_at, 359 - body, 360 - reply_to, 361 - created, 362 - edited 363 - ) 364 - values (?, ?, ?, ?, ?, ?, null) 365 - on conflict(did, rkey) do update set 366 - issue_at = excluded.issue_at, 367 - body = excluded.body, 368 - edited = case 369 - when 370 - issue_comments.issue_at != excluded.issue_at 371 - or issue_comments.body != excluded.body 372 - or issue_comments.reply_to != excluded.reply_to 373 - then ? 374 - else issue_comments.edited 375 - end`, 376 - c.Did, 377 - c.Rkey, 378 - c.IssueAt, 379 - c.Body, 380 - c.ReplyTo, 381 - c.Created.Format(time.RFC3339), 382 - time.Now().Format(time.RFC3339), 383 - ) 384 - if err != nil { 385 - return 0, err 386 - } 387 - 388 - id, err := result.LastInsertId() 389 - if err != nil { 390 - return 0, err 391 - } 392 - 393 - if err := putReferences(tx, c.AtUri(), c.References); err != nil { 394 - return 0, fmt.Errorf("put reference_links: %w", err) 395 - } 396 - 397 - return id, nil 398 - } 399 - 400 - func DeleteIssueComments(e Execer, filters ...filter) error { 401 - var conditions []string 402 - var args []any 403 - for _, filter := range filters { 404 - conditions = append(conditions, filter.Condition()) 405 - args = append(args, filter.Arg()...) 406 - } 407 - 408 - whereClause := "" 409 - if conditions != nil { 410 - whereClause = " where " + strings.Join(conditions, " and ") 411 - } 412 - 413 - query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause) 414 - 415 - _, err := e.Exec(query, args...) 416 - return err 417 - } 418 - 419 - func GetIssueComments(e Execer, filters ...filter) ([]models.IssueComment, error) { 420 - commentMap := make(map[string]*models.IssueComment) 421 - 422 - var conditions []string 423 - var args []any 424 - for _, filter := range filters { 425 - conditions = append(conditions, filter.Condition()) 426 - args = append(args, filter.Arg()...) 427 - } 428 - 429 - whereClause := "" 430 - if conditions != nil { 431 - whereClause = " where " + strings.Join(conditions, " and ") 432 - } 433 - 434 - query := fmt.Sprintf(` 435 - select 436 - id, 437 - did, 438 - rkey, 439 - issue_at, 440 - reply_to, 441 - body, 442 - created, 443 - edited, 444 - deleted 445 - from 446 - issue_comments 447 - %s 448 - `, whereClause) 449 - 450 - rows, err := e.Query(query, args...) 451 - if err != nil { 452 - return nil, err 453 - } 454 - 455 - for rows.Next() { 456 - var comment models.IssueComment 457 - var created string 458 - var rkey, edited, deleted, replyTo sql.Null[string] 459 - err := rows.Scan( 460 - &comment.Id, 461 - &comment.Did, 462 - &rkey, 463 - &comment.IssueAt, 464 - &replyTo, 465 - &comment.Body, 466 - &created, 467 - &edited, 468 - &deleted, 469 - ) 470 - if err != nil { 471 - return nil, err 472 - } 473 - 474 - // this is a remnant from old times, newer comments always have rkey 475 - if rkey.Valid { 476 - comment.Rkey = rkey.V 477 - } 478 - 479 - if t, err := time.Parse(time.RFC3339, created); err == nil { 480 - comment.Created = t 481 - } 482 - 483 - if edited.Valid { 484 - if t, err := time.Parse(time.RFC3339, edited.V); err == nil { 485 - comment.Edited = &t 486 - } 487 - } 488 - 489 - if deleted.Valid { 490 - if t, err := time.Parse(time.RFC3339, deleted.V); err == nil { 491 - comment.Deleted = &t 492 - } 493 - } 494 - 495 - if replyTo.Valid { 496 - comment.ReplyTo = &replyTo.V 497 - } 498 - 499 - atUri := comment.AtUri().String() 500 - commentMap[atUri] = &comment 501 - } 502 - 503 - if err = rows.Err(); err != nil { 504 - return nil, err 505 - } 506 - 507 - // collect references for each comments 508 - commentAts := slices.Collect(maps.Keys(commentMap)) 509 - allReferencs, err := GetReferencesAll(e, FilterIn("from_at", commentAts)) 510 - if err != nil { 511 - return nil, fmt.Errorf("failed to query reference_links: %w", err) 512 - } 513 - for commentAt, references := range allReferencs { 514 - if comment, ok := commentMap[commentAt.String()]; ok { 515 - comment.References = references 516 - } 517 - } 518 - 519 - var comments []models.IssueComment 520 - for _, c := range commentMap { 521 - comments = append(comments, *c) 522 - } 523 - 524 - sort.Slice(comments, func(i, j int) bool { 525 - return comments[i].Created.After(comments[j].Created) 526 - }) 527 - 528 - return comments, nil 529 - } 530 - 531 func DeleteIssues(tx *sql.Tx, did, rkey string) error { 532 _, err := tx.Exec( 533 `delete from issues
··· 99 } 100 101 func GetIssuesPaginated(e Execer, page pagination.Page, filters ...filter) ([]models.Issue, error) { 102 + issueMap := make(map[syntax.ATURI]*models.Issue) // at-uri -> issue 103 104 var conditions []string 105 var args []any ··· 195 } 196 } 197 198 + issueMap[issue.AtUri()] = &issue 199 } 200 201 // collect reverse repos ··· 227 // collect comments 228 issueAts := slices.Collect(maps.Keys(issueMap)) 229 230 + comments, err := GetComments(e, FilterIn("subject_at", issueAts)) 231 if err != nil { 232 return nil, fmt.Errorf("failed to query comments: %w", err) 233 } 234 for i := range comments { 235 + issueAt := comments[i].Subject 236 if issue, ok := issueMap[issueAt]; ok { 237 issue.Comments = append(issue.Comments, comments[i]) 238 } ··· 244 return nil, fmt.Errorf("failed to query labels: %w", err) 245 } 246 for issueAt, labels := range allLabels { 247 + if issue, ok := issueMap[issueAt]; ok { 248 issue.Labels = labels 249 } 250 } ··· 255 return nil, fmt.Errorf("failed to query reference_links: %w", err) 256 } 257 for issueAt, references := range allReferencs { 258 + if issue, ok := issueMap[issueAt]; ok { 259 issue.References = references 260 } 261 } ··· 349 return ids, nil 350 } 351 352 func DeleteIssues(tx *sql.Tx, did, rkey string) error { 353 _, err := tx.Exec( 354 `delete from issues
+13 -24
appview/db/reference.go
··· 10 "tangled.org/core/appview/models" 11 ) 12 13 - // ValidateReferenceLinks resolves refLinks to Issue/PR/IssueComment/PullComment ATURIs. 14 // It will ignore missing refLinks. 15 func ValidateReferenceLinks(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) { 16 var ( ··· 52 values %s 53 ) 54 select 55 - i.did, i.rkey, 56 - c.did, c.rkey 57 from input inp 58 join repos r 59 on r.did = inp.owner_did ··· 61 join issues i 62 on i.repo_at = r.at_uri 63 and i.issue_id = inp.issue_id 64 - left join issue_comments c 65 on inp.comment_id is not null 66 - and c.issue_at = i.at_uri 67 and c.id = inp.comment_id 68 `, 69 strings.Join(vals, ","), ··· 78 79 for rows.Next() { 80 // Scan rows 81 - var issueOwner, issueRkey string 82 - var commentOwner, commentRkey sql.NullString 83 var uri syntax.ATURI 84 - if err := rows.Scan(&issueOwner, &issueRkey, &commentOwner, &commentRkey); err != nil { 85 return nil, err 86 } 87 - if commentOwner.Valid && commentRkey.Valid { 88 - uri = syntax.ATURI(fmt.Sprintf( 89 - "at://%s/%s/%s", 90 - commentOwner.String, 91 - tangled.RepoIssueCommentNSID, 92 - commentRkey.String, 93 - )) 94 } else { 95 - uri = syntax.ATURI(fmt.Sprintf( 96 - "at://%s/%s/%s", 97 - issueOwner, 98 - tangled.RepoIssueNSID, 99 - issueRkey, 100 - )) 101 } 102 uris = append(uris, uri) 103 } ··· 281 return nil, fmt.Errorf("get issue backlinks: %w", err) 282 } 283 backlinks = append(backlinks, ls...) 284 - ls, err = getIssueCommentBacklinks(e, backlinksMap[tangled.RepoIssueCommentNSID]) 285 if err != nil { 286 return nil, fmt.Errorf("get issue_comment backlinks: %w", err) 287 } ··· 350 rows, err := e.Query( 351 fmt.Sprintf( 352 `select r.did, r.name, i.issue_id, c.id, i.title, i.open 353 - from issue_comments c 354 join issues i 355 - on i.at_uri = c.issue_at 356 join repos r 357 on r.at_uri = i.repo_at 358 where %s`,
··· 10 "tangled.org/core/appview/models" 11 ) 12 13 + // ValidateReferenceLinks resolves refLinks to Issue/PR/Comment ATURIs. 14 // It will ignore missing refLinks. 15 func ValidateReferenceLinks(e Execer, refLinks []models.ReferenceLink) ([]syntax.ATURI, error) { 16 var ( ··· 52 values %s 53 ) 54 select 55 + i.at_uri, c.at_uri 56 from input inp 57 join repos r 58 on r.did = inp.owner_did ··· 60 join issues i 61 on i.repo_at = r.at_uri 62 and i.issue_id = inp.issue_id 63 + left join comments c 64 on inp.comment_id is not null 65 + and c.subject_at = i.at_uri 66 and c.id = inp.comment_id 67 `, 68 strings.Join(vals, ","), ··· 77 78 for rows.Next() { 79 // Scan rows 80 + var issueUri string 81 + var commentUri sql.NullString 82 var uri syntax.ATURI 83 + if err := rows.Scan(&issueUri, &commentUri); err != nil { 84 return nil, err 85 } 86 + if commentUri.Valid { 87 + uri = syntax.ATURI(commentUri.String) 88 } else { 89 + uri = syntax.ATURI(issueUri) 90 } 91 uris = append(uris, uri) 92 } ··· 270 return nil, fmt.Errorf("get issue backlinks: %w", err) 271 } 272 backlinks = append(backlinks, ls...) 273 + ls, err = getIssueCommentBacklinks(e, backlinksMap[tangled.CommentNSID]) 274 if err != nil { 275 return nil, fmt.Errorf("get issue_comment backlinks: %w", err) 276 } ··· 339 rows, err := e.Query( 340 fmt.Sprintf( 341 `select r.did, r.name, i.issue_id, c.id, i.title, i.open 342 + from comments c 343 join issues i 344 + on i.at_uri = c.subject_at 345 join repos r 346 on r.at_uri = i.repo_at 347 where %s`,
+19 -11
appview/ingester.go
··· 78 err = i.ingestString(e) 79 case tangled.RepoIssueNSID: 80 err = i.ingestIssue(ctx, e) 81 - case tangled.RepoIssueCommentNSID: 82 - err = i.ingestIssueComment(e) 83 case tangled.LabelDefinitionNSID: 84 err = i.ingestLabelDefinition(e) 85 case tangled.LabelOpNSID: ··· 867 return nil 868 } 869 870 - func (i *Ingester) ingestIssueComment(e *jmodels.Event) error { 871 did := e.Did 872 rkey := e.Commit.RKey 873 874 var err error 875 876 - l := i.Logger.With("handler", "ingestIssueComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 877 l.Info("ingesting record") 878 879 ddb, ok := i.Db.Execer.(*db.DB) ··· 884 switch e.Commit.Operation { 885 case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 886 raw := json.RawMessage(e.Commit.Record) 887 - record := tangled.RepoIssueComment{} 888 err = json.Unmarshal(raw, &record) 889 if err != nil { 890 return fmt.Errorf("invalid record: %w", err) 891 } 892 893 - comment, err := models.IssueCommentFromRecord(did, rkey, record) 894 if err != nil { 895 return fmt.Errorf("failed to parse comment from record: %w", err) 896 } 897 898 - if err := i.Validator.ValidateIssueComment(comment); err != nil { 899 return fmt.Errorf("failed to validate comment: %w", err) 900 } 901 ··· 905 } 906 defer tx.Rollback() 907 908 - _, err = db.AddIssueComment(tx, *comment) 909 if err != nil { 910 - return fmt.Errorf("failed to create issue comment: %w", err) 911 } 912 913 return tx.Commit() 914 915 case jmodels.CommitOperationDelete: 916 - if err := db.DeleteIssueComments( 917 ddb, 918 db.FilterEq("did", did), 919 db.FilterEq("rkey", rkey), 920 ); err != nil { 921 - return fmt.Errorf("failed to delete issue comment record: %w", err) 922 } 923 924 return nil
··· 78 err = i.ingestString(e) 79 case tangled.RepoIssueNSID: 80 err = i.ingestIssue(ctx, e) 81 + case tangled.CommentNSID: 82 + err = i.ingestComment(e) 83 case tangled.LabelDefinitionNSID: 84 err = i.ingestLabelDefinition(e) 85 case tangled.LabelOpNSID: ··· 867 return nil 868 } 869 870 + func (i *Ingester) ingestComment(e *jmodels.Event) error { 871 did := e.Did 872 rkey := e.Commit.RKey 873 874 var err error 875 876 + l := i.Logger.With("handler", "ingestComment", "nsid", e.Commit.Collection, "did", did, "rkey", rkey) 877 l.Info("ingesting record") 878 879 ddb, ok := i.Db.Execer.(*db.DB) ··· 884 switch e.Commit.Operation { 885 case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate: 886 raw := json.RawMessage(e.Commit.Record) 887 + record := tangled.Comment{} 888 err = json.Unmarshal(raw, &record) 889 if err != nil { 890 return fmt.Errorf("invalid record: %w", err) 891 } 892 893 + comment, err := models.CommentFromRecord(did, rkey, record) 894 if err != nil { 895 return fmt.Errorf("failed to parse comment from record: %w", err) 896 } 897 898 + // TODO: ingest pull comments 899 + // we aren't ingesting pull comments yet because pull itself isn't fully atprotated. 900 + // so we cannot know which round this comment is pointing to 901 + if comment.Subject.Collection().String() == tangled.RepoPullNSID { 902 + l.Info("skip ingesting pull comments") 903 + return nil 904 + } 905 + 906 + if err := comment.Validate(); err != nil { 907 return fmt.Errorf("failed to validate comment: %w", err) 908 } 909 ··· 913 } 914 defer tx.Rollback() 915 916 + err = db.PutComment(tx, comment) 917 if err != nil { 918 + return fmt.Errorf("failed to create comment: %w", err) 919 } 920 921 return tx.Commit() 922 923 case jmodels.CommitOperationDelete: 924 + if err := db.DeleteComments( 925 ddb, 926 db.FilterEq("did", did), 927 db.FilterEq("rkey", rkey), 928 ); err != nil { 929 + return fmt.Errorf("failed to delete comment record: %w", err) 930 } 931 932 return nil
+30 -28
appview/issues/issues.go
··· 402 403 body := r.FormValue("body") 404 if body == "" { 405 - rp.pages.Notice(w, "issue", "Body is required") 406 return 407 } 408 409 - replyToUri := r.FormValue("reply-to") 410 - var replyTo *string 411 - if replyToUri != "" { 412 - replyTo = &replyToUri 413 } 414 415 mentions, references := rp.refResolver.Resolve(r.Context(), body) 416 417 - comment := models.IssueComment{ 418 - Did: user.Did, 419 Rkey: tid.TID(), 420 - IssueAt: issue.AtUri().String(), 421 ReplyTo: replyTo, 422 Body: body, 423 Created: time.Now(), 424 Mentions: mentions, 425 References: references, 426 } 427 - if err = rp.validator.ValidateIssueComment(&comment); err != nil { 428 l.Error("failed to validate comment", "err", err) 429 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 430 return ··· 440 441 // create a record first 442 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 443 - Collection: tangled.RepoIssueCommentNSID, 444 - Repo: comment.Did, 445 Rkey: comment.Rkey, 446 Record: &lexutil.LexiconTypeDecoder{ 447 Val: &record, ··· 467 } 468 defer tx.Rollback() 469 470 - commentId, err := db.AddIssueComment(tx, comment) 471 if err != nil { 472 l.Error("failed to create comment", "err", err) 473 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") ··· 483 // reset atUri to make rollback a no-op 484 atUri = "" 485 486 - // notify about the new comment 487 - comment.Id = commentId 488 - 489 rp.notifier.NewIssueComment(r.Context(), &comment, mentions) 490 491 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 492 - rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, commentId)) 493 } 494 495 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { ··· 504 } 505 506 commentId := chi.URLParam(r, "commentId") 507 - comments, err := db.GetIssueComments( 508 rp.db, 509 db.FilterEq("id", commentId), 510 ) ··· 540 } 541 542 commentId := chi.URLParam(r, "commentId") 543 - comments, err := db.GetIssueComments( 544 rp.db, 545 db.FilterEq("id", commentId), 546 ) ··· 556 } 557 comment := comments[0] 558 559 - if comment.Did != user.Did { 560 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 561 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 562 return ··· 596 } 597 defer tx.Rollback() 598 599 - _, err = db.AddIssueComment(tx, newComment) 600 if err != nil { 601 l.Error("failed to perferom update-description query", "err", err) 602 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") ··· 607 // rkey is optional, it was introduced later 608 if newComment.Rkey != "" { 609 // update the record on pds 610 - ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, comment.Rkey) 611 if err != nil { 612 l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 613 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") ··· 615 } 616 617 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 618 - Collection: tangled.RepoIssueCommentNSID, 619 Repo: user.Did, 620 Rkey: newComment.Rkey, 621 SwapRecord: ex.Cid, ··· 650 } 651 652 commentId := chi.URLParam(r, "commentId") 653 - comments, err := db.GetIssueComments( 654 rp.db, 655 db.FilterEq("id", commentId), 656 ) ··· 686 } 687 688 commentId := chi.URLParam(r, "commentId") 689 - comments, err := db.GetIssueComments( 690 rp.db, 691 db.FilterEq("id", commentId), 692 ) ··· 722 } 723 724 commentId := chi.URLParam(r, "commentId") 725 - comments, err := db.GetIssueComments( 726 rp.db, 727 db.FilterEq("id", commentId), 728 ) ··· 738 } 739 comment := comments[0] 740 741 - if comment.Did != user.Did { 742 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 743 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 744 return ··· 751 752 // optimistic deletion 753 deleted := time.Now() 754 - err = db.DeleteIssueComments(rp.db, db.FilterEq("id", comment.Id)) 755 if err != nil { 756 l.Error("failed to delete comment", "err", err) 757 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") ··· 767 return 768 } 769 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 770 - Collection: tangled.RepoIssueCommentNSID, 771 Repo: user.Did, 772 Rkey: comment.Rkey, 773 })
··· 402 403 body := r.FormValue("body") 404 if body == "" { 405 + rp.pages.Notice(w, "issue-comment", "Body is required") 406 return 407 } 408 409 + var replyTo *syntax.ATURI 410 + replyToRaw := r.FormValue("reply-to") 411 + if replyToRaw != "" { 412 + aturi, err := syntax.ParseATURI(r.FormValue("reply-to")) 413 + if err != nil { 414 + rp.pages.Notice(w, "issue-comment", "reply-to should be valid AT-URI") 415 + return 416 + } 417 + replyTo = &aturi 418 } 419 420 mentions, references := rp.refResolver.Resolve(r.Context(), body) 421 422 + comment := models.Comment{ 423 + Did: syntax.DID(user.Did), 424 Rkey: tid.TID(), 425 + Subject: issue.AtUri(), 426 ReplyTo: replyTo, 427 Body: body, 428 Created: time.Now(), 429 Mentions: mentions, 430 References: references, 431 } 432 + if err = comment.Validate(); err != nil { 433 l.Error("failed to validate comment", "err", err) 434 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") 435 return ··· 445 446 // create a record first 447 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 448 + Collection: tangled.CommentNSID, 449 + Repo: user.Did, 450 Rkey: comment.Rkey, 451 Record: &lexutil.LexiconTypeDecoder{ 452 Val: &record, ··· 472 } 473 defer tx.Rollback() 474 475 + err = db.PutComment(tx, &comment) 476 if err != nil { 477 l.Error("failed to create comment", "err", err) 478 rp.pages.Notice(w, "issue-comment", "Failed to create comment.") ··· 488 // reset atUri to make rollback a no-op 489 atUri = "" 490 491 rp.notifier.NewIssueComment(r.Context(), &comment, mentions) 492 493 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 494 + rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", ownerSlashRepo, issue.IssueId, comment.Id)) 495 } 496 497 func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) { ··· 506 } 507 508 commentId := chi.URLParam(r, "commentId") 509 + comments, err := db.GetComments( 510 rp.db, 511 db.FilterEq("id", commentId), 512 ) ··· 542 } 543 544 commentId := chi.URLParam(r, "commentId") 545 + comments, err := db.GetComments( 546 rp.db, 547 db.FilterEq("id", commentId), 548 ) ··· 558 } 559 comment := comments[0] 560 561 + if comment.Did.String() != user.Did { 562 l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Did) 563 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 564 return ··· 598 } 599 defer tx.Rollback() 600 601 + err = db.PutComment(tx, &newComment) 602 if err != nil { 603 l.Error("failed to perferom update-description query", "err", err) 604 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.") ··· 609 // rkey is optional, it was introduced later 610 if newComment.Rkey != "" { 611 // update the record on pds 612 + ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.CommentNSID, user.Did, comment.Rkey) 613 if err != nil { 614 l.Error("failed to get record", "err", err, "did", newComment.Did, "rkey", newComment.Rkey) 615 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.") ··· 617 } 618 619 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 620 + Collection: tangled.CommentNSID, 621 Repo: user.Did, 622 Rkey: newComment.Rkey, 623 SwapRecord: ex.Cid, ··· 652 } 653 654 commentId := chi.URLParam(r, "commentId") 655 + comments, err := db.GetComments( 656 rp.db, 657 db.FilterEq("id", commentId), 658 ) ··· 688 } 689 690 commentId := chi.URLParam(r, "commentId") 691 + comments, err := db.GetComments( 692 rp.db, 693 db.FilterEq("id", commentId), 694 ) ··· 724 } 725 726 commentId := chi.URLParam(r, "commentId") 727 + comments, err := db.GetComments( 728 rp.db, 729 db.FilterEq("id", commentId), 730 ) ··· 740 } 741 comment := comments[0] 742 743 + if comment.Did.String() != user.Did { 744 l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Did) 745 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized) 746 return ··· 753 754 // optimistic deletion 755 deleted := time.Now() 756 + err = db.DeleteComments(rp.db, db.FilterEq("id", comment.Id)) 757 if err != nil { 758 l.Error("failed to delete comment", "err", err) 759 rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment") ··· 769 return 770 } 771 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{ 772 + Collection: tangled.CommentNSID, 773 Repo: user.Did, 774 Rkey: comment.Rkey, 775 })
+8 -89
appview/models/issue.go
··· 26 27 // optionally, populate this when querying for reverse mappings 28 // like comment counts, parent repo etc. 29 - Comments []IssueComment 30 Labels LabelState 31 Repo *Repo 32 } ··· 62 } 63 64 type CommentListItem struct { 65 - Self *IssueComment 66 - Replies []*IssueComment 67 } 68 69 func (it *CommentListItem) Participants() []syntax.DID { ··· 88 89 func (i *Issue) CommentList() []CommentListItem { 90 // Create a map to quickly find comments by their aturi 91 - toplevel := make(map[string]*CommentListItem) 92 - var replies []*IssueComment 93 94 // collect top level comments into the map 95 for _, comment := range i.Comments { 96 if comment.IsTopLevel() { 97 - toplevel[comment.AtUri().String()] = &CommentListItem{ 98 Self: &comment, 99 } 100 } else { ··· 115 } 116 117 // sort everything 118 - sortFunc := func(a, b *IssueComment) bool { 119 return a.Created.Before(b.Created) 120 } 121 sort.Slice(listing, func(i, j int) bool { ··· 144 addParticipant(i.Did) 145 146 for _, c := range i.Comments { 147 - addParticipant(c.Did) 148 } 149 150 return participants ··· 171 Open: true, // new issues are open by default 172 } 173 } 174 - 175 - type IssueComment struct { 176 - Id int64 177 - Did string 178 - Rkey string 179 - IssueAt string 180 - ReplyTo *string 181 - Body string 182 - Created time.Time 183 - Edited *time.Time 184 - Deleted *time.Time 185 - Mentions []syntax.DID 186 - References []syntax.ATURI 187 - } 188 - 189 - func (i *IssueComment) AtUri() syntax.ATURI { 190 - return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", i.Did, tangled.RepoIssueCommentNSID, i.Rkey)) 191 - } 192 - 193 - func (i *IssueComment) AsRecord() tangled.RepoIssueComment { 194 - mentions := make([]string, len(i.Mentions)) 195 - for i, did := range i.Mentions { 196 - mentions[i] = string(did) 197 - } 198 - references := make([]string, len(i.References)) 199 - for i, uri := range i.References { 200 - references[i] = string(uri) 201 - } 202 - return tangled.RepoIssueComment{ 203 - Body: i.Body, 204 - Issue: i.IssueAt, 205 - CreatedAt: i.Created.Format(time.RFC3339), 206 - ReplyTo: i.ReplyTo, 207 - Mentions: mentions, 208 - References: references, 209 - } 210 - } 211 - 212 - func (i *IssueComment) IsTopLevel() bool { 213 - return i.ReplyTo == nil 214 - } 215 - 216 - func (i *IssueComment) IsReply() bool { 217 - return i.ReplyTo != nil 218 - } 219 - 220 - func IssueCommentFromRecord(did, rkey string, record tangled.RepoIssueComment) (*IssueComment, error) { 221 - created, err := time.Parse(time.RFC3339, record.CreatedAt) 222 - if err != nil { 223 - created = time.Now() 224 - } 225 - 226 - ownerDid := did 227 - 228 - if _, err = syntax.ParseATURI(record.Issue); err != nil { 229 - return nil, err 230 - } 231 - 232 - i := record 233 - mentions := make([]syntax.DID, len(record.Mentions)) 234 - for i, did := range record.Mentions { 235 - mentions[i] = syntax.DID(did) 236 - } 237 - references := make([]syntax.ATURI, len(record.References)) 238 - for i, uri := range i.References { 239 - references[i] = syntax.ATURI(uri) 240 - } 241 - 242 - comment := IssueComment{ 243 - Did: ownerDid, 244 - Rkey: rkey, 245 - Body: record.Body, 246 - IssueAt: record.Issue, 247 - ReplyTo: record.ReplyTo, 248 - Created: created, 249 - Mentions: mentions, 250 - References: references, 251 - } 252 - 253 - return &comment, nil 254 - }
··· 26 27 // optionally, populate this when querying for reverse mappings 28 // like comment counts, parent repo etc. 29 + Comments []Comment 30 Labels LabelState 31 Repo *Repo 32 } ··· 62 } 63 64 type CommentListItem struct { 65 + Self *Comment 66 + Replies []*Comment 67 } 68 69 func (it *CommentListItem) Participants() []syntax.DID { ··· 88 89 func (i *Issue) CommentList() []CommentListItem { 90 // Create a map to quickly find comments by their aturi 91 + toplevel := make(map[syntax.ATURI]*CommentListItem) 92 + var replies []*Comment 93 94 // collect top level comments into the map 95 for _, comment := range i.Comments { 96 if comment.IsTopLevel() { 97 + toplevel[comment.AtUri()] = &CommentListItem{ 98 Self: &comment, 99 } 100 } else { ··· 115 } 116 117 // sort everything 118 + sortFunc := func(a, b *Comment) bool { 119 return a.Created.Before(b.Created) 120 } 121 sort.Slice(listing, func(i, j int) bool { ··· 144 addParticipant(i.Did) 145 146 for _, c := range i.Comments { 147 + addParticipant(c.Did.String()) 148 } 149 150 return participants ··· 171 Open: true, // new issues are open by default 172 } 173 }
+4 -4
appview/notify/db/db.go
··· 118 ) 119 } 120 121 - func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 122 - issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.IssueAt)) 123 if err != nil { 124 log.Printf("NewIssueComment: failed to get issues: %v", err) 125 return 126 } 127 if len(issues) == 0 { 128 - log.Printf("NewIssueComment: no issue found for %s", comment.IssueAt) 129 return 130 } 131 issue := issues[0] ··· 140 141 // find the parent thread, and add all DIDs from here to the recipient list 142 for _, t := range allThreads { 143 - if t.Self.AtUri().String() == parentAtUri { 144 recipients = append(recipients, t.Participants()...) 145 } 146 }
··· 118 ) 119 } 120 121 + func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { 122 + issues, err := db.GetIssues(n.db, db.FilterEq("at_uri", comment.Subject)) 123 if err != nil { 124 log.Printf("NewIssueComment: failed to get issues: %v", err) 125 return 126 } 127 if len(issues) == 0 { 128 + log.Printf("NewIssueComment: no issue found for %s", comment.Subject) 129 return 130 } 131 issue := issues[0] ··· 140 141 // find the parent thread, and add all DIDs from here to the recipient list 142 for _, t := range allThreads { 143 + if t.Self.AtUri() == parentAtUri { 144 recipients = append(recipients, t.Participants()...) 145 } 146 }
+1 -1
appview/notify/merged_notifier.go
··· 58 m.fanout("NewIssue", ctx, issue, mentions) 59 } 60 61 - func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 62 m.fanout("NewIssueComment", ctx, comment, mentions) 63 } 64
··· 58 m.fanout("NewIssue", ctx, issue, mentions) 59 } 60 61 + func (m *mergedNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { 62 m.fanout("NewIssueComment", ctx, comment, mentions) 63 } 64
+2 -2
appview/notify/notifier.go
··· 14 DeleteStar(ctx context.Context, star *models.Star) 15 16 NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) 17 - NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) 18 NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) 19 DeleteIssue(ctx context.Context, issue *models.Issue) 20 ··· 43 func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 44 45 func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {} 46 - func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 47 } 48 func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {} 49 func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {}
··· 14 DeleteStar(ctx context.Context, star *models.Star) 15 16 NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) 17 + NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) 18 NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) 19 DeleteIssue(ctx context.Context, issue *models.Issue) 20 ··· 43 func (m *BaseNotifier) DeleteStar(ctx context.Context, star *models.Star) {} 44 45 func (m *BaseNotifier) NewIssue(ctx context.Context, issue *models.Issue, mentions []syntax.DID) {} 46 + func (m *BaseNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { 47 } 48 func (m *BaseNotifier) NewIssueState(ctx context.Context, actor syntax.DID, issue *models.Issue) {} 49 func (m *BaseNotifier) DeleteIssue(ctx context.Context, issue *models.Issue) {}
+3 -3
appview/notify/posthog/notifier.go
··· 179 } 180 } 181 182 - func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) { 183 err := n.client.Enqueue(posthog.Capture{ 184 - DistinctId: comment.Did, 185 Event: "new_issue_comment", 186 Properties: posthog.Properties{ 187 - "issue_at": comment.IssueAt, 188 "mentions": mentions, 189 }, 190 })
··· 179 } 180 } 181 182 + func (n *posthogNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) { 183 err := n.client.Enqueue(posthog.Capture{ 184 + DistinctId: comment.Did.String(), 185 Event: "new_issue_comment", 186 Properties: posthog.Properties{ 187 + "issue_at": comment.Subject, 188 "mentions": mentions, 189 }, 190 })
+4 -4
appview/pages/pages.go
··· 989 LoggedInUser *oauth.User 990 RepoInfo repoinfo.RepoInfo 991 Issue *models.Issue 992 - Comment *models.IssueComment 993 } 994 995 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { ··· 1000 LoggedInUser *oauth.User 1001 RepoInfo repoinfo.RepoInfo 1002 Issue *models.Issue 1003 - Comment *models.IssueComment 1004 } 1005 1006 func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { ··· 1011 LoggedInUser *oauth.User 1012 RepoInfo repoinfo.RepoInfo 1013 Issue *models.Issue 1014 - Comment *models.IssueComment 1015 } 1016 1017 func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { ··· 1022 LoggedInUser *oauth.User 1023 RepoInfo repoinfo.RepoInfo 1024 Issue *models.Issue 1025 - Comment *models.IssueComment 1026 } 1027 1028 func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
··· 989 LoggedInUser *oauth.User 990 RepoInfo repoinfo.RepoInfo 991 Issue *models.Issue 992 + Comment *models.Comment 993 } 994 995 func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error { ··· 1000 LoggedInUser *oauth.User 1001 RepoInfo repoinfo.RepoInfo 1002 Issue *models.Issue 1003 + Comment *models.Comment 1004 } 1005 1006 func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error { ··· 1011 LoggedInUser *oauth.User 1012 RepoInfo repoinfo.RepoInfo 1013 Issue *models.Issue 1014 + Comment *models.Comment 1015 } 1016 1017 func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error { ··· 1022 LoggedInUser *oauth.User 1023 RepoInfo repoinfo.RepoInfo 1024 Issue *models.Issue 1025 + Comment *models.Comment 1026 } 1027 1028 func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
+2 -2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
··· 1 {{ define "repo/issues/fragments/issueCommentHeader" }} 2 <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 "> 3 - {{ template "user/fragments/picHandleLink" .Comment.Did }} 4 {{ template "hats" $ }} 5 {{ template "timestamp" . }} 6 - {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }} 7 {{ if and $isCommentOwner (not .Comment.Deleted) }} 8 {{ template "editIssueComment" . }} 9 {{ template "deleteIssueComment" . }}
··· 1 {{ define "repo/issues/fragments/issueCommentHeader" }} 2 <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 "> 3 + {{ template "user/fragments/picHandleLink" .Comment.Did.String }} 4 {{ template "hats" $ }} 5 {{ template "timestamp" . }} 6 + {{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did.String) }} 7 {{ if and $isCommentOwner (not .Comment.Deleted) }} 8 {{ template "editIssueComment" . }} 9 {{ template "deleteIssueComment" . }}
+1 -1
appview/state/state.go
··· 116 tangled.SpindleNSID, 117 tangled.StringNSID, 118 tangled.RepoIssueNSID, 119 - tangled.RepoIssueCommentNSID, 120 tangled.LabelDefinitionNSID, 121 tangled.LabelOpNSID, 122 },
··· 116 tangled.SpindleNSID, 117 tangled.StringNSID, 118 tangled.RepoIssueNSID, 119 + tangled.CommentNSID, 120 tangled.LabelDefinitionNSID, 121 tangled.LabelOpNSID, 122 },
-26
appview/validator/issue.go
··· 4 "fmt" 5 "strings" 6 7 - "tangled.org/core/appview/db" 8 "tangled.org/core/appview/models" 9 ) 10 11 - func (v *Validator) ValidateIssueComment(comment *models.IssueComment) error { 12 - // if comments have parents, only ingest ones that are 1 level deep 13 - if comment.ReplyTo != nil { 14 - parents, err := db.GetIssueComments(v.db, db.FilterEq("at_uri", *comment.ReplyTo)) 15 - if err != nil { 16 - return fmt.Errorf("failed to fetch parent comment: %w", err) 17 - } 18 - if len(parents) != 1 { 19 - return fmt.Errorf("incorrect number of parent comments returned: %d", len(parents)) 20 - } 21 - 22 - // depth check 23 - parent := parents[0] 24 - if parent.ReplyTo != nil { 25 - return fmt.Errorf("incorrect depth, this comment is replying at depth >1") 26 - } 27 - } 28 - 29 - if sb := strings.TrimSpace(v.sanitizer.SanitizeDefault(comment.Body)); sb == "" { 30 - return fmt.Errorf("body is empty after HTML sanitization") 31 - } 32 - 33 - return nil 34 - } 35 - 36 func (v *Validator) ValidateIssue(issue *models.Issue) error { 37 if issue.Title == "" { 38 return fmt.Errorf("issue title is empty")
··· 4 "fmt" 5 "strings" 6 7 "tangled.org/core/appview/models" 8 ) 9 10 func (v *Validator) ValidateIssue(issue *models.Issue) error { 11 if issue.Title == "" { 12 return fmt.Errorf("issue title is empty")