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 user := s.auth.GetUser(r)
79 s.pages.RepoIndexPage(w, pages.RepoIndexParams{
80 LoggedInUser: user,
81 RepoInfo: f.RepoInfo(s, user),
82 TagMap: tagMap,
83 RepoIndexResponse: result,
84 })
85
86 return
87}
88
89func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) {
90 f, err := fullyResolvedRepo(r)
91 if err != nil {
92 log.Println("failed to fully resolve repo", err)
93 return
94 }
95
96 page := 1
97 if r.URL.Query().Get("page") != "" {
98 page, err = strconv.Atoi(r.URL.Query().Get("page"))
99 if err != nil {
100 page = 1
101 }
102 }
103
104 ref := chi.URLParam(r, "ref")
105
106 protocol := "http"
107 if !s.config.Dev {
108 protocol = "https"
109 }
110
111 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))
112 if err != nil {
113 log.Println("failed to reach knotserver", err)
114 return
115 }
116
117 body, err := io.ReadAll(resp.Body)
118 if err != nil {
119 log.Printf("error reading response body: %v", err)
120 return
121 }
122
123 var repolog types.RepoLogResponse
124 err = json.Unmarshal(body, &repolog)
125 if err != nil {
126 log.Println("failed to parse json response", err)
127 return
128 }
129
130 user := s.auth.GetUser(r)
131 s.pages.RepoLog(w, pages.RepoLogParams{
132 LoggedInUser: user,
133 RepoInfo: f.RepoInfo(s, user),
134 RepoLogResponse: repolog,
135 })
136 return
137}
138
139func (s *State) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
140 f, err := fullyResolvedRepo(r)
141 if err != nil {
142 log.Println("failed to get repo and knot", err)
143 w.WriteHeader(http.StatusBadRequest)
144 return
145 }
146
147 user := s.auth.GetUser(r)
148 s.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
149 RepoInfo: f.RepoInfo(s, user),
150 })
151 return
152}
153
154func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) {
155 f, err := fullyResolvedRepo(r)
156 if err != nil {
157 log.Println("failed to get repo and knot", err)
158 w.WriteHeader(http.StatusBadRequest)
159 return
160 }
161
162 repoAt := f.RepoAt
163 rkey := repoAt.RecordKey().String()
164 if rkey == "" {
165 log.Println("invalid aturi for repo", err)
166 w.WriteHeader(http.StatusInternalServerError)
167 return
168 }
169
170 user := s.auth.GetUser(r)
171
172 switch r.Method {
173 case http.MethodGet:
174 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
175 RepoInfo: f.RepoInfo(s, user),
176 })
177 return
178 case http.MethodPut:
179 user := s.auth.GetUser(r)
180 newDescription := r.FormValue("description")
181 client, _ := s.auth.AuthorizedClient(r)
182
183 // optimistic update
184 err = db.UpdateDescription(s.db, string(repoAt), newDescription)
185 if err != nil {
186 log.Println("failed to perferom update-description query", err)
187 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
188 return
189 }
190
191 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
192 //
193 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
194 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoNSID, user.Did, rkey)
195 if err != nil {
196 // failed to get record
197 s.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
198 return
199 }
200 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
201 Collection: tangled.RepoNSID,
202 Repo: user.Did,
203 Rkey: rkey,
204 SwapRecord: ex.Cid,
205 Record: &lexutil.LexiconTypeDecoder{
206 Val: &tangled.Repo{
207 Knot: f.Knot,
208 Name: f.RepoName,
209 Owner: user.Did,
210 AddedAt: &f.AddedAt,
211 Description: &newDescription,
212 },
213 },
214 })
215
216 if err != nil {
217 log.Println("failed to perferom update-description query", err)
218 // failed to get record
219 s.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.")
220 return
221 }
222
223 newRepoInfo := f.RepoInfo(s, user)
224 newRepoInfo.Description = newDescription
225
226 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
227 RepoInfo: newRepoInfo,
228 })
229 return
230 }
231}
232
233func (s *State) EditPatch(w http.ResponseWriter, r *http.Request) {
234 user := s.auth.GetUser(r)
235
236 patch := r.FormValue("patch")
237 if patch == "" {
238 s.pages.Notice(w, "pull-error", "Patch is required.")
239 return
240 }
241
242 pull, ok := r.Context().Value("pull").(*db.Pull)
243 if !ok {
244 log.Println("failed to get pull")
245 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
246 return
247 }
248
249 if pull.OwnerDid != user.Did {
250 log.Println("failed to edit pull information")
251 s.pages.Notice(w, "pull-error", "Unauthorized")
252 return
253 }
254
255 f, err := fullyResolvedRepo(r)
256 if err != nil {
257 log.Println("failed to get repo and knot", err)
258 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
259 return
260 }
261
262 // Start a transaction for database operations
263 tx, err := s.db.BeginTx(r.Context(), nil)
264 if err != nil {
265 log.Println("failed to start transaction", err)
266 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
267 return
268 }
269
270 // Set up deferred rollback that will be overridden by commit if successful
271 defer tx.Rollback()
272
273 // Update patch in the database within transaction
274 err = db.EditPatch(tx, f.RepoAt, pull.PullId, patch)
275 if err != nil {
276 log.Println("failed to update patch", err)
277 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
278 return
279 }
280
281 // Update the atproto record
282 client, _ := s.auth.AuthorizedClient(r)
283 pullAt := pull.PullAt
284
285 // Get the existing record first
286 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pullAt.RecordKey().String())
287 if err != nil {
288 log.Println("failed to get existing pull record", err)
289 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
290 return
291 }
292
293 // Update the record
294 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
295 Collection: tangled.RepoPullNSID,
296 Repo: user.Did,
297 Rkey: pullAt.RecordKey().String(),
298 SwapRecord: ex.Cid,
299 Record: &lexutil.LexiconTypeDecoder{
300 Val: &tangled.RepoPull{
301 Title: pull.Title,
302 PullId: int64(pull.PullId),
303 TargetRepo: string(f.RepoAt),
304 TargetBranch: pull.TargetBranch,
305 Patch: patch,
306 },
307 },
308 })
309
310 if err != nil {
311 log.Println("failed to update pull record in atproto", err)
312 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
313 return
314 }
315
316 // Commit the transaction now that both operations have succeeded
317 err = tx.Commit()
318 if err != nil {
319 log.Println("failed to commit transaction", err)
320 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
321 return
322 }
323
324 targetBranch := pull.TargetBranch
325
326 // Perform merge check
327 secret, err := db.GetRegistrationKey(s.db, f.Knot)
328 if err != nil {
329 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
330 s.pages.Notice(w, "pull-success", "Patch updated successfully, but couldn't check mergeability.")
331 return
332 }
333
334 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
335 if err != nil {
336 log.Printf("failed to create signed client for %s", f.Knot)
337 s.pages.Notice(w, "pull-success", "Patch updated successfully, but couldn't check mergeability.")
338 return
339 }
340
341 resp, err := ksClient.MergeCheck([]byte(patch), user.Did, f.RepoName, targetBranch)
342 if err != nil {
343 log.Println("failed to check mergeability", err)
344 s.pages.Notice(w, "pull-success", "Patch updated successfully, but couldn't check mergeability.")
345 return
346 }
347
348 respBody, err := io.ReadAll(resp.Body)
349 if err != nil {
350 log.Println("failed to read knotserver response body")
351 s.pages.Notice(w, "pull-success", "Patch updated successfully, but couldn't check mergeability.")
352 return
353 }
354
355 var mergeCheckResponse types.MergeCheckResponse
356 err = json.Unmarshal(respBody, &mergeCheckResponse)
357 if err != nil {
358 log.Println("failed to unmarshal merge check response", err)
359 s.pages.Notice(w, "pull-success", "Patch updated successfully, but couldn't check mergeability.")
360 return
361 }
362
363 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
364 return
365}
366
367func (s *State) NewPull(w http.ResponseWriter, r *http.Request) {
368 user := s.auth.GetUser(r)
369 f, err := fullyResolvedRepo(r)
370 if err != nil {
371 log.Println("failed to get repo and knot", err)
372 return
373 }
374
375 switch r.Method {
376 case http.MethodGet:
377 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
378 if err != nil {
379 log.Printf("failed to create unsigned client for %s", f.Knot)
380 s.pages.Error503(w)
381 return
382 }
383
384 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
385 if err != nil {
386 log.Println("failed to reach knotserver", err)
387 return
388 }
389
390 body, err := io.ReadAll(resp.Body)
391 if err != nil {
392 log.Printf("Error reading response body: %v", err)
393 return
394 }
395
396 var result types.RepoBranchesResponse
397 err = json.Unmarshal(body, &result)
398 if err != nil {
399 log.Println("failed to parse response:", err)
400 return
401 }
402
403 s.pages.RepoNewPull(w, pages.RepoNewPullParams{
404 LoggedInUser: user,
405 RepoInfo: f.RepoInfo(s, user),
406 Branches: result.Branches,
407 })
408 case http.MethodPost:
409 title := r.FormValue("title")
410 body := r.FormValue("body")
411 targetBranch := r.FormValue("targetBranch")
412 patch := r.FormValue("patch")
413
414 if title == "" || body == "" || patch == "" || targetBranch == "" {
415 s.pages.Notice(w, "pull", "Title, body and patch diff are required.")
416 return
417 }
418
419 tx, err := s.db.BeginTx(r.Context(), nil)
420 if err != nil {
421 log.Println("failed to start tx")
422 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
423 return
424 }
425
426 defer func() {
427 tx.Rollback()
428 err = s.enforcer.E.LoadPolicy()
429 if err != nil {
430 log.Println("failed to rollback policies")
431 }
432 }()
433
434 err = db.NewPull(tx, &db.Pull{
435 Title: title,
436 Body: body,
437 TargetBranch: targetBranch,
438 Patch: patch,
439 OwnerDid: user.Did,
440 RepoAt: f.RepoAt,
441 })
442 if err != nil {
443 log.Println("failed to create pull request", err)
444 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
445 return
446 }
447 client, _ := s.auth.AuthorizedClient(r)
448 pullId, err := db.NextPullId(s.db, f.RepoAt)
449 if err != nil {
450 log.Println("failed to get pull id", err)
451 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
452 return
453 }
454
455 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
456 Collection: tangled.RepoPullNSID,
457 Repo: user.Did,
458 Rkey: s.TID(),
459 Record: &lexutil.LexiconTypeDecoder{
460 Val: &tangled.RepoPull{
461 Title: title,
462 PullId: int64(pullId),
463 TargetRepo: string(f.RepoAt),
464 TargetBranch: targetBranch,
465 Patch: patch,
466 },
467 },
468 })
469
470 err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri)
471 if err != nil {
472 log.Println("failed to get pull id", err)
473 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
474 return
475 }
476
477 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
478 return
479 }
480}
481
482func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) {
483 user := s.auth.GetUser(r)
484 f, err := fullyResolvedRepo(r)
485 if err != nil {
486 log.Println("failed to get repo and knot", err)
487 return
488 }
489
490 pull, ok1 := r.Context().Value("pull").(*db.Pull)
491 comments, ok2 := r.Context().Value("pull_comments").([]db.PullComment)
492 if !ok1 || !ok2 {
493 log.Println("failed to get pull")
494 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
495 return
496 }
497
498 identsToResolve := make([]string, len(comments))
499 for i, comment := range comments {
500 identsToResolve[i] = comment.OwnerDid
501 }
502 identsToResolve = append(identsToResolve, pull.OwnerDid)
503
504 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
505 didHandleMap := make(map[string]string)
506 for _, identity := range resolvedIds {
507 if !identity.Handle.IsInvalidHandle() {
508 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
509 } else {
510 didHandleMap[identity.DID.String()] = identity.DID.String()
511 }
512 }
513
514 var mergeCheckResponse types.MergeCheckResponse
515
516 // Only perform merge check if the pull request is not already merged
517 if pull.State != db.PullMerged {
518 secret, err := db.GetRegistrationKey(s.db, f.Knot)
519 if err != nil {
520 log.Printf("failed to get registration key for %s", f.Knot)
521 s.pages.Notice(w, "pull", "Failed to load pull request. Try again later.")
522 return
523 }
524
525 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
526 if err == nil {
527 resp, err := ksClient.MergeCheck([]byte(pull.Patch), pull.OwnerDid, f.RepoName, pull.TargetBranch)
528 if err != nil {
529 log.Println("failed to check for mergeability:", err)
530 } else {
531 respBody, err := io.ReadAll(resp.Body)
532 if err != nil {
533 log.Println("failed to read merge check response body")
534 } else {
535 err = json.Unmarshal(respBody, &mergeCheckResponse)
536 if err != nil {
537 log.Println("failed to unmarshal merge check response", err)
538 }
539 }
540 }
541 } else {
542 log.Printf("failed to setup signed client for %s; ignoring...", f.Knot)
543 }
544 }
545
546 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
547 LoggedInUser: user,
548 RepoInfo: f.RepoInfo(s, user),
549 Pull: *pull,
550 Comments: comments,
551 DidHandleMap: didHandleMap,
552 MergeCheck: mergeCheckResponse,
553 })
554}
555
556func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) {
557 f, err := fullyResolvedRepo(r)
558 if err != nil {
559 log.Println("failed to fully resolve repo", err)
560 return
561 }
562 ref := chi.URLParam(r, "ref")
563 protocol := "http"
564 if !s.config.Dev {
565 protocol = "https"
566 }
567 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref))
568 if err != nil {
569 log.Println("failed to reach knotserver", err)
570 return
571 }
572
573 body, err := io.ReadAll(resp.Body)
574 if err != nil {
575 log.Printf("Error reading response body: %v", err)
576 return
577 }
578
579 var result types.RepoCommitResponse
580 err = json.Unmarshal(body, &result)
581 if err != nil {
582 log.Println("failed to parse response:", err)
583 return
584 }
585
586 user := s.auth.GetUser(r)
587 s.pages.RepoCommit(w, pages.RepoCommitParams{
588 LoggedInUser: user,
589 RepoInfo: f.RepoInfo(s, user),
590 RepoCommitResponse: result,
591 })
592 return
593}
594
595func (s *State) RepoTree(w http.ResponseWriter, r *http.Request) {
596 f, err := fullyResolvedRepo(r)
597 if err != nil {
598 log.Println("failed to fully resolve repo", err)
599 return
600 }
601
602 ref := chi.URLParam(r, "ref")
603 treePath := chi.URLParam(r, "*")
604 protocol := "http"
605 if !s.config.Dev {
606 protocol = "https"
607 }
608 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath))
609 if err != nil {
610 log.Println("failed to reach knotserver", err)
611 return
612 }
613
614 body, err := io.ReadAll(resp.Body)
615 if err != nil {
616 log.Printf("Error reading response body: %v", err)
617 return
618 }
619
620 var result types.RepoTreeResponse
621 err = json.Unmarshal(body, &result)
622 if err != nil {
623 log.Println("failed to parse response:", err)
624 return
625 }
626
627 user := s.auth.GetUser(r)
628
629 var breadcrumbs [][]string
630 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)})
631 if treePath != "" {
632 for idx, elem := range strings.Split(treePath, "/") {
633 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
634 }
635 }
636
637 baseTreeLink := path.Join(f.OwnerDid(), f.RepoName, "tree", ref, treePath)
638 baseBlobLink := path.Join(f.OwnerDid(), f.RepoName, "blob", ref, treePath)
639
640 s.pages.RepoTree(w, pages.RepoTreeParams{
641 LoggedInUser: user,
642 BreadCrumbs: breadcrumbs,
643 BaseTreeLink: baseTreeLink,
644 BaseBlobLink: baseBlobLink,
645 RepoInfo: f.RepoInfo(s, user),
646 RepoTreeResponse: result,
647 })
648 return
649}
650
651func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) {
652 f, err := fullyResolvedRepo(r)
653 if err != nil {
654 log.Println("failed to get repo and knot", err)
655 return
656 }
657
658 protocol := "http"
659 if !s.config.Dev {
660 protocol = "https"
661 }
662
663 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tags", protocol, f.Knot, f.OwnerDid(), f.RepoName))
664 if err != nil {
665 log.Println("failed to reach knotserver", err)
666 return
667 }
668
669 body, err := io.ReadAll(resp.Body)
670 if err != nil {
671 log.Printf("Error reading response body: %v", err)
672 return
673 }
674
675 var result types.RepoTagsResponse
676 err = json.Unmarshal(body, &result)
677 if err != nil {
678 log.Println("failed to parse response:", err)
679 return
680 }
681
682 user := s.auth.GetUser(r)
683 s.pages.RepoTags(w, pages.RepoTagsParams{
684 LoggedInUser: user,
685 RepoInfo: f.RepoInfo(s, user),
686 RepoTagsResponse: result,
687 })
688 return
689}
690
691func (s *State) RepoBranches(w http.ResponseWriter, r *http.Request) {
692 f, err := fullyResolvedRepo(r)
693 if err != nil {
694 log.Println("failed to get repo and knot", err)
695 return
696 }
697
698 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
699 if err != nil {
700 log.Println("failed to create unsigned client", err)
701 return
702 }
703
704 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
705 if err != nil {
706 log.Println("failed to reach knotserver", err)
707 return
708 }
709
710 body, err := io.ReadAll(resp.Body)
711 if err != nil {
712 log.Printf("Error reading response body: %v", err)
713 return
714 }
715
716 var result types.RepoBranchesResponse
717 err = json.Unmarshal(body, &result)
718 if err != nil {
719 log.Println("failed to parse response:", err)
720 return
721 }
722
723 user := s.auth.GetUser(r)
724 s.pages.RepoBranches(w, pages.RepoBranchesParams{
725 LoggedInUser: user,
726 RepoInfo: f.RepoInfo(s, user),
727 RepoBranchesResponse: result,
728 })
729 return
730}
731
732func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) {
733 f, err := fullyResolvedRepo(r)
734 if err != nil {
735 log.Println("failed to get repo and knot", err)
736 return
737 }
738
739 ref := chi.URLParam(r, "ref")
740 filePath := chi.URLParam(r, "*")
741 protocol := "http"
742 if !s.config.Dev {
743 protocol = "https"
744 }
745 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
746 if err != nil {
747 log.Println("failed to reach knotserver", err)
748 return
749 }
750
751 body, err := io.ReadAll(resp.Body)
752 if err != nil {
753 log.Printf("Error reading response body: %v", err)
754 return
755 }
756
757 var result types.RepoBlobResponse
758 err = json.Unmarshal(body, &result)
759 if err != nil {
760 log.Println("failed to parse response:", err)
761 return
762 }
763
764 var breadcrumbs [][]string
765 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/%s/tree/%s", f.OwnerDid(), f.RepoName, ref)})
766 if filePath != "" {
767 for idx, elem := range strings.Split(filePath, "/") {
768 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
769 }
770 }
771
772 user := s.auth.GetUser(r)
773 s.pages.RepoBlob(w, pages.RepoBlobParams{
774 LoggedInUser: user,
775 RepoInfo: f.RepoInfo(s, user),
776 RepoBlobResponse: result,
777 BreadCrumbs: breadcrumbs,
778 })
779 return
780}
781
782func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) {
783 f, err := fullyResolvedRepo(r)
784 if err != nil {
785 log.Println("failed to get repo and knot", err)
786 return
787 }
788
789 collaborator := r.FormValue("collaborator")
790 if collaborator == "" {
791 http.Error(w, "malformed form", http.StatusBadRequest)
792 return
793 }
794
795 collaboratorIdent, err := s.resolver.ResolveIdent(r.Context(), collaborator)
796 if err != nil {
797 w.Write([]byte("failed to resolve collaborator did to a handle"))
798 return
799 }
800 log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot)
801
802 // TODO: create an atproto record for this
803
804 secret, err := db.GetRegistrationKey(s.db, f.Knot)
805 if err != nil {
806 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
807 return
808 }
809
810 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
811 if err != nil {
812 log.Println("failed to create client to ", f.Knot)
813 return
814 }
815
816 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String())
817 if err != nil {
818 log.Printf("failed to make request to %s: %s", f.Knot, err)
819 return
820 }
821
822 if ksResp.StatusCode != http.StatusNoContent {
823 w.Write([]byte(fmt.Sprint("knotserver failed to add collaborator: ", err)))
824 return
825 }
826
827 tx, err := s.db.BeginTx(r.Context(), nil)
828 if err != nil {
829 log.Println("failed to start tx")
830 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
831 return
832 }
833 defer func() {
834 tx.Rollback()
835 err = s.enforcer.E.LoadPolicy()
836 if err != nil {
837 log.Println("failed to rollback policies")
838 }
839 }()
840
841 err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.OwnerSlashRepo())
842 if err != nil {
843 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
844 return
845 }
846
847 err = db.AddCollaborator(s.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot)
848 if err != nil {
849 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
850 return
851 }
852
853 err = tx.Commit()
854 if err != nil {
855 log.Println("failed to commit changes", err)
856 http.Error(w, err.Error(), http.StatusInternalServerError)
857 return
858 }
859
860 err = s.enforcer.E.SavePolicy()
861 if err != nil {
862 log.Println("failed to update ACLs", err)
863 http.Error(w, err.Error(), http.StatusInternalServerError)
864 return
865 }
866
867 w.Write([]byte(fmt.Sprint("added collaborator: ", collaboratorIdent.Handle.String())))
868
869}
870
871func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) {
872 f, err := fullyResolvedRepo(r)
873 if err != nil {
874 log.Println("failed to get repo and knot", err)
875 return
876 }
877
878 switch r.Method {
879 case http.MethodGet:
880 // for now, this is just pubkeys
881 user := s.auth.GetUser(r)
882 repoCollaborators, err := f.Collaborators(r.Context(), s)
883 if err != nil {
884 log.Println("failed to get collaborators", err)
885 }
886
887 isCollaboratorInviteAllowed := false
888 if user != nil {
889 ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.OwnerSlashRepo())
890 if err == nil && ok {
891 isCollaboratorInviteAllowed = true
892 }
893 }
894
895 s.pages.RepoSettings(w, pages.RepoSettingsParams{
896 LoggedInUser: user,
897 RepoInfo: f.RepoInfo(s, user),
898 Collaborators: repoCollaborators,
899 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
900 })
901 }
902}
903
904type FullyResolvedRepo struct {
905 Knot string
906 OwnerId identity.Identity
907 RepoName string
908 RepoAt syntax.ATURI
909 Description string
910 AddedAt string
911}
912
913func (f *FullyResolvedRepo) OwnerDid() string {
914 return f.OwnerId.DID.String()
915}
916
917func (f *FullyResolvedRepo) OwnerHandle() string {
918 return f.OwnerId.Handle.String()
919}
920
921func (f *FullyResolvedRepo) OwnerSlashRepo() string {
922 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
923 return p
924}
925
926func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) {
927 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.OwnerSlashRepo(), f.Knot)
928 if err != nil {
929 return nil, err
930 }
931
932 var collaborators []pages.Collaborator
933 for _, item := range repoCollaborators {
934 // currently only two roles: owner and member
935 var role string
936 if item[3] == "repo:owner" {
937 role = "owner"
938 } else if item[3] == "repo:collaborator" {
939 role = "collaborator"
940 } else {
941 continue
942 }
943
944 did := item[0]
945
946 c := pages.Collaborator{
947 Did: did,
948 Handle: "",
949 Role: role,
950 }
951 collaborators = append(collaborators, c)
952 }
953
954 // populate all collborators with handles
955 identsToResolve := make([]string, len(collaborators))
956 for i, collab := range collaborators {
957 identsToResolve[i] = collab.Did
958 }
959
960 resolvedIdents := s.resolver.ResolveIdents(ctx, identsToResolve)
961 for i, resolved := range resolvedIdents {
962 if resolved != nil {
963 collaborators[i].Handle = resolved.Handle.String()
964 }
965 }
966
967 return collaborators, nil
968}
969
970func (f *FullyResolvedRepo) RepoInfo(s *State, u *auth.User) pages.RepoInfo {
971 isStarred := false
972 if u != nil {
973 isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt))
974 }
975
976 starCount, err := db.GetStarCount(s.db, f.RepoAt)
977 if err != nil {
978 log.Println("failed to get star count for ", f.RepoAt)
979 }
980 issueCount, err := db.GetIssueCount(s.db, f.RepoAt)
981 if err != nil {
982 log.Println("failed to get issue count for ", f.RepoAt)
983 }
984 pullCount, err := db.GetPullCount(s.db, f.RepoAt)
985 if err != nil {
986 log.Println("failed to get issue count for ", f.RepoAt)
987 }
988
989 knot := f.Knot
990 if knot == "knot1.tangled.sh" {
991 knot = "tangled.sh"
992 }
993
994 return pages.RepoInfo{
995 OwnerDid: f.OwnerDid(),
996 OwnerHandle: f.OwnerHandle(),
997 Name: f.RepoName,
998 RepoAt: f.RepoAt,
999 Description: f.Description,
1000 IsStarred: isStarred,
1001 Knot: knot,
1002 Roles: RolesInRepo(s, u, f),
1003 Stats: db.RepoStats{
1004 StarCount: starCount,
1005 IssueCount: issueCount,
1006 PullCount: pullCount,
1007 },
1008 }
1009}
1010
1011func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
1012 user := s.auth.GetUser(r)
1013 f, err := fullyResolvedRepo(r)
1014 if err != nil {
1015 log.Println("failed to get repo and knot", err)
1016 return
1017 }
1018
1019 issueId := chi.URLParam(r, "issue")
1020 issueIdInt, err := strconv.Atoi(issueId)
1021 if err != nil {
1022 http.Error(w, "bad issue id", http.StatusBadRequest)
1023 log.Println("failed to parse issue id", err)
1024 return
1025 }
1026
1027 issue, comments, err := db.GetIssueWithComments(s.db, f.RepoAt, issueIdInt)
1028 if err != nil {
1029 log.Println("failed to get issue and comments", err)
1030 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1031 return
1032 }
1033
1034 issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid)
1035 if err != nil {
1036 log.Println("failed to resolve issue owner", err)
1037 }
1038
1039 identsToResolve := make([]string, len(comments))
1040 for i, comment := range comments {
1041 identsToResolve[i] = comment.OwnerDid
1042 }
1043 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
1044 didHandleMap := make(map[string]string)
1045 for _, identity := range resolvedIds {
1046 if !identity.Handle.IsInvalidHandle() {
1047 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1048 } else {
1049 didHandleMap[identity.DID.String()] = identity.DID.String()
1050 }
1051 }
1052
1053 s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
1054 LoggedInUser: user,
1055 RepoInfo: f.RepoInfo(s, user),
1056 Issue: *issue,
1057 Comments: comments,
1058
1059 IssueOwnerHandle: issueOwnerIdent.Handle.String(),
1060 DidHandleMap: didHandleMap,
1061 })
1062
1063}
1064
1065func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) {
1066 user := s.auth.GetUser(r)
1067 f, err := fullyResolvedRepo(r)
1068 if err != nil {
1069 log.Println("failed to get repo and knot", err)
1070 return
1071 }
1072
1073 issueId := chi.URLParam(r, "issue")
1074 issueIdInt, err := strconv.Atoi(issueId)
1075 if err != nil {
1076 http.Error(w, "bad issue id", http.StatusBadRequest)
1077 log.Println("failed to parse issue id", err)
1078 return
1079 }
1080
1081 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1082 if err != nil {
1083 log.Println("failed to get issue", err)
1084 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1085 return
1086 }
1087
1088 collaborators, err := f.Collaborators(r.Context(), s)
1089 if err != nil {
1090 log.Println("failed to fetch repo collaborators: %w", err)
1091 }
1092 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
1093 return user.Did == collab.Did
1094 })
1095 isIssueOwner := user.Did == issue.OwnerDid
1096
1097 // TODO: make this more granular
1098 if isIssueOwner || isCollaborator {
1099
1100 closed := tangled.RepoIssueStateClosed
1101
1102 client, _ := s.auth.AuthorizedClient(r)
1103 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1104 Collection: tangled.RepoIssueStateNSID,
1105 Repo: issue.OwnerDid,
1106 Rkey: s.TID(),
1107 Record: &lexutil.LexiconTypeDecoder{
1108 Val: &tangled.RepoIssueState{
1109 Issue: issue.IssueAt,
1110 State: &closed,
1111 },
1112 },
1113 })
1114
1115 if err != nil {
1116 log.Println("failed to update issue state", err)
1117 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1118 return
1119 }
1120
1121 err := db.CloseIssue(s.db, f.RepoAt, issueIdInt)
1122 if err != nil {
1123 log.Println("failed to close issue", err)
1124 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1125 return
1126 }
1127
1128 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
1129 return
1130 } else {
1131 log.Println("user is not permitted to close issue")
1132 http.Error(w, "for biden", http.StatusUnauthorized)
1133 return
1134 }
1135}
1136
1137func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) {
1138 user := s.auth.GetUser(r)
1139 f, err := fullyResolvedRepo(r)
1140 if err != nil {
1141 log.Println("failed to get repo and knot", err)
1142 return
1143 }
1144
1145 issueId := chi.URLParam(r, "issue")
1146 issueIdInt, err := strconv.Atoi(issueId)
1147 if err != nil {
1148 http.Error(w, "bad issue id", http.StatusBadRequest)
1149 log.Println("failed to parse issue id", err)
1150 return
1151 }
1152
1153 if user.Did == f.OwnerDid() {
1154 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt)
1155 if err != nil {
1156 log.Println("failed to reopen issue", err)
1157 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
1158 return
1159 }
1160 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
1161 return
1162 } else {
1163 log.Println("user is not the owner of the repo")
1164 http.Error(w, "forbidden", http.StatusUnauthorized)
1165 return
1166 }
1167}
1168
1169func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) {
1170 user := s.auth.GetUser(r)
1171 f, err := fullyResolvedRepo(r)
1172 if err != nil {
1173 log.Println("failed to get repo and knot", err)
1174 return
1175 }
1176
1177 issueId := chi.URLParam(r, "issue")
1178 issueIdInt, err := strconv.Atoi(issueId)
1179 if err != nil {
1180 http.Error(w, "bad issue id", http.StatusBadRequest)
1181 log.Println("failed to parse issue id", err)
1182 return
1183 }
1184
1185 switch r.Method {
1186 case http.MethodPost:
1187 body := r.FormValue("body")
1188 if body == "" {
1189 s.pages.Notice(w, "issue", "Body is required")
1190 return
1191 }
1192
1193 commentId := rand.IntN(1000000)
1194
1195 err := db.NewComment(s.db, &db.Comment{
1196 OwnerDid: user.Did,
1197 RepoAt: f.RepoAt,
1198 Issue: issueIdInt,
1199 CommentId: commentId,
1200 Body: body,
1201 })
1202 if err != nil {
1203 log.Println("failed to create comment", err)
1204 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1205 return
1206 }
1207
1208 createdAt := time.Now().Format(time.RFC3339)
1209 commentIdInt64 := int64(commentId)
1210 ownerDid := user.Did
1211 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt)
1212 if err != nil {
1213 log.Println("failed to get issue at", err)
1214 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1215 return
1216 }
1217
1218 atUri := f.RepoAt.String()
1219 client, _ := s.auth.AuthorizedClient(r)
1220 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1221 Collection: tangled.RepoIssueCommentNSID,
1222 Repo: user.Did,
1223 Rkey: s.TID(),
1224 Record: &lexutil.LexiconTypeDecoder{
1225 Val: &tangled.RepoIssueComment{
1226 Repo: &atUri,
1227 Issue: issueAt,
1228 CommentId: &commentIdInt64,
1229 Owner: &ownerDid,
1230 Body: &body,
1231 CreatedAt: &createdAt,
1232 },
1233 },
1234 })
1235 if err != nil {
1236 log.Println("failed to create comment", err)
1237 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1238 return
1239 }
1240
1241 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
1242 return
1243 }
1244}
1245
1246func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) {
1247 params := r.URL.Query()
1248 state := params.Get("state")
1249 isOpen := true
1250 switch state {
1251 case "open":
1252 isOpen = true
1253 case "closed":
1254 isOpen = false
1255 default:
1256 isOpen = true
1257 }
1258
1259 user := s.auth.GetUser(r)
1260 f, err := fullyResolvedRepo(r)
1261 if err != nil {
1262 log.Println("failed to get repo and knot", err)
1263 return
1264 }
1265
1266 issues, err := db.GetIssues(s.db, f.RepoAt, isOpen)
1267 if err != nil {
1268 log.Println("failed to get issues", err)
1269 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
1270 return
1271 }
1272
1273 identsToResolve := make([]string, len(issues))
1274 for i, issue := range issues {
1275 identsToResolve[i] = issue.OwnerDid
1276 }
1277 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
1278 didHandleMap := make(map[string]string)
1279 for _, identity := range resolvedIds {
1280 if !identity.Handle.IsInvalidHandle() {
1281 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1282 } else {
1283 didHandleMap[identity.DID.String()] = identity.DID.String()
1284 }
1285 }
1286
1287 s.pages.RepoIssues(w, pages.RepoIssuesParams{
1288 LoggedInUser: s.auth.GetUser(r),
1289 RepoInfo: f.RepoInfo(s, user),
1290 Issues: issues,
1291 DidHandleMap: didHandleMap,
1292 FilteringByOpen: isOpen,
1293 })
1294 return
1295}
1296
1297func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) {
1298 user := s.auth.GetUser(r)
1299
1300 f, err := fullyResolvedRepo(r)
1301 if err != nil {
1302 log.Println("failed to get repo and knot", err)
1303 return
1304 }
1305
1306 switch r.Method {
1307 case http.MethodGet:
1308 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
1309 LoggedInUser: user,
1310 RepoInfo: f.RepoInfo(s, user),
1311 })
1312 case http.MethodPost:
1313 title := r.FormValue("title")
1314 body := r.FormValue("body")
1315
1316 if title == "" || body == "" {
1317 s.pages.Notice(w, "issues", "Title and body are required")
1318 return
1319 }
1320
1321 tx, err := s.db.BeginTx(r.Context(), nil)
1322 if err != nil {
1323 s.pages.Notice(w, "issues", "Failed to create issue, try again later")
1324 return
1325 }
1326
1327 err = db.NewIssue(tx, &db.Issue{
1328 RepoAt: f.RepoAt,
1329 Title: title,
1330 Body: body,
1331 OwnerDid: user.Did,
1332 })
1333 if err != nil {
1334 log.Println("failed to create issue", err)
1335 s.pages.Notice(w, "issues", "Failed to create issue.")
1336 return
1337 }
1338
1339 issueId, err := db.GetIssueId(s.db, f.RepoAt)
1340 if err != nil {
1341 log.Println("failed to get issue id", err)
1342 s.pages.Notice(w, "issues", "Failed to create issue.")
1343 return
1344 }
1345
1346 client, _ := s.auth.AuthorizedClient(r)
1347 atUri := f.RepoAt.String()
1348 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1349 Collection: tangled.RepoIssueNSID,
1350 Repo: user.Did,
1351 Rkey: s.TID(),
1352 Record: &lexutil.LexiconTypeDecoder{
1353 Val: &tangled.RepoIssue{
1354 Repo: atUri,
1355 Title: title,
1356 Body: &body,
1357 Owner: user.Did,
1358 IssueId: int64(issueId),
1359 },
1360 },
1361 })
1362 if err != nil {
1363 log.Println("failed to create issue", err)
1364 s.pages.Notice(w, "issues", "Failed to create issue.")
1365 return
1366 }
1367
1368 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri)
1369 if err != nil {
1370 log.Println("failed to set issue at", err)
1371 s.pages.Notice(w, "issues", "Failed to create issue.")
1372 return
1373 }
1374
1375 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))
1376 return
1377 }
1378}
1379
1380func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) {
1381 user := s.auth.GetUser(r)
1382 params := r.URL.Query()
1383
1384 state := db.PullOpen
1385 switch params.Get("state") {
1386 case "closed":
1387 state = db.PullClosed
1388 case "merged":
1389 state = db.PullMerged
1390 }
1391
1392 f, err := fullyResolvedRepo(r)
1393 if err != nil {
1394 log.Println("failed to get repo and knot", err)
1395 return
1396 }
1397
1398 pulls, err := db.GetPulls(s.db, f.RepoAt, state)
1399 if err != nil {
1400 log.Println("failed to get pulls", err)
1401 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
1402 return
1403 }
1404
1405 identsToResolve := make([]string, len(pulls))
1406 for i, pull := range pulls {
1407 identsToResolve[i] = pull.OwnerDid
1408 }
1409 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
1410 didHandleMap := make(map[string]string)
1411 for _, identity := range resolvedIds {
1412 if !identity.Handle.IsInvalidHandle() {
1413 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1414 } else {
1415 didHandleMap[identity.DID.String()] = identity.DID.String()
1416 }
1417 }
1418
1419 s.pages.RepoPulls(w, pages.RepoPullsParams{
1420 LoggedInUser: s.auth.GetUser(r),
1421 RepoInfo: f.RepoInfo(s, user),
1422 Pulls: pulls,
1423 DidHandleMap: didHandleMap,
1424 FilteringBy: state,
1425 })
1426 return
1427}
1428
1429func (s *State) MergePull(w http.ResponseWriter, r *http.Request) {
1430 user := s.auth.GetUser(r)
1431 f, err := fullyResolvedRepo(r)
1432 if err != nil {
1433 log.Println("failed to resolve repo:", err)
1434 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1435 return
1436 }
1437
1438 pull, ok := r.Context().Value("pull").(*db.Pull)
1439 if !ok {
1440 log.Println("failed to get pull")
1441 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1442 return
1443 }
1444
1445 secret, err := db.GetRegistrationKey(s.db, f.Knot)
1446 if err != nil {
1447 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
1448 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1449 return
1450 }
1451
1452 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
1453 if err != nil {
1454 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
1455 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1456 return
1457 }
1458
1459 // Merge the pull request
1460 resp, err := ksClient.Merge([]byte(pull.Patch), user.Did, f.RepoName, pull.TargetBranch)
1461 if err != nil {
1462 log.Printf("failed to merge pull request: %s", err)
1463 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1464 return
1465 }
1466
1467 if resp.StatusCode == http.StatusOK {
1468 err := db.MergePull(s.db, f.RepoAt, pull.PullId)
1469 if err != nil {
1470 log.Printf("failed to update pull request status in database: %s", err)
1471 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1472 return
1473 }
1474 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
1475 } else {
1476 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
1477 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1478 }
1479}
1480
1481func (s *State) PullComment(w http.ResponseWriter, r *http.Request) {
1482 user := s.auth.GetUser(r)
1483 f, err := fullyResolvedRepo(r)
1484 if err != nil {
1485 log.Println("failed to get repo and knot", err)
1486 return
1487 }
1488
1489 pullId := chi.URLParam(r, "pull")
1490 pullIdInt, err := strconv.Atoi(pullId)
1491 if err != nil {
1492 http.Error(w, "bad pull id", http.StatusBadRequest)
1493 log.Println("failed to parse pull id", err)
1494 return
1495 }
1496
1497 switch r.Method {
1498 case http.MethodPost:
1499 body := r.FormValue("body")
1500 if body == "" {
1501 s.pages.Notice(w, "pull", "Comment body is required")
1502 return
1503 }
1504
1505 // Start a transaction
1506 tx, err := s.db.BeginTx(r.Context(), nil)
1507 if err != nil {
1508 log.Println("failed to start transaction", err)
1509 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
1510 return
1511 }
1512 defer tx.Rollback() // Will be ignored if we commit
1513
1514 commentId := rand.IntN(1000000)
1515 createdAt := time.Now().Format(time.RFC3339)
1516 commentIdInt64 := int64(commentId)
1517 ownerDid := user.Did
1518
1519 pullAt, err := db.GetPullAt(s.db, f.RepoAt, pullIdInt)
1520 if err != nil {
1521 log.Println("failed to get pull at", err)
1522 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
1523 return
1524 }
1525
1526 atUri := f.RepoAt.String()
1527 client, _ := s.auth.AuthorizedClient(r)
1528 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1529 Collection: tangled.RepoPullCommentNSID,
1530 Repo: user.Did,
1531 Rkey: s.TID(),
1532 Record: &lexutil.LexiconTypeDecoder{
1533 Val: &tangled.RepoPullComment{
1534 Repo: &atUri,
1535 Pull: pullAt,
1536 CommentId: &commentIdInt64,
1537 Owner: &ownerDid,
1538 Body: &body,
1539 CreatedAt: &createdAt,
1540 },
1541 },
1542 })
1543 if err != nil {
1544 log.Println("failed to create pull comment", err)
1545 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
1546 return
1547 }
1548
1549 // Create the pull comment in the database with the commentAt field
1550 err = db.NewPullComment(tx, &db.PullComment{
1551 OwnerDid: user.Did,
1552 RepoAt: f.RepoAt.String(),
1553 CommentId: commentId,
1554 PullId: pullIdInt,
1555 Body: body,
1556 CommentAt: atResp.Uri,
1557 })
1558 if err != nil {
1559 log.Println("failed to create pull comment", err)
1560 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
1561 return
1562 }
1563
1564 // Commit the transaction
1565 if err = tx.Commit(); err != nil {
1566 log.Println("failed to commit transaction", err)
1567 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
1568 return
1569 }
1570
1571 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pullIdInt, commentId))
1572 return
1573 }
1574}
1575
1576func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) {
1577 user := s.auth.GetUser(r)
1578
1579 f, err := fullyResolvedRepo(r)
1580 if err != nil {
1581 log.Println("malformed middleware")
1582 return
1583 }
1584
1585 pull, ok := r.Context().Value("pull").(*db.Pull)
1586 if !ok {
1587 log.Println("failed to get pull")
1588 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1589 return
1590 }
1591
1592 // auth filter: only owner or collaborators can close
1593 roles := RolesInRepo(s, user, f)
1594 isCollaborator := roles.IsCollaborator()
1595 isPullAuthor := user.Did == pull.OwnerDid
1596 isCloseAllowed := isCollaborator || isPullAuthor
1597 if !isCloseAllowed {
1598 log.Println("failed to close pull")
1599 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
1600 return
1601 }
1602
1603 // Start a transaction
1604 tx, err := s.db.BeginTx(r.Context(), nil)
1605 if err != nil {
1606 log.Println("failed to start transaction", err)
1607 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1608 return
1609 }
1610
1611 // Close the pull in the database
1612 err = db.ClosePull(tx, f.RepoAt, pull.PullId)
1613 if err != nil {
1614 log.Println("failed to close pull", err)
1615 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1616 return
1617 }
1618
1619 // Commit the transaction
1620 if err = tx.Commit(); err != nil {
1621 log.Println("failed to commit transaction", err)
1622 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1623 return
1624 }
1625
1626 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1627 return
1628}
1629
1630func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) {
1631 user := s.auth.GetUser(r)
1632
1633 f, err := fullyResolvedRepo(r)
1634 if err != nil {
1635 log.Println("failed to resolve repo", err)
1636 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1637 return
1638 }
1639
1640 pull, ok := r.Context().Value("pull").(*db.Pull)
1641 if !ok {
1642 log.Println("failed to get pull")
1643 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1644 return
1645 }
1646
1647 // auth filter: only owner or collaborators can close
1648 roles := RolesInRepo(s, user, f)
1649 isCollaborator := roles.IsCollaborator()
1650 isPullAuthor := user.Did == pull.OwnerDid
1651 isCloseAllowed := isCollaborator || isPullAuthor
1652 if !isCloseAllowed {
1653 log.Println("failed to close pull")
1654 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
1655 return
1656 }
1657
1658 // Start a transaction
1659 tx, err := s.db.BeginTx(r.Context(), nil)
1660 if err != nil {
1661 log.Println("failed to start transaction", err)
1662 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1663 return
1664 }
1665
1666 // Reopen the pull in the database
1667 err = db.ReopenPull(tx, f.RepoAt, pull.PullId)
1668 if err != nil {
1669 log.Println("failed to reopen pull", err)
1670 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1671 return
1672 }
1673
1674 // Commit the transaction
1675 if err = tx.Commit(); err != nil {
1676 log.Println("failed to commit transaction", err)
1677 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1678 return
1679 }
1680
1681 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1682 return
1683}
1684
1685func fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) {
1686 repoName := chi.URLParam(r, "repo")
1687 knot, ok := r.Context().Value("knot").(string)
1688 if !ok {
1689 log.Println("malformed middleware")
1690 return nil, fmt.Errorf("malformed middleware")
1691 }
1692 id, ok := r.Context().Value("resolvedId").(identity.Identity)
1693 if !ok {
1694 log.Println("malformed middleware")
1695 return nil, fmt.Errorf("malformed middleware")
1696 }
1697
1698 repoAt, ok := r.Context().Value("repoAt").(string)
1699 if !ok {
1700 log.Println("malformed middleware")
1701 return nil, fmt.Errorf("malformed middleware")
1702 }
1703
1704 parsedRepoAt, err := syntax.ParseATURI(repoAt)
1705 if err != nil {
1706 log.Println("malformed repo at-uri")
1707 return nil, fmt.Errorf("malformed middleware")
1708 }
1709
1710 // pass through values from the middleware
1711 description, ok := r.Context().Value("repoDescription").(string)
1712 addedAt, ok := r.Context().Value("repoAddedAt").(string)
1713
1714 return &FullyResolvedRepo{
1715 Knot: knot,
1716 OwnerId: id,
1717 RepoName: repoName,
1718 RepoAt: parsedRepoAt,
1719 Description: description,
1720 AddedAt: addedAt,
1721 }, nil
1722}
1723
1724func RolesInRepo(s *State, u *auth.User, f *FullyResolvedRepo) pages.RolesInRepo {
1725 if u != nil {
1726 r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.OwnerSlashRepo())
1727 return pages.RolesInRepo{r}
1728 } else {
1729 return pages.RolesInRepo{}
1730 }
1731}