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