this repo has no description
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: user.Did,
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 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1154 if err != nil {
1155 log.Println("failed to get issue", err)
1156 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1157 return
1158 }
1159
1160 collaborators, err := f.Collaborators(r.Context(), s)
1161 if err != nil {
1162 log.Println("failed to fetch repo collaborators: %w", err)
1163 }
1164 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
1165 return user.Did == collab.Did
1166 })
1167 isIssueOwner := user.Did == issue.OwnerDid
1168
1169 if isCollaborator || isIssueOwner {
1170 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt)
1171 if err != nil {
1172 log.Println("failed to reopen issue", err)
1173 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
1174 return
1175 }
1176 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
1177 return
1178 } else {
1179 log.Println("user is not the owner of the repo")
1180 http.Error(w, "forbidden", http.StatusUnauthorized)
1181 return
1182 }
1183}
1184
1185func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) {
1186 user := s.auth.GetUser(r)
1187 f, err := fullyResolvedRepo(r)
1188 if err != nil {
1189 log.Println("failed to get repo and knot", err)
1190 return
1191 }
1192
1193 issueId := chi.URLParam(r, "issue")
1194 issueIdInt, err := strconv.Atoi(issueId)
1195 if err != nil {
1196 http.Error(w, "bad issue id", http.StatusBadRequest)
1197 log.Println("failed to parse issue id", err)
1198 return
1199 }
1200
1201 switch r.Method {
1202 case http.MethodPost:
1203 body := r.FormValue("body")
1204 if body == "" {
1205 s.pages.Notice(w, "issue", "Body is required")
1206 return
1207 }
1208
1209 commentId := rand.IntN(1000000)
1210
1211 err := db.NewComment(s.db, &db.Comment{
1212 OwnerDid: user.Did,
1213 RepoAt: f.RepoAt,
1214 Issue: issueIdInt,
1215 CommentId: commentId,
1216 Body: body,
1217 })
1218 if err != nil {
1219 log.Println("failed to create comment", err)
1220 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1221 return
1222 }
1223
1224 createdAt := time.Now().Format(time.RFC3339)
1225 commentIdInt64 := int64(commentId)
1226 ownerDid := user.Did
1227 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt)
1228 if err != nil {
1229 log.Println("failed to get issue at", err)
1230 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1231 return
1232 }
1233
1234 atUri := f.RepoAt.String()
1235 client, _ := s.auth.AuthorizedClient(r)
1236 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1237 Collection: tangled.RepoIssueCommentNSID,
1238 Repo: user.Did,
1239 Rkey: s.TID(),
1240 Record: &lexutil.LexiconTypeDecoder{
1241 Val: &tangled.RepoIssueComment{
1242 Repo: &atUri,
1243 Issue: issueAt,
1244 CommentId: &commentIdInt64,
1245 Owner: &ownerDid,
1246 Body: &body,
1247 CreatedAt: &createdAt,
1248 },
1249 },
1250 })
1251 if err != nil {
1252 log.Println("failed to create comment", err)
1253 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1254 return
1255 }
1256
1257 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
1258 return
1259 }
1260}
1261
1262func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) {
1263 params := r.URL.Query()
1264 state := params.Get("state")
1265 isOpen := true
1266 switch state {
1267 case "open":
1268 isOpen = true
1269 case "closed":
1270 isOpen = false
1271 default:
1272 isOpen = true
1273 }
1274
1275 user := s.auth.GetUser(r)
1276 f, err := fullyResolvedRepo(r)
1277 if err != nil {
1278 log.Println("failed to get repo and knot", err)
1279 return
1280 }
1281
1282 issues, err := db.GetIssues(s.db, f.RepoAt, isOpen)
1283 if err != nil {
1284 log.Println("failed to get issues", err)
1285 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
1286 return
1287 }
1288
1289 identsToResolve := make([]string, len(issues))
1290 for i, issue := range issues {
1291 identsToResolve[i] = issue.OwnerDid
1292 }
1293 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
1294 didHandleMap := make(map[string]string)
1295 for _, identity := range resolvedIds {
1296 if !identity.Handle.IsInvalidHandle() {
1297 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1298 } else {
1299 didHandleMap[identity.DID.String()] = identity.DID.String()
1300 }
1301 }
1302
1303 s.pages.RepoIssues(w, pages.RepoIssuesParams{
1304 LoggedInUser: s.auth.GetUser(r),
1305 RepoInfo: f.RepoInfo(s, user),
1306 Issues: issues,
1307 DidHandleMap: didHandleMap,
1308 FilteringByOpen: isOpen,
1309 })
1310 return
1311}
1312
1313func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) {
1314 user := s.auth.GetUser(r)
1315
1316 f, err := fullyResolvedRepo(r)
1317 if err != nil {
1318 log.Println("failed to get repo and knot", err)
1319 return
1320 }
1321
1322 switch r.Method {
1323 case http.MethodGet:
1324 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
1325 LoggedInUser: user,
1326 RepoInfo: f.RepoInfo(s, user),
1327 })
1328 case http.MethodPost:
1329 title := r.FormValue("title")
1330 body := r.FormValue("body")
1331
1332 if title == "" || body == "" {
1333 s.pages.Notice(w, "issues", "Title and body are required")
1334 return
1335 }
1336
1337 tx, err := s.db.BeginTx(r.Context(), nil)
1338 if err != nil {
1339 s.pages.Notice(w, "issues", "Failed to create issue, try again later")
1340 return
1341 }
1342
1343 err = db.NewIssue(tx, &db.Issue{
1344 RepoAt: f.RepoAt,
1345 Title: title,
1346 Body: body,
1347 OwnerDid: user.Did,
1348 })
1349 if err != nil {
1350 log.Println("failed to create issue", err)
1351 s.pages.Notice(w, "issues", "Failed to create issue.")
1352 return
1353 }
1354
1355 issueId, err := db.GetIssueId(s.db, f.RepoAt)
1356 if err != nil {
1357 log.Println("failed to get issue id", err)
1358 s.pages.Notice(w, "issues", "Failed to create issue.")
1359 return
1360 }
1361
1362 client, _ := s.auth.AuthorizedClient(r)
1363 atUri := f.RepoAt.String()
1364 resp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1365 Collection: tangled.RepoIssueNSID,
1366 Repo: user.Did,
1367 Rkey: s.TID(),
1368 Record: &lexutil.LexiconTypeDecoder{
1369 Val: &tangled.RepoIssue{
1370 Repo: atUri,
1371 Title: title,
1372 Body: &body,
1373 Owner: user.Did,
1374 IssueId: int64(issueId),
1375 },
1376 },
1377 })
1378 if err != nil {
1379 log.Println("failed to create issue", err)
1380 s.pages.Notice(w, "issues", "Failed to create issue.")
1381 return
1382 }
1383
1384 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri)
1385 if err != nil {
1386 log.Println("failed to set issue at", err)
1387 s.pages.Notice(w, "issues", "Failed to create issue.")
1388 return
1389 }
1390
1391 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))
1392 return
1393 }
1394}
1395
1396func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) {
1397 user := s.auth.GetUser(r)
1398 params := r.URL.Query()
1399
1400 state := db.PullOpen
1401 switch params.Get("state") {
1402 case "closed":
1403 state = db.PullClosed
1404 case "merged":
1405 state = db.PullMerged
1406 }
1407
1408 f, err := fullyResolvedRepo(r)
1409 if err != nil {
1410 log.Println("failed to get repo and knot", err)
1411 return
1412 }
1413
1414 pulls, err := db.GetPulls(s.db, f.RepoAt, state)
1415 if err != nil {
1416 log.Println("failed to get pulls", err)
1417 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
1418 return
1419 }
1420
1421 identsToResolve := make([]string, len(pulls))
1422 for i, pull := range pulls {
1423 identsToResolve[i] = pull.OwnerDid
1424 }
1425 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
1426 didHandleMap := make(map[string]string)
1427 for _, identity := range resolvedIds {
1428 if !identity.Handle.IsInvalidHandle() {
1429 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1430 } else {
1431 didHandleMap[identity.DID.String()] = identity.DID.String()
1432 }
1433 }
1434
1435 s.pages.RepoPulls(w, pages.RepoPullsParams{
1436 LoggedInUser: s.auth.GetUser(r),
1437 RepoInfo: f.RepoInfo(s, user),
1438 Pulls: pulls,
1439 DidHandleMap: didHandleMap,
1440 FilteringBy: state,
1441 })
1442 return
1443}
1444
1445func (s *State) MergePull(w http.ResponseWriter, r *http.Request) {
1446 user := s.auth.GetUser(r)
1447 f, err := fullyResolvedRepo(r)
1448 if err != nil {
1449 log.Println("failed to resolve repo:", err)
1450 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1451 return
1452 }
1453
1454 pull, ok := r.Context().Value("pull").(*db.Pull)
1455 if !ok {
1456 log.Println("failed to get pull")
1457 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1458 return
1459 }
1460
1461 secret, err := db.GetRegistrationKey(s.db, f.Knot)
1462 if err != nil {
1463 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
1464 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1465 return
1466 }
1467
1468 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
1469 if err != nil {
1470 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
1471 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1472 return
1473 }
1474
1475 // Merge the pull request
1476 resp, err := ksClient.Merge([]byte(pull.Patch), user.Did, f.RepoName, pull.TargetBranch)
1477 if err != nil {
1478 log.Printf("failed to merge pull request: %s", err)
1479 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1480 return
1481 }
1482
1483 if resp.StatusCode == http.StatusOK {
1484 err := db.MergePull(s.db, f.RepoAt, pull.PullId)
1485 if err != nil {
1486 log.Printf("failed to update pull request status in database: %s", err)
1487 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1488 return
1489 }
1490 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
1491 } else {
1492 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
1493 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1494 }
1495}
1496
1497func (s *State) PullComment(w http.ResponseWriter, r *http.Request) {
1498 user := s.auth.GetUser(r)
1499 f, err := fullyResolvedRepo(r)
1500 if err != nil {
1501 log.Println("failed to get repo and knot", err)
1502 return
1503 }
1504
1505 pullId := chi.URLParam(r, "pull")
1506 pullIdInt, err := strconv.Atoi(pullId)
1507 if err != nil {
1508 http.Error(w, "bad pull id", http.StatusBadRequest)
1509 log.Println("failed to parse pull id", err)
1510 return
1511 }
1512
1513 switch r.Method {
1514 case http.MethodPost:
1515 body := r.FormValue("body")
1516 if body == "" {
1517 s.pages.Notice(w, "pull", "Comment body is required")
1518 return
1519 }
1520
1521 // Start a transaction
1522 tx, err := s.db.BeginTx(r.Context(), nil)
1523 if err != nil {
1524 log.Println("failed to start transaction", err)
1525 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
1526 return
1527 }
1528 defer tx.Rollback() // Will be ignored if we commit
1529
1530 commentId := rand.IntN(1000000)
1531 createdAt := time.Now().Format(time.RFC3339)
1532 commentIdInt64 := int64(commentId)
1533 ownerDid := user.Did
1534
1535 pullAt, err := db.GetPullAt(s.db, f.RepoAt, pullIdInt)
1536 if err != nil {
1537 log.Println("failed to get pull at", err)
1538 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
1539 return
1540 }
1541
1542 atUri := f.RepoAt.String()
1543 client, _ := s.auth.AuthorizedClient(r)
1544 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1545 Collection: tangled.RepoPullCommentNSID,
1546 Repo: user.Did,
1547 Rkey: s.TID(),
1548 Record: &lexutil.LexiconTypeDecoder{
1549 Val: &tangled.RepoPullComment{
1550 Repo: &atUri,
1551 Pull: pullAt,
1552 CommentId: &commentIdInt64,
1553 Owner: &ownerDid,
1554 Body: &body,
1555 CreatedAt: &createdAt,
1556 },
1557 },
1558 })
1559 if err != nil {
1560 log.Println("failed to create pull comment", err)
1561 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
1562 return
1563 }
1564
1565 // Create the pull comment in the database with the commentAt field
1566 err = db.NewPullComment(tx, &db.PullComment{
1567 OwnerDid: user.Did,
1568 RepoAt: f.RepoAt.String(),
1569 CommentId: commentId,
1570 PullId: pullIdInt,
1571 Body: body,
1572 CommentAt: atResp.Uri,
1573 })
1574 if err != nil {
1575 log.Println("failed to create pull comment", err)
1576 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
1577 return
1578 }
1579
1580 // Commit the transaction
1581 if err = tx.Commit(); err != nil {
1582 log.Println("failed to commit transaction", err)
1583 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
1584 return
1585 }
1586
1587 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pullIdInt, commentId))
1588 return
1589 }
1590}
1591
1592func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) {
1593 user := s.auth.GetUser(r)
1594
1595 f, err := fullyResolvedRepo(r)
1596 if err != nil {
1597 log.Println("malformed middleware")
1598 return
1599 }
1600
1601 pull, ok := r.Context().Value("pull").(*db.Pull)
1602 if !ok {
1603 log.Println("failed to get pull")
1604 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1605 return
1606 }
1607
1608 // auth filter: only owner or collaborators can close
1609 roles := RolesInRepo(s, user, f)
1610 isCollaborator := roles.IsCollaborator()
1611 isPullAuthor := user.Did == pull.OwnerDid
1612 isCloseAllowed := isCollaborator || isPullAuthor
1613 if !isCloseAllowed {
1614 log.Println("failed to close pull")
1615 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
1616 return
1617 }
1618
1619 // Start a transaction
1620 tx, err := s.db.BeginTx(r.Context(), nil)
1621 if err != nil {
1622 log.Println("failed to start transaction", err)
1623 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1624 return
1625 }
1626
1627 // Close the pull in the database
1628 err = db.ClosePull(tx, f.RepoAt, pull.PullId)
1629 if err != nil {
1630 log.Println("failed to close pull", err)
1631 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1632 return
1633 }
1634
1635 // Commit the transaction
1636 if err = tx.Commit(); err != nil {
1637 log.Println("failed to commit transaction", err)
1638 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1639 return
1640 }
1641
1642 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1643 return
1644}
1645
1646func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) {
1647 user := s.auth.GetUser(r)
1648
1649 f, err := fullyResolvedRepo(r)
1650 if err != nil {
1651 log.Println("failed to resolve repo", err)
1652 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1653 return
1654 }
1655
1656 pull, ok := r.Context().Value("pull").(*db.Pull)
1657 if !ok {
1658 log.Println("failed to get pull")
1659 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1660 return
1661 }
1662
1663 // auth filter: only owner or collaborators can close
1664 roles := RolesInRepo(s, user, f)
1665 isCollaborator := roles.IsCollaborator()
1666 isPullAuthor := user.Did == pull.OwnerDid
1667 isCloseAllowed := isCollaborator || isPullAuthor
1668 if !isCloseAllowed {
1669 log.Println("failed to close pull")
1670 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
1671 return
1672 }
1673
1674 // Start a transaction
1675 tx, err := s.db.BeginTx(r.Context(), nil)
1676 if err != nil {
1677 log.Println("failed to start transaction", err)
1678 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1679 return
1680 }
1681
1682 // Reopen the pull in the database
1683 err = db.ReopenPull(tx, f.RepoAt, pull.PullId)
1684 if err != nil {
1685 log.Println("failed to reopen pull", err)
1686 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1687 return
1688 }
1689
1690 // Commit the transaction
1691 if err = tx.Commit(); err != nil {
1692 log.Println("failed to commit transaction", err)
1693 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1694 return
1695 }
1696
1697 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1698 return
1699}
1700
1701func fullyResolvedRepo(r *http.Request) (*FullyResolvedRepo, error) {
1702 repoName := chi.URLParam(r, "repo")
1703 knot, ok := r.Context().Value("knot").(string)
1704 if !ok {
1705 log.Println("malformed middleware")
1706 return nil, fmt.Errorf("malformed middleware")
1707 }
1708 id, ok := r.Context().Value("resolvedId").(identity.Identity)
1709 if !ok {
1710 log.Println("malformed middleware")
1711 return nil, fmt.Errorf("malformed middleware")
1712 }
1713
1714 repoAt, ok := r.Context().Value("repoAt").(string)
1715 if !ok {
1716 log.Println("malformed middleware")
1717 return nil, fmt.Errorf("malformed middleware")
1718 }
1719
1720 parsedRepoAt, err := syntax.ParseATURI(repoAt)
1721 if err != nil {
1722 log.Println("malformed repo at-uri")
1723 return nil, fmt.Errorf("malformed middleware")
1724 }
1725
1726 // pass through values from the middleware
1727 description, ok := r.Context().Value("repoDescription").(string)
1728 addedAt, ok := r.Context().Value("repoAddedAt").(string)
1729
1730 return &FullyResolvedRepo{
1731 Knot: knot,
1732 OwnerId: id,
1733 RepoName: repoName,
1734 RepoAt: parsedRepoAt,
1735 Description: description,
1736 AddedAt: addedAt,
1737 }, nil
1738}
1739
1740func RolesInRepo(s *State, u *auth.User, f *FullyResolvedRepo) pages.RolesInRepo {
1741 if u != nil {
1742 r := s.enforcer.GetPermissionsInRepo(u.Did, f.Knot, f.OwnerSlashRepo())
1743 return pages.RolesInRepo{r}
1744 } else {
1745 return pages.RolesInRepo{}
1746 }
1747}