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/data"
18 "github.com/bluesky-social/indigo/atproto/identity"
19 "github.com/bluesky-social/indigo/atproto/syntax"
20 securejoin "github.com/cyphar/filepath-securejoin"
21 "github.com/go-chi/chi/v5"
22 "tangled.sh/tangled.sh/core/api/tangled"
23 "tangled.sh/tangled.sh/core/appview/auth"
24 "tangled.sh/tangled.sh/core/appview/db"
25 "tangled.sh/tangled.sh/core/appview/pages"
26 "tangled.sh/tangled.sh/core/types"
27
28 comatproto "github.com/bluesky-social/indigo/api/atproto"
29 lexutil "github.com/bluesky-social/indigo/lex/util"
30)
31
32func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) {
33 ref := chi.URLParam(r, "ref")
34 f, err := fullyResolvedRepo(r)
35 if err != nil {
36 log.Println("failed to fully resolve repo", err)
37 return
38 }
39
40 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
41 if err != nil {
42 log.Printf("failed to create unsigned client for %s", f.Knot)
43 s.pages.Error503(w)
44 return
45 }
46
47 resp, err := us.Index(f.OwnerDid(), f.RepoName, ref)
48 if err != nil {
49 s.pages.Error503(w)
50 log.Println("failed to reach knotserver", err)
51 return
52 }
53 defer resp.Body.Close()
54
55 body, err := io.ReadAll(resp.Body)
56 if err != nil {
57 log.Printf("Error reading response body: %v", err)
58 return
59 }
60
61 var result types.RepoIndexResponse
62 err = json.Unmarshal(body, &result)
63 if err != nil {
64 log.Printf("Error unmarshalling response body: %v", err)
65 return
66 }
67
68 tagMap := make(map[string][]string)
69 for _, tag := range result.Tags {
70 hash := tag.Hash
71 tagMap[hash] = append(tagMap[hash], tag.Name)
72 }
73
74 for _, branch := range result.Branches {
75 hash := branch.Hash
76 tagMap[hash] = append(tagMap[hash], branch.Name)
77 }
78
79 emails := uniqueEmails(result.Commits)
80
81 user := s.auth.GetUser(r)
82 s.pages.RepoIndexPage(w, pages.RepoIndexParams{
83 LoggedInUser: user,
84 RepoInfo: f.RepoInfo(s, user),
85 TagMap: tagMap,
86 RepoIndexResponse: result,
87 EmailToDidOrHandle: EmailToDidOrHandle(s, emails),
88 })
89 return
90}
91
92func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) {
93 f, err := fullyResolvedRepo(r)
94 if err != nil {
95 log.Println("failed to fully resolve repo", err)
96 return
97 }
98
99 page := 1
100 if r.URL.Query().Get("page") != "" {
101 page, err = strconv.Atoi(r.URL.Query().Get("page"))
102 if err != nil {
103 page = 1
104 }
105 }
106
107 ref := chi.URLParam(r, "ref")
108
109 protocol := "http"
110 if !s.config.Dev {
111 protocol = "https"
112 }
113
114 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))
115 if err != nil {
116 log.Println("failed to reach knotserver", err)
117 return
118 }
119
120 body, err := io.ReadAll(resp.Body)
121 if err != nil {
122 log.Printf("error reading response body: %v", err)
123 return
124 }
125
126 var repolog types.RepoLogResponse
127 err = json.Unmarshal(body, &repolog)
128 if err != nil {
129 log.Println("failed to parse json response", err)
130 return
131 }
132
133 user := s.auth.GetUser(r)
134 s.pages.RepoLog(w, pages.RepoLogParams{
135 LoggedInUser: user,
136 RepoInfo: f.RepoInfo(s, user),
137 RepoLogResponse: repolog,
138 EmailToDidOrHandle: EmailToDidOrHandle(s, uniqueEmails(repolog.Commits)),
139 })
140 return
141}
142
143func (s *State) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
144 f, err := fullyResolvedRepo(r)
145 if err != nil {
146 log.Println("failed to get repo and knot", err)
147 w.WriteHeader(http.StatusBadRequest)
148 return
149 }
150
151 user := s.auth.GetUser(r)
152 s.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
153 RepoInfo: f.RepoInfo(s, user),
154 })
155 return
156}
157
158func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) {
159 f, err := fullyResolvedRepo(r)
160 if err != nil {
161 log.Println("failed to get repo and knot", err)
162 w.WriteHeader(http.StatusBadRequest)
163 return
164 }
165
166 repoAt := f.RepoAt
167 rkey := repoAt.RecordKey().String()
168 if rkey == "" {
169 log.Println("invalid aturi for repo", err)
170 w.WriteHeader(http.StatusInternalServerError)
171 return
172 }
173
174 user := s.auth.GetUser(r)
175
176 switch r.Method {
177 case http.MethodGet:
178 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
179 RepoInfo: f.RepoInfo(s, user),
180 })
181 return
182 case http.MethodPut:
183 user := s.auth.GetUser(r)
184 newDescription := r.FormValue("description")
185 client, _ := s.auth.AuthorizedClient(r)
186
187 // optimistic update
188 err = db.UpdateDescription(s.db, string(repoAt), newDescription)
189 if err != nil {
190 log.Println("failed to perferom update-description query", err)
191 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
192 return
193 }
194
195 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
196 //
197 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
198 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, user.Did, rkey)
199 if err != nil {
200 // failed to get record
201 s.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
202 return
203 }
204 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
205 Collection: tangled.RepoNSID,
206 Repo: user.Did,
207 Rkey: rkey,
208 SwapRecord: ex.Cid,
209 Record: &lexutil.LexiconTypeDecoder{
210 Val: &tangled.Repo{
211 Knot: f.Knot,
212 Name: f.RepoName,
213 Owner: user.Did,
214 AddedAt: &f.AddedAt,
215 Description: &newDescription,
216 },
217 },
218 })
219
220 if err != nil {
221 log.Println("failed to perferom update-description query", err)
222 // failed to get record
223 s.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.")
224 return
225 }
226
227 newRepoInfo := f.RepoInfo(s, user)
228 newRepoInfo.Description = newDescription
229
230 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
231 RepoInfo: newRepoInfo,
232 })
233 return
234 }
235}
236
237func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) {
238 f, err := fullyResolvedRepo(r)
239 if err != nil {
240 log.Println("failed to fully resolve repo", err)
241 return
242 }
243 ref := chi.URLParam(r, "ref")
244 protocol := "http"
245 if !s.config.Dev {
246 protocol = "https"
247 }
248 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref))
249 if err != nil {
250 log.Println("failed to reach knotserver", err)
251 return
252 }
253
254 body, err := io.ReadAll(resp.Body)
255 if err != nil {
256 log.Printf("Error reading response body: %v", err)
257 return
258 }
259
260 var result types.RepoCommitResponse
261 err = json.Unmarshal(body, &result)
262 if err != nil {
263 log.Println("failed to parse response:", err)
264 return
265 }
266
267 user := s.auth.GetUser(r)
268 s.pages.RepoCommit(w, pages.RepoCommitParams{
269 LoggedInUser: user,
270 RepoInfo: f.RepoInfo(s, user),
271 RepoCommitResponse: result,
272 EmailToDidOrHandle: EmailToDidOrHandle(s, []string{result.Diff.Commit.Author.Email}),
273 })
274 return
275}
276
277func (s *State) RepoTree(w http.ResponseWriter, r *http.Request) {
278 f, err := fullyResolvedRepo(r)
279 if err != nil {
280 log.Println("failed to fully resolve repo", err)
281 return
282 }
283
284 ref := chi.URLParam(r, "ref")
285 treePath := chi.URLParam(r, "*")
286 protocol := "http"
287 if !s.config.Dev {
288 protocol = "https"
289 }
290 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath))
291 if err != nil {
292 log.Println("failed to reach knotserver", err)
293 return
294 }
295
296 body, err := io.ReadAll(resp.Body)
297 if err != nil {
298 log.Printf("Error reading response body: %v", err)
299 return
300 }
301
302 var result types.RepoTreeResponse
303 err = json.Unmarshal(body, &result)
304 if err != nil {
305 log.Println("failed to parse response:", err)
306 return
307 }
308
309 user := s.auth.GetUser(r)
310
311 var breadcrumbs [][]string
312 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)})
313 if treePath != "" {
314 for idx, elem := range strings.Split(treePath, "/") {
315 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
316 }
317 }
318
319 baseTreeLink := path.Join(f.OwnerDid(), f.RepoName, "tree", ref, treePath)
320 baseBlobLink := path.Join(f.OwnerDid(), f.RepoName, "blob", ref, treePath)
321
322 s.pages.RepoTree(w, pages.RepoTreeParams{
323 LoggedInUser: user,
324 BreadCrumbs: breadcrumbs,
325 BaseTreeLink: baseTreeLink,
326 BaseBlobLink: baseBlobLink,
327 RepoInfo: f.RepoInfo(s, user),
328 RepoTreeResponse: result,
329 })
330 return
331}
332
333func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) {
334 f, err := fullyResolvedRepo(r)
335 if err != nil {
336 log.Println("failed to get repo and knot", err)
337 return
338 }
339
340 protocol := "http"
341 if !s.config.Dev {
342 protocol = "https"
343 }
344
345 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tags", protocol, f.Knot, f.OwnerDid(), f.RepoName))
346 if err != nil {
347 log.Println("failed to reach knotserver", err)
348 return
349 }
350
351 body, err := io.ReadAll(resp.Body)
352 if err != nil {
353 log.Printf("Error reading response body: %v", err)
354 return
355 }
356
357 var result types.RepoTagsResponse
358 err = json.Unmarshal(body, &result)
359 if err != nil {
360 log.Println("failed to parse response:", err)
361 return
362 }
363
364 user := s.auth.GetUser(r)
365 s.pages.RepoTags(w, pages.RepoTagsParams{
366 LoggedInUser: user,
367 RepoInfo: f.RepoInfo(s, user),
368 RepoTagsResponse: result,
369 })
370 return
371}
372
373func (s *State) RepoBranches(w http.ResponseWriter, r *http.Request) {
374 f, err := fullyResolvedRepo(r)
375 if err != nil {
376 log.Println("failed to get repo and knot", err)
377 return
378 }
379
380 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
381 if err != nil {
382 log.Println("failed to create unsigned client", err)
383 return
384 }
385
386 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
387 if err != nil {
388 log.Println("failed to reach knotserver", err)
389 return
390 }
391
392 body, err := io.ReadAll(resp.Body)
393 if err != nil {
394 log.Printf("Error reading response body: %v", err)
395 return
396 }
397
398 var result types.RepoBranchesResponse
399 err = json.Unmarshal(body, &result)
400 if err != nil {
401 log.Println("failed to parse response:", err)
402 return
403 }
404
405 user := s.auth.GetUser(r)
406 s.pages.RepoBranches(w, pages.RepoBranchesParams{
407 LoggedInUser: user,
408 RepoInfo: f.RepoInfo(s, user),
409 RepoBranchesResponse: result,
410 })
411 return
412}
413
414func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) {
415 f, err := fullyResolvedRepo(r)
416 if err != nil {
417 log.Println("failed to get repo and knot", err)
418 return
419 }
420
421 ref := chi.URLParam(r, "ref")
422 filePath := chi.URLParam(r, "*")
423 protocol := "http"
424 if !s.config.Dev {
425 protocol = "https"
426 }
427 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
428 if err != nil {
429 log.Println("failed to reach knotserver", err)
430 return
431 }
432
433 body, err := io.ReadAll(resp.Body)
434 if err != nil {
435 log.Printf("Error reading response body: %v", err)
436 return
437 }
438
439 var result types.RepoBlobResponse
440 err = json.Unmarshal(body, &result)
441 if err != nil {
442 log.Println("failed to parse response:", err)
443 return
444 }
445
446 var breadcrumbs [][]string
447 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)})
448 if filePath != "" {
449 for idx, elem := range strings.Split(filePath, "/") {
450 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
451 }
452 }
453
454 user := s.auth.GetUser(r)
455 s.pages.RepoBlob(w, pages.RepoBlobParams{
456 LoggedInUser: user,
457 RepoInfo: f.RepoInfo(s, user),
458 RepoBlobResponse: result,
459 BreadCrumbs: breadcrumbs,
460 })
461 return
462}
463
464func (s *State) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
465 f, err := fullyResolvedRepo(r)
466 if err != nil {
467 log.Println("failed to get repo and knot", err)
468 return
469 }
470
471 ref := chi.URLParam(r, "ref")
472 filePath := chi.URLParam(r, "*")
473
474 protocol := "http"
475 if !s.config.Dev {
476 protocol = "https"
477 }
478 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
479 if err != nil {
480 log.Println("failed to reach knotserver", err)
481 return
482 }
483
484 body, err := io.ReadAll(resp.Body)
485 if err != nil {
486 log.Printf("Error reading response body: %v", err)
487 return
488 }
489
490 var result types.RepoBlobResponse
491 err = json.Unmarshal(body, &result)
492 if err != nil {
493 log.Println("failed to parse response:", err)
494 return
495 }
496
497 if result.IsBinary {
498 w.Header().Set("Content-Type", "application/octet-stream")
499 w.Write(body)
500 return
501 }
502
503 w.Header().Set("Content-Type", "text/plain")
504 w.Write([]byte(result.Contents))
505 return
506}
507
508func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) {
509 f, err := fullyResolvedRepo(r)
510 if err != nil {
511 log.Println("failed to get repo and knot", err)
512 return
513 }
514
515 collaborator := r.FormValue("collaborator")
516 if collaborator == "" {
517 http.Error(w, "malformed form", http.StatusBadRequest)
518 return
519 }
520
521 collaboratorIdent, err := s.resolver.ResolveIdent(r.Context(), collaborator)
522 if err != nil {
523 w.Write([]byte("failed to resolve collaborator did to a handle"))
524 return
525 }
526 log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot)
527
528 // TODO: create an atproto record for this
529
530 secret, err := db.GetRegistrationKey(s.db, f.Knot)
531 if err != nil {
532 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
533 return
534 }
535
536 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
537 if err != nil {
538 log.Println("failed to create client to ", f.Knot)
539 return
540 }
541
542 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String())
543 if err != nil {
544 log.Printf("failed to make request to %s: %s", f.Knot, err)
545 return
546 }
547
548 if ksResp.StatusCode != http.StatusNoContent {
549 w.Write([]byte(fmt.Sprint("knotserver failed to add collaborator: ", err)))
550 return
551 }
552
553 tx, err := s.db.BeginTx(r.Context(), nil)
554 if err != nil {
555 log.Println("failed to start tx")
556 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
557 return
558 }
559 defer func() {
560 tx.Rollback()
561 err = s.enforcer.E.LoadPolicy()
562 if err != nil {
563 log.Println("failed to rollback policies")
564 }
565 }()
566
567 err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.OwnerSlashRepo())
568 if err != nil {
569 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
570 return
571 }
572
573 err = db.AddCollaborator(s.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot)
574 if err != nil {
575 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
576 return
577 }
578
579 err = tx.Commit()
580 if err != nil {
581 log.Println("failed to commit changes", err)
582 http.Error(w, err.Error(), http.StatusInternalServerError)
583 return
584 }
585
586 err = s.enforcer.E.SavePolicy()
587 if err != nil {
588 log.Println("failed to update ACLs", err)
589 http.Error(w, err.Error(), http.StatusInternalServerError)
590 return
591 }
592
593 w.Write([]byte(fmt.Sprint("added collaborator: ", collaboratorIdent.Handle.String())))
594
595}
596
597func (s *State) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
598 f, err := fullyResolvedRepo(r)
599 if err != nil {
600 log.Println("failed to get repo and knot", err)
601 return
602 }
603
604 branch := r.FormValue("branch")
605 if branch == "" {
606 http.Error(w, "malformed form", http.StatusBadRequest)
607 return
608 }
609
610 secret, err := db.GetRegistrationKey(s.db, f.Knot)
611 if err != nil {
612 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
613 return
614 }
615
616 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
617 if err != nil {
618 log.Println("failed to create client to ", f.Knot)
619 return
620 }
621
622 ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch)
623 if err != nil {
624 log.Printf("failed to make request to %s: %s", f.Knot, err)
625 return
626 }
627
628 if ksResp.StatusCode != http.StatusNoContent {
629 s.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.")
630 return
631 }
632
633 w.Write([]byte(fmt.Sprint("default branch set to: ", branch)))
634}
635
636func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) {
637 f, err := fullyResolvedRepo(r)
638 if err != nil {
639 log.Println("failed to get repo and knot", err)
640 return
641 }
642
643 switch r.Method {
644 case http.MethodGet:
645 // for now, this is just pubkeys
646 user := s.auth.GetUser(r)
647 repoCollaborators, err := f.Collaborators(r.Context(), s)
648 if err != nil {
649 log.Println("failed to get collaborators", err)
650 }
651
652 isCollaboratorInviteAllowed := false
653 if user != nil {
654 ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.OwnerSlashRepo())
655 if err == nil && ok {
656 isCollaboratorInviteAllowed = true
657 }
658 }
659
660 var branchNames []string
661 var defaultBranch string
662 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
663 if err != nil {
664 log.Println("failed to create unsigned client", err)
665 } else {
666 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
667 if err != nil {
668 log.Println("failed to reach knotserver", err)
669 } else {
670 defer resp.Body.Close()
671
672 body, err := io.ReadAll(resp.Body)
673 if err != nil {
674 log.Printf("Error reading response body: %v", err)
675 } else {
676 var result types.RepoBranchesResponse
677 err = json.Unmarshal(body, &result)
678 if err != nil {
679 log.Println("failed to parse response:", err)
680 } else {
681 for _, branch := range result.Branches {
682 branchNames = append(branchNames, branch.Name)
683 }
684 }
685 }
686 }
687
688 resp, err = us.DefaultBranch(f.OwnerDid(), f.RepoName)
689 if err != nil {
690 log.Println("failed to reach knotserver", err)
691 } else {
692 defer resp.Body.Close()
693
694 body, err := io.ReadAll(resp.Body)
695 if err != nil {
696 log.Printf("Error reading response body: %v", err)
697 } else {
698 var result types.RepoDefaultBranchResponse
699 err = json.Unmarshal(body, &result)
700 if err != nil {
701 log.Println("failed to parse response:", err)
702 } else {
703 defaultBranch = result.Branch
704 }
705 }
706 }
707 }
708
709 s.pages.RepoSettings(w, pages.RepoSettingsParams{
710 LoggedInUser: user,
711 RepoInfo: f.RepoInfo(s, user),
712 Collaborators: repoCollaborators,
713 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
714 Branches: branchNames,
715 DefaultBranch: defaultBranch,
716 })
717 }
718}
719
720type FullyResolvedRepo struct {
721 Knot string
722 OwnerId identity.Identity
723 RepoName string
724 RepoAt syntax.ATURI
725 Description string
726 AddedAt string
727}
728
729func (f *FullyResolvedRepo) OwnerDid() string {
730 return f.OwnerId.DID.String()
731}
732
733func (f *FullyResolvedRepo) OwnerHandle() string {
734 return f.OwnerId.Handle.String()
735}
736
737func (f *FullyResolvedRepo) OwnerSlashRepo() string {
738 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
739 return p
740}
741
742func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) {
743 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.OwnerSlashRepo(), f.Knot)
744 if err != nil {
745 return nil, err
746 }
747
748 var collaborators []pages.Collaborator
749 for _, item := range repoCollaborators {
750 // currently only two roles: owner and member
751 var role string
752 if item[3] == "repo:owner" {
753 role = "owner"
754 } else if item[3] == "repo:collaborator" {
755 role = "collaborator"
756 } else {
757 continue
758 }
759
760 did := item[0]
761
762 c := pages.Collaborator{
763 Did: did,
764 Handle: "",
765 Role: role,
766 }
767 collaborators = append(collaborators, c)
768 }
769
770 // populate all collborators with handles
771 identsToResolve := make([]string, len(collaborators))
772 for i, collab := range collaborators {
773 identsToResolve[i] = collab.Did
774 }
775
776 resolvedIdents := s.resolver.ResolveIdents(ctx, identsToResolve)
777 for i, resolved := range resolvedIdents {
778 if resolved != nil {
779 collaborators[i].Handle = resolved.Handle.String()
780 }
781 }
782
783 return collaborators, nil
784}
785
786func (f *FullyResolvedRepo) RepoInfo(s *State, u *auth.User) pages.RepoInfo {
787 isStarred := false
788 if u != nil {
789 isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt))
790 }
791
792 starCount, err := db.GetStarCount(s.db, f.RepoAt)
793 if err != nil {
794 log.Println("failed to get star count for ", f.RepoAt)
795 }
796 issueCount, err := db.GetIssueCount(s.db, f.RepoAt)
797 if err != nil {
798 log.Println("failed to get issue count for ", f.RepoAt)
799 }
800 pullCount, err := db.GetPullCount(s.db, f.RepoAt)
801 if err != nil {
802 log.Println("failed to get issue count for ", f.RepoAt)
803 }
804
805 knot := f.Knot
806 if knot == "knot1.tangled.sh" {
807 knot = "tangled.sh"
808 }
809
810 return pages.RepoInfo{
811 OwnerDid: f.OwnerDid(),
812 OwnerHandle: f.OwnerHandle(),
813 Name: f.RepoName,
814 RepoAt: f.RepoAt,
815 Description: f.Description,
816 IsStarred: isStarred,
817 Knot: knot,
818 Roles: RolesInRepo(s, u, f),
819 Stats: db.RepoStats{
820 StarCount: starCount,
821 IssueCount: issueCount,
822 PullCount: pullCount,
823 },
824 }
825}
826
827func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
828 user := s.auth.GetUser(r)
829 f, err := fullyResolvedRepo(r)
830 if err != nil {
831 log.Println("failed to get repo and knot", err)
832 return
833 }
834
835 issueId := chi.URLParam(r, "issue")
836 issueIdInt, err := strconv.Atoi(issueId)
837 if err != nil {
838 http.Error(w, "bad issue id", http.StatusBadRequest)
839 log.Println("failed to parse issue id", err)
840 return
841 }
842
843 issue, comments, err := db.GetIssueWithComments(s.db, f.RepoAt, issueIdInt)
844 if err != nil {
845 log.Println("failed to get issue and comments", err)
846 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
847 return
848 }
849
850 issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid)
851 if err != nil {
852 log.Println("failed to resolve issue owner", err)
853 }
854
855 identsToResolve := make([]string, len(comments))
856 for i, comment := range comments {
857 identsToResolve[i] = comment.OwnerDid
858 }
859 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
860 didHandleMap := make(map[string]string)
861 for _, identity := range resolvedIds {
862 if !identity.Handle.IsInvalidHandle() {
863 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
864 } else {
865 didHandleMap[identity.DID.String()] = identity.DID.String()
866 }
867 }
868
869 s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
870 LoggedInUser: user,
871 RepoInfo: f.RepoInfo(s, user),
872 Issue: *issue,
873 Comments: comments,
874
875 IssueOwnerHandle: issueOwnerIdent.Handle.String(),
876 DidHandleMap: didHandleMap,
877 })
878
879}
880
881func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) {
882 user := s.auth.GetUser(r)
883 f, err := fullyResolvedRepo(r)
884 if err != nil {
885 log.Println("failed to get repo and knot", err)
886 return
887 }
888
889 issueId := chi.URLParam(r, "issue")
890 issueIdInt, err := strconv.Atoi(issueId)
891 if err != nil {
892 http.Error(w, "bad issue id", http.StatusBadRequest)
893 log.Println("failed to parse issue id", err)
894 return
895 }
896
897 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
898 if err != nil {
899 log.Println("failed to get issue", err)
900 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
901 return
902 }
903
904 collaborators, err := f.Collaborators(r.Context(), s)
905 if err != nil {
906 log.Println("failed to fetch repo collaborators: %w", err)
907 }
908 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
909 return user.Did == collab.Did
910 })
911 isIssueOwner := user.Did == issue.OwnerDid
912
913 // TODO: make this more granular
914 if isIssueOwner || isCollaborator {
915
916 closed := tangled.RepoIssueStateClosed
917
918 client, _ := s.auth.AuthorizedClient(r)
919 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
920 Collection: tangled.RepoIssueStateNSID,
921 Repo: user.Did,
922 Rkey: s.TID(),
923 Record: &lexutil.LexiconTypeDecoder{
924 Val: &tangled.RepoIssueState{
925 Issue: issue.IssueAt,
926 State: &closed,
927 },
928 },
929 })
930
931 if err != nil {
932 log.Println("failed to update issue state", err)
933 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
934 return
935 }
936
937 err := db.CloseIssue(s.db, f.RepoAt, issueIdInt)
938 if err != nil {
939 log.Println("failed to close issue", err)
940 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
941 return
942 }
943
944 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
945 return
946 } else {
947 log.Println("user is not permitted to close issue")
948 http.Error(w, "for biden", http.StatusUnauthorized)
949 return
950 }
951}
952
953func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) {
954 user := s.auth.GetUser(r)
955 f, err := fullyResolvedRepo(r)
956 if err != nil {
957 log.Println("failed to get repo and knot", err)
958 return
959 }
960
961 issueId := chi.URLParam(r, "issue")
962 issueIdInt, err := strconv.Atoi(issueId)
963 if err != nil {
964 http.Error(w, "bad issue id", http.StatusBadRequest)
965 log.Println("failed to parse issue id", err)
966 return
967 }
968
969 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
970 if err != nil {
971 log.Println("failed to get issue", err)
972 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
973 return
974 }
975
976 collaborators, err := f.Collaborators(r.Context(), s)
977 if err != nil {
978 log.Println("failed to fetch repo collaborators: %w", err)
979 }
980 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
981 return user.Did == collab.Did
982 })
983 isIssueOwner := user.Did == issue.OwnerDid
984
985 if isCollaborator || isIssueOwner {
986 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt)
987 if err != nil {
988 log.Println("failed to reopen issue", err)
989 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
990 return
991 }
992 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
993 return
994 } else {
995 log.Println("user is not the owner of the repo")
996 http.Error(w, "forbidden", http.StatusUnauthorized)
997 return
998 }
999}
1000
1001func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) {
1002 user := s.auth.GetUser(r)
1003 f, err := fullyResolvedRepo(r)
1004 if err != nil {
1005 log.Println("failed to get repo and knot", err)
1006 return
1007 }
1008
1009 issueId := chi.URLParam(r, "issue")
1010 issueIdInt, err := strconv.Atoi(issueId)
1011 if err != nil {
1012 http.Error(w, "bad issue id", http.StatusBadRequest)
1013 log.Println("failed to parse issue id", err)
1014 return
1015 }
1016
1017 switch r.Method {
1018 case http.MethodPost:
1019 body := r.FormValue("body")
1020 if body == "" {
1021 s.pages.Notice(w, "issue", "Body is required")
1022 return
1023 }
1024
1025 commentId := rand.IntN(1000000)
1026 rkey := s.TID()
1027
1028 err := db.NewIssueComment(s.db, &db.Comment{
1029 OwnerDid: user.Did,
1030 RepoAt: f.RepoAt,
1031 Issue: issueIdInt,
1032 CommentId: commentId,
1033 Body: body,
1034 Rkey: rkey,
1035 })
1036 if err != nil {
1037 log.Println("failed to create comment", err)
1038 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1039 return
1040 }
1041
1042 createdAt := time.Now().Format(time.RFC3339)
1043 commentIdInt64 := int64(commentId)
1044 ownerDid := user.Did
1045 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt)
1046 if err != nil {
1047 log.Println("failed to get issue at", err)
1048 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1049 return
1050 }
1051
1052 atUri := f.RepoAt.String()
1053 client, _ := s.auth.AuthorizedClient(r)
1054 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1055 Collection: tangled.RepoIssueCommentNSID,
1056 Repo: user.Did,
1057 Rkey: rkey,
1058 Record: &lexutil.LexiconTypeDecoder{
1059 Val: &tangled.RepoIssueComment{
1060 Repo: &atUri,
1061 Issue: issueAt,
1062 CommentId: &commentIdInt64,
1063 Owner: &ownerDid,
1064 Body: &body,
1065 CreatedAt: &createdAt,
1066 },
1067 },
1068 })
1069 if err != nil {
1070 log.Println("failed to create comment", err)
1071 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1072 return
1073 }
1074
1075 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
1076 return
1077 }
1078}
1079
1080func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) {
1081 user := s.auth.GetUser(r)
1082 f, err := fullyResolvedRepo(r)
1083 if err != nil {
1084 log.Println("failed to get repo and knot", err)
1085 return
1086 }
1087
1088 issueId := chi.URLParam(r, "issue")
1089 issueIdInt, err := strconv.Atoi(issueId)
1090 if err != nil {
1091 http.Error(w, "bad issue id", http.StatusBadRequest)
1092 log.Println("failed to parse issue id", err)
1093 return
1094 }
1095
1096 commentId := chi.URLParam(r, "comment_id")
1097 commentIdInt, err := strconv.Atoi(commentId)
1098 if err != nil {
1099 http.Error(w, "bad comment id", http.StatusBadRequest)
1100 log.Println("failed to parse issue id", err)
1101 return
1102 }
1103
1104 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1105 if err != nil {
1106 log.Println("failed to get issue", err)
1107 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1108 return
1109 }
1110
1111 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1112 if err != nil {
1113 http.Error(w, "bad comment id", http.StatusBadRequest)
1114 return
1115 }
1116
1117 identity, err := s.resolver.ResolveIdent(r.Context(), comment.OwnerDid)
1118 if err != nil {
1119 log.Println("failed to resolve did")
1120 return
1121 }
1122
1123 didHandleMap := make(map[string]string)
1124 if !identity.Handle.IsInvalidHandle() {
1125 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1126 } else {
1127 didHandleMap[identity.DID.String()] = identity.DID.String()
1128 }
1129
1130 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1131 LoggedInUser: user,
1132 RepoInfo: f.RepoInfo(s, user),
1133 DidHandleMap: didHandleMap,
1134 Issue: issue,
1135 Comment: comment,
1136 })
1137}
1138
1139func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) {
1140 user := s.auth.GetUser(r)
1141 f, err := fullyResolvedRepo(r)
1142 if err != nil {
1143 log.Println("failed to get repo and knot", err)
1144 return
1145 }
1146
1147 issueId := chi.URLParam(r, "issue")
1148 issueIdInt, err := strconv.Atoi(issueId)
1149 if err != nil {
1150 http.Error(w, "bad issue id", http.StatusBadRequest)
1151 log.Println("failed to parse issue id", err)
1152 return
1153 }
1154
1155 commentId := chi.URLParam(r, "comment_id")
1156 commentIdInt, err := strconv.Atoi(commentId)
1157 if err != nil {
1158 http.Error(w, "bad comment id", http.StatusBadRequest)
1159 log.Println("failed to parse issue id", err)
1160 return
1161 }
1162
1163 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1164 if err != nil {
1165 log.Println("failed to get issue", err)
1166 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1167 return
1168 }
1169
1170 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1171 if err != nil {
1172 http.Error(w, "bad comment id", http.StatusBadRequest)
1173 return
1174 }
1175
1176 if comment.OwnerDid != user.Did {
1177 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
1178 return
1179 }
1180
1181 switch r.Method {
1182 case http.MethodGet:
1183 s.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{
1184 LoggedInUser: user,
1185 RepoInfo: f.RepoInfo(s, user),
1186 Issue: issue,
1187 Comment: comment,
1188 })
1189 case http.MethodPost:
1190 // extract form value
1191 newBody := r.FormValue("body")
1192 client, _ := s.auth.AuthorizedClient(r)
1193 rkey := comment.Rkey
1194
1195 // optimistic update
1196 edited := time.Now()
1197 err = db.EditComment(s.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody)
1198 if err != nil {
1199 log.Println("failed to perferom update-description query", err)
1200 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
1201 return
1202 }
1203
1204 // rkey is optional, it was introduced later
1205 if comment.Rkey != "" {
1206 // update the record on pds
1207 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoIssueCommentNSID, user.Did, rkey)
1208 if err != nil {
1209 // failed to get record
1210 log.Println(err, rkey)
1211 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
1212 return
1213 }
1214 value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json
1215 record, _ := data.UnmarshalJSON(value)
1216
1217 repoAt := record["repo"].(string)
1218 issueAt := record["issue"].(string)
1219 createdAt := record["createdAt"].(string)
1220 commentIdInt64 := int64(commentIdInt)
1221
1222 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1223 Collection: tangled.RepoIssueCommentNSID,
1224 Repo: user.Did,
1225 Rkey: rkey,
1226 SwapRecord: ex.Cid,
1227 Record: &lexutil.LexiconTypeDecoder{
1228 Val: &tangled.RepoIssueComment{
1229 Repo: &repoAt,
1230 Issue: issueAt,
1231 CommentId: &commentIdInt64,
1232 Owner: &comment.OwnerDid,
1233 Body: &newBody,
1234 CreatedAt: &createdAt,
1235 },
1236 },
1237 })
1238 if err != nil {
1239 log.Println(err)
1240 }
1241 }
1242
1243 // optimistic update for htmx
1244 didHandleMap := map[string]string{
1245 user.Did: user.Handle,
1246 }
1247 comment.Body = newBody
1248 comment.Edited = &edited
1249
1250 // return new comment body with htmx
1251 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1252 LoggedInUser: user,
1253 RepoInfo: f.RepoInfo(s, user),
1254 DidHandleMap: didHandleMap,
1255 Issue: issue,
1256 Comment: comment,
1257 })
1258 return
1259
1260 }
1261
1262}
1263
1264func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
1265 user := s.auth.GetUser(r)
1266 f, err := fullyResolvedRepo(r)
1267 if err != nil {
1268 log.Println("failed to get repo and knot", err)
1269 return
1270 }
1271
1272 issueId := chi.URLParam(r, "issue")
1273 issueIdInt, err := strconv.Atoi(issueId)
1274 if err != nil {
1275 http.Error(w, "bad issue id", http.StatusBadRequest)
1276 log.Println("failed to parse issue id", err)
1277 return
1278 }
1279
1280 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1281 if err != nil {
1282 log.Println("failed to get issue", err)
1283 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1284 return
1285 }
1286
1287 commentId := chi.URLParam(r, "comment_id")
1288 commentIdInt, err := strconv.Atoi(commentId)
1289 if err != nil {
1290 http.Error(w, "bad comment id", http.StatusBadRequest)
1291 log.Println("failed to parse issue id", err)
1292 return
1293 }
1294
1295 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1296 if err != nil {
1297 http.Error(w, "bad comment id", http.StatusBadRequest)
1298 return
1299 }
1300
1301 if comment.OwnerDid != user.Did {
1302 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
1303 return
1304 }
1305
1306 if comment.Deleted != nil {
1307 http.Error(w, "comment already deleted", http.StatusBadRequest)
1308 return
1309 }
1310
1311 // optimistic deletion
1312 deleted := time.Now()
1313 err = db.DeleteComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1314 if err != nil {
1315 log.Println("failed to delete comment")
1316 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
1317 return
1318 }
1319
1320 // delete from pds
1321 if comment.Rkey != "" {
1322 client, _ := s.auth.AuthorizedClient(r)
1323 _, err = comatproto.RepoDeleteRecord(r.Context(), client, &comatproto.RepoDeleteRecord_Input{
1324 Collection: tangled.GraphFollowNSID,
1325 Repo: user.Did,
1326 Rkey: comment.Rkey,
1327 })
1328 if err != nil {
1329 log.Println(err)
1330 }
1331 }
1332
1333 // optimistic update for htmx
1334 didHandleMap := map[string]string{
1335 user.Did: user.Handle,
1336 }
1337 comment.Body = ""
1338 comment.Deleted = &deleted
1339
1340 // htmx fragment of comment after deletion
1341 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1342 LoggedInUser: user,
1343 RepoInfo: f.RepoInfo(s, user),
1344 DidHandleMap: didHandleMap,
1345 Issue: issue,
1346 Comment: comment,
1347 })
1348 return
1349}
1350
1351func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) {
1352 params := r.URL.Query()
1353 state := params.Get("state")
1354 isOpen := true
1355 switch state {
1356 case "open":
1357 isOpen = true
1358 case "closed":
1359 isOpen = false
1360 default:
1361 isOpen = true
1362 }
1363
1364 user := s.auth.GetUser(r)
1365 f, err := fullyResolvedRepo(r)
1366 if err != nil {
1367 log.Println("failed to get repo and knot", err)
1368 return
1369 }
1370
1371 issues, err := db.GetIssues(s.db, f.RepoAt, isOpen)
1372 if err != nil {
1373 log.Println("failed to get issues", err)
1374 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
1375 return
1376 }
1377
1378 identsToResolve := make([]string, len(issues))
1379 for i, issue := range issues {
1380 identsToResolve[i] = issue.OwnerDid
1381 }
1382 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
1383 didHandleMap := make(map[string]string)
1384 for _, identity := range resolvedIds {
1385 if !identity.Handle.IsInvalidHandle() {
1386 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1387 } else {
1388 didHandleMap[identity.DID.String()] = identity.DID.String()
1389 }
1390 }
1391
1392 s.pages.RepoIssues(w, pages.RepoIssuesParams{
1393 LoggedInUser: s.auth.GetUser(r),
1394 RepoInfo: f.RepoInfo(s, user),
1395 Issues: issues,
1396 DidHandleMap: didHandleMap,
1397 FilteringByOpen: isOpen,
1398 })
1399 return
1400}
1401
1402func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) {
1403 user := s.auth.GetUser(r)
1404
1405 f, err := fullyResolvedRepo(r)
1406 if err != nil {
1407 log.Println("failed to get repo and knot", err)
1408 return
1409 }
1410
1411 switch r.Method {
1412 case http.MethodGet:
1413 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
1414 LoggedInUser: user,
1415 RepoInfo: f.RepoInfo(s, user),
1416 })
1417 case http.MethodPost:
1418 title := r.FormValue("title")
1419 body := r.FormValue("body")
1420
1421 if title == "" || body == "" {
1422 s.pages.Notice(w, "issues", "Title and body are required")
1423 return
1424 }
1425
1426 tx, err := s.db.BeginTx(r.Context(), nil)
1427 if err != nil {
1428 s.pages.Notice(w, "issues", "Failed to create issue, try again later")
1429 return
1430 }
1431
1432 err = db.NewIssue(tx, &db.Issue{
1433 RepoAt: f.RepoAt,
1434 Title: title,
1435 Body: body,
1436 OwnerDid: user.Did,
1437 })
1438 if err != nil {
1439 log.Println("failed to create issue", err)
1440 s.pages.Notice(w, "issues", "Failed to create issue.")
1441 return
1442 }
1443
1444 issueId, err := db.GetIssueId(s.db, f.RepoAt)
1445 if err != nil {
1446 log.Println("failed to get issue id", err)
1447 s.pages.Notice(w, "issues", "Failed to create issue.")
1448 return
1449 }
1450
1451 client, _ := s.auth.AuthorizedClient(r)
1452 atUri := f.RepoAt.String()
1453 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1454 Collection: tangled.RepoIssueNSID,
1455 Repo: user.Did,
1456 Rkey: s.TID(),
1457 Record: &lexutil.LexiconTypeDecoder{
1458 Val: &tangled.RepoIssue{
1459 Repo: atUri,
1460 Title: title,
1461 Body: &body,
1462 Owner: user.Did,
1463 IssueId: int64(issueId),
1464 },
1465 },
1466 })
1467 if err != nil {
1468 log.Println("failed to create issue", err)
1469 s.pages.Notice(w, "issues", "Failed to create issue.")
1470 return
1471 }
1472
1473 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri)
1474 if err != nil {
1475 log.Println("failed to set issue at", err)
1476 s.pages.Notice(w, "issues", "Failed to create issue.")
1477 return
1478 }
1479
1480 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))
1481 return
1482 }
1483}