this repo has no description
1package state
2
3import (
4 "context"
5 "database/sql"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "log"
11 mathrand "math/rand/v2"
12 "net/http"
13 "path"
14 "slices"
15 "strconv"
16 "strings"
17 "time"
18
19 "tangled.sh/tangled.sh/core/api/tangled"
20 "tangled.sh/tangled.sh/core/appview"
21 "tangled.sh/tangled.sh/core/appview/db"
22 "tangled.sh/tangled.sh/core/appview/oauth"
23 "tangled.sh/tangled.sh/core/appview/pages"
24 "tangled.sh/tangled.sh/core/appview/pages/markup"
25 "tangled.sh/tangled.sh/core/appview/pages/repoinfo"
26 "tangled.sh/tangled.sh/core/appview/pagination"
27 "tangled.sh/tangled.sh/core/knotclient"
28 "tangled.sh/tangled.sh/core/patchutil"
29 "tangled.sh/tangled.sh/core/types"
30
31 "github.com/bluesky-social/indigo/atproto/data"
32 "github.com/bluesky-social/indigo/atproto/identity"
33 "github.com/bluesky-social/indigo/atproto/syntax"
34 securejoin "github.com/cyphar/filepath-securejoin"
35 "github.com/go-chi/chi/v5"
36 "github.com/go-git/go-git/v5/plumbing"
37 "github.com/posthog/posthog-go"
38
39 comatproto "github.com/bluesky-social/indigo/api/atproto"
40 lexutil "github.com/bluesky-social/indigo/lex/util"
41)
42
43func (s *State) RepoIndex(w http.ResponseWriter, r *http.Request) {
44 ref := chi.URLParam(r, "ref")
45 f, err := s.fullyResolvedRepo(r)
46 if err != nil {
47 log.Println("failed to fully resolve repo", err)
48 return
49 }
50
51 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
52 if err != nil {
53 log.Printf("failed to create unsigned client for %s", f.Knot)
54 s.pages.Error503(w)
55 return
56 }
57
58 result, err := us.Index(f.OwnerDid(), f.RepoName, ref)
59 if err != nil {
60 s.pages.Error503(w)
61 log.Println("failed to reach knotserver", err)
62 return
63 }
64
65 tagMap := make(map[string][]string)
66 for _, tag := range result.Tags {
67 hash := tag.Hash
68 if tag.Tag != nil {
69 hash = tag.Tag.Target.String()
70 }
71 tagMap[hash] = append(tagMap[hash], tag.Name)
72 }
73
74 for _, branch := range result.Branches {
75 hash := branch.Hash
76 tagMap[hash] = append(tagMap[hash], branch.Name)
77 }
78
79 slices.SortFunc(result.Branches, func(a, b types.Branch) int {
80 if a.Name == result.Ref {
81 return -1
82 }
83 if a.IsDefault {
84 return -1
85 }
86 if b.IsDefault {
87 return 1
88 }
89 if a.Commit != nil {
90 if a.Commit.Committer.When.Before(b.Commit.Committer.When) {
91 return 1
92 } else {
93 return -1
94 }
95 }
96 return strings.Compare(a.Name, b.Name) * -1
97 })
98
99 commitCount := len(result.Commits)
100 branchCount := len(result.Branches)
101 tagCount := len(result.Tags)
102 fileCount := len(result.Files)
103
104 commitCount, branchCount, tagCount = balanceIndexItems(commitCount, branchCount, tagCount, fileCount)
105 commitsTrunc := result.Commits[:min(commitCount, len(result.Commits))]
106 tagsTrunc := result.Tags[:min(tagCount, len(result.Tags))]
107 branchesTrunc := result.Branches[:min(branchCount, len(result.Branches))]
108
109 emails := uniqueEmails(commitsTrunc)
110
111 user := s.oauth.GetUser(r)
112 repoInfo := f.RepoInfo(s, user)
113
114 secret, err := db.GetRegistrationKey(s.db, f.Knot)
115 if err != nil {
116 log.Printf("failed to get registration key for %s: %s", f.Knot, err)
117 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
118 }
119
120 signedClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
121 if err != nil {
122 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
123 return
124 }
125
126 var forkInfo *types.ForkInfo
127 if user != nil && (repoInfo.Roles.IsOwner() || repoInfo.Roles.IsCollaborator()) {
128 forkInfo, err = getForkInfo(repoInfo, s, f, user, signedClient)
129 if err != nil {
130 log.Printf("Failed to fetch fork information: %v", err)
131 return
132 }
133 }
134
135 repoLanguages, err := signedClient.RepoLanguages(f.OwnerDid(), f.RepoName, ref)
136 if err != nil {
137 log.Printf("failed to compute language percentages: %s", err)
138 // non-fatal
139 }
140
141 s.pages.RepoIndexPage(w, pages.RepoIndexParams{
142 LoggedInUser: user,
143 RepoInfo: repoInfo,
144 TagMap: tagMap,
145 RepoIndexResponse: *result,
146 CommitsTrunc: commitsTrunc,
147 TagsTrunc: tagsTrunc,
148 ForkInfo: forkInfo,
149 BranchesTrunc: branchesTrunc,
150 EmailToDidOrHandle: EmailToDidOrHandle(s, emails),
151 Languages: repoLanguages,
152 })
153 return
154}
155
156func getForkInfo(
157 repoInfo repoinfo.RepoInfo,
158 s *State,
159 f *FullyResolvedRepo,
160 user *oauth.User,
161 signedClient *knotclient.SignedClient,
162) (*types.ForkInfo, error) {
163 if user == nil {
164 return nil, nil
165 }
166
167 forkInfo := types.ForkInfo{
168 IsFork: repoInfo.Source != nil,
169 Status: types.UpToDate,
170 }
171
172 if !forkInfo.IsFork {
173 forkInfo.IsFork = false
174 return &forkInfo, nil
175 }
176
177 us, err := knotclient.NewUnsignedClient(repoInfo.Source.Knot, s.config.Core.Dev)
178 if err != nil {
179 log.Printf("failed to create unsigned client for %s", repoInfo.Source.Knot)
180 return nil, err
181 }
182
183 result, err := us.Branches(repoInfo.Source.Did, repoInfo.Source.Name)
184 if err != nil {
185 log.Println("failed to reach knotserver", err)
186 return nil, err
187 }
188
189 if !slices.ContainsFunc(result.Branches, func(branch types.Branch) bool {
190 return branch.Name == f.Ref
191 }) {
192 forkInfo.Status = types.MissingBranch
193 return &forkInfo, nil
194 }
195
196 newHiddenRefResp, err := signedClient.NewHiddenRef(user.Did, repoInfo.Name, f.Ref, f.Ref)
197 if err != nil || newHiddenRefResp.StatusCode != http.StatusNoContent {
198 log.Printf("failed to update tracking branch: %s", err)
199 return nil, err
200 }
201
202 hiddenRef := fmt.Sprintf("hidden/%s/%s", f.Ref, f.Ref)
203
204 var status types.AncestorCheckResponse
205 forkSyncableResp, err := signedClient.RepoForkAheadBehind(user.Did, string(f.RepoAt), repoInfo.Name, f.Ref, hiddenRef)
206 if err != nil {
207 log.Printf("failed to check if fork is ahead/behind: %s", err)
208 return nil, err
209 }
210
211 if err := json.NewDecoder(forkSyncableResp.Body).Decode(&status); err != nil {
212 log.Printf("failed to decode fork status: %s", err)
213 return nil, err
214 }
215
216 forkInfo.Status = status.Status
217 return &forkInfo, nil
218}
219
220func (s *State) RepoLog(w http.ResponseWriter, r *http.Request) {
221 f, err := s.fullyResolvedRepo(r)
222 if err != nil {
223 log.Println("failed to fully resolve repo", err)
224 return
225 }
226
227 page := 1
228 if r.URL.Query().Get("page") != "" {
229 page, err = strconv.Atoi(r.URL.Query().Get("page"))
230 if err != nil {
231 page = 1
232 }
233 }
234
235 ref := chi.URLParam(r, "ref")
236
237 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
238 if err != nil {
239 log.Println("failed to create unsigned client", err)
240 return
241 }
242
243 repolog, err := us.Log(f.OwnerDid(), f.RepoName, ref, page)
244 if err != nil {
245 log.Println("failed to reach knotserver", err)
246 return
247 }
248
249 result, err := us.Tags(f.OwnerDid(), f.RepoName)
250 if err != nil {
251 log.Println("failed to reach knotserver", err)
252 return
253 }
254
255 tagMap := make(map[string][]string)
256 for _, tag := range result.Tags {
257 hash := tag.Hash
258 if tag.Tag != nil {
259 hash = tag.Tag.Target.String()
260 }
261 tagMap[hash] = append(tagMap[hash], tag.Name)
262 }
263
264 user := s.oauth.GetUser(r)
265 s.pages.RepoLog(w, pages.RepoLogParams{
266 LoggedInUser: user,
267 TagMap: tagMap,
268 RepoInfo: f.RepoInfo(s, user),
269 RepoLogResponse: *repolog,
270 EmailToDidOrHandle: EmailToDidOrHandle(s, uniqueEmails(repolog.Commits)),
271 })
272 return
273}
274
275func (s *State) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
276 f, err := s.fullyResolvedRepo(r)
277 if err != nil {
278 log.Println("failed to get repo and knot", err)
279 w.WriteHeader(http.StatusBadRequest)
280 return
281 }
282
283 user := s.oauth.GetUser(r)
284 s.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
285 RepoInfo: f.RepoInfo(s, user),
286 })
287 return
288}
289
290func (s *State) RepoDescription(w http.ResponseWriter, r *http.Request) {
291 f, err := s.fullyResolvedRepo(r)
292 if err != nil {
293 log.Println("failed to get repo and knot", err)
294 w.WriteHeader(http.StatusBadRequest)
295 return
296 }
297
298 repoAt := f.RepoAt
299 rkey := repoAt.RecordKey().String()
300 if rkey == "" {
301 log.Println("invalid aturi for repo", err)
302 w.WriteHeader(http.StatusInternalServerError)
303 return
304 }
305
306 user := s.oauth.GetUser(r)
307
308 switch r.Method {
309 case http.MethodGet:
310 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
311 RepoInfo: f.RepoInfo(s, user),
312 })
313 return
314 case http.MethodPut:
315 user := s.oauth.GetUser(r)
316 newDescription := r.FormValue("description")
317 client, err := s.oauth.AuthorizedClient(r)
318 if err != nil {
319 log.Println("failed to get client")
320 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
321 return
322 }
323
324 // optimistic update
325 err = db.UpdateDescription(s.db, string(repoAt), newDescription)
326 if err != nil {
327 log.Println("failed to perferom update-description query", err)
328 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
329 return
330 }
331
332 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
333 //
334 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
335 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
336 if err != nil {
337 // failed to get record
338 s.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
339 return
340 }
341 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
342 Collection: tangled.RepoNSID,
343 Repo: user.Did,
344 Rkey: rkey,
345 SwapRecord: ex.Cid,
346 Record: &lexutil.LexiconTypeDecoder{
347 Val: &tangled.Repo{
348 Knot: f.Knot,
349 Name: f.RepoName,
350 Owner: user.Did,
351 CreatedAt: f.CreatedAt,
352 Description: &newDescription,
353 },
354 },
355 })
356
357 if err != nil {
358 log.Println("failed to perferom update-description query", err)
359 // failed to get record
360 s.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.")
361 return
362 }
363
364 newRepoInfo := f.RepoInfo(s, user)
365 newRepoInfo.Description = newDescription
366
367 s.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
368 RepoInfo: newRepoInfo,
369 })
370 return
371 }
372}
373
374func (s *State) RepoCommit(w http.ResponseWriter, r *http.Request) {
375 f, err := s.fullyResolvedRepo(r)
376 if err != nil {
377 log.Println("failed to fully resolve repo", err)
378 return
379 }
380 ref := chi.URLParam(r, "ref")
381 protocol := "http"
382 if !s.config.Core.Dev {
383 protocol = "https"
384 }
385
386 if !plumbing.IsHash(ref) {
387 s.pages.Error404(w)
388 return
389 }
390
391 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref))
392 if err != nil {
393 log.Println("failed to reach knotserver", err)
394 return
395 }
396
397 body, err := io.ReadAll(resp.Body)
398 if err != nil {
399 log.Printf("Error reading response body: %v", err)
400 return
401 }
402
403 var result types.RepoCommitResponse
404 err = json.Unmarshal(body, &result)
405 if err != nil {
406 log.Println("failed to parse response:", err)
407 return
408 }
409
410 user := s.oauth.GetUser(r)
411 s.pages.RepoCommit(w, pages.RepoCommitParams{
412 LoggedInUser: user,
413 RepoInfo: f.RepoInfo(s, user),
414 RepoCommitResponse: result,
415 EmailToDidOrHandle: EmailToDidOrHandle(s, []string{result.Diff.Commit.Author.Email}),
416 })
417 return
418}
419
420func (s *State) RepoTree(w http.ResponseWriter, r *http.Request) {
421 f, err := s.fullyResolvedRepo(r)
422 if err != nil {
423 log.Println("failed to fully resolve repo", err)
424 return
425 }
426
427 ref := chi.URLParam(r, "ref")
428 treePath := chi.URLParam(r, "*")
429 protocol := "http"
430 if !s.config.Core.Dev {
431 protocol = "https"
432 }
433 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath))
434 if err != nil {
435 log.Println("failed to reach knotserver", err)
436 return
437 }
438
439 body, err := io.ReadAll(resp.Body)
440 if err != nil {
441 log.Printf("Error reading response body: %v", err)
442 return
443 }
444
445 var result types.RepoTreeResponse
446 err = json.Unmarshal(body, &result)
447 if err != nil {
448 log.Println("failed to parse response:", err)
449 return
450 }
451
452 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
453 // so we can safely redirect to the "parent" (which is the same file).
454 if len(result.Files) == 0 && result.Parent == treePath {
455 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound)
456 return
457 }
458
459 user := s.oauth.GetUser(r)
460
461 var breadcrumbs [][]string
462 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
463 if treePath != "" {
464 for idx, elem := range strings.Split(treePath, "/") {
465 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
466 }
467 }
468
469 baseTreeLink := path.Join(f.OwnerSlashRepo(), "tree", ref, treePath)
470 baseBlobLink := path.Join(f.OwnerSlashRepo(), "blob", ref, treePath)
471
472 s.pages.RepoTree(w, pages.RepoTreeParams{
473 LoggedInUser: user,
474 BreadCrumbs: breadcrumbs,
475 BaseTreeLink: baseTreeLink,
476 BaseBlobLink: baseBlobLink,
477 RepoInfo: f.RepoInfo(s, user),
478 RepoTreeResponse: result,
479 })
480 return
481}
482
483func (s *State) RepoTags(w http.ResponseWriter, r *http.Request) {
484 f, err := s.fullyResolvedRepo(r)
485 if err != nil {
486 log.Println("failed to get repo and knot", err)
487 return
488 }
489
490 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
491 if err != nil {
492 log.Println("failed to create unsigned client", err)
493 return
494 }
495
496 result, err := us.Tags(f.OwnerDid(), f.RepoName)
497 if err != nil {
498 log.Println("failed to reach knotserver", err)
499 return
500 }
501
502 artifacts, err := db.GetArtifact(s.db, db.FilterEq("repo_at", f.RepoAt))
503 if err != nil {
504 log.Println("failed grab artifacts", err)
505 return
506 }
507
508 // convert artifacts to map for easy UI building
509 artifactMap := make(map[plumbing.Hash][]db.Artifact)
510 for _, a := range artifacts {
511 artifactMap[a.Tag] = append(artifactMap[a.Tag], a)
512 }
513
514 var danglingArtifacts []db.Artifact
515 for _, a := range artifacts {
516 found := false
517 for _, t := range result.Tags {
518 if t.Tag != nil {
519 if t.Tag.Hash == a.Tag {
520 found = true
521 }
522 }
523 }
524
525 if !found {
526 danglingArtifacts = append(danglingArtifacts, a)
527 }
528 }
529
530 user := s.oauth.GetUser(r)
531 s.pages.RepoTags(w, pages.RepoTagsParams{
532 LoggedInUser: user,
533 RepoInfo: f.RepoInfo(s, user),
534 RepoTagsResponse: *result,
535 ArtifactMap: artifactMap,
536 DanglingArtifacts: danglingArtifacts,
537 })
538 return
539}
540
541func (s *State) RepoBranches(w http.ResponseWriter, r *http.Request) {
542 f, err := s.fullyResolvedRepo(r)
543 if err != nil {
544 log.Println("failed to get repo and knot", err)
545 return
546 }
547
548 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
549 if err != nil {
550 log.Println("failed to create unsigned client", err)
551 return
552 }
553
554 result, err := us.Branches(f.OwnerDid(), f.RepoName)
555 if err != nil {
556 log.Println("failed to reach knotserver", err)
557 return
558 }
559
560 slices.SortFunc(result.Branches, func(a, b types.Branch) int {
561 if a.IsDefault {
562 return -1
563 }
564 if b.IsDefault {
565 return 1
566 }
567 if a.Commit != nil {
568 if a.Commit.Committer.When.Before(b.Commit.Committer.When) {
569 return 1
570 } else {
571 return -1
572 }
573 }
574 return strings.Compare(a.Name, b.Name) * -1
575 })
576
577 user := s.oauth.GetUser(r)
578 s.pages.RepoBranches(w, pages.RepoBranchesParams{
579 LoggedInUser: user,
580 RepoInfo: f.RepoInfo(s, user),
581 RepoBranchesResponse: *result,
582 })
583 return
584}
585
586func (s *State) RepoBlob(w http.ResponseWriter, r *http.Request) {
587 f, err := s.fullyResolvedRepo(r)
588 if err != nil {
589 log.Println("failed to get repo and knot", err)
590 return
591 }
592
593 ref := chi.URLParam(r, "ref")
594 filePath := chi.URLParam(r, "*")
595 protocol := "http"
596 if !s.config.Core.Dev {
597 protocol = "https"
598 }
599 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
600 if err != nil {
601 log.Println("failed to reach knotserver", err)
602 return
603 }
604
605 body, err := io.ReadAll(resp.Body)
606 if err != nil {
607 log.Printf("Error reading response body: %v", err)
608 return
609 }
610
611 var result types.RepoBlobResponse
612 err = json.Unmarshal(body, &result)
613 if err != nil {
614 log.Println("failed to parse response:", err)
615 return
616 }
617
618 var breadcrumbs [][]string
619 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
620 if filePath != "" {
621 for idx, elem := range strings.Split(filePath, "/") {
622 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
623 }
624 }
625
626 showRendered := false
627 renderToggle := false
628
629 if markup.GetFormat(result.Path) == markup.FormatMarkdown {
630 renderToggle = true
631 showRendered = r.URL.Query().Get("code") != "true"
632 }
633
634 user := s.oauth.GetUser(r)
635 s.pages.RepoBlob(w, pages.RepoBlobParams{
636 LoggedInUser: user,
637 RepoInfo: f.RepoInfo(s, user),
638 RepoBlobResponse: result,
639 BreadCrumbs: breadcrumbs,
640 ShowRendered: showRendered,
641 RenderToggle: renderToggle,
642 })
643 return
644}
645
646func (s *State) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
647 f, err := s.fullyResolvedRepo(r)
648 if err != nil {
649 log.Println("failed to get repo and knot", err)
650 return
651 }
652
653 ref := chi.URLParam(r, "ref")
654 filePath := chi.URLParam(r, "*")
655
656 protocol := "http"
657 if !s.config.Core.Dev {
658 protocol = "https"
659 }
660 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
661 if err != nil {
662 log.Println("failed to reach knotserver", err)
663 return
664 }
665
666 body, err := io.ReadAll(resp.Body)
667 if err != nil {
668 log.Printf("Error reading response body: %v", err)
669 return
670 }
671
672 var result types.RepoBlobResponse
673 err = json.Unmarshal(body, &result)
674 if err != nil {
675 log.Println("failed to parse response:", err)
676 return
677 }
678
679 if result.IsBinary {
680 w.Header().Set("Content-Type", "application/octet-stream")
681 w.Write(body)
682 return
683 }
684
685 w.Header().Set("Content-Type", "text/plain")
686 w.Write([]byte(result.Contents))
687 return
688}
689
690func (s *State) AddCollaborator(w http.ResponseWriter, r *http.Request) {
691 f, err := s.fullyResolvedRepo(r)
692 if err != nil {
693 log.Println("failed to get repo and knot", err)
694 return
695 }
696
697 collaborator := r.FormValue("collaborator")
698 if collaborator == "" {
699 http.Error(w, "malformed form", http.StatusBadRequest)
700 return
701 }
702
703 collaboratorIdent, err := s.resolver.ResolveIdent(r.Context(), collaborator)
704 if err != nil {
705 w.Write([]byte("failed to resolve collaborator did to a handle"))
706 return
707 }
708 log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot)
709
710 // TODO: create an atproto record for this
711
712 secret, err := db.GetRegistrationKey(s.db, f.Knot)
713 if err != nil {
714 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
715 return
716 }
717
718 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
719 if err != nil {
720 log.Println("failed to create client to ", f.Knot)
721 return
722 }
723
724 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String())
725 if err != nil {
726 log.Printf("failed to make request to %s: %s", f.Knot, err)
727 return
728 }
729
730 if ksResp.StatusCode != http.StatusNoContent {
731 w.Write([]byte(fmt.Sprint("knotserver failed to add collaborator: ", err)))
732 return
733 }
734
735 tx, err := s.db.BeginTx(r.Context(), nil)
736 if err != nil {
737 log.Println("failed to start tx")
738 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
739 return
740 }
741 defer func() {
742 tx.Rollback()
743 err = s.enforcer.E.LoadPolicy()
744 if err != nil {
745 log.Println("failed to rollback policies")
746 }
747 }()
748
749 err = s.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
750 if err != nil {
751 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
752 return
753 }
754
755 err = db.AddCollaborator(s.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot)
756 if err != nil {
757 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
758 return
759 }
760
761 err = tx.Commit()
762 if err != nil {
763 log.Println("failed to commit changes", err)
764 http.Error(w, err.Error(), http.StatusInternalServerError)
765 return
766 }
767
768 err = s.enforcer.E.SavePolicy()
769 if err != nil {
770 log.Println("failed to update ACLs", err)
771 http.Error(w, err.Error(), http.StatusInternalServerError)
772 return
773 }
774
775 w.Write([]byte(fmt.Sprint("added collaborator: ", collaboratorIdent.Handle.String())))
776
777}
778
779func (s *State) DeleteRepo(w http.ResponseWriter, r *http.Request) {
780 user := s.oauth.GetUser(r)
781
782 f, err := s.fullyResolvedRepo(r)
783 if err != nil {
784 log.Println("failed to get repo and knot", err)
785 return
786 }
787
788 // remove record from pds
789 xrpcClient, err := s.oauth.AuthorizedClient(r)
790 if err != nil {
791 log.Println("failed to get authorized client", err)
792 return
793 }
794 repoRkey := f.RepoAt.RecordKey().String()
795 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
796 Collection: tangled.RepoNSID,
797 Repo: user.Did,
798 Rkey: repoRkey,
799 })
800 if err != nil {
801 log.Printf("failed to delete record: %s", err)
802 s.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.")
803 return
804 }
805 log.Println("removed repo record ", f.RepoAt.String())
806
807 secret, err := db.GetRegistrationKey(s.db, f.Knot)
808 if err != nil {
809 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
810 return
811 }
812
813 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
814 if err != nil {
815 log.Println("failed to create client to ", f.Knot)
816 return
817 }
818
819 ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName)
820 if err != nil {
821 log.Printf("failed to make request to %s: %s", f.Knot, err)
822 return
823 }
824
825 if ksResp.StatusCode != http.StatusNoContent {
826 log.Println("failed to remove repo from knot, continuing anyway ", f.Knot)
827 } else {
828 log.Println("removed repo from knot ", f.Knot)
829 }
830
831 tx, err := s.db.BeginTx(r.Context(), nil)
832 if err != nil {
833 log.Println("failed to start tx")
834 w.Write([]byte(fmt.Sprint("failed to add collaborator: ", err)))
835 return
836 }
837 defer func() {
838 tx.Rollback()
839 err = s.enforcer.E.LoadPolicy()
840 if err != nil {
841 log.Println("failed to rollback policies")
842 }
843 }()
844
845 // remove collaborator RBAC
846 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
847 if err != nil {
848 s.pages.Notice(w, "settings-delete", "Failed to remove collaborators")
849 return
850 }
851 for _, c := range repoCollaborators {
852 did := c[0]
853 s.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo())
854 }
855 log.Println("removed collaborators")
856
857 // remove repo RBAC
858 err = s.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
859 if err != nil {
860 s.pages.Notice(w, "settings-delete", "Failed to update RBAC rules")
861 return
862 }
863
864 // remove repo from db
865 err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName)
866 if err != nil {
867 s.pages.Notice(w, "settings-delete", "Failed to update appview")
868 return
869 }
870 log.Println("removed repo from db")
871
872 err = tx.Commit()
873 if err != nil {
874 log.Println("failed to commit changes", err)
875 http.Error(w, err.Error(), http.StatusInternalServerError)
876 return
877 }
878
879 err = s.enforcer.E.SavePolicy()
880 if err != nil {
881 log.Println("failed to update ACLs", err)
882 http.Error(w, err.Error(), http.StatusInternalServerError)
883 return
884 }
885
886 s.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
887}
888
889func (s *State) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
890 f, err := s.fullyResolvedRepo(r)
891 if err != nil {
892 log.Println("failed to get repo and knot", err)
893 return
894 }
895
896 branch := r.FormValue("branch")
897 if branch == "" {
898 http.Error(w, "malformed form", http.StatusBadRequest)
899 return
900 }
901
902 secret, err := db.GetRegistrationKey(s.db, f.Knot)
903 if err != nil {
904 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
905 return
906 }
907
908 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
909 if err != nil {
910 log.Println("failed to create client to ", f.Knot)
911 return
912 }
913
914 ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch)
915 if err != nil {
916 log.Printf("failed to make request to %s: %s", f.Knot, err)
917 return
918 }
919
920 if ksResp.StatusCode != http.StatusNoContent {
921 s.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.")
922 return
923 }
924
925 w.Write([]byte(fmt.Sprint("default branch set to: ", branch)))
926}
927
928func (s *State) RepoSettings(w http.ResponseWriter, r *http.Request) {
929 f, err := s.fullyResolvedRepo(r)
930 if err != nil {
931 log.Println("failed to get repo and knot", err)
932 return
933 }
934
935 switch r.Method {
936 case http.MethodGet:
937 // for now, this is just pubkeys
938 user := s.oauth.GetUser(r)
939 repoCollaborators, err := f.Collaborators(r.Context(), s)
940 if err != nil {
941 log.Println("failed to get collaborators", err)
942 }
943
944 isCollaboratorInviteAllowed := false
945 if user != nil {
946 ok, err := s.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo())
947 if err == nil && ok {
948 isCollaboratorInviteAllowed = true
949 }
950 }
951
952 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
953 if err != nil {
954 log.Println("failed to create unsigned client", err)
955 return
956 }
957
958 result, err := us.Branches(f.OwnerDid(), f.RepoName)
959 if err != nil {
960 log.Println("failed to reach knotserver", err)
961 return
962 }
963
964 s.pages.RepoSettings(w, pages.RepoSettingsParams{
965 LoggedInUser: user,
966 RepoInfo: f.RepoInfo(s, user),
967 Collaborators: repoCollaborators,
968 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
969 Branches: result.Branches,
970 })
971 }
972}
973
974type FullyResolvedRepo struct {
975 Knot string
976 OwnerId identity.Identity
977 RepoName string
978 RepoAt syntax.ATURI
979 Description string
980 CreatedAt string
981 Ref string
982 CurrentDir string
983}
984
985func (f *FullyResolvedRepo) OwnerDid() string {
986 return f.OwnerId.DID.String()
987}
988
989func (f *FullyResolvedRepo) OwnerHandle() string {
990 return f.OwnerId.Handle.String()
991}
992
993func (f *FullyResolvedRepo) OwnerSlashRepo() string {
994 handle := f.OwnerId.Handle
995
996 var p string
997 if handle != "" && !handle.IsInvalidHandle() {
998 p, _ = securejoin.SecureJoin(fmt.Sprintf("@%s", handle), f.RepoName)
999 } else {
1000 p, _ = securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
1001 }
1002
1003 return p
1004}
1005
1006func (f *FullyResolvedRepo) DidSlashRepo() string {
1007 p, _ := securejoin.SecureJoin(f.OwnerDid(), f.RepoName)
1008 return p
1009}
1010
1011func (f *FullyResolvedRepo) Collaborators(ctx context.Context, s *State) ([]pages.Collaborator, error) {
1012 repoCollaborators, err := s.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
1013 if err != nil {
1014 return nil, err
1015 }
1016
1017 var collaborators []pages.Collaborator
1018 for _, item := range repoCollaborators {
1019 // currently only two roles: owner and member
1020 var role string
1021 if item[3] == "repo:owner" {
1022 role = "owner"
1023 } else if item[3] == "repo:collaborator" {
1024 role = "collaborator"
1025 } else {
1026 continue
1027 }
1028
1029 did := item[0]
1030
1031 c := pages.Collaborator{
1032 Did: did,
1033 Handle: "",
1034 Role: role,
1035 }
1036 collaborators = append(collaborators, c)
1037 }
1038
1039 // populate all collborators with handles
1040 identsToResolve := make([]string, len(collaborators))
1041 for i, collab := range collaborators {
1042 identsToResolve[i] = collab.Did
1043 }
1044
1045 resolvedIdents := s.resolver.ResolveIdents(ctx, identsToResolve)
1046 for i, resolved := range resolvedIdents {
1047 if resolved != nil {
1048 collaborators[i].Handle = resolved.Handle.String()
1049 }
1050 }
1051
1052 return collaborators, nil
1053}
1054
1055func (f *FullyResolvedRepo) RepoInfo(s *State, u *oauth.User) repoinfo.RepoInfo {
1056 isStarred := false
1057 if u != nil {
1058 isStarred = db.GetStarStatus(s.db, u.Did, syntax.ATURI(f.RepoAt))
1059 }
1060
1061 starCount, err := db.GetStarCount(s.db, f.RepoAt)
1062 if err != nil {
1063 log.Println("failed to get star count for ", f.RepoAt)
1064 }
1065 issueCount, err := db.GetIssueCount(s.db, f.RepoAt)
1066 if err != nil {
1067 log.Println("failed to get issue count for ", f.RepoAt)
1068 }
1069 pullCount, err := db.GetPullCount(s.db, f.RepoAt)
1070 if err != nil {
1071 log.Println("failed to get issue count for ", f.RepoAt)
1072 }
1073 source, err := db.GetRepoSource(s.db, f.RepoAt)
1074 if errors.Is(err, sql.ErrNoRows) {
1075 source = ""
1076 } else if err != nil {
1077 log.Println("failed to get repo source for ", f.RepoAt, err)
1078 }
1079
1080 var sourceRepo *db.Repo
1081 if source != "" {
1082 sourceRepo, err = db.GetRepoByAtUri(s.db, source)
1083 if err != nil {
1084 log.Println("failed to get repo by at uri", err)
1085 }
1086 }
1087
1088 var sourceHandle *identity.Identity
1089 if sourceRepo != nil {
1090 sourceHandle, err = s.resolver.ResolveIdent(context.Background(), sourceRepo.Did)
1091 if err != nil {
1092 log.Println("failed to resolve source repo", err)
1093 }
1094 }
1095
1096 knot := f.Knot
1097 var disableFork bool
1098 us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev)
1099 if err != nil {
1100 log.Printf("failed to create unsigned client for %s: %v", knot, err)
1101 } else {
1102 result, err := us.Branches(f.OwnerDid(), f.RepoName)
1103 if err != nil {
1104 log.Printf("failed to get branches for %s/%s: %v", f.OwnerDid(), f.RepoName, err)
1105 }
1106
1107 if len(result.Branches) == 0 {
1108 disableFork = true
1109 }
1110 }
1111
1112 repoInfo := repoinfo.RepoInfo{
1113 OwnerDid: f.OwnerDid(),
1114 OwnerHandle: f.OwnerHandle(),
1115 Name: f.RepoName,
1116 RepoAt: f.RepoAt,
1117 Description: f.Description,
1118 Ref: f.Ref,
1119 IsStarred: isStarred,
1120 Knot: knot,
1121 Roles: RolesInRepo(s, u, f),
1122 Stats: db.RepoStats{
1123 StarCount: starCount,
1124 IssueCount: issueCount,
1125 PullCount: pullCount,
1126 },
1127 DisableFork: disableFork,
1128 CurrentDir: f.CurrentDir,
1129 }
1130
1131 if sourceRepo != nil {
1132 repoInfo.Source = sourceRepo
1133 repoInfo.SourceHandle = sourceHandle.Handle.String()
1134 }
1135
1136 return repoInfo
1137}
1138
1139func (s *State) RepoSingleIssue(w http.ResponseWriter, r *http.Request) {
1140 user := s.oauth.GetUser(r)
1141 f, err := s.fullyResolvedRepo(r)
1142 if err != nil {
1143 log.Println("failed to get repo and knot", err)
1144 return
1145 }
1146
1147 issueId := chi.URLParam(r, "issue")
1148 issueIdInt, err := strconv.Atoi(issueId)
1149 if err != nil {
1150 http.Error(w, "bad issue id", http.StatusBadRequest)
1151 log.Println("failed to parse issue id", err)
1152 return
1153 }
1154
1155 issue, comments, err := db.GetIssueWithComments(s.db, f.RepoAt, issueIdInt)
1156 if err != nil {
1157 log.Println("failed to get issue and comments", err)
1158 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1159 return
1160 }
1161
1162 issueOwnerIdent, err := s.resolver.ResolveIdent(r.Context(), issue.OwnerDid)
1163 if err != nil {
1164 log.Println("failed to resolve issue owner", err)
1165 }
1166
1167 identsToResolve := make([]string, len(comments))
1168 for i, comment := range comments {
1169 identsToResolve[i] = comment.OwnerDid
1170 }
1171 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
1172 didHandleMap := make(map[string]string)
1173 for _, identity := range resolvedIds {
1174 if !identity.Handle.IsInvalidHandle() {
1175 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1176 } else {
1177 didHandleMap[identity.DID.String()] = identity.DID.String()
1178 }
1179 }
1180
1181 s.pages.RepoSingleIssue(w, pages.RepoSingleIssueParams{
1182 LoggedInUser: user,
1183 RepoInfo: f.RepoInfo(s, user),
1184 Issue: *issue,
1185 Comments: comments,
1186
1187 IssueOwnerHandle: issueOwnerIdent.Handle.String(),
1188 DidHandleMap: didHandleMap,
1189 })
1190
1191}
1192
1193func (s *State) CloseIssue(w http.ResponseWriter, r *http.Request) {
1194 user := s.oauth.GetUser(r)
1195 f, err := s.fullyResolvedRepo(r)
1196 if err != nil {
1197 log.Println("failed to get repo and knot", err)
1198 return
1199 }
1200
1201 issueId := chi.URLParam(r, "issue")
1202 issueIdInt, err := strconv.Atoi(issueId)
1203 if err != nil {
1204 http.Error(w, "bad issue id", http.StatusBadRequest)
1205 log.Println("failed to parse issue id", err)
1206 return
1207 }
1208
1209 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1210 if err != nil {
1211 log.Println("failed to get issue", err)
1212 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1213 return
1214 }
1215
1216 collaborators, err := f.Collaborators(r.Context(), s)
1217 if err != nil {
1218 log.Println("failed to fetch repo collaborators: %w", err)
1219 }
1220 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
1221 return user.Did == collab.Did
1222 })
1223 isIssueOwner := user.Did == issue.OwnerDid
1224
1225 // TODO: make this more granular
1226 if isIssueOwner || isCollaborator {
1227
1228 closed := tangled.RepoIssueStateClosed
1229
1230 client, err := s.oauth.AuthorizedClient(r)
1231 if err != nil {
1232 log.Println("failed to get authorized client", err)
1233 return
1234 }
1235 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1236 Collection: tangled.RepoIssueStateNSID,
1237 Repo: user.Did,
1238 Rkey: appview.TID(),
1239 Record: &lexutil.LexiconTypeDecoder{
1240 Val: &tangled.RepoIssueState{
1241 Issue: issue.IssueAt,
1242 State: closed,
1243 },
1244 },
1245 })
1246
1247 if err != nil {
1248 log.Println("failed to update issue state", err)
1249 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1250 return
1251 }
1252
1253 err = db.CloseIssue(s.db, f.RepoAt, issueIdInt)
1254 if err != nil {
1255 log.Println("failed to close issue", err)
1256 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1257 return
1258 }
1259
1260 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
1261 return
1262 } else {
1263 log.Println("user is not permitted to close issue")
1264 http.Error(w, "for biden", http.StatusUnauthorized)
1265 return
1266 }
1267}
1268
1269func (s *State) ReopenIssue(w http.ResponseWriter, r *http.Request) {
1270 user := s.oauth.GetUser(r)
1271 f, err := s.fullyResolvedRepo(r)
1272 if err != nil {
1273 log.Println("failed to get repo and knot", err)
1274 return
1275 }
1276
1277 issueId := chi.URLParam(r, "issue")
1278 issueIdInt, err := strconv.Atoi(issueId)
1279 if err != nil {
1280 http.Error(w, "bad issue id", http.StatusBadRequest)
1281 log.Println("failed to parse issue id", err)
1282 return
1283 }
1284
1285 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1286 if err != nil {
1287 log.Println("failed to get issue", err)
1288 s.pages.Notice(w, "issue-action", "Failed to close issue. Try again later.")
1289 return
1290 }
1291
1292 collaborators, err := f.Collaborators(r.Context(), s)
1293 if err != nil {
1294 log.Println("failed to fetch repo collaborators: %w", err)
1295 }
1296 isCollaborator := slices.ContainsFunc(collaborators, func(collab pages.Collaborator) bool {
1297 return user.Did == collab.Did
1298 })
1299 isIssueOwner := user.Did == issue.OwnerDid
1300
1301 if isCollaborator || isIssueOwner {
1302 err := db.ReopenIssue(s.db, f.RepoAt, issueIdInt)
1303 if err != nil {
1304 log.Println("failed to reopen issue", err)
1305 s.pages.Notice(w, "issue-action", "Failed to reopen issue. Try again later.")
1306 return
1307 }
1308 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueIdInt))
1309 return
1310 } else {
1311 log.Println("user is not the owner of the repo")
1312 http.Error(w, "forbidden", http.StatusUnauthorized)
1313 return
1314 }
1315}
1316
1317func (s *State) NewIssueComment(w http.ResponseWriter, r *http.Request) {
1318 user := s.oauth.GetUser(r)
1319 f, err := s.fullyResolvedRepo(r)
1320 if err != nil {
1321 log.Println("failed to get repo and knot", err)
1322 return
1323 }
1324
1325 issueId := chi.URLParam(r, "issue")
1326 issueIdInt, err := strconv.Atoi(issueId)
1327 if err != nil {
1328 http.Error(w, "bad issue id", http.StatusBadRequest)
1329 log.Println("failed to parse issue id", err)
1330 return
1331 }
1332
1333 switch r.Method {
1334 case http.MethodPost:
1335 body := r.FormValue("body")
1336 if body == "" {
1337 s.pages.Notice(w, "issue", "Body is required")
1338 return
1339 }
1340
1341 commentId := mathrand.IntN(1000000)
1342 rkey := appview.TID()
1343
1344 err := db.NewIssueComment(s.db, &db.Comment{
1345 OwnerDid: user.Did,
1346 RepoAt: f.RepoAt,
1347 Issue: issueIdInt,
1348 CommentId: commentId,
1349 Body: body,
1350 Rkey: rkey,
1351 })
1352 if err != nil {
1353 log.Println("failed to create comment", err)
1354 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1355 return
1356 }
1357
1358 createdAt := time.Now().Format(time.RFC3339)
1359 commentIdInt64 := int64(commentId)
1360 ownerDid := user.Did
1361 issueAt, err := db.GetIssueAt(s.db, f.RepoAt, issueIdInt)
1362 if err != nil {
1363 log.Println("failed to get issue at", err)
1364 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1365 return
1366 }
1367
1368 atUri := f.RepoAt.String()
1369 client, err := s.oauth.AuthorizedClient(r)
1370 if err != nil {
1371 log.Println("failed to get authorized client", err)
1372 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1373 return
1374 }
1375 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1376 Collection: tangled.RepoIssueCommentNSID,
1377 Repo: user.Did,
1378 Rkey: rkey,
1379 Record: &lexutil.LexiconTypeDecoder{
1380 Val: &tangled.RepoIssueComment{
1381 Repo: &atUri,
1382 Issue: issueAt,
1383 CommentId: &commentIdInt64,
1384 Owner: &ownerDid,
1385 Body: body,
1386 CreatedAt: createdAt,
1387 },
1388 },
1389 })
1390 if err != nil {
1391 log.Println("failed to create comment", err)
1392 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1393 return
1394 }
1395
1396 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d#comment-%d", f.OwnerSlashRepo(), issueIdInt, commentId))
1397 return
1398 }
1399}
1400
1401func (s *State) IssueComment(w http.ResponseWriter, r *http.Request) {
1402 user := s.oauth.GetUser(r)
1403 f, err := s.fullyResolvedRepo(r)
1404 if err != nil {
1405 log.Println("failed to get repo and knot", err)
1406 return
1407 }
1408
1409 issueId := chi.URLParam(r, "issue")
1410 issueIdInt, err := strconv.Atoi(issueId)
1411 if err != nil {
1412 http.Error(w, "bad issue id", http.StatusBadRequest)
1413 log.Println("failed to parse issue id", err)
1414 return
1415 }
1416
1417 commentId := chi.URLParam(r, "comment_id")
1418 commentIdInt, err := strconv.Atoi(commentId)
1419 if err != nil {
1420 http.Error(w, "bad comment id", http.StatusBadRequest)
1421 log.Println("failed to parse issue id", err)
1422 return
1423 }
1424
1425 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1426 if err != nil {
1427 log.Println("failed to get issue", err)
1428 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1429 return
1430 }
1431
1432 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1433 if err != nil {
1434 http.Error(w, "bad comment id", http.StatusBadRequest)
1435 return
1436 }
1437
1438 identity, err := s.resolver.ResolveIdent(r.Context(), comment.OwnerDid)
1439 if err != nil {
1440 log.Println("failed to resolve did")
1441 return
1442 }
1443
1444 didHandleMap := make(map[string]string)
1445 if !identity.Handle.IsInvalidHandle() {
1446 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1447 } else {
1448 didHandleMap[identity.DID.String()] = identity.DID.String()
1449 }
1450
1451 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1452 LoggedInUser: user,
1453 RepoInfo: f.RepoInfo(s, user),
1454 DidHandleMap: didHandleMap,
1455 Issue: issue,
1456 Comment: comment,
1457 })
1458}
1459
1460func (s *State) EditIssueComment(w http.ResponseWriter, r *http.Request) {
1461 user := s.oauth.GetUser(r)
1462 f, err := s.fullyResolvedRepo(r)
1463 if err != nil {
1464 log.Println("failed to get repo and knot", err)
1465 return
1466 }
1467
1468 issueId := chi.URLParam(r, "issue")
1469 issueIdInt, err := strconv.Atoi(issueId)
1470 if err != nil {
1471 http.Error(w, "bad issue id", http.StatusBadRequest)
1472 log.Println("failed to parse issue id", err)
1473 return
1474 }
1475
1476 commentId := chi.URLParam(r, "comment_id")
1477 commentIdInt, err := strconv.Atoi(commentId)
1478 if err != nil {
1479 http.Error(w, "bad comment id", http.StatusBadRequest)
1480 log.Println("failed to parse issue id", err)
1481 return
1482 }
1483
1484 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1485 if err != nil {
1486 log.Println("failed to get issue", err)
1487 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1488 return
1489 }
1490
1491 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1492 if err != nil {
1493 http.Error(w, "bad comment id", http.StatusBadRequest)
1494 return
1495 }
1496
1497 if comment.OwnerDid != user.Did {
1498 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
1499 return
1500 }
1501
1502 switch r.Method {
1503 case http.MethodGet:
1504 s.pages.EditIssueCommentFragment(w, pages.EditIssueCommentParams{
1505 LoggedInUser: user,
1506 RepoInfo: f.RepoInfo(s, user),
1507 Issue: issue,
1508 Comment: comment,
1509 })
1510 case http.MethodPost:
1511 // extract form value
1512 newBody := r.FormValue("body")
1513 client, err := s.oauth.AuthorizedClient(r)
1514 if err != nil {
1515 log.Println("failed to get authorized client", err)
1516 s.pages.Notice(w, "issue-comment", "Failed to create comment.")
1517 return
1518 }
1519 rkey := comment.Rkey
1520
1521 // optimistic update
1522 edited := time.Now()
1523 err = db.EditComment(s.db, comment.RepoAt, comment.Issue, comment.CommentId, newBody)
1524 if err != nil {
1525 log.Println("failed to perferom update-description query", err)
1526 s.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
1527 return
1528 }
1529
1530 // rkey is optional, it was introduced later
1531 if comment.Rkey != "" {
1532 // update the record on pds
1533 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoIssueCommentNSID, user.Did, rkey)
1534 if err != nil {
1535 // failed to get record
1536 log.Println(err, rkey)
1537 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "Failed to update description, no record found on PDS.")
1538 return
1539 }
1540 value, _ := ex.Value.MarshalJSON() // we just did get record; it is valid json
1541 record, _ := data.UnmarshalJSON(value)
1542
1543 repoAt := record["repo"].(string)
1544 issueAt := record["issue"].(string)
1545 createdAt := record["createdAt"].(string)
1546 commentIdInt64 := int64(commentIdInt)
1547
1548 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1549 Collection: tangled.RepoIssueCommentNSID,
1550 Repo: user.Did,
1551 Rkey: rkey,
1552 SwapRecord: ex.Cid,
1553 Record: &lexutil.LexiconTypeDecoder{
1554 Val: &tangled.RepoIssueComment{
1555 Repo: &repoAt,
1556 Issue: issueAt,
1557 CommentId: &commentIdInt64,
1558 Owner: &comment.OwnerDid,
1559 Body: newBody,
1560 CreatedAt: createdAt,
1561 },
1562 },
1563 })
1564 if err != nil {
1565 log.Println(err)
1566 }
1567 }
1568
1569 // optimistic update for htmx
1570 didHandleMap := map[string]string{
1571 user.Did: user.Handle,
1572 }
1573 comment.Body = newBody
1574 comment.Edited = &edited
1575
1576 // return new comment body with htmx
1577 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1578 LoggedInUser: user,
1579 RepoInfo: f.RepoInfo(s, user),
1580 DidHandleMap: didHandleMap,
1581 Issue: issue,
1582 Comment: comment,
1583 })
1584 return
1585
1586 }
1587
1588}
1589
1590func (s *State) DeleteIssueComment(w http.ResponseWriter, r *http.Request) {
1591 user := s.oauth.GetUser(r)
1592 f, err := s.fullyResolvedRepo(r)
1593 if err != nil {
1594 log.Println("failed to get repo and knot", err)
1595 return
1596 }
1597
1598 issueId := chi.URLParam(r, "issue")
1599 issueIdInt, err := strconv.Atoi(issueId)
1600 if err != nil {
1601 http.Error(w, "bad issue id", http.StatusBadRequest)
1602 log.Println("failed to parse issue id", err)
1603 return
1604 }
1605
1606 issue, err := db.GetIssue(s.db, f.RepoAt, issueIdInt)
1607 if err != nil {
1608 log.Println("failed to get issue", err)
1609 s.pages.Notice(w, "issues", "Failed to load issue. Try again later.")
1610 return
1611 }
1612
1613 commentId := chi.URLParam(r, "comment_id")
1614 commentIdInt, err := strconv.Atoi(commentId)
1615 if err != nil {
1616 http.Error(w, "bad comment id", http.StatusBadRequest)
1617 log.Println("failed to parse issue id", err)
1618 return
1619 }
1620
1621 comment, err := db.GetComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1622 if err != nil {
1623 http.Error(w, "bad comment id", http.StatusBadRequest)
1624 return
1625 }
1626
1627 if comment.OwnerDid != user.Did {
1628 http.Error(w, "you are not the author of this comment", http.StatusUnauthorized)
1629 return
1630 }
1631
1632 if comment.Deleted != nil {
1633 http.Error(w, "comment already deleted", http.StatusBadRequest)
1634 return
1635 }
1636
1637 // optimistic deletion
1638 deleted := time.Now()
1639 err = db.DeleteComment(s.db, f.RepoAt, issueIdInt, commentIdInt)
1640 if err != nil {
1641 log.Println("failed to delete comment")
1642 s.pages.Notice(w, fmt.Sprintf("comment-%s-status", commentId), "failed to delete comment")
1643 return
1644 }
1645
1646 // delete from pds
1647 if comment.Rkey != "" {
1648 client, err := s.oauth.AuthorizedClient(r)
1649 if err != nil {
1650 log.Println("failed to get authorized client", err)
1651 s.pages.Notice(w, "issue-comment", "Failed to delete comment.")
1652 return
1653 }
1654 _, err = client.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
1655 Collection: tangled.GraphFollowNSID,
1656 Repo: user.Did,
1657 Rkey: comment.Rkey,
1658 })
1659 if err != nil {
1660 log.Println(err)
1661 }
1662 }
1663
1664 // optimistic update for htmx
1665 didHandleMap := map[string]string{
1666 user.Did: user.Handle,
1667 }
1668 comment.Body = ""
1669 comment.Deleted = &deleted
1670
1671 // htmx fragment of comment after deletion
1672 s.pages.SingleIssueCommentFragment(w, pages.SingleIssueCommentParams{
1673 LoggedInUser: user,
1674 RepoInfo: f.RepoInfo(s, user),
1675 DidHandleMap: didHandleMap,
1676 Issue: issue,
1677 Comment: comment,
1678 })
1679 return
1680}
1681
1682func (s *State) RepoIssues(w http.ResponseWriter, r *http.Request) {
1683 params := r.URL.Query()
1684 state := params.Get("state")
1685 isOpen := true
1686 switch state {
1687 case "open":
1688 isOpen = true
1689 case "closed":
1690 isOpen = false
1691 default:
1692 isOpen = true
1693 }
1694
1695 page, ok := r.Context().Value("page").(pagination.Page)
1696 if !ok {
1697 log.Println("failed to get page")
1698 page = pagination.FirstPage()
1699 }
1700
1701 user := s.oauth.GetUser(r)
1702 f, err := s.fullyResolvedRepo(r)
1703 if err != nil {
1704 log.Println("failed to get repo and knot", err)
1705 return
1706 }
1707
1708 issues, err := db.GetIssues(s.db, f.RepoAt, isOpen, page)
1709 if err != nil {
1710 log.Println("failed to get issues", err)
1711 s.pages.Notice(w, "issues", "Failed to load issues. Try again later.")
1712 return
1713 }
1714
1715 identsToResolve := make([]string, len(issues))
1716 for i, issue := range issues {
1717 identsToResolve[i] = issue.OwnerDid
1718 }
1719 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
1720 didHandleMap := make(map[string]string)
1721 for _, identity := range resolvedIds {
1722 if !identity.Handle.IsInvalidHandle() {
1723 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
1724 } else {
1725 didHandleMap[identity.DID.String()] = identity.DID.String()
1726 }
1727 }
1728
1729 s.pages.RepoIssues(w, pages.RepoIssuesParams{
1730 LoggedInUser: s.oauth.GetUser(r),
1731 RepoInfo: f.RepoInfo(s, user),
1732 Issues: issues,
1733 DidHandleMap: didHandleMap,
1734 FilteringByOpen: isOpen,
1735 Page: page,
1736 })
1737 return
1738}
1739
1740func (s *State) NewIssue(w http.ResponseWriter, r *http.Request) {
1741 user := s.oauth.GetUser(r)
1742
1743 f, err := s.fullyResolvedRepo(r)
1744 if err != nil {
1745 log.Println("failed to get repo and knot", err)
1746 return
1747 }
1748
1749 switch r.Method {
1750 case http.MethodGet:
1751 s.pages.RepoNewIssue(w, pages.RepoNewIssueParams{
1752 LoggedInUser: user,
1753 RepoInfo: f.RepoInfo(s, user),
1754 })
1755 case http.MethodPost:
1756 title := r.FormValue("title")
1757 body := r.FormValue("body")
1758
1759 if title == "" || body == "" {
1760 s.pages.Notice(w, "issues", "Title and body are required")
1761 return
1762 }
1763
1764 tx, err := s.db.BeginTx(r.Context(), nil)
1765 if err != nil {
1766 s.pages.Notice(w, "issues", "Failed to create issue, try again later")
1767 return
1768 }
1769
1770 err = db.NewIssue(tx, &db.Issue{
1771 RepoAt: f.RepoAt,
1772 Title: title,
1773 Body: body,
1774 OwnerDid: user.Did,
1775 })
1776 if err != nil {
1777 log.Println("failed to create issue", err)
1778 s.pages.Notice(w, "issues", "Failed to create issue.")
1779 return
1780 }
1781
1782 issueId, err := db.GetIssueId(s.db, f.RepoAt)
1783 if err != nil {
1784 log.Println("failed to get issue id", err)
1785 s.pages.Notice(w, "issues", "Failed to create issue.")
1786 return
1787 }
1788
1789 client, err := s.oauth.AuthorizedClient(r)
1790 if err != nil {
1791 log.Println("failed to get authorized client", err)
1792 s.pages.Notice(w, "issues", "Failed to create issue.")
1793 return
1794 }
1795 atUri := f.RepoAt.String()
1796 resp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1797 Collection: tangled.RepoIssueNSID,
1798 Repo: user.Did,
1799 Rkey: appview.TID(),
1800 Record: &lexutil.LexiconTypeDecoder{
1801 Val: &tangled.RepoIssue{
1802 Repo: atUri,
1803 Title: title,
1804 Body: &body,
1805 Owner: user.Did,
1806 IssueId: int64(issueId),
1807 },
1808 },
1809 })
1810 if err != nil {
1811 log.Println("failed to create issue", err)
1812 s.pages.Notice(w, "issues", "Failed to create issue.")
1813 return
1814 }
1815
1816 err = db.SetIssueAt(s.db, f.RepoAt, issueId, resp.Uri)
1817 if err != nil {
1818 log.Println("failed to set issue at", err)
1819 s.pages.Notice(w, "issues", "Failed to create issue.")
1820 return
1821 }
1822
1823 if !s.config.Core.Dev {
1824 err = s.posthog.Enqueue(posthog.Capture{
1825 DistinctId: user.Did,
1826 Event: "new_issue",
1827 Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "issue_id": issueId},
1828 })
1829 if err != nil {
1830 log.Println("failed to enqueue posthog event:", err)
1831 }
1832 }
1833
1834 s.pages.HxLocation(w, fmt.Sprintf("/%s/issues/%d", f.OwnerSlashRepo(), issueId))
1835 return
1836 }
1837}
1838
1839func (s *State) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
1840 user := s.oauth.GetUser(r)
1841 f, err := s.fullyResolvedRepo(r)
1842 if err != nil {
1843 log.Printf("failed to resolve source repo: %v", err)
1844 return
1845 }
1846
1847 switch r.Method {
1848 case http.MethodPost:
1849 secret, err := db.GetRegistrationKey(s.db, f.Knot)
1850 if err != nil {
1851 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot))
1852 return
1853 }
1854
1855 client, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
1856 if err != nil {
1857 s.pages.Notice(w, "repo", "Failed to reach knot server.")
1858 return
1859 }
1860
1861 var uri string
1862 if s.config.Core.Dev {
1863 uri = "http"
1864 } else {
1865 uri = "https"
1866 }
1867 forkName := fmt.Sprintf("%s", f.RepoName)
1868 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1869
1870 _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref)
1871 if err != nil {
1872 s.pages.Notice(w, "repo", "Failed to sync repository fork.")
1873 return
1874 }
1875
1876 s.pages.HxRefresh(w)
1877 return
1878 }
1879}
1880
1881func (s *State) ForkRepo(w http.ResponseWriter, r *http.Request) {
1882 user := s.oauth.GetUser(r)
1883 f, err := s.fullyResolvedRepo(r)
1884 if err != nil {
1885 log.Printf("failed to resolve source repo: %v", err)
1886 return
1887 }
1888
1889 switch r.Method {
1890 case http.MethodGet:
1891 user := s.oauth.GetUser(r)
1892 knots, err := s.enforcer.GetDomainsForUser(user.Did)
1893 if err != nil {
1894 s.pages.Notice(w, "repo", "Invalid user account.")
1895 return
1896 }
1897
1898 s.pages.ForkRepo(w, pages.ForkRepoParams{
1899 LoggedInUser: user,
1900 Knots: knots,
1901 RepoInfo: f.RepoInfo(s, user),
1902 })
1903
1904 case http.MethodPost:
1905
1906 knot := r.FormValue("knot")
1907 if knot == "" {
1908 s.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
1909 return
1910 }
1911
1912 ok, err := s.enforcer.E.Enforce(user.Did, knot, knot, "repo:create")
1913 if err != nil || !ok {
1914 s.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
1915 return
1916 }
1917
1918 forkName := fmt.Sprintf("%s", f.RepoName)
1919
1920 // this check is *only* to see if the forked repo name already exists
1921 // in the user's account.
1922 existingRepo, err := db.GetRepo(s.db, user.Did, f.RepoName)
1923 if err != nil {
1924 if errors.Is(err, sql.ErrNoRows) {
1925 // no existing repo with this name found, we can use the name as is
1926 } else {
1927 log.Println("error fetching existing repo from db", err)
1928 s.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
1929 return
1930 }
1931 } else if existingRepo != nil {
1932 // repo with this name already exists, append random string
1933 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
1934 }
1935 secret, err := db.GetRegistrationKey(s.db, knot)
1936 if err != nil {
1937 s.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot))
1938 return
1939 }
1940
1941 client, err := knotclient.NewSignedClient(knot, secret, s.config.Core.Dev)
1942 if err != nil {
1943 s.pages.Notice(w, "repo", "Failed to reach knot server.")
1944 return
1945 }
1946
1947 var uri string
1948 if s.config.Core.Dev {
1949 uri = "http"
1950 } else {
1951 uri = "https"
1952 }
1953 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1954 sourceAt := f.RepoAt.String()
1955
1956 rkey := appview.TID()
1957 repo := &db.Repo{
1958 Did: user.Did,
1959 Name: forkName,
1960 Knot: knot,
1961 Rkey: rkey,
1962 Source: sourceAt,
1963 }
1964
1965 tx, err := s.db.BeginTx(r.Context(), nil)
1966 if err != nil {
1967 log.Println(err)
1968 s.pages.Notice(w, "repo", "Failed to save repository information.")
1969 return
1970 }
1971 defer func() {
1972 tx.Rollback()
1973 err = s.enforcer.E.LoadPolicy()
1974 if err != nil {
1975 log.Println("failed to rollback policies")
1976 }
1977 }()
1978
1979 resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName)
1980 if err != nil {
1981 s.pages.Notice(w, "repo", "Failed to create repository on knot server.")
1982 return
1983 }
1984
1985 switch resp.StatusCode {
1986 case http.StatusConflict:
1987 s.pages.Notice(w, "repo", "A repository with that name already exists.")
1988 return
1989 case http.StatusInternalServerError:
1990 s.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
1991 case http.StatusNoContent:
1992 // continue
1993 }
1994
1995 xrpcClient, err := s.oauth.AuthorizedClient(r)
1996 if err != nil {
1997 log.Println("failed to get authorized client", err)
1998 s.pages.Notice(w, "repo", "Failed to create repository.")
1999 return
2000 }
2001
2002 createdAt := time.Now().Format(time.RFC3339)
2003 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
2004 Collection: tangled.RepoNSID,
2005 Repo: user.Did,
2006 Rkey: rkey,
2007 Record: &lexutil.LexiconTypeDecoder{
2008 Val: &tangled.Repo{
2009 Knot: repo.Knot,
2010 Name: repo.Name,
2011 CreatedAt: createdAt,
2012 Owner: user.Did,
2013 Source: &sourceAt,
2014 }},
2015 })
2016 if err != nil {
2017 log.Printf("failed to create record: %s", err)
2018 s.pages.Notice(w, "repo", "Failed to announce repository creation.")
2019 return
2020 }
2021 log.Println("created repo record: ", atresp.Uri)
2022
2023 repo.AtUri = atresp.Uri
2024 err = db.AddRepo(tx, repo)
2025 if err != nil {
2026 log.Println(err)
2027 s.pages.Notice(w, "repo", "Failed to save repository information.")
2028 return
2029 }
2030
2031 // acls
2032 p, _ := securejoin.SecureJoin(user.Did, forkName)
2033 err = s.enforcer.AddRepo(user.Did, knot, p)
2034 if err != nil {
2035 log.Println(err)
2036 s.pages.Notice(w, "repo", "Failed to set up repository permissions.")
2037 return
2038 }
2039
2040 err = tx.Commit()
2041 if err != nil {
2042 log.Println("failed to commit changes", err)
2043 http.Error(w, err.Error(), http.StatusInternalServerError)
2044 return
2045 }
2046
2047 err = s.enforcer.E.SavePolicy()
2048 if err != nil {
2049 log.Println("failed to update ACLs", err)
2050 http.Error(w, err.Error(), http.StatusInternalServerError)
2051 return
2052 }
2053
2054 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
2055 return
2056 }
2057}
2058
2059func (s *State) RepoCompare(w http.ResponseWriter, r *http.Request) {
2060 user := s.oauth.GetUser(r)
2061 f, err := s.fullyResolvedRepo(r)
2062 if err != nil {
2063 log.Println("failed to get repo and knot", err)
2064 return
2065 }
2066
2067 // if user is navigating to one of
2068 // /compare/{base}/{head}
2069 // /compare/{base}...{head}
2070 base := chi.URLParam(r, "base")
2071 head := chi.URLParam(r, "head")
2072 if base == "" && head == "" {
2073 rest := chi.URLParam(r, "*") // master...feature/xyz
2074 parts := strings.SplitN(rest, "...", 2)
2075 if len(parts) == 2 {
2076 base = parts[0]
2077 head = parts[1]
2078 }
2079 }
2080
2081 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
2082 if err != nil {
2083 log.Printf("failed to create unsigned client for %s", f.Knot)
2084 s.pages.Error503(w)
2085 return
2086 }
2087
2088 branches, err := us.Branches(f.OwnerDid(), f.RepoName)
2089 if err != nil {
2090 s.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2091 log.Println("failed to reach knotserver", err)
2092 return
2093 }
2094
2095 tags, err := us.Tags(f.OwnerDid(), f.RepoName)
2096 if err != nil {
2097 s.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2098 log.Println("failed to reach knotserver", err)
2099 return
2100 }
2101
2102 var forks []db.Repo
2103 if user != nil {
2104 var err error
2105 forks, err = db.GetForksByDid(s.db, user.Did)
2106 if err != nil {
2107 s.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2108 log.Println("failed to get forks", err)
2109 return
2110 }
2111 }
2112
2113 var allowPull bool = false
2114 if user != nil {
2115 if slices.ContainsFunc(branches.Branches, func(branch types.Branch) bool {
2116 return branch.Name == head || branch.Name == base
2117 }) {
2118 allowPull = true
2119 }
2120 }
2121
2122 s.pages.RepoCompare(w, pages.RepoCompareParams{
2123 LoggedInUser: user,
2124 RepoInfo: f.RepoInfo(s, user),
2125 Forks: forks,
2126 Branches: branches.Branches,
2127 Tags: tags.Tags,
2128 Base: base,
2129 Head: head,
2130 AllowPull: allowPull,
2131 })
2132
2133}
2134
2135func (s *State) RepoCompareAllowPullFragment(w http.ResponseWriter, r *http.Request) {
2136 user := s.oauth.GetUser(r)
2137 f, err := s.fullyResolvedRepo(r)
2138 if err != nil {
2139 log.Println("failed to get repo and knot", err)
2140 return
2141 }
2142
2143 s.pages.RepoCompareAllowPullFragment(w, pages.RepoCompareAllowPullParams{
2144 Head: chi.URLParam(r, "head"),
2145 Base: chi.URLParam(r, "base"),
2146 RepoInfo: f.RepoInfo(s, user),
2147 LoggedInUser: user,
2148 })
2149}
2150
2151func (s *State) RepoCompareDiffFragment(w http.ResponseWriter, r *http.Request) {
2152 f, err := s.fullyResolvedRepo(r)
2153 if err != nil {
2154 log.Println("failed to get repo and knot", err)
2155 return
2156 }
2157 user := s.oauth.GetUser(r)
2158
2159 base := chi.URLParam(r, "base")
2160 head := chi.URLParam(r, "head")
2161
2162 if base == "" || head == "" {
2163 s.pages.Notice(w, "compare-error", "Invalid ref format.")
2164 return
2165 }
2166
2167 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
2168 if err != nil {
2169 s.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2170 log.Println("failed to reach knotserver", err)
2171 return
2172 }
2173
2174 formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head)
2175 if err != nil {
2176 s.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
2177 log.Println("failed to compare", err)
2178 return
2179 }
2180 diff := patchutil.AsNiceDiff(formatPatch.Patch, base)
2181
2182 w.Header().Add("Hx-Push-Url", fmt.Sprintf("/%s/compare/%s...%s", f.OwnerSlashRepo(), base, head))
2183 s.pages.RepoCompareDiff(w, pages.RepoCompareDiffParams{
2184 LoggedInUser: user,
2185 RepoInfo: f.RepoInfo(s, user),
2186 Diff: diff,
2187 })
2188}