appview: replace IssueComment to Comment #866

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