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