+757
appview/issues/issues.go
+757
appview/issues/issues.go
···
1
+
package issues
2
+
3
+
import (
4
+
"fmt"
5
+
"log"
6
+
mathrand "math/rand/v2"
7
+
"net/http"
8
+
"slices"
9
+
"strconv"
10
+
"time"
11
+
12
+
comatproto "github.com/bluesky-social/indigo/api/atproto"
13
+
"github.com/bluesky-social/indigo/atproto/data"
14
+
lexutil "github.com/bluesky-social/indigo/lex/util"
15
+
"github.com/go-chi/chi/v5"
16
+
"github.com/posthog/posthog-go"
17
+
18
+
"tangled.sh/tangled.sh/core/api/tangled"
19
+
"tangled.sh/tangled.sh/core/appview"
20
+
"tangled.sh/tangled.sh/core/appview/config"
21
+
"tangled.sh/tangled.sh/core/appview/db"
22
+
"tangled.sh/tangled.sh/core/appview/idresolver"
23
+
"tangled.sh/tangled.sh/core/appview/oauth"
24
+
"tangled.sh/tangled.sh/core/appview/pages"
25
+
"tangled.sh/tangled.sh/core/appview/pagination"
26
+
"tangled.sh/tangled.sh/core/appview/reporesolver"
27
+
)
28
+
29
+
type Issues struct {
30
+
oauth *oauth.OAuth
31
+
repoResolver *reporesolver.RepoResolver
32
+
pages *pages.Pages
33
+
idResolver *idresolver.Resolver
34
+
db *db.DB
35
+
config *config.Config
36
+
posthog posthog.Client
37
+
}
38
+
39
+
func New(
40
+
oauth *oauth.OAuth,
41
+
repoResolver *reporesolver.RepoResolver,
42
+
pages *pages.Pages,
43
+
idResolver *idresolver.Resolver,
44
+
db *db.DB,
45
+
config *config.Config,
46
+
posthog posthog.Client,
47
+
) *Issues {
48
+
return &Issues{
49
+
oauth: oauth,
50
+
repoResolver: repoResolver,
51
+
pages: pages,
52
+
idResolver: idResolver,
53
+
db: db,
54
+
config: config,
55
+
posthog: posthog,
56
+
}
57
+
}
58
+
59
+
func (rp *Issues) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
60
+
user := rp.oauth.GetUser(r)
61
+
f, err := rp.repoResolver.Resolve(r)
62
+
if err != nil {
63
+
log.Println("failed to get repo and knot", err)
64
+
return
65
+
}
66
+
67
+
issueId := chi.URLParam(r, "issue")
68
+
issueIdInt, err := strconv.Atoi(issueId)
69
+
if err != nil {
70
+
http.Error(w, "bad issue id", http.StatusBadRequest)
71
+
log.Println("failed to parse issue id", err)
72
+
return
73
+
}
74
+
75
+
issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt, issueIdInt)
76
+
if err != nil {
77
+
log.Println("failed to get issue and comments", err)
78
+
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
79
+
return
80
+
}
81
+
82
+
issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid)
83
+
if err != nil {
84
+
log.Println("failed to resolve issue owner", err)
85
+
}
86
+
87
+
identsToResolve := make([]string, len(comments))
88
+
for i, comment := range comments {
89
+
identsToResolve[i] = comment.OwnerDid
90
+
}
91
+
resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve)
92
+
didHandleMap := make(map[string]string)
93
+
for _, identity := range resolvedIds {
94
+
if !identity.Handle.IsInvalidHandle() {
95
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
96
+
} else {
97
+
didHandleMap[identity.DID.String()] = identity.DID.String()
98
+
}
99
+
}
100
+
101
+
rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
102
+
LoggedInUser: user,
103
+
RepoInfo: f.RepoInfo(user),
104
+
Issue: *issue,
105
+
Comments: comments,
106
+
107
+
IssueOwnerHandle: issueOwnerIdent.Handle.String(),
108
+
DidHandleMap: didHandleMap,
109
+
})
110
+
111
+
}
112
+
113
+
func (rp *Issues) CloseIssue(w http.ResponseWriter, r *http.Request) {
114
+
user := rp.oauth.GetUser(r)
115
+
f, err := rp.repoResolver.Resolve(r)
116
+
if err != nil {
117
+
log.Println("failed to get repo and knot", err)
118
+
return
119
+
}
120
+
121
+
issueId := chi.URLParam(r, "issue")
122
+
issueIdInt, err := strconv.Atoi(issueId)
123
+
if err != nil {
124
+
http.Error(w, "bad issue id", http.StatusBadRequest)
125
+
log.Println("failed to parse issue id", err)
126
+
return
127
+
}
128
+
129
+
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
130
+
if err != nil {
131
+
log.Println("failed to get issue", err)
132
+
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
133
+
return
134
+
}
135
+
136
+
collaborators, err := f.Collaborators(r.Context())
137
+
if err != nil {
138
+
log.Println("failed to fetch repo collaborators: %w", err)
139
+
}
140
+
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
141
+
return user.Did == collab.Did
142
+
})
143
+
isIssueOwner := user.Did == issue.OwnerDid
144
+
145
+
// TODO: make this more granular
146
+
if isIssueOwner || isCollaborator {
147
+
148
+
closed := tangled.RepoIssueStateClosed
149
+
150
+
client, err := rp.oauth.AuthorizedClient(r)
151
+
if err != nil {
152
+
log.Println("failed to get authorized client", err)
153
+
return
154
+
}
155
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
156
+
Collection: tangled.RepoIssueStateNSID,
157
+
Repo: user.Did,
158
+
Rkey: appview.TID(),
159
+
Record: &lexutil.LexiconTypeDecoder{
160
+
Val: &tangled.RepoIssueState{
161
+
Issue: issue.IssueAt,
162
+
State: closed,
163
+
},
164
+
},
165
+
})
166
+
167
+
if err != nil {
168
+
log.Println("failed to update issue state", err)
169
+
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
170
+
return
171
+
}
172
+
173
+
err = db.CloseIssue(rp.db, f.RepoAt, issueIdInt)
174
+
if err != nil {
175
+
log.Println("failed to close issue", err)
176
+
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
177
+
return
178
+
}
179
+
180
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
181
+
return
182
+
} else {
183
+
log.Println("user is not permitted to close issue")
184
+
http.Error(w, "for biden", http.StatusUnauthorized)
185
+
return
186
+
}
187
+
}
188
+
189
+
func (rp *Issues) ReopenIssue(w http.ResponseWriter, r *http.Request) {
190
+
user := rp.oauth.GetUser(r)
191
+
f, err := rp.repoResolver.Resolve(r)
192
+
if err != nil {
193
+
log.Println("failed to get repo and knot", err)
194
+
return
195
+
}
196
+
197
+
issueId := chi.URLParam(r, "issue")
198
+
issueIdInt, err := strconv.Atoi(issueId)
199
+
if err != nil {
200
+
http.Error(w, "bad issue id", http.StatusBadRequest)
201
+
log.Println("failed to parse issue id", err)
202
+
return
203
+
}
204
+
205
+
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
206
+
if err != nil {
207
+
log.Println("failed to get issue", err)
208
+
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
209
+
return
210
+
}
211
+
212
+
collaborators, err := f.Collaborators(r.Context())
213
+
if err != nil {
214
+
log.Println("failed to fetch repo collaborators: %w", err)
215
+
}
216
+
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
217
+
return user.Did == collab.Did
218
+
})
219
+
isIssueOwner := user.Did == issue.OwnerDid
220
+
221
+
if isCollaborator || isIssueOwner {
222
+
err := db.ReopenIssue(rp.db, f.RepoAt, issueIdInt)
223
+
if err != nil {
224
+
log.Println("failed to reopen issue", err)
225
+
rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
226
+
return
227
+
}
228
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
229
+
return
230
+
} else {
231
+
log.Println("user is not the owner of the repo")
232
+
http.Error(w, "forbidden", http.StatusUnauthorized)
233
+
return
234
+
}
235
+
}
236
+
237
+
func (rp *Issues) NewIssueComment(w http.ResponseWriter, r *http.Request) {
238
+
user := rp.oauth.GetUser(r)
239
+
f, err := rp.repoResolver.Resolve(r)
240
+
if err != nil {
241
+
log.Println("failed to get repo and knot", err)
242
+
return
243
+
}
244
+
245
+
issueId := chi.URLParam(r, "issue")
246
+
issueIdInt, err := strconv.Atoi(issueId)
247
+
if err != nil {
248
+
http.Error(w, "bad issue id", http.StatusBadRequest)
249
+
log.Println("failed to parse issue id", err)
250
+
return
251
+
}
252
+
253
+
switch r.Method {
254
+
case http.MethodPost:
255
+
body := r.FormValue("body")
256
+
if body == "" {
257
+
rp.pages.Notice(w, "issue", "Body is required")
258
+
return
259
+
}
260
+
261
+
commentId := mathrand.IntN(1000000)
262
+
rkey := appview.TID()
263
+
264
+
err := db.NewIssueComment(rp.db, &db.Comment{
265
+
OwnerDid: user.Did,
266
+
RepoAt: f.RepoAt,
267
+
Issue: issueIdInt,
268
+
CommentId: commentId,
269
+
Body: body,
270
+
Rkey: rkey,
271
+
})
272
+
if err != nil {
273
+
log.Println("failed to create comment", err)
274
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
275
+
return
276
+
}
277
+
278
+
createdAt := time.Now().Format(time.RFC3339)
279
+
commentIdInt64 := int64(commentId)
280
+
ownerDid := user.Did
281
+
issueAt, err := db.GetIssueAt(rp.db, f.RepoAt, issueIdInt)
282
+
if err != nil {
283
+
log.Println("failed to get issue at", err)
284
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
285
+
return
286
+
}
287
+
288
+
atUri := f.RepoAt.String()
289
+
client, err := rp.oauth.AuthorizedClient(r)
290
+
if err != nil {
291
+
log.Println("failed to get authorized client", err)
292
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
293
+
return
294
+
}
295
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
296
+
Collection: tangled.RepoIssueCommentNSID,
297
+
Repo: user.Did,
298
+
Rkey: rkey,
299
+
Record: &lexutil.LexiconTypeDecoder{
300
+
Val: &tangled.RepoIssueComment{
301
+
Repo: &atUri,
302
+
Issue: issueAt,
303
+
CommentId: &commentIdInt64,
304
+
Owner: &ownerDid,
305
+
Body: body,
306
+
CreatedAt: createdAt,
307
+
},
308
+
},
309
+
})
310
+
if err != nil {
311
+
log.Println("failed to create comment", err)
312
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
313
+
return
314
+
}
315
+
316
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
317
+
return
318
+
}
319
+
}
320
+
321
+
func (rp *Issues) IssueComment(w http.ResponseWriter, r *http.Request) {
322
+
user := rp.oauth.GetUser(r)
323
+
f, err := rp.repoResolver.Resolve(r)
324
+
if err != nil {
325
+
log.Println("failed to get repo and knot", err)
326
+
return
327
+
}
328
+
329
+
issueId := chi.URLParam(r, "issue")
330
+
issueIdInt, err := strconv.Atoi(issueId)
331
+
if err != nil {
332
+
http.Error(w, "bad issue id", http.StatusBadRequest)
333
+
log.Println("failed to parse issue id", err)
334
+
return
335
+
}
336
+
337
+
commentId := chi.URLParam(r, "comment_id")
338
+
commentIdInt, err := strconv.Atoi(commentId)
339
+
if err != nil {
340
+
http.Error(w, "bad comment id", http.StatusBadRequest)
341
+
log.Println("failed to parse issue id", err)
342
+
return
343
+
}
344
+
345
+
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
346
+
if err != nil {
347
+
log.Println("failed to get issue", err)
348
+
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
349
+
return
350
+
}
351
+
352
+
comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
353
+
if err != nil {
354
+
http.Error(w, "bad comment id", http.StatusBadRequest)
355
+
return
356
+
}
357
+
358
+
identity, err := rp.idResolver.ResolveIdent(r.Context(), comment.OwnerDid)
359
+
if err != nil {
360
+
log.Println("failed to resolve did")
361
+
return
362
+
}
363
+
364
+
didHandleMap := make(map[string]string)
365
+
if !identity.Handle.IsInvalidHandle() {
366
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
367
+
} else {
368
+
didHandleMap[identity.DID.String()] = identity.DID.String()
369
+
}
370
+
371
+
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
372
+
LoggedInUser: user,
373
+
RepoInfo: f.RepoInfo(user),
374
+
DidHandleMap: didHandleMap,
375
+
Issue: issue,
376
+
Comment: comment,
377
+
})
378
+
}
379
+
380
+
func (rp *Issues) EditIssueComment(w http.ResponseWriter, r *http.Request) {
381
+
user := rp.oauth.GetUser(r)
382
+
f, err := rp.repoResolver.Resolve(r)
383
+
if err != nil {
384
+
log.Println("failed to get repo and knot", err)
385
+
return
386
+
}
387
+
388
+
issueId := chi.URLParam(r, "issue")
389
+
issueIdInt, err := strconv.Atoi(issueId)
390
+
if err != nil {
391
+
http.Error(w, "bad issue id", http.StatusBadRequest)
392
+
log.Println("failed to parse issue id", err)
393
+
return
394
+
}
395
+
396
+
commentId := chi.URLParam(r, "comment_id")
397
+
commentIdInt, err := strconv.Atoi(commentId)
398
+
if err != nil {
399
+
http.Error(w, "bad comment id", http.StatusBadRequest)
400
+
log.Println("failed to parse issue id", err)
401
+
return
402
+
}
403
+
404
+
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
405
+
if err != nil {
406
+
log.Println("failed to get issue", err)
407
+
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
408
+
return
409
+
}
410
+
411
+
comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
412
+
if err != nil {
413
+
http.Error(w, "bad comment id", http.StatusBadRequest)
414
+
return
415
+
}
416
+
417
+
if comment.OwnerDid != user.Did {
418
+
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
419
+
return
420
+
}
421
+
422
+
switch r.Method {
423
+
case http.MethodGet:
424
+
rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{
425
+
LoggedInUser: user,
426
+
RepoInfo: f.RepoInfo(user),
427
+
Issue: issue,
428
+
Comment: comment,
429
+
})
430
+
case http.MethodPost:
431
+
// extract form value
432
+
newBody := r.FormValue("body")
433
+
client, err := rp.oauth.AuthorizedClient(r)
434
+
if err != nil {
435
+
log.Println("failed to get authorized client", err)
436
+
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
437
+
return
438
+
}
439
+
rkey := comment.Rkey
440
+
441
+
// optimistic update
442
+
edited := time.Now()
443
+
err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody)
444
+
if err != nil {
445
+
log.Println("failed to perferom update-description query", err)
446
+
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
447
+
return
448
+
}
449
+
450
+
// rkey is optional, it was introduced later
451
+
if comment.Rkey != "" {
452
+
// update the record on pds
453
+
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey)
454
+
if err != nil {
455
+
// failed to get record
456
+
log.Println(err, rkey)
457
+
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
458
+
return
459
+
}
460
+
value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json
461
+
record, _ := data.UnmarshalJSON(value)
462
+
463
+
repoAt := record["repo"].(string)
464
+
issueAt := record["issue"].(string)
465
+
createdAt := record["createdAt"].(string)
466
+
commentIdInt64 := int64(commentIdInt)
467
+
468
+
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
469
+
Collection: tangled.RepoIssueCommentNSID,
470
+
Repo: user.Did,
471
+
Rkey: rkey,
472
+
SwapRecord: ex.Cid,
473
+
Record: &lexutil.LexiconTypeDecoder{
474
+
Val: &tangled.RepoIssueComment{
475
+
Repo: &repoAt,
476
+
Issue: issueAt,
477
+
CommentId: &commentIdInt64,
478
+
Owner: &comment.OwnerDid,
479
+
Body: newBody,
480
+
CreatedAt: createdAt,
481
+
},
482
+
},
483
+
})
484
+
if err != nil {
485
+
log.Println(err)
486
+
}
487
+
}
488
+
489
+
// optimistic update for htmx
490
+
didHandleMap := map[string]string{
491
+
user.Did: user.Handle,
492
+
}
493
+
comment.Body = newBody
494
+
comment.Edited = &edited
495
+
496
+
// return new comment body with htmx
497
+
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
498
+
LoggedInUser: user,
499
+
RepoInfo: f.RepoInfo(user),
500
+
DidHandleMap: didHandleMap,
501
+
Issue: issue,
502
+
Comment: comment,
503
+
})
504
+
return
505
+
506
+
}
507
+
508
+
}
509
+
510
+
func (rp *Issues) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
511
+
user := rp.oauth.GetUser(r)
512
+
f, err := rp.repoResolver.Resolve(r)
513
+
if err != nil {
514
+
log.Println("failed to get repo and knot", err)
515
+
return
516
+
}
517
+
518
+
issueId := chi.URLParam(r, "issue")
519
+
issueIdInt, err := strconv.Atoi(issueId)
520
+
if err != nil {
521
+
http.Error(w, "bad issue id", http.StatusBadRequest)
522
+
log.Println("failed to parse issue id", err)
523
+
return
524
+
}
525
+
526
+
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
527
+
if err != nil {
528
+
log.Println("failed to get issue", err)
529
+
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
530
+
return
531
+
}
532
+
533
+
commentId := chi.URLParam(r, "comment_id")
534
+
commentIdInt, err := strconv.Atoi(commentId)
535
+
if err != nil {
536
+
http.Error(w, "bad comment id", http.StatusBadRequest)
537
+
log.Println("failed to parse issue id", err)
538
+
return
539
+
}
540
+
541
+
comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
542
+
if err != nil {
543
+
http.Error(w, "bad comment id", http.StatusBadRequest)
544
+
return
545
+
}
546
+
547
+
if comment.OwnerDid != user.Did {
548
+
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
549
+
return
550
+
}
551
+
552
+
if comment.Deleted != nil {
553
+
http.Error(w, "comment already deleted", http.StatusBadRequest)
554
+
return
555
+
}
556
+
557
+
// optimistic deletion
558
+
deleted := time.Now()
559
+
err = db.DeleteComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
560
+
if err != nil {
561
+
log.Println("failed to delete comment")
562
+
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
563
+
return
564
+
}
565
+
566
+
// delete from pds
567
+
if comment.Rkey != "" {
568
+
client, err := rp.oauth.AuthorizedClient(r)
569
+
if err != nil {
570
+
log.Println("failed to get authorized client", err)
571
+
rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
572
+
return
573
+
}
574
+
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
575
+
Collection: tangled.GraphFollowNSID,
576
+
Repo: user.Did,
577
+
Rkey: comment.Rkey,
578
+
})
579
+
if err != nil {
580
+
log.Println(err)
581
+
}
582
+
}
583
+
584
+
// optimistic update for htmx
585
+
didHandleMap := map[string]string{
586
+
user.Did: user.Handle,
587
+
}
588
+
comment.Body = ""
589
+
comment.Deleted = &deleted
590
+
591
+
// htmx fragment of comment after deletion
592
+
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
593
+
LoggedInUser: user,
594
+
RepoInfo: f.RepoInfo(user),
595
+
DidHandleMap: didHandleMap,
596
+
Issue: issue,
597
+
Comment: comment,
598
+
})
599
+
return
600
+
}
601
+
602
+
func (rp *Issues) RepoIssues(w http.ResponseWriter, r *http.Request) {
603
+
params := r.URL.Query()
604
+
state := params.Get("state")
605
+
isOpen := true
606
+
switch state {
607
+
case "open":
608
+
isOpen = true
609
+
case "closed":
610
+
isOpen = false
611
+
default:
612
+
isOpen = true
613
+
}
614
+
615
+
page, ok := r.Context().Value("page").(pagination.Page)
616
+
if !ok {
617
+
log.Println("failed to get page")
618
+
page = pagination.FirstPage()
619
+
}
620
+
621
+
user := rp.oauth.GetUser(r)
622
+
f, err := rp.repoResolver.Resolve(r)
623
+
if err != nil {
624
+
log.Println("failed to get repo and knot", err)
625
+
return
626
+
}
627
+
628
+
issues, err := db.GetIssues(rp.db, f.RepoAt, isOpen, page)
629
+
if err != nil {
630
+
log.Println("failed to get issues", err)
631
+
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
632
+
return
633
+
}
634
+
635
+
identsToResolve := make([]string, len(issues))
636
+
for i, issue := range issues {
637
+
identsToResolve[i] = issue.OwnerDid
638
+
}
639
+
resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve)
640
+
didHandleMap := make(map[string]string)
641
+
for _, identity := range resolvedIds {
642
+
if !identity.Handle.IsInvalidHandle() {
643
+
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
644
+
} else {
645
+
didHandleMap[identity.DID.String()] = identity.DID.String()
646
+
}
647
+
}
648
+
649
+
rp.pages.RepoIssues(w, pages.RepoIssuesParams{
650
+
LoggedInUser: rp.oauth.GetUser(r),
651
+
RepoInfo: f.RepoInfo(user),
652
+
Issues: issues,
653
+
DidHandleMap: didHandleMap,
654
+
FilteringByOpen: isOpen,
655
+
Page: page,
656
+
})
657
+
return
658
+
}
659
+
660
+
func (rp *Issues) NewIssue(w http.ResponseWriter, r *http.Request) {
661
+
user := rp.oauth.GetUser(r)
662
+
663
+
f, err := rp.repoResolver.Resolve(r)
664
+
if err != nil {
665
+
log.Println("failed to get repo and knot", err)
666
+
return
667
+
}
668
+
669
+
switch r.Method {
670
+
case http.MethodGet:
671
+
rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
672
+
LoggedInUser: user,
673
+
RepoInfo: f.RepoInfo(user),
674
+
})
675
+
case http.MethodPost:
676
+
title := r.FormValue("title")
677
+
body := r.FormValue("body")
678
+
679
+
if title == "" || body == "" {
680
+
rp.pages.Notice(w, "issues", "Title and body are required")
681
+
return
682
+
}
683
+
684
+
tx, err := rp.db.BeginTx(r.Context(), nil)
685
+
if err != nil {
686
+
rp.pages.Notice(w, "issues", "Failed to create issue, try again later")
687
+
return
688
+
}
689
+
690
+
err = db.NewIssue(tx, &db.Issue{
691
+
RepoAt: f.RepoAt,
692
+
Title: title,
693
+
Body: body,
694
+
OwnerDid: user.Did,
695
+
})
696
+
if err != nil {
697
+
log.Println("failed to create issue", err)
698
+
rp.pages.Notice(w, "issues", "Failed to create issue.")
699
+
return
700
+
}
701
+
702
+
issueId, err := db.GetIssueId(rp.db, f.RepoAt)
703
+
if err != nil {
704
+
log.Println("failed to get issue id", err)
705
+
rp.pages.Notice(w, "issues", "Failed to create issue.")
706
+
return
707
+
}
708
+
709
+
client, err := rp.oauth.AuthorizedClient(r)
710
+
if err != nil {
711
+
log.Println("failed to get authorized client", err)
712
+
rp.pages.Notice(w, "issues", "Failed to create issue.")
713
+
return
714
+
}
715
+
atUri := f.RepoAt.String()
716
+
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
717
+
Collection: tangled.RepoIssueNSID,
718
+
Repo: user.Did,
719
+
Rkey: appview.TID(),
720
+
Record: &lexutil.LexiconTypeDecoder{
721
+
Val: &tangled.RepoIssue{
722
+
Repo: atUri,
723
+
Title: title,
724
+
Body: &body,
725
+
Owner: user.Did,
726
+
IssueId: int64(issueId),
727
+
},
728
+
},
729
+
})
730
+
if err != nil {
731
+
log.Println("failed to create issue", err)
732
+
rp.pages.Notice(w, "issues", "Failed to create issue.")
733
+
return
734
+
}
735
+
736
+
err = db.SetIssueAt(rp.db, f.RepoAt, issueId, resp.Uri)
737
+
if err != nil {
738
+
log.Println("failed to set issue at", err)
739
+
rp.pages.Notice(w, "issues", "Failed to create issue.")
740
+
return
741
+
}
742
+
743
+
if !rp.config.Core.Dev {
744
+
err = rp.posthog.Enqueue(posthog.Capture{
745
+
DistinctId: user.Did,
746
+
Event: "new_issue",
747
+
Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "issue_id": issueId},
748
+
})
749
+
if err != nil {
750
+
log.Println("failed to enqueue posthog event:", err)
751
+
}
752
+
}
753
+
754
+
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))
755
+
return
756
+
}
757
+
}
+34
appview/issues/router.go
+34
appview/issues/router.go
···
1
+
package issues
2
+
3
+
import (
4
+
"net/http"
5
+
6
+
"github.com/go-chi/chi/v5"
7
+
"tangled.sh/tangled.sh/core/appview/middleware"
8
+
)
9
+
10
+
func (i *Issues) Router(mw *middleware.Middleware) http.Handler {
11
+
r := chi.NewRouter()
12
+
13
+
r.Route("/", func(r chi.Router) {
14
+
r.With(middleware.Paginate).Get("/", i.RepoIssues)
15
+
r.Get("/{issue}", i.RepoSingleIssue)
16
+
17
+
r.Group(func(r chi.Router) {
18
+
r.Use(middleware.AuthMiddleware(i.oauth))
19
+
r.Get("/new", i.NewIssue)
20
+
r.Post("/new", i.NewIssue)
21
+
r.Post("/{issue}/comment", i.NewIssueComment)
22
+
r.Route("/{issue}/comment/{comment_id}/", func(r chi.Router) {
23
+
r.Get("/", i.IssueComment)
24
+
r.Delete("/", i.DeleteIssueComment)
25
+
r.Get("/edit", i.EditIssueComment)
26
+
r.Post("/edit", i.EditIssueComment)
27
+
})
28
+
r.Post("/{issue}/close", i.CloseIssue)
29
+
r.Post("/{issue}/reopen", i.ReopenIssue)
30
+
})
31
+
})
32
+
33
+
return r
34
+
}
-703
appview/repo/repo.go
-703
appview/repo/repo.go
···
7
7
"fmt"
8
8
"io"
9
9
"log"
10
-
mathrand "math/rand/v2"
11
10
"net/http"
12
11
"path"
13
12
"slices"
···
25
24
"tangled.sh/tangled.sh/core/appview/pages"
26
25
"tangled.sh/tangled.sh/core/appview/pages/markup"
27
26
"tangled.sh/tangled.sh/core/appview/pages/repoinfo"
28
-
"tangled.sh/tangled.sh/core/appview/pagination"
29
27
"tangled.sh/tangled.sh/core/appview/reporesolver"
30
28
"tangled.sh/tangled.sh/core/knotclient"
31
29
"tangled.sh/tangled.sh/core/patchutil"
32
30
"tangled.sh/tangled.sh/core/rbac"
33
31
"tangled.sh/tangled.sh/core/types"
34
32
35
-
"github.com/bluesky-social/indigo/atproto/data"
36
33
securejoin "github.com/cyphar/filepath-securejoin"
37
34
"github.com/go-chi/chi/v5"
38
35
"github.com/go-git/go-git/v5/plumbing"
···
1002
999
IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
1003
1000
Branches: result.Branches,
1004
1001
})
1005
-
}
1006
-
}
1007
-
1008
-
func (rp *Repo) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
1009
-
user := rp.oauth.GetUser(r)
1010
-
f, err := rp.repoResolver.Resolve(r)
1011
-
if err != nil {
1012
-
log.Println("failed to get repo and knot", err)
1013
-
return
1014
-
}
1015
-
1016
-
issueId := chi.URLParam(r, "issue")
1017
-
issueIdInt, err := strconv.Atoi(issueId)
1018
-
if err != nil {
1019
-
http.Error(w, "bad issue id", http.StatusBadRequest)
1020
-
log.Println("failed to parse issue id", err)
1021
-
return
1022
-
}
1023
-
1024
-
issue, comments, err := db.GetIssueWithComments(rp.db, f.RepoAt, issueIdInt)
1025
-
if err != nil {
1026
-
log.Println("failed to get issue and comments", err)
1027
-
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1028
-
return
1029
-
}
1030
-
1031
-
issueOwnerIdent, err := rp.idResolver.ResolveIdent(r.Context(), issue.OwnerDid)
1032
-
if err != nil {
1033
-
log.Println("failed to resolve issue owner", err)
1034
-
}
1035
-
1036
-
identsToResolve := make([]string, len(comments))
1037
-
for i, comment := range comments {
1038
-
identsToResolve[i] = comment.OwnerDid
1039
-
}
1040
-
resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve)
1041
-
didHandleMap := make(map[string]string)
1042
-
for _, identity := range resolvedIds {
1043
-
if !identity.Handle.IsInvalidHandle() {
1044
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1045
-
} else {
1046
-
didHandleMap[identity.DID.String()] = identity.DID.String()
1047
-
}
1048
-
}
1049
-
1050
-
rp.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
1051
-
LoggedInUser: user,
1052
-
RepoInfo: f.RepoInfo(user),
1053
-
Issue: *issue,
1054
-
Comments: comments,
1055
-
1056
-
IssueOwnerHandle: issueOwnerIdent.Handle.String(),
1057
-
DidHandleMap: didHandleMap,
1058
-
})
1059
-
1060
-
}
1061
-
1062
-
func (rp *Repo) CloseIssue(w http.ResponseWriter, r *http.Request) {
1063
-
user := rp.oauth.GetUser(r)
1064
-
f, err := rp.repoResolver.Resolve(r)
1065
-
if err != nil {
1066
-
log.Println("failed to get repo and knot", err)
1067
-
return
1068
-
}
1069
-
1070
-
issueId := chi.URLParam(r, "issue")
1071
-
issueIdInt, err := strconv.Atoi(issueId)
1072
-
if err != nil {
1073
-
http.Error(w, "bad issue id", http.StatusBadRequest)
1074
-
log.Println("failed to parse issue id", err)
1075
-
return
1076
-
}
1077
-
1078
-
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
1079
-
if err != nil {
1080
-
log.Println("failed to get issue", err)
1081
-
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1082
-
return
1083
-
}
1084
-
1085
-
collaborators, err := f.Collaborators(r.Context())
1086
-
if err != nil {
1087
-
log.Println("failed to fetch repo collaborators: %w", err)
1088
-
}
1089
-
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
1090
-
return user.Did == collab.Did
1091
-
})
1092
-
isIssueOwner := user.Did == issue.OwnerDid
1093
-
1094
-
// TODO: make this more granular
1095
-
if isIssueOwner || isCollaborator {
1096
-
1097
-
closed := tangled.RepoIssueStateClosed
1098
-
1099
-
client, err := rp.oauth.AuthorizedClient(r)
1100
-
if err != nil {
1101
-
log.Println("failed to get authorized client", err)
1102
-
return
1103
-
}
1104
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1105
-
Collection: tangled.RepoIssueStateNSID,
1106
-
Repo: user.Did,
1107
-
Rkey: appview.TID(),
1108
-
Record: &lexutil.LexiconTypeDecoder{
1109
-
Val: &tangled.RepoIssueState{
1110
-
Issue: issue.IssueAt,
1111
-
State: closed,
1112
-
},
1113
-
},
1114
-
})
1115
-
1116
-
if err != nil {
1117
-
log.Println("failed to update issue state", err)
1118
-
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1119
-
return
1120
-
}
1121
-
1122
-
err = db.CloseIssue(rp.db, f.RepoAt, issueIdInt)
1123
-
if err != nil {
1124
-
log.Println("failed to close issue", err)
1125
-
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1126
-
return
1127
-
}
1128
-
1129
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
1130
-
return
1131
-
} else {
1132
-
log.Println("user is not permitted to close issue")
1133
-
http.Error(w, "for biden", http.StatusUnauthorized)
1134
-
return
1135
-
}
1136
-
}
1137
-
1138
-
func (rp *Repo) ReopenIssue(w http.ResponseWriter, r *http.Request) {
1139
-
user := rp.oauth.GetUser(r)
1140
-
f, err := rp.repoResolver.Resolve(r)
1141
-
if err != nil {
1142
-
log.Println("failed to get repo and knot", err)
1143
-
return
1144
-
}
1145
-
1146
-
issueId := chi.URLParam(r, "issue")
1147
-
issueIdInt, err := strconv.Atoi(issueId)
1148
-
if err != nil {
1149
-
http.Error(w, "bad issue id", http.StatusBadRequest)
1150
-
log.Println("failed to parse issue id", err)
1151
-
return
1152
-
}
1153
-
1154
-
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
1155
-
if err != nil {
1156
-
log.Println("failed to get issue", err)
1157
-
rp.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1158
-
return
1159
-
}
1160
-
1161
-
collaborators, err := f.Collaborators(r.Context())
1162
-
if err != nil {
1163
-
log.Println("failed to fetch repo collaborators: %w", err)
1164
-
}
1165
-
isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
1166
-
return user.Did == collab.Did
1167
-
})
1168
-
isIssueOwner := user.Did == issue.OwnerDid
1169
-
1170
-
if isCollaborator || isIssueOwner {
1171
-
err := db.ReopenIssue(rp.db, f.RepoAt, issueIdInt)
1172
-
if err != nil {
1173
-
log.Println("failed to reopen issue", err)
1174
-
rp.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
1175
-
return
1176
-
}
1177
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
1178
-
return
1179
-
} else {
1180
-
log.Println("user is not the owner of the repo")
1181
-
http.Error(w, "forbidden", http.StatusUnauthorized)
1182
-
return
1183
-
}
1184
-
}
1185
-
1186
-
func (rp *Repo) NewIssueComment(w http.ResponseWriter, r *http.Request) {
1187
-
user := rp.oauth.GetUser(r)
1188
-
f, err := rp.repoResolver.Resolve(r)
1189
-
if err != nil {
1190
-
log.Println("failed to get repo and knot", err)
1191
-
return
1192
-
}
1193
-
1194
-
issueId := chi.URLParam(r, "issue")
1195
-
issueIdInt, err := strconv.Atoi(issueId)
1196
-
if err != nil {
1197
-
http.Error(w, "bad issue id", http.StatusBadRequest)
1198
-
log.Println("failed to parse issue id", err)
1199
-
return
1200
-
}
1201
-
1202
-
switch r.Method {
1203
-
case http.MethodPost:
1204
-
body := r.FormValue("body")
1205
-
if body == "" {
1206
-
rp.pages.Notice(w, "issue", "Body is required")
1207
-
return
1208
-
}
1209
-
1210
-
commentId := mathrand.IntN(1000000)
1211
-
rkey := appview.TID()
1212
-
1213
-
err := db.NewIssueComment(rp.db, &db.Comment{
1214
-
OwnerDid: user.Did,
1215
-
RepoAt: f.RepoAt,
1216
-
Issue: issueIdInt,
1217
-
CommentId: commentId,
1218
-
Body: body,
1219
-
Rkey: rkey,
1220
-
})
1221
-
if err != nil {
1222
-
log.Println("failed to create comment", err)
1223
-
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
1224
-
return
1225
-
}
1226
-
1227
-
createdAt := time.Now().Format(time.RFC3339)
1228
-
commentIdInt64 := int64(commentId)
1229
-
ownerDid := user.Did
1230
-
issueAt, err := db.GetIssueAt(rp.db, f.RepoAt, issueIdInt)
1231
-
if err != nil {
1232
-
log.Println("failed to get issue at", err)
1233
-
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
1234
-
return
1235
-
}
1236
-
1237
-
atUri := f.RepoAt.String()
1238
-
client, err := rp.oauth.AuthorizedClient(r)
1239
-
if err != nil {
1240
-
log.Println("failed to get authorized client", err)
1241
-
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
1242
-
return
1243
-
}
1244
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1245
-
Collection: tangled.RepoIssueCommentNSID,
1246
-
Repo: user.Did,
1247
-
Rkey: rkey,
1248
-
Record: &lexutil.LexiconTypeDecoder{
1249
-
Val: &tangled.RepoIssueComment{
1250
-
Repo: &atUri,
1251
-
Issue: issueAt,
1252
-
CommentId: &commentIdInt64,
1253
-
Owner: &ownerDid,
1254
-
Body: body,
1255
-
CreatedAt: createdAt,
1256
-
},
1257
-
},
1258
-
})
1259
-
if err != nil {
1260
-
log.Println("failed to create comment", err)
1261
-
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
1262
-
return
1263
-
}
1264
-
1265
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
1266
-
return
1267
-
}
1268
-
}
1269
-
1270
-
func (rp *Repo) IssueComment(w http.ResponseWriter, r *http.Request) {
1271
-
user := rp.oauth.GetUser(r)
1272
-
f, err := rp.repoResolver.Resolve(r)
1273
-
if err != nil {
1274
-
log.Println("failed to get repo and knot", err)
1275
-
return
1276
-
}
1277
-
1278
-
issueId := chi.URLParam(r, "issue")
1279
-
issueIdInt, err := strconv.Atoi(issueId)
1280
-
if err != nil {
1281
-
http.Error(w, "bad issue id", http.StatusBadRequest)
1282
-
log.Println("failed to parse issue id", err)
1283
-
return
1284
-
}
1285
-
1286
-
commentId := chi.URLParam(r, "comment_id")
1287
-
commentIdInt, err := strconv.Atoi(commentId)
1288
-
if err != nil {
1289
-
http.Error(w, "bad comment id", http.StatusBadRequest)
1290
-
log.Println("failed to parse issue id", err)
1291
-
return
1292
-
}
1293
-
1294
-
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
1295
-
if err != nil {
1296
-
log.Println("failed to get issue", err)
1297
-
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1298
-
return
1299
-
}
1300
-
1301
-
comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
1302
-
if err != nil {
1303
-
http.Error(w, "bad comment id", http.StatusBadRequest)
1304
-
return
1305
-
}
1306
-
1307
-
identity, err := rp.idResolver.ResolveIdent(r.Context(), comment.OwnerDid)
1308
-
if err != nil {
1309
-
log.Println("failed to resolve did")
1310
-
return
1311
-
}
1312
-
1313
-
didHandleMap := make(map[string]string)
1314
-
if !identity.Handle.IsInvalidHandle() {
1315
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1316
-
} else {
1317
-
didHandleMap[identity.DID.String()] = identity.DID.String()
1318
-
}
1319
-
1320
-
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1321
-
LoggedInUser: user,
1322
-
RepoInfo: f.RepoInfo(user),
1323
-
DidHandleMap: didHandleMap,
1324
-
Issue: issue,
1325
-
Comment: comment,
1326
-
})
1327
-
}
1328
-
1329
-
func (rp *Repo) EditIssueComment(w http.ResponseWriter, r *http.Request) {
1330
-
user := rp.oauth.GetUser(r)
1331
-
f, err := rp.repoResolver.Resolve(r)
1332
-
if err != nil {
1333
-
log.Println("failed to get repo and knot", err)
1334
-
return
1335
-
}
1336
-
1337
-
issueId := chi.URLParam(r, "issue")
1338
-
issueIdInt, err := strconv.Atoi(issueId)
1339
-
if err != nil {
1340
-
http.Error(w, "bad issue id", http.StatusBadRequest)
1341
-
log.Println("failed to parse issue id", err)
1342
-
return
1343
-
}
1344
-
1345
-
commentId := chi.URLParam(r, "comment_id")
1346
-
commentIdInt, err := strconv.Atoi(commentId)
1347
-
if err != nil {
1348
-
http.Error(w, "bad comment id", http.StatusBadRequest)
1349
-
log.Println("failed to parse issue id", err)
1350
-
return
1351
-
}
1352
-
1353
-
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
1354
-
if err != nil {
1355
-
log.Println("failed to get issue", err)
1356
-
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1357
-
return
1358
-
}
1359
-
1360
-
comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
1361
-
if err != nil {
1362
-
http.Error(w, "bad comment id", http.StatusBadRequest)
1363
-
return
1364
-
}
1365
-
1366
-
if comment.OwnerDid != user.Did {
1367
-
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
1368
-
return
1369
-
}
1370
-
1371
-
switch r.Method {
1372
-
case http.MethodGet:
1373
-
rp.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{
1374
-
LoggedInUser: user,
1375
-
RepoInfo: f.RepoInfo(user),
1376
-
Issue: issue,
1377
-
Comment: comment,
1378
-
})
1379
-
case http.MethodPost:
1380
-
// extract form value
1381
-
newBody := r.FormValue("body")
1382
-
client, err := rp.oauth.AuthorizedClient(r)
1383
-
if err != nil {
1384
-
log.Println("failed to get authorized client", err)
1385
-
rp.pages.Notice(w, "issue-comment", "Failed to create comment.")
1386
-
return
1387
-
}
1388
-
rkey := comment.Rkey
1389
-
1390
-
// optimistic update
1391
-
edited := time.Now()
1392
-
err = db.EditComment(rp.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody)
1393
-
if err != nil {
1394
-
log.Println("failed to perferom update-description query", err)
1395
-
rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
1396
-
return
1397
-
}
1398
-
1399
-
// rkey is optional, it was introduced later
1400
-
if comment.Rkey != "" {
1401
-
// update the record on pds
1402
-
ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey)
1403
-
if err != nil {
1404
-
// failed to get record
1405
-
log.Println(err, rkey)
1406
-
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
1407
-
return
1408
-
}
1409
-
value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json
1410
-
record, _ := data.UnmarshalJSON(value)
1411
-
1412
-
repoAt := record["repo"].(string)
1413
-
issueAt := record["issue"].(string)
1414
-
createdAt := record["createdAt"].(string)
1415
-
commentIdInt64 := int64(commentIdInt)
1416
-
1417
-
_, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1418
-
Collection: tangled.RepoIssueCommentNSID,
1419
-
Repo: user.Did,
1420
-
Rkey: rkey,
1421
-
SwapRecord: ex.Cid,
1422
-
Record: &lexutil.LexiconTypeDecoder{
1423
-
Val: &tangled.RepoIssueComment{
1424
-
Repo: &repoAt,
1425
-
Issue: issueAt,
1426
-
CommentId: &commentIdInt64,
1427
-
Owner: &comment.OwnerDid,
1428
-
Body: newBody,
1429
-
CreatedAt: createdAt,
1430
-
},
1431
-
},
1432
-
})
1433
-
if err != nil {
1434
-
log.Println(err)
1435
-
}
1436
-
}
1437
-
1438
-
// optimistic update for htmx
1439
-
didHandleMap := map[string]string{
1440
-
user.Did: user.Handle,
1441
-
}
1442
-
comment.Body = newBody
1443
-
comment.Edited = &edited
1444
-
1445
-
// return new comment body with htmx
1446
-
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1447
-
LoggedInUser: user,
1448
-
RepoInfo: f.RepoInfo(user),
1449
-
DidHandleMap: didHandleMap,
1450
-
Issue: issue,
1451
-
Comment: comment,
1452
-
})
1453
-
return
1454
-
1455
-
}
1456
-
1457
-
}
1458
-
1459
-
func (rp *Repo) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
1460
-
user := rp.oauth.GetUser(r)
1461
-
f, err := rp.repoResolver.Resolve(r)
1462
-
if err != nil {
1463
-
log.Println("failed to get repo and knot", err)
1464
-
return
1465
-
}
1466
-
1467
-
issueId := chi.URLParam(r, "issue")
1468
-
issueIdInt, err := strconv.Atoi(issueId)
1469
-
if err != nil {
1470
-
http.Error(w, "bad issue id", http.StatusBadRequest)
1471
-
log.Println("failed to parse issue id", err)
1472
-
return
1473
-
}
1474
-
1475
-
issue, err := db.GetIssue(rp.db, f.RepoAt, issueIdInt)
1476
-
if err != nil {
1477
-
log.Println("failed to get issue", err)
1478
-
rp.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1479
-
return
1480
-
}
1481
-
1482
-
commentId := chi.URLParam(r, "comment_id")
1483
-
commentIdInt, err := strconv.Atoi(commentId)
1484
-
if err != nil {
1485
-
http.Error(w, "bad comment id", http.StatusBadRequest)
1486
-
log.Println("failed to parse issue id", err)
1487
-
return
1488
-
}
1489
-
1490
-
comment, err := db.GetComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
1491
-
if err != nil {
1492
-
http.Error(w, "bad comment id", http.StatusBadRequest)
1493
-
return
1494
-
}
1495
-
1496
-
if comment.OwnerDid != user.Did {
1497
-
http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
1498
-
return
1499
-
}
1500
-
1501
-
if comment.Deleted != nil {
1502
-
http.Error(w, "comment already deleted", http.StatusBadRequest)
1503
-
return
1504
-
}
1505
-
1506
-
// optimistic deletion
1507
-
deleted := time.Now()
1508
-
err = db.DeleteComment(rp.db, f.RepoAt, issueIdInt, commentIdInt)
1509
-
if err != nil {
1510
-
log.Println("failed to delete comment")
1511
-
rp.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
1512
-
return
1513
-
}
1514
-
1515
-
// delete from pds
1516
-
if comment.Rkey != "" {
1517
-
client, err := rp.oauth.AuthorizedClient(r)
1518
-
if err != nil {
1519
-
log.Println("failed to get authorized client", err)
1520
-
rp.pages.Notice(w, "issue-comment", "Failed to delete comment.")
1521
-
return
1522
-
}
1523
-
_, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
1524
-
Collection: tangled.GraphFollowNSID,
1525
-
Repo: user.Did,
1526
-
Rkey: comment.Rkey,
1527
-
})
1528
-
if err != nil {
1529
-
log.Println(err)
1530
-
}
1531
-
}
1532
-
1533
-
// optimistic update for htmx
1534
-
didHandleMap := map[string]string{
1535
-
user.Did: user.Handle,
1536
-
}
1537
-
comment.Body = ""
1538
-
comment.Deleted = &deleted
1539
-
1540
-
// htmx fragment of comment after deletion
1541
-
rp.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1542
-
LoggedInUser: user,
1543
-
RepoInfo: f.RepoInfo(user),
1544
-
DidHandleMap: didHandleMap,
1545
-
Issue: issue,
1546
-
Comment: comment,
1547
-
})
1548
-
return
1549
-
}
1550
-
1551
-
func (rp *Repo) RepoIssues(w http.ResponseWriter, r *http.Request) {
1552
-
params := r.URL.Query()
1553
-
state := params.Get("state")
1554
-
isOpen := true
1555
-
switch state {
1556
-
case "open":
1557
-
isOpen = true
1558
-
case "closed":
1559
-
isOpen = false
1560
-
default:
1561
-
isOpen = true
1562
-
}
1563
-
1564
-
page, ok := r.Context().Value("page").(pagination.Page)
1565
-
if !ok {
1566
-
log.Println("failed to get page")
1567
-
page = pagination.FirstPage()
1568
-
}
1569
-
1570
-
user := rp.oauth.GetUser(r)
1571
-
f, err := rp.repoResolver.Resolve(r)
1572
-
if err != nil {
1573
-
log.Println("failed to get repo and knot", err)
1574
-
return
1575
-
}
1576
-
1577
-
issues, err := db.GetIssues(rp.db, f.RepoAt, isOpen, page)
1578
-
if err != nil {
1579
-
log.Println("failed to get issues", err)
1580
-
rp.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
1581
-
return
1582
-
}
1583
-
1584
-
identsToResolve := make([]string, len(issues))
1585
-
for i, issue := range issues {
1586
-
identsToResolve[i] = issue.OwnerDid
1587
-
}
1588
-
resolvedIds := rp.idResolver.ResolveIdents(r.Context(), identsToResolve)
1589
-
didHandleMap := make(map[string]string)
1590
-
for _, identity := range resolvedIds {
1591
-
if !identity.Handle.IsInvalidHandle() {
1592
-
didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1593
-
} else {
1594
-
didHandleMap[identity.DID.String()] = identity.DID.String()
1595
-
}
1596
-
}
1597
-
1598
-
rp.pages.RepoIssues(w, pages.RepoIssuesParams{
1599
-
LoggedInUser: rp.oauth.GetUser(r),
1600
-
RepoInfo: f.RepoInfo(user),
1601
-
Issues: issues,
1602
-
DidHandleMap: didHandleMap,
1603
-
FilteringByOpen: isOpen,
1604
-
Page: page,
1605
-
})
1606
-
return
1607
-
}
1608
-
1609
-
func (rp *Repo) NewIssue(w http.ResponseWriter, r *http.Request) {
1610
-
user := rp.oauth.GetUser(r)
1611
-
1612
-
f, err := rp.repoResolver.Resolve(r)
1613
-
if err != nil {
1614
-
log.Println("failed to get repo and knot", err)
1615
-
return
1616
-
}
1617
-
1618
-
switch r.Method {
1619
-
case http.MethodGet:
1620
-
rp.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
1621
-
LoggedInUser: user,
1622
-
RepoInfo: f.RepoInfo(user),
1623
-
})
1624
-
case http.MethodPost:
1625
-
title := r.FormValue("title")
1626
-
body := r.FormValue("body")
1627
-
1628
-
if title == "" || body == "" {
1629
-
rp.pages.Notice(w, "issues", "Title and body are required")
1630
-
return
1631
-
}
1632
-
1633
-
tx, err := rp.db.BeginTx(r.Context(), nil)
1634
-
if err != nil {
1635
-
rp.pages.Notice(w, "issues", "Failed to create issue, try again later")
1636
-
return
1637
-
}
1638
-
1639
-
err = db.NewIssue(tx, &db.Issue{
1640
-
RepoAt: f.RepoAt,
1641
-
Title: title,
1642
-
Body: body,
1643
-
OwnerDid: user.Did,
1644
-
})
1645
-
if err != nil {
1646
-
log.Println("failed to create issue", err)
1647
-
rp.pages.Notice(w, "issues", "Failed to create issue.")
1648
-
return
1649
-
}
1650
-
1651
-
issueId, err := db.GetIssueId(rp.db, f.RepoAt)
1652
-
if err != nil {
1653
-
log.Println("failed to get issue id", err)
1654
-
rp.pages.Notice(w, "issues", "Failed to create issue.")
1655
-
return
1656
-
}
1657
-
1658
-
client, err := rp.oauth.AuthorizedClient(r)
1659
-
if err != nil {
1660
-
log.Println("failed to get authorized client", err)
1661
-
rp.pages.Notice(w, "issues", "Failed to create issue.")
1662
-
return
1663
-
}
1664
-
atUri := f.RepoAt.String()
1665
-
resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1666
-
Collection: tangled.RepoIssueNSID,
1667
-
Repo: user.Did,
1668
-
Rkey: appview.TID(),
1669
-
Record: &lexutil.LexiconTypeDecoder{
1670
-
Val: &tangled.RepoIssue{
1671
-
Repo: atUri,
1672
-
Title: title,
1673
-
Body: &body,
1674
-
Owner: user.Did,
1675
-
IssueId: int64(issueId),
1676
-
},
1677
-
},
1678
-
})
1679
-
if err != nil {
1680
-
log.Println("failed to create issue", err)
1681
-
rp.pages.Notice(w, "issues", "Failed to create issue.")
1682
-
return
1683
-
}
1684
-
1685
-
err = db.SetIssueAt(rp.db, f.RepoAt, issueId, resp.Uri)
1686
-
if err != nil {
1687
-
log.Println("failed to set issue at", err)
1688
-
rp.pages.Notice(w, "issues", "Failed to create issue.")
1689
-
return
1690
-
}
1691
-
1692
-
if !rp.config.Core.Dev {
1693
-
err = rp.posthog.Enqueue(posthog.Capture{
1694
-
DistinctId: user.Did,
1695
-
Event: "new_issue",
1696
-
Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "issue_id": issueId},
1697
-
})
1698
-
if err != nil {
1699
-
log.Println("failed to enqueue posthog event:", err)
1700
-
}
1701
-
}
1702
-
1703
-
rp.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))
1704
-
return
1705
1002
}
1706
1003
}
1707
1004
-20
appview/repo/router.go
-20
appview/repo/router.go
···
38
38
r.Get("/blob/{ref}/*", rp.RepoBlob)
39
39
r.Get("/raw/{ref}/*", rp.RepoBlobRaw)
40
40
41
-
r.Route("/issues", func(r chi.Router) {
42
-
r.With(middleware.Paginate).Get("/", rp.RepoIssues)
43
-
r.Get("/{issue}", rp.RepoSingleIssue)
44
-
45
-
r.Group(func(r chi.Router) {
46
-
r.Use(middleware.AuthMiddleware(rp.oauth))
47
-
r.Get("/new", rp.NewIssue)
48
-
r.Post("/new", rp.NewIssue)
49
-
r.Post("/{issue}/comment", rp.NewIssueComment)
50
-
r.Route("/{issue}/comment/{comment_id}/", func(r chi.Router) {
51
-
r.Get("/", rp.IssueComment)
52
-
r.Delete("/", rp.DeleteIssueComment)
53
-
r.Get("/edit", rp.EditIssueComment)
54
-
r.Post("/edit", rp.EditIssueComment)
55
-
})
56
-
r.Post("/{issue}/close", rp.CloseIssue)
57
-
r.Post("/{issue}/reopen", rp.ReopenIssue)
58
-
})
59
-
})
60
-
61
41
r.Route("/fork", func(r chi.Router) {
62
42
r.Use(middleware.AuthMiddleware(rp.oauth))
63
43
r.Get("/", rp.ForkRepo)
+10
-3
appview/state/router.go
+10
-3
appview/state/router.go
···
6
6
7
7
"github.com/go-chi/chi/v5"
8
8
"github.com/gorilla/sessions"
9
+
"tangled.sh/tangled.sh/core/appview/issues"
9
10
"tangled.sh/tangled.sh/core/appview/middleware"
10
-
oauth "tangled.sh/tangled.sh/core/appview/oauth/handler"
11
+
oauthhandler "tangled.sh/tangled.sh/core/appview/oauth/handler"
11
12
"tangled.sh/tangled.sh/core/appview/pulls"
12
13
"tangled.sh/tangled.sh/core/appview/repo"
13
14
"tangled.sh/tangled.sh/core/appview/settings"
···
71
72
r.Use(mw.GoImport())
72
73
73
74
r.Mount("/", s.RepoRouter(mw))
74
-
75
+
r.Mount("/issues", s.IssuesRouter(mw))
75
76
r.Mount("/pulls", s.PullsRouter(mw))
76
77
77
78
// These routes get proxied to the knot
···
155
156
156
157
func (s *State) OAuthRouter() http.Handler {
157
158
store := sessions.NewCookieStore([]byte(s.config.Core.CookieSecret))
158
-
oauth := oauth.New(s.config, s.pages, s.idResolver, s.db, store, s.oauth, s.enforcer, s.posthog)
159
+
oauth := oauthhandler.New(s.config, s.pages, s.idResolver, s.db, store, s.oauth, s.enforcer, s.posthog)
159
160
return oauth.Router()
160
161
}
161
162
···
168
169
}
169
170
170
171
return settings.Router()
172
+
}
173
+
174
+
func (s *State) IssuesRouter(mw *middleware.Middleware) http.Handler {
175
+
issues := issues.New(s.oauth, s.repoResolver, s.pages, s.idResolver, s.db, s.config, s.posthog)
176
+
return issues.Router(mw)
177
+
171
178
}
172
179
173
180
func (s *State) PullsRouter(mw *middleware.Middleware) http.Handler {