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