1package state
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "io"
8 "log"
9 "math/rand/v2"
10 "net/http"
11 "path"
12 "slices"
13 "strconv"
14 "strings"
15 "time"
16
17 "github.com/bluesky-social/indigo/atproto/identity"
18 "github.com/bluesky-social/indigo/atproto/syntax"
19 securejoin "github.com/cyphar/filepath-securejoin"
20 "github.com/go-chi/chi/v5"
21 "github.com/sotangled/tangled/api/tangled"
22 "github.com/sotangled/tangled/appview/auth"
23 "github.com/sotangled/tangled/appview/db"
24 "github.com/sotangled/tangled/appview/pages"
25 "github.com/sotangled/tangled/types"
26
27 comatproto "github.com/bluesky-social/indigo/api/atproto"
28 lexutil "github.com/bluesky-social/indigo/lex/util"
29)
30
31func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) {
32 ref := chi.URLParam(r, "ref")
33 f, err := fullyResolvedRepo(r)
34 if err != nil {
35 log.Println("failed to fully resolve repo", err)
36 return
37 }
38
39 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
40 if err != nil {
41 log.Printf("failed to create unsigned client for %s", f.Knot)
42 s.pages.Error503(w)
43 return
44 }
45
46 resp, err := us.Index(f.OwnerDid(), f.RepoName, ref)
47 if err != nil {
48 s.pages.Error503(w)
49 log.Println("failed to reach knotserver", err)
50 return
51 }
52 defer resp.Body.Close()
53
54 body, err := io.ReadAll(resp.Body)
55 if err != nil {
56 log.Printf("Error reading response body: %v", err)
57 return
58 }
59
60 var result types.RepoIndexResponse
61 err = json.Unmarshal(body, &result)
62 if err != nil {
63 log.Printf("Error unmarshalling response body: %v", err)
64 return
65 }
66
67 tagMap := make(map[string][]string)
68 for _, tag := range result.Tags {
69 hash := tag.Hash
70 tagMap[hash] = append(tagMap[hash], tag.Name)
71 }
72
73 for _, branch := range result.Branches {
74 hash := branch.Hash
75 tagMap[hash] = append(tagMap[hash], branch.Name)
76 }
77
78 emails := uniqueEmails(result.Commits)
79
80 user := s.auth.GetUser(r)
81 s.pages.RepoIndexPage(w, pages.RepoIndexParams{
82 LoggedInUser: user,
83 RepoInfo: f.RepoInfo(s, user),
84 TagMap: tagMap,
85 RepoIndexResponse: result,
86 EmailToDidOrHandle: EmailToDidOrHandle(s, emails),
87 })
88 return
89}
90
91func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) {
92 f, err := fullyResolvedRepo(r)
93 if err != nil {
94 log.Println("failed to fully resolve repo", err)
95 return
96 }
97
98 page := 1
99 if r.URL.Query().Get("page") != "" {
100 page, err = strconv.Atoi(r.URL.Query().Get("page"))
101 if err != nil {
102 page = 1
103 }
104 }
105
106 ref := chi.URLParam(r, "ref")
107
108 protocol := "http"
109 if !s.config.Dev {
110 protocol = "https"
111 }
112
113 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/log/%s?page=%d&per_page=30", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, page))
114 if err != nil {
115 log.Println("failed to reach knotserver", err)
116 return
117 }
118
119 body, err := io.ReadAll(resp.Body)
120 if err != nil {
121 log.Printf("error reading response body: %v", err)
122 return
123 }
124
125 var repolog types.RepoLogResponse
126 err = json.Unmarshal(body, &repolog)
127 if err != nil {
128 log.Println("failed to parse json response", err)
129 return
130 }
131
132 user := s.auth.GetUser(r)
133 s.pages.RepoLog(w, pages.RepoLogParams{
134 LoggedInUser: user,
135 RepoInfo: f.RepoInfo(s, user),
136 RepoLogResponse: repolog,
137 EmailToDidOrHandle: EmailToDidOrHandle(s, uniqueEmails(repolog.Commits)),
138 })
139 return
140}
141
142func (s *State) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
143 f, err := fullyResolvedRepo(r)
144 if err != nil {
145 log.Println("failed to get repo and knot", err)
146 w.WriteHeader(http.StatusBadRequest)
147 return
148 }
149
150 user := s.auth.GetUser(r)
151 s.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
152 RepoInfo: f.RepoInfo(s, user),
153 })
154 return
155}
156
157func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) {
158 f, err := fullyResolvedRepo(r)
159 if err != nil {
160 log.Println("failed to get repo and knot", err)
161 w.WriteHeader(http.StatusBadRequest)
162 return
163 }
164
165 repoAt := f.RepoAt
166 rkey := repoAt.RecordKey().String()
167 if rkey == "" {
168 log.Println("invalid aturi for repo", err)
169 w.WriteHeader(http.StatusInternalServerError)
170 return
171 }
172
173 user := s.auth.GetUser(r)
174
175 switch r.Method {
176 case http.MethodGet:
177 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
178 RepoInfo: f.RepoInfo(s, user),
179 })
180 return
181 case http.MethodPut:
182 user := s.auth.GetUser(r)
183 newDescription := r.FormValue("description")
184 client, _ := s.auth.AuthorizedClient(r)
185
186 // optimistic update
187 err = db.UpdateDescription(s.db, string(repoAt), newDescription)
188 if err != nil {
189 log.Println("failed to perferom update-description query", err)
190 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
191 return
192 }
193
194 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
195 //
196 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
197 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, user.Did, rkey)
198 if err != nil {
199 // failed to get record
200 s.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
201 return
202 }
203 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
204 Collection: tangled.RepoNSID,
205 Repo: user.Did,
206 Rkey: rkey,
207 SwapRecord: ex.Cid,
208 Record: &lexutil.LexiconTypeDecoder{
209 Val: &tangled.Repo{
210 Knot: f.Knot,
211 Name: f.RepoName,
212 Owner: user.Did,
213 AddedAt: &f.AddedAt,
214 Description: &newDescription,
215 },
216 },
217 })
218
219 if err != nil {
220 log.Println("failed to perferom update-description query", err)
221 // failed to get record
222 s.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.")
223 return
224 }
225
226 newRepoInfo := f.RepoInfo(s, user)
227 newRepoInfo.Description = newDescription
228
229 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
230 RepoInfo: newRepoInfo,
231 })
232 return
233 }
234}
235
236func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) {
237 f, err := fullyResolvedRepo(r)
238 if err != nil {
239 log.Println("failed to fully resolve repo", err)
240 return
241 }
242 ref := chi.URLParam(r, "ref")
243 protocol := "http"
244 if !s.config.Dev {
245 protocol = "https"
246 }
247 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref))
248 if err != nil {
249 log.Println("failed to reach knotserver", err)
250 return
251 }
252
253 body, err := io.ReadAll(resp.Body)
254 if err != nil {
255 log.Printf("Error reading response body: %v", err)
256 return
257 }
258
259 var result types.RepoCommitResponse
260 err = json.Unmarshal(body, &result)
261 if err != nil {
262 log.Println("failed to parse response:", err)
263 return
264 }
265
266 user := s.auth.GetUser(r)
267 s.pages.RepoCommit(w, pages.RepoCommitParams{
268 LoggedInUser: user,
269 RepoInfo: f.RepoInfo(s, user),
270 RepoCommitResponse: result,
271 EmailToDidOrHandle: EmailToDidOrHandle(s, []string{result.Diff.Commit.Author.Email}),
272 })
273 return
274}
275
276func (s *State) RepoTree(w http.ResponseWriter, r *http.Request) {
277 f, err := fullyResolvedRepo(r)
278 if err != nil {
279 log.Println("failed to fully resolve repo", err)
280 return
281 }
282
283 ref := chi.URLParam(r, "ref")
284 treePath := chi.URLParam(r, "*")
285 protocol := "http"
286 if !s.config.Dev {
287 protocol = "https"
288 }
289 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath))
290 if err != nil {
291 log.Println("failed to reach knotserver", err)
292 return
293 }
294
295 body, err := io.ReadAll(resp.Body)
296 if err != nil {
297 log.Printf("Error reading response body: %v", err)
298 return
299 }
300
301 var result types.RepoTreeResponse
302 err = json.Unmarshal(body, &result)
303 if err != nil {
304 log.Println("failed to parse response:", err)
305 return
306 }
307
308 user := s.auth.GetUser(r)
309
310 var breadcrumbs [][]string
311 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)})
312 if treePath != "" {
313 for idx, elem := range strings.Split(treePath, "/") {
314 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
315 }
316 }
317
318 baseTreeLink := path.Join(f.OwnerDid(), f.RepoName, "tree", ref, treePath)
319 baseBlobLink := path.Join(f.OwnerDid(), f.RepoName, "blob", ref, treePath)
320
321 s.pages.RepoTree(w, pages.RepoTreeParams{
322 LoggedInUser: user,
323 BreadCrumbs: breadcrumbs,
324 BaseTreeLink: baseTreeLink,
325 BaseBlobLink: baseBlobLink,
326 RepoInfo: f.RepoInfo(s, user),
327 RepoTreeResponse: result,
328 })
329 return
330}
331
332func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) {
333 f, err := fullyResolvedRepo(r)
334 if err != nil {
335 log.Println("failed to get repo and knot", err)
336 return
337 }
338
339 protocol := "http"
340 if !s.config.Dev {
341 protocol = "https"
342 }
343
344 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tags", protocol, f.Knot, f.OwnerDid(), f.RepoName))
345 if err != nil {
346 log.Println("failed to reach knotserver", err)
347 return
348 }
349
350 body, err := io.ReadAll(resp.Body)
351 if err != nil {
352 log.Printf("Error reading response body: %v", err)
353 return
354 }
355
356 var result types.RepoTagsResponse
357 err = json.Unmarshal(body, &result)
358 if err != nil {
359 log.Println("failed to parse response:", err)
360 return
361 }
362
363 user := s.auth.GetUser(r)
364 s.pages.RepoTags(w, pages.RepoTagsParams{
365 LoggedInUser: user,
366 RepoInfo: f.RepoInfo(s, user),
367 RepoTagsResponse: result,
368 })
369 return
370}
371
372func (s *State) RepoBranches(w http.ResponseWriter, r *http.Request) {
373 f, err := fullyResolvedRepo(r)
374 if err != nil {
375 log.Println("failed to get repo and knot", err)
376 return
377 }
378
379 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
380 if err != nil {
381 log.Println("failed to create unsigned client", err)
382 return
383 }
384
385 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
386 if err != nil {
387 log.Println("failed to reach knotserver", err)
388 return
389 }
390
391 body, err := io.ReadAll(resp.Body)
392 if err != nil {
393 log.Printf("Error reading response body: %v", err)
394 return
395 }
396
397 var result types.RepoBranchesResponse
398 err = json.Unmarshal(body, &result)
399 if err != nil {
400 log.Println("failed to parse response:", err)
401 return
402 }
403
404 user := s.auth.GetUser(r)
405 s.pages.RepoBranches(w, pages.RepoBranchesParams{
406 LoggedInUser: user,
407 RepoInfo: f.RepoInfo(s, user),
408 RepoBranchesResponse: result,
409 })
410 return
411}
412
413func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) {
414 f, err := fullyResolvedRepo(r)
415 if err != nil {
416 log.Println("failed to get repo and knot", err)
417 return
418 }
419
420 ref := chi.URLParam(r, "ref")
421 filePath := chi.URLParam(r, "*")
422 protocol := "http"
423 if !s.config.Dev {
424 protocol = "https"
425 }
426 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
427 if err != nil {
428 log.Println("failed to reach knotserver", err)
429 return
430 }
431
432 body, err := io.ReadAll(resp.Body)
433 if err != nil {
434 log.Printf("Error reading response body: %v", err)
435 return
436 }
437
438 var result types.RepoBlobResponse
439 err = json.Unmarshal(body, &result)
440 if err != nil {
441 log.Println("failed to parse response:", err)
442 return
443 }
444
445 var breadcrumbs [][]string
446 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)})
447 if filePath != "" {
448 for idx, elem := range strings.Split(filePath, "/") {
449 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
450 }
451 }
452
453 user := s.auth.GetUser(r)
454 s.pages.RepoBlob(w, pages.RepoBlobParams{
455 LoggedInUser: user,
456 RepoInfo: f.RepoInfo(s, user),
457 RepoBlobResponse: result,
458 BreadCrumbs: breadcrumbs,
459 })
460 return
461}
462
463func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) {
464 f, err := fullyResolvedRepo(r)
465 if err != nil {
466 log.Println("failed to get repo and knot", err)
467 return
468 }
469
470 collaborator := r.FormValue("collaborator")
471 if collaborator == "" {
472 http.Error(w, "malformed form", http.StatusBadRequest)
473 return
474 }
475
476 collaboratorIdent, err := s.resolver.ResolveIdent(r.Context(), collaborator)
477 if err != nil {
478 w.Write([]byte("failed to resolve collaborator did to a handle"))
479 return
480 }
481 log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot)
482
483 // TODO: create an atproto record for this
484
485 secret, err := db.GetRegistrationKey(s.db, f.Knot)
486 if err != nil {
487 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
488 return
489 }
490
491 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
492 if err != nil {
493 log.Println("failed to create client to ", f.Knot)
494 return
495 }
496
497 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String())
498 if err != nil {
499 log.Printf("failed to make request to %s: %s", f.Knot, err)
500 return
501 }
502
503 if ksResp.StatusCode != http.StatusNoContent {
504 w.Write([]byte(fmt.Sprint("knotserver failed to add collaborator: ", err)))
505 return
506 }
507
508 tx, err := s.db.BeginTx(r.Context(), nil)
509 if err != nil {
510 log.Println("failed to start tx")
511 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
512 return
513 }
514 defer func() {
515 tx.Rollback()
516 err = s.enforcer.E.LoadPolicy()
517 if err != nil {
518 log.Println("failed to rollback policies")
519 }
520 }()
521
522 err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.OwnerSlashRepo())
523 if err != nil {
524 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
525 return
526 }
527
528 err = db.AddCollaborator(s.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot)
529 if err != nil {
530 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
531 return
532 }
533
534 err = tx.Commit()
535 if err != nil {
536 log.Println("failed to commit changes", err)
537 http.Error(w, err.Error(), http.StatusInternalServerError)
538 return
539 }
540
541 err = s.enforcer.E.SavePolicy()
542 if err != nil {
543 log.Println("failed to update ACLs", err)
544 http.Error(w, err.Error(), http.StatusInternalServerError)
545 return
546 }
547
548 w.Write([]byte(fmt.Sprint("added collaborator: ", collaboratorIdent.Handle.String())))
549
550}
551
552func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) {
553 f, err := fullyResolvedRepo(r)
554 if err != nil {
555 log.Println("failed to get repo and knot", err)
556 return
557 }
558
559 switch r.Method {
560 case http.MethodGet:
561 // for now, this is just pubkeys
562 user := s.auth.GetUser(r)
563 repoCollaborators, err := f.Collaborators(r.Context(), s)
564 if err != nil {
565 log.Println("failed to get collaborators", err)
566 }
567
568 isCollaboratorInviteAllowed := false
569 if user != nil {
570 ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.OwnerSlashRepo())
571 if err == nil && ok {
572 isCollaboratorInviteAllowed = true
573 }
574 }
575
576 s.pages.RepoSettings(w, pages.RepoSettingsParams{
577 LoggedInUser: user,
578 RepoInfo: f.RepoInfo(s, user),
579 Collaborators: repoCollaborators,
580 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
581 })
582 }
583}
584
585type FullyResolvedRepo struct {
586 Knot string
587 OwnerId identity.Identity
588 RepoName string
589 RepoAt syntax.ATURI
590 Description string
591 AddedAt string
592}
593
594func (f *FullyResolvedRepo) OwnerDid() string {
595 return f.OwnerId.DID.String()
596}
597
598func (f *FullyResolvedRepo) OwnerHandle() string {
599 return f.OwnerId.Handle.String()
600}
601
602func (f *FullyResolvedRepo) OwnerSlashRepo() string {
603 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
604 return p
605}
606
607func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) {
608 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.OwnerSlashRepo(), f.Knot)
609 if err != nil {
610 return nil, err
611 }
612
613 var collaborators []pages.Collaborator
614 for _, item := range repoCollaborators {
615 // currently only two roles: owner and member
616 var role string
617 if item[3] == "repo:owner" {
618 role = "owner"
619 } else if item[3] == "repo:collaborator" {
620 role = "collaborator"
621 } else {
622 continue
623 }
624
625 did := item[0]
626
627 c := pages.Collaborator{
628 Did: did,
629 Handle: "",
630 Role: role,
631 }
632 collaborators = append(collaborators, c)
633 }
634
635 // populate all collborators with handles
636 identsToResolve := make([]string, len(collaborators))
637 for i, collab := range collaborators {
638 identsToResolve[i] = collab.Did
639 }
640
641 resolvedIdents := s.resolver.ResolveIdents(ctx, identsToResolve)
642 for i, resolved := range resolvedIdents {
643 if resolved != nil {
644 collaborators[i].Handle = resolved.Handle.String()
645 }
646 }
647
648 return collaborators, nil
649}
650
651func (f *FullyResolvedRepo) RepoInfo(s *State, u *auth.User) pages.RepoInfo {
652 isStarred := false
653 if u != nil {
654 isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt))
655 }
656
657 starCount, err := db.GetStarCount(s.db, f.RepoAt)
658 if err != nil {
659 log.Println("failed to get star count for ", f.RepoAt)
660 }
661 issueCount, err := db.GetIssueCount(s.db, f.RepoAt)
662 if err != nil {
663 log.Println("failed to get issue count for ", f.RepoAt)
664 }
665 pullCount, err := db.GetPullCount(s.db, f.RepoAt)
666 if err != nil {
667 log.Println("failed to get issue count for ", f.RepoAt)
668 }
669
670 knot := f.Knot
671 if knot == "knot1.tangled.sh" {
672 knot = "tangled.sh"
673 }
674
675 return pages.RepoInfo{
676 OwnerDid: f.OwnerDid(),
677 OwnerHandle: f.OwnerHandle(),
678 Name: f.RepoName,
679 RepoAt: f.RepoAt,
680 Description: f.Description,
681 IsStarred: isStarred,
682 Knot: knot,
683 Roles: RolesInRepo(s, u, f),
684 Stats: db.RepoStats{
685 StarCount: starCount,
686 IssueCount: issueCount,
687 PullCount: pullCount,
688 },
689 }
690}
691
692func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
693 user := s.auth.GetUser(r)
694 f, err := fullyResolvedRepo(r)
695 if err != nil {
696 log.Println("failed to get repo and knot", err)
697 return
698 }
699
700 issueId := chi.URLParam(r, "issue")
701 issueIdInt, err := strconv.Atoi(issueId)
702 if err != nil {
703 http.Error(w, "bad issue id", http.StatusBadRequest)
704 log.Println("failed to parse issue id", err)
705 return
706 }
707
708 issue, comments, err := db.GetIssueWithComments(s.db, f.RepoAt, issueIdInt)
709 if err != nil {
710 log.Println("failed to get issue and comments", err)
711 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
712 return
713 }
714
715 issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid)
716 if err != nil {
717 log.Println("failed to resolve issue owner", err)
718 }
719
720 identsToResolve := make([]string, len(comments))
721 for i, comment := range comments {
722 identsToResolve[i] = comment.OwnerDid
723 }
724 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
725 didHandleMap := make(map[string]string)
726 for _, identity := range resolvedIds {
727 if !identity.Handle.IsInvalidHandle() {
728 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
729 } else {
730 didHandleMap[identity.DID.String()] = identity.DID.String()
731 }
732 }
733
734 s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
735 LoggedInUser: user,
736 RepoInfo: f.RepoInfo(s, user),
737 Issue: *issue,
738 Comments: comments,
739
740 IssueOwnerHandle: issueOwnerIdent.Handle.String(),
741 DidHandleMap: didHandleMap,
742 })
743
744}
745
746func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) {
747 user := s.auth.GetUser(r)
748 f, err := fullyResolvedRepo(r)
749 if err != nil {
750 log.Println("failed to get repo and knot", err)
751 return
752 }
753
754 issueId := chi.URLParam(r, "issue")
755 issueIdInt, err := strconv.Atoi(issueId)
756 if err != nil {
757 http.Error(w, "bad issue id", http.StatusBadRequest)
758 log.Println("failed to parse issue id", err)
759 return
760 }
761
762 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
763 if err != nil {
764 log.Println("failed to get issue", err)
765 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
766 return
767 }
768
769 collaborators, err := f.Collaborators(r.Context(), s)
770 if err != nil {
771 log.Println("failed to fetch repo collaborators: %w", err)
772 }
773 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
774 return user.Did == collab.Did
775 })
776 isIssueOwner := user.Did == issue.OwnerDid
777
778 // TODO: make this more granular
779 if isIssueOwner || isCollaborator {
780
781 closed := tangled.RepoIssueStateClosed
782
783 client, _ := s.auth.AuthorizedClient(r)
784 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
785 Collection: tangled.RepoIssueStateNSID,
786 Repo: user.Did,
787 Rkey: s.TID(),
788 Record: &lexutil.LexiconTypeDecoder{
789 Val: &tangled.RepoIssueState{
790 Issue: issue.IssueAt,
791 State: &closed,
792 },
793 },
794 })
795
796 if err != nil {
797 log.Println("failed to update issue state", err)
798 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
799 return
800 }
801
802 err := db.CloseIssue(s.db, f.RepoAt, issueIdInt)
803 if err != nil {
804 log.Println("failed to close issue", err)
805 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
806 return
807 }
808
809 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
810 return
811 } else {
812 log.Println("user is not permitted to close issue")
813 http.Error(w, "for biden", http.StatusUnauthorized)
814 return
815 }
816}
817
818func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) {
819 user := s.auth.GetUser(r)
820 f, err := fullyResolvedRepo(r)
821 if err != nil {
822 log.Println("failed to get repo and knot", err)
823 return
824 }
825
826 issueId := chi.URLParam(r, "issue")
827 issueIdInt, err := strconv.Atoi(issueId)
828 if err != nil {
829 http.Error(w, "bad issue id", http.StatusBadRequest)
830 log.Println("failed to parse issue id", err)
831 return
832 }
833
834 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
835 if err != nil {
836 log.Println("failed to get issue", err)
837 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
838 return
839 }
840
841 collaborators, err := f.Collaborators(r.Context(), s)
842 if err != nil {
843 log.Println("failed to fetch repo collaborators: %w", err)
844 }
845 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
846 return user.Did == collab.Did
847 })
848 isIssueOwner := user.Did == issue.OwnerDid
849
850 if isCollaborator || isIssueOwner {
851 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt)
852 if err != nil {
853 log.Println("failed to reopen issue", err)
854 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
855 return
856 }
857 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
858 return
859 } else {
860 log.Println("user is not the owner of the repo")
861 http.Error(w, "forbidden", http.StatusUnauthorized)
862 return
863 }
864}
865
866func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) {
867 user := s.auth.GetUser(r)
868 f, err := fullyResolvedRepo(r)
869 if err != nil {
870 log.Println("failed to get repo and knot", err)
871 return
872 }
873
874 issueId := chi.URLParam(r, "issue")
875 issueIdInt, err := strconv.Atoi(issueId)
876 if err != nil {
877 http.Error(w, "bad issue id", http.StatusBadRequest)
878 log.Println("failed to parse issue id", err)
879 return
880 }
881
882 switch r.Method {
883 case http.MethodPost:
884 body := r.FormValue("body")
885 if body == "" {
886 s.pages.Notice(w, "issue", "Body is required")
887 return
888 }
889
890 commentId := rand.IntN(1000000)
891
892 err := db.NewComment(s.db, &db.Comment{
893 OwnerDid: user.Did,
894 RepoAt: f.RepoAt,
895 Issue: issueIdInt,
896 CommentId: commentId,
897 Body: body,
898 })
899 if err != nil {
900 log.Println("failed to create comment", err)
901 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
902 return
903 }
904
905 createdAt := time.Now().Format(time.RFC3339)
906 commentIdInt64 := int64(commentId)
907 ownerDid := user.Did
908 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt)
909 if err != nil {
910 log.Println("failed to get issue at", err)
911 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
912 return
913 }
914
915 atUri := f.RepoAt.String()
916 client, _ := s.auth.AuthorizedClient(r)
917 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
918 Collection: tangled.RepoIssueCommentNSID,
919 Repo: user.Did,
920 Rkey: s.TID(),
921 Record: &lexutil.LexiconTypeDecoder{
922 Val: &tangled.RepoIssueComment{
923 Repo: &atUri,
924 Issue: issueAt,
925 CommentId: &commentIdInt64,
926 Owner: &ownerDid,
927 Body: &body,
928 CreatedAt: &createdAt,
929 },
930 },
931 })
932 if err != nil {
933 log.Println("failed to create comment", err)
934 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
935 return
936 }
937
938 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
939 return
940 }
941}
942
943func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) {
944 params := r.URL.Query()
945 state := params.Get("state")
946 isOpen := true
947 switch state {
948 case "open":
949 isOpen = true
950 case "closed":
951 isOpen = false
952 default:
953 isOpen = true
954 }
955
956 user := s.auth.GetUser(r)
957 f, err := fullyResolvedRepo(r)
958 if err != nil {
959 log.Println("failed to get repo and knot", err)
960 return
961 }
962
963 issues, err := db.GetIssues(s.db, f.RepoAt, isOpen)
964 if err != nil {
965 log.Println("failed to get issues", err)
966 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
967 return
968 }
969
970 identsToResolve := make([]string, len(issues))
971 for i, issue := range issues {
972 identsToResolve[i] = issue.OwnerDid
973 }
974 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
975 didHandleMap := make(map[string]string)
976 for _, identity := range resolvedIds {
977 if !identity.Handle.IsInvalidHandle() {
978 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
979 } else {
980 didHandleMap[identity.DID.String()] = identity.DID.String()
981 }
982 }
983
984 s.pages.RepoIssues(w, pages.RepoIssuesParams{
985 LoggedInUser: s.auth.GetUser(r),
986 RepoInfo: f.RepoInfo(s, user),
987 Issues: issues,
988 DidHandleMap: didHandleMap,
989 FilteringByOpen: isOpen,
990 })
991 return
992}
993
994func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) {
995 user := s.auth.GetUser(r)
996
997 f, err := fullyResolvedRepo(r)
998 if err != nil {
999 log.Println("failed to get repo and knot", err)
1000 return
1001 }
1002
1003 switch r.Method {
1004 case http.MethodGet:
1005 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
1006 LoggedInUser: user,
1007 RepoInfo: f.RepoInfo(s, user),
1008 })
1009 case http.MethodPost:
1010 title := r.FormValue("title")
1011 body := r.FormValue("body")
1012
1013 if title == "" || body == "" {
1014 s.pages.Notice(w, "issues", "Title and body are required")
1015 return
1016 }
1017
1018 tx, err := s.db.BeginTx(r.Context(), nil)
1019 if err != nil {
1020 s.pages.Notice(w, "issues", "Failed to create issue, try again later")
1021 return
1022 }
1023
1024 err = db.NewIssue(tx, &db.Issue{
1025 RepoAt: f.RepoAt,
1026 Title: title,
1027 Body: body,
1028 OwnerDid: user.Did,
1029 })
1030 if err != nil {
1031 log.Println("failed to create issue", err)
1032 s.pages.Notice(w, "issues", "Failed to create issue.")
1033 return
1034 }
1035
1036 issueId, err := db.GetIssueId(s.db, f.RepoAt)
1037 if err != nil {
1038 log.Println("failed to get issue id", err)
1039 s.pages.Notice(w, "issues", "Failed to create issue.")
1040 return
1041 }
1042
1043 client, _ := s.auth.AuthorizedClient(r)
1044 atUri := f.RepoAt.String()
1045 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1046 Collection: tangled.RepoIssueNSID,
1047 Repo: user.Did,
1048 Rkey: s.TID(),
1049 Record: &lexutil.LexiconTypeDecoder{
1050 Val: &tangled.RepoIssue{
1051 Repo: atUri,
1052 Title: title,
1053 Body: &body,
1054 Owner: user.Did,
1055 IssueId: int64(issueId),
1056 },
1057 },
1058 })
1059 if err != nil {
1060 log.Println("failed to create issue", err)
1061 s.pages.Notice(w, "issues", "Failed to create issue.")
1062 return
1063 }
1064
1065 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri)
1066 if err != nil {
1067 log.Println("failed to set issue at", err)
1068 s.pages.Notice(w, "issues", "Failed to create issue.")
1069 return
1070 }
1071
1072 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))
1073 return
1074 }
1075}