Signed-off-by: Seongmin Lee git@boltless.me
+1
appview/db/comments.go
+1
appview/db/comments.go
+6
-186
appview/db/issues.go
+6
-186
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
}
···
295
return GetIssuesPaginated(e, pagination.Page{}, filters...)
296
}
297
298
-
func AddIssueComment(tx *sql.Tx, c models.IssueComment) (int64, error) {
299
-
result, err := tx.Exec(
300
-
`insert into issue_comments (
301
-
did,
302
-
rkey,
303
-
issue_at,
304
-
body,
305
-
reply_to,
306
-
created,
307
-
edited
308
-
)
309
-
values (?, ?, ?, ?, ?, ?, null)
310
-
on conflict(did, rkey) do update set
311
-
issue_at = excluded.issue_at,
312
-
body = excluded.body,
313
-
edited = case
314
-
when
315
-
issue_comments.issue_at != excluded.issue_at
316
-
or issue_comments.body != excluded.body
317
-
or issue_comments.reply_to != excluded.reply_to
318
-
then ?
319
-
else issue_comments.edited
320
-
end`,
321
-
c.Did,
322
-
c.Rkey,
323
-
c.IssueAt,
324
-
c.Body,
325
-
c.ReplyTo,
326
-
c.Created.Format(time.RFC3339),
327
-
time.Now().Format(time.RFC3339),
328
-
)
329
-
if err != nil {
330
-
return 0, err
331
-
}
332
-
333
-
id, err := result.LastInsertId()
334
-
if err != nil {
335
-
return 0, err
336
-
}
337
-
338
-
if err := putReferences(tx, c.AtUri(), c.References); err != nil {
339
-
return 0, fmt.Errorf("put reference_links: %w", err)
340
-
}
341
-
342
-
return id, nil
343
-
}
344
-
345
-
func DeleteIssueComments(e Execer, filters ...orm.Filter) error {
346
-
var conditions []string
347
-
var args []any
348
-
for _, filter := range filters {
349
-
conditions = append(conditions, filter.Condition())
350
-
args = append(args, filter.Arg()...)
351
-
}
352
-
353
-
whereClause := ""
354
-
if conditions != nil {
355
-
whereClause = " where " + strings.Join(conditions, " and ")
356
-
}
357
-
358
-
query := fmt.Sprintf(`update issue_comments set body = "", deleted = strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ', 'now') %s`, whereClause)
359
-
360
-
_, err := e.Exec(query, args...)
361
-
return err
362
-
}
363
-
364
-
func GetIssueComments(e Execer, filters ...orm.Filter) ([]models.IssueComment, error) {
365
-
commentMap := make(map[string]*models.IssueComment)
366
-
367
-
var conditions []string
368
-
var args []any
369
-
for _, filter := range filters {
370
-
conditions = append(conditions, filter.Condition())
371
-
args = append(args, filter.Arg()...)
372
-
}
373
-
374
-
whereClause := ""
375
-
if conditions != nil {
376
-
whereClause = " where " + strings.Join(conditions, " and ")
377
-
}
378
-
379
-
query := fmt.Sprintf(`
380
-
select
381
-
id,
382
-
did,
383
-
rkey,
384
-
issue_at,
385
-
reply_to,
386
-
body,
387
-
created,
388
-
edited,
389
-
deleted
390
-
from
391
-
issue_comments
392
-
%s
393
-
`, whereClause)
394
-
395
-
rows, err := e.Query(query, args...)
396
-
if err != nil {
397
-
return nil, err
398
-
}
399
-
defer rows.Close()
400
-
401
-
for rows.Next() {
402
-
var comment models.IssueComment
403
-
var created string
404
-
var rkey, edited, deleted, replyTo sql.Null[string]
405
-
err := rows.Scan(
406
-
&comment.Id,
407
-
&comment.Did,
408
-
&rkey,
409
-
&comment.IssueAt,
410
-
&replyTo,
411
-
&comment.Body,
412
-
&created,
413
-
&edited,
414
-
&deleted,
415
-
)
416
-
if err != nil {
417
-
return nil, err
418
-
}
419
-
420
-
// this is a remnant from old times, newer comments always have rkey
421
-
if rkey.Valid {
422
-
comment.Rkey = rkey.V
423
-
}
424
-
425
-
if t, err := time.Parse(time.RFC3339, created); err == nil {
426
-
comment.Created = t
427
-
}
428
-
429
-
if edited.Valid {
430
-
if t, err := time.Parse(time.RFC3339, edited.V); err == nil {
431
-
comment.Edited = &t
432
-
}
433
-
}
434
-
435
-
if deleted.Valid {
436
-
if t, err := time.Parse(time.RFC3339, deleted.V); err == nil {
437
-
comment.Deleted = &t
438
-
}
439
-
}
440
-
441
-
if replyTo.Valid {
442
-
comment.ReplyTo = &replyTo.V
443
-
}
444
-
445
-
atUri := comment.AtUri().String()
446
-
commentMap[atUri] = &comment
447
-
}
448
-
449
-
if err = rows.Err(); err != nil {
450
-
return nil, err
451
-
}
452
-
453
-
// collect references for each comments
454
-
commentAts := slices.Collect(maps.Keys(commentMap))
455
-
allReferencs, err := GetReferencesAll(e, orm.FilterIn("from_at", commentAts))
456
-
if err != nil {
457
-
return nil, fmt.Errorf("failed to query reference_links: %w", err)
458
-
}
459
-
for commentAt, references := range allReferencs {
460
-
if comment, ok := commentMap[commentAt.String()]; ok {
461
-
comment.References = references
462
-
}
463
-
}
464
-
465
-
var comments []models.IssueComment
466
-
for _, c := range commentMap {
467
-
comments = append(comments, *c)
468
-
}
469
-
470
-
sort.Slice(comments, func(i, j int) bool {
471
-
return comments[i].Created.After(comments[j].Created)
472
-
})
473
-
474
-
return comments, nil
475
-
}
476
-
477
func DeleteIssues(tx *sql.Tx, did, rkey string) error {
478
_, err := tx.Exec(
479
`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
}
···
294
return GetIssuesPaginated(e, pagination.Page{}, filters...)
295
}
296
297
func DeleteIssues(tx *sql.Tx, did, rkey string) error {
298
_, err := tx.Exec(
299
`delete from issues
+13
-24
appview/db/reference.go
+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
+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
+33
-31
appview/issues/issues.go
+33
-31
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.Active.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.Active.Did {
561
-
l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Active.Did)
562
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
563
return
564
}
···
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.Active.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.Active.Did,
621
Rkey: newComment.Rkey,
622
SwapRecord: ex.Cid,
623
Record: &lexutil.LexiconTypeDecoder{
···
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.Active.Did {
743
-
l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Active.Did)
744
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
745
return
746
}
···
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.Active.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.Active.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: comment.Did.String(),
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.Active.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
566
}
···
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, newComment.Did.String(), newComment.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: newComment.Did.String(),
623
Rkey: newComment.Rkey,
624
SwapRecord: ex.Cid,
625
Record: &lexutil.LexiconTypeDecoder{
···
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.Active.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
748
}
···
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.Active.Did,
775
Rkey: comment.Rkey,
776
})
+8
-89
appview/models/issue.go
+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
+4
-4
appview/notify/db/db.go
···
122
)
123
}
124
125
-
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.IssueComment, mentions []syntax.DID) {
126
-
issues, err := db.GetIssues(n.db, orm.FilterEq("at_uri", comment.IssueAt))
127
if err != nil {
128
log.Printf("NewIssueComment: failed to get issues: %v", err)
129
return
130
}
131
if len(issues) == 0 {
132
-
log.Printf("NewIssueComment: no issue found for %s", comment.IssueAt)
133
return
134
}
135
issue := issues[0]
···
147
148
// find the parent thread, and add all DIDs from here to the recipient list
149
for _, t := range issue.CommentList() {
150
-
if t.Self.AtUri().String() == parentAtUri {
151
for _, p := range t.Participants() {
152
recipients.Insert(p)
153
}
···
122
)
123
}
124
125
+
func (n *databaseNotifier) NewIssueComment(ctx context.Context, comment *models.Comment, mentions []syntax.DID) {
126
+
issues, err := db.GetIssues(n.db, orm.FilterEq("at_uri", comment.Subject))
127
if err != nil {
128
log.Printf("NewIssueComment: failed to get issues: %v", err)
129
return
130
}
131
if len(issues) == 0 {
132
+
log.Printf("NewIssueComment: no issue found for %s", comment.Subject)
133
return
134
}
135
issue := issues[0]
···
147
148
// find the parent thread, and add all DIDs from here to the recipient list
149
for _, t := range issue.CommentList() {
150
+
if t.Self.AtUri() == parentAtUri {
151
for _, p := range t.Participants() {
152
recipients.Insert(p)
153
}
+1
-1
appview/notify/merged_notifier.go
+1
-1
appview/notify/merged_notifier.go
+2
-2
appview/notify/notifier.go
+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
+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
+4
-4
appview/pages/pages.go
···
1004
LoggedInUser *oauth.MultiAccountUser
1005
RepoInfo repoinfo.RepoInfo
1006
Issue *models.Issue
1007
-
Comment *models.IssueComment
1008
}
1009
1010
func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
···
1015
LoggedInUser *oauth.MultiAccountUser
1016
RepoInfo repoinfo.RepoInfo
1017
Issue *models.Issue
1018
-
Comment *models.IssueComment
1019
}
1020
1021
func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error {
···
1026
LoggedInUser *oauth.MultiAccountUser
1027
RepoInfo repoinfo.RepoInfo
1028
Issue *models.Issue
1029
-
Comment *models.IssueComment
1030
}
1031
1032
func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error {
···
1037
LoggedInUser *oauth.MultiAccountUser
1038
RepoInfo repoinfo.RepoInfo
1039
Issue *models.Issue
1040
-
Comment *models.IssueComment
1041
}
1042
1043
func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
···
1004
LoggedInUser *oauth.MultiAccountUser
1005
RepoInfo repoinfo.RepoInfo
1006
Issue *models.Issue
1007
+
Comment *models.Comment
1008
}
1009
1010
func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
···
1015
LoggedInUser *oauth.MultiAccountUser
1016
RepoInfo repoinfo.RepoInfo
1017
Issue *models.Issue
1018
+
Comment *models.Comment
1019
}
1020
1021
func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error {
···
1026
LoggedInUser *oauth.MultiAccountUser
1027
RepoInfo repoinfo.RepoInfo
1028
Issue *models.Issue
1029
+
Comment *models.Comment
1030
}
1031
1032
func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error {
···
1037
LoggedInUser *oauth.MultiAccountUser
1038
RepoInfo repoinfo.RepoInfo
1039
Issue *models.Issue
1040
+
Comment *models.Comment
1041
}
1042
1043
func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
+2
-2
appview/pages/templates/repo/issues/fragments/commentList.html
+2
-2
appview/pages/templates/repo/issues/fragments/commentList.html
···
42
<div class="rounded px-6 py-4 bg-white dark:bg-gray-800 flex gap-2 ">
43
<div class="flex-shrink-0">
44
<img
45
-
src="{{ tinyAvatar .Comment.Did }}"
46
alt=""
47
class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900"
48
/>
···
58
<div class="py-4 pr-4 w-full mx-auto overflow-hidden flex gap-2 ">
59
<div class="flex-shrink-0">
60
<img
61
-
src="{{ tinyAvatar .Comment.Did }}"
62
alt=""
63
class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900"
64
/>
···
42
<div class="rounded px-6 py-4 bg-white dark:bg-gray-800 flex gap-2 ">
43
<div class="flex-shrink-0">
44
<img
45
+
src="{{ tinyAvatar .Comment.Did.String }}"
46
alt=""
47
class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900"
48
/>
···
58
<div class="py-4 pr-4 w-full mx-auto overflow-hidden flex gap-2 ">
59
<div class="flex-shrink-0">
60
<img
61
+
src="{{ tinyAvatar .Comment.Did.String }}"
62
alt=""
63
class="rounded-full size-8 mr-1 border-2 border-gray-100 dark:border-gray-900"
64
/>
+2
-2
appview/pages/templates/repo/issues/fragments/issueCommentHeader.html
+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
-
{{ resolve .Comment.Did }}
4
{{ template "hats" $ }}
5
<span class="before:content-['路']"></span>
6
{{ template "timestamp" . }}
7
-
{{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }}
8
{{ if and $isCommentOwner (not .Comment.Deleted) }}
9
{{ template "editIssueComment" . }}
10
{{ 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
+
{{ resolve .Comment.Did.String }}
4
{{ template "hats" $ }}
5
<span class="before:content-['路']"></span>
6
{{ template "timestamp" . }}
7
+
{{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did.String) }}
8
{{ if and $isCommentOwner (not .Comment.Deleted) }}
9
{{ template "editIssueComment" . }}
10
{{ template "deleteIssueComment" . }}
+1
-1
appview/state/state.go
+1
-1
appview/state/state.go
-27
appview/validator/issue.go
-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")
History
8 rounds
1 comment
boltless.me
submitted
#7
1 commit
expand
collapse
appview: replace
IssueComment to Comment
Signed-off-by: Seongmin Lee <git@boltless.me>
2/3 failed, 1/3 success
expand
collapse
merge conflicts detected
expand
collapse
expand
collapse
- appview/notify/db/db.go:260
- appview/notify/merged_notifier.go:81
- appview/notify/notifier.go:22
- appview/pulls/opengraph.go:277
expand 0 comments
boltless.me
submitted
#6
1 commit
expand
collapse
appview: replace
IssueComment to Comment
Signed-off-by: Seongmin Lee <git@boltless.me>
2/3 failed, 1/3 success
expand
collapse
expand 0 comments
boltless.me
submitted
#5
1 commit
expand
collapse
appview: replace
IssueComment to Comment
Signed-off-by: Seongmin Lee <git@boltless.me>
3/3 success
expand
collapse
expand 0 comments
boltless.me
submitted
#4
1 commit
expand
collapse
appview: replace
IssueComment to Comment
Signed-off-by: Seongmin Lee <git@boltless.me>
3/3 success
expand
collapse
expand 0 comments
boltless.me
submitted
#3
1 commit
expand
collapse
appview: replace
IssueComment to Comment
Signed-off-by: Seongmin Lee <git@boltless.me>
1/3 failed, 2/3 success
expand
collapse
expand 0 comments
boltless.me
submitted
#2
1 commit
expand
collapse
appview: replace
IssueComment to Comment
Signed-off-by: Seongmin Lee <git@boltless.me>
1/3 failed, 2/3 success
expand
collapse
expand 0 comments
boltless.me
submitted
#1
1 commit
expand
collapse
appview: replace
IssueComment to Comment
Signed-off-by: Seongmin Lee <git@boltless.me>
3/3 success
expand
collapse
expand 1 comment
boltless.me
submitted
#0
1 commit
expand
collapse
appview: replace
IssueComment to Comment
Signed-off-by: Seongmin Lee <git@boltless.me>
imo: we should still continue to ingest
sh.tangled.issue.comment(and pull comments), but just try to convert to the regular lexicon. this would only be used for backfill purposes, newer appviews should ideally not create the old NSID anymore.the way i see it, the new lexicon is just an alias for the old ones, so the ingesters should be backwards compatible.
i will do a deeper dive into the code itself shortly.