Signed-off-by: Seongmin Lee git@boltless.me
+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`,
+15
-6
appview/ingester.go
+15
-6
appview/ingester.go
···
891
}
892
893
switch e.Commit.Operation {
894
-
case jmodels.CommitOperationCreate, jmodels.CommitOperationUpdate:
895
raw := json.RawMessage(e.Commit.Record)
896
record := tangled.RepoIssueComment{}
897
err = json.Unmarshal(raw, &record)
···
899
return fmt.Errorf("invalid record: %w", err)
900
}
901
902
-
comment, err := models.IssueCommentFromRecord(did, rkey, record)
903
if err != nil {
904
return fmt.Errorf("failed to parse comment from record: %w", err)
905
}
906
907
-
if err := i.Validator.ValidateIssueComment(comment); err != nil {
908
return fmt.Errorf("failed to validate comment: %w", err)
909
}
910
···
914
}
915
defer tx.Rollback()
916
917
-
_, err = db.AddIssueComment(tx, *comment)
918
if err != nil {
919
-
return fmt.Errorf("failed to create issue comment: %w", err)
920
}
921
922
return tx.Commit()
923
924
case jmodels.CommitOperationDelete:
925
-
if err := db.DeleteIssueComments(
926
ddb,
927
orm.FilterEq("did", did),
928
orm.FilterEq("rkey", rkey),
929
); err != nil {
930
return fmt.Errorf("failed to delete issue comment record: %w", err)
···
891
}
892
893
switch e.Commit.Operation {
894
+
case jmodels.CommitOperationUpdate:
895
raw := json.RawMessage(e.Commit.Record)
896
record := tangled.RepoIssueComment{}
897
err = json.Unmarshal(raw, &record)
···
899
return fmt.Errorf("invalid record: %w", err)
900
}
901
902
+
// convert 'sh.tangled.repo.issue.comment' to 'sh.tangled.comment'
903
+
comment, err := models.CommentFromRecord(syntax.DID(did), syntax.RecordKey(rkey), tangled.Comment{
904
+
Body: record.Body,
905
+
CreatedAt: record.CreatedAt,
906
+
Mentions: record.Mentions,
907
+
References: record.References,
908
+
ReplyTo: record.ReplyTo,
909
+
Subject: record.Issue,
910
+
})
911
if err != nil {
912
return fmt.Errorf("failed to parse comment from record: %w", err)
913
}
914
915
+
if err := comment.Validate(); err != nil {
916
return fmt.Errorf("failed to validate comment: %w", err)
917
}
918
···
922
}
923
defer tx.Rollback()
924
925
+
err = db.PutComment(tx, comment)
926
if err != nil {
927
+
return fmt.Errorf("failed to create comment: %w", err)
928
}
929
930
return tx.Commit()
931
932
case jmodels.CommitOperationDelete:
933
+
if err := db.DeleteComments(
934
ddb,
935
orm.FilterEq("did", did),
936
+
orm.FilterEq("collection", e.Commit.Collection),
937
orm.FilterEq("rkey", rkey),
938
); err != nil {
939
return fmt.Errorf("failed to delete issue comment record: %w", err)
+38
-36
appview/issues/issues.go
+38
-36
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.mentionsResolver.Resolve(r.Context(), body)
416
417
-
comment := models.IssueComment{
418
-
Did: user.Active.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
431
}
432
-
record := comment.AsRecord()
433
434
client, err := rp.oauth.AuthorizedClient(r)
435
if err != nil {
···
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,
448
},
449
})
450
if err != nil {
···
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
orm.FilterEq("id", commentId),
510
)
···
540
}
541
542
commentId := chi.URLParam(r, "commentId")
543
-
comments, err := db.GetIssueComments(
544
rp.db,
545
orm.FilterEq("id", commentId),
546
)
···
556
}
557
comment := comments[0]
558
559
-
if comment.Did != user.Active.Did {
560
l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Active.Did)
561
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
562
return
···
586
newComment.Edited = &now
587
newComment.Mentions, newComment.References = rp.mentionsResolver.Resolve(r.Context(), newBody)
588
589
-
record := newComment.AsRecord()
590
-
591
tx, err := rp.db.Begin()
592
if err != nil {
593
l.Error("failed to start transaction", "err", err)
···
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.")
···
606
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.Active.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.")
614
return
615
}
616
617
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
618
-
Collection: tangled.RepoIssueCommentNSID,
619
-
Repo: user.Active.Did,
620
Rkey: newComment.Rkey,
621
SwapRecord: ex.Cid,
622
Record: &lexutil.LexiconTypeDecoder{
623
-
Val: &record,
624
},
625
})
626
if err != nil {
···
650
}
651
652
commentId := chi.URLParam(r, "commentId")
653
-
comments, err := db.GetIssueComments(
654
rp.db,
655
orm.FilterEq("id", commentId),
656
)
···
686
}
687
688
commentId := chi.URLParam(r, "commentId")
689
-
comments, err := db.GetIssueComments(
690
rp.db,
691
orm.FilterEq("id", commentId),
692
)
···
722
}
723
724
commentId := chi.URLParam(r, "commentId")
725
-
comments, err := db.GetIssueComments(
726
rp.db,
727
orm.FilterEq("id", commentId),
728
)
···
738
}
739
comment := comments[0]
740
741
-
if comment.Did != user.Active.Did {
742
l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Active.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, orm.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.Active.Did,
772
Rkey: comment.Rkey,
773
})
774
if err != nil {
···
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(replyToRaw)
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.mentionsResolver.Resolve(r.Context(), body)
421
422
+
comment := models.Comment{
423
+
Did: syntax.DID(user.Active.Did),
424
+
Collection: tangled.CommentNSID,
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
437
}
438
439
client, err := rp.oauth.AuthorizedClient(r)
440
if err != nil {
···
445
446
// create a record first
447
resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
448
+
Collection: comment.Collection.String(),
449
+
Repo: comment.Did.String(),
450
Rkey: comment.Rkey,
451
Record: &lexutil.LexiconTypeDecoder{
452
+
Val: comment.AsRecord(),
453
},
454
})
455
if err != nil {
···
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
orm.FilterEq("id", commentId),
512
)
···
542
}
543
544
commentId := chi.URLParam(r, "commentId")
545
+
comments, err := db.GetComments(
546
rp.db,
547
orm.FilterEq("id", commentId),
548
)
···
558
}
559
comment := comments[0]
560
561
+
if comment.Did.String() != user.Active.Did {
562
l.Error("unauthorized comment edit", "expectedDid", comment.Did, "gotDid", user.Active.Did)
563
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
564
return
···
588
newComment.Edited = &now
589
newComment.Mentions, newComment.References = rp.mentionsResolver.Resolve(r.Context(), newBody)
590
591
tx, err := rp.db.Begin()
592
if err != nil {
593
l.Error("failed to start transaction", "err", err)
···
596
}
597
defer tx.Rollback()
598
599
+
err = db.PutComment(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.")
···
606
607
// rkey is optional, it was introduced later
608
if newComment.Rkey != "" {
609
+
// TODO: update correct comment
610
+
611
// update the record on pds
612
+
ex, err := comatproto.RepoGetRecord(r.Context(), client, "", newComment.Collection.String(), newComment.Did.String(), newComment.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 comment, no record found on PDS.")
616
return
617
}
618
619
_, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
620
+
Collection: newComment.Collection.String(),
621
+
Repo: newComment.Did.String(),
622
Rkey: newComment.Rkey,
623
SwapRecord: ex.Cid,
624
Record: &lexutil.LexiconTypeDecoder{
625
+
Val: newComment.AsRecord(),
626
},
627
})
628
if err != nil {
···
652
}
653
654
commentId := chi.URLParam(r, "commentId")
655
+
comments, err := db.GetComments(
656
rp.db,
657
orm.FilterEq("id", commentId),
658
)
···
688
}
689
690
commentId := chi.URLParam(r, "commentId")
691
+
comments, err := db.GetComments(
692
rp.db,
693
orm.FilterEq("id", commentId),
694
)
···
724
}
725
726
commentId := chi.URLParam(r, "commentId")
727
+
comments, err := db.GetComments(
728
rp.db,
729
orm.FilterEq("id", commentId),
730
)
···
740
}
741
comment := comments[0]
742
743
+
if comment.Did.String() != user.Active.Did {
744
l.Error("unauthorized action", "expectedDid", comment.Did, "gotDid", user.Active.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, orm.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: comment.Collection.String(),
773
+
Repo: comment.Did.String(),
774
Rkey: comment.Rkey,
775
})
776
if err != nil {
+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
···
1003
LoggedInUser *oauth.MultiAccountUser
1004
RepoInfo repoinfo.RepoInfo
1005
Issue *models.Issue
1006
-
Comment *models.IssueComment
1007
}
1008
1009
func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
···
1014
LoggedInUser *oauth.MultiAccountUser
1015
RepoInfo repoinfo.RepoInfo
1016
Issue *models.Issue
1017
-
Comment *models.IssueComment
1018
}
1019
1020
func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error {
···
1025
LoggedInUser *oauth.MultiAccountUser
1026
RepoInfo repoinfo.RepoInfo
1027
Issue *models.Issue
1028
-
Comment *models.IssueComment
1029
}
1030
1031
func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error {
···
1036
LoggedInUser *oauth.MultiAccountUser
1037
RepoInfo repoinfo.RepoInfo
1038
Issue *models.Issue
1039
-
Comment *models.IssueComment
1040
}
1041
1042
func (p *Pages) IssueCommentBodyFragment(w io.Writer, params IssueCommentBodyParams) error {
···
1003
LoggedInUser *oauth.MultiAccountUser
1004
RepoInfo repoinfo.RepoInfo
1005
Issue *models.Issue
1006
+
Comment *models.Comment
1007
}
1008
1009
func (p *Pages) EditIssueCommentFragment(w io.Writer, params EditIssueCommentParams) error {
···
1014
LoggedInUser *oauth.MultiAccountUser
1015
RepoInfo repoinfo.RepoInfo
1016
Issue *models.Issue
1017
+
Comment *models.Comment
1018
}
1019
1020
func (p *Pages) ReplyIssueCommentPlaceholderFragment(w io.Writer, params ReplyIssueCommentPlaceholderParams) error {
···
1025
LoggedInUser *oauth.MultiAccountUser
1026
RepoInfo repoinfo.RepoInfo
1027
Issue *models.Issue
1028
+
Comment *models.Comment
1029
}
1030
1031
func (p *Pages) ReplyIssueCommentFragment(w io.Writer, params ReplyIssueCommentParams) error {
···
1036
LoggedInUser *oauth.MultiAccountUser
1037
RepoInfo repoinfo.RepoInfo
1038
Issue *models.Issue
1039
+
Comment *models.Comment
1040
}
1041
1042
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
-
{{ $handle := resolve .Comment.Did }}
4
<a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="/{{ $handle }}">{{ $handle }}</a>
5
{{ template "hats" $ }}
6
<span class="before:content-['路']"></span>
7
{{ template "timestamp" . }}
8
-
{{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did) }}
9
{{ if and $isCommentOwner (not .Comment.Deleted) }}
10
{{ template "editIssueComment" . }}
11
{{ 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
+
{{ $handle := resolve .Comment.Did.String }}
4
<a class="text-gray-500 dark:text-gray-400 hover:text-gray-500 dark:hover:text-gray-300" href="/{{ $handle }}">{{ $handle }}</a>
5
{{ template "hats" $ }}
6
<span class="before:content-['路']"></span>
7
{{ template "timestamp" . }}
8
+
{{ $isCommentOwner := and .LoggedInUser (eq .LoggedInUser.Did .Comment.Did.String) }}
9
{{ if and $isCommentOwner (not .Comment.Deleted) }}
10
{{ template "editIssueComment" . }}
11
{{ template "deleteIssueComment" . }}
-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.