this repo has no description
1package repo
2
3import (
4 "context"
5 "database/sql"
6 "encoding/json"
7 "errors"
8 "fmt"
9 "io"
10 "log"
11 "net/http"
12 "net/url"
13 "path/filepath"
14 "slices"
15 "strconv"
16 "strings"
17 "time"
18
19 "tangled.sh/tangled.sh/core/api/tangled"
20 "tangled.sh/tangled.sh/core/appview/commitverify"
21 "tangled.sh/tangled.sh/core/appview/config"
22 "tangled.sh/tangled.sh/core/appview/db"
23 "tangled.sh/tangled.sh/core/appview/notify"
24 "tangled.sh/tangled.sh/core/appview/oauth"
25 "tangled.sh/tangled.sh/core/appview/pages"
26 "tangled.sh/tangled.sh/core/appview/pages/markup"
27 "tangled.sh/tangled.sh/core/appview/reporesolver"
28 "tangled.sh/tangled.sh/core/eventconsumer"
29 "tangled.sh/tangled.sh/core/idresolver"
30 "tangled.sh/tangled.sh/core/knotclient"
31 "tangled.sh/tangled.sh/core/patchutil"
32 "tangled.sh/tangled.sh/core/rbac"
33 "tangled.sh/tangled.sh/core/tid"
34 "tangled.sh/tangled.sh/core/types"
35
36 securejoin "github.com/cyphar/filepath-securejoin"
37 "github.com/go-chi/chi/v5"
38 "github.com/go-git/go-git/v5/plumbing"
39
40 comatproto "github.com/bluesky-social/indigo/api/atproto"
41 lexutil "github.com/bluesky-social/indigo/lex/util"
42)
43
44type Repo struct {
45 repoResolver *reporesolver.RepoResolver
46 idResolver *idresolver.Resolver
47 config *config.Config
48 oauth *oauth.OAuth
49 pages *pages.Pages
50 spindlestream *eventconsumer.Consumer
51 db *db.DB
52 enforcer *rbac.Enforcer
53 notifier notify.Notifier
54}
55
56func New(
57 oauth *oauth.OAuth,
58 repoResolver *reporesolver.RepoResolver,
59 pages *pages.Pages,
60 spindlestream *eventconsumer.Consumer,
61 idResolver *idresolver.Resolver,
62 db *db.DB,
63 config *config.Config,
64 notifier notify.Notifier,
65 enforcer *rbac.Enforcer,
66) *Repo {
67 return &Repo{oauth: oauth,
68 repoResolver: repoResolver,
69 pages: pages,
70 idResolver: idResolver,
71 config: config,
72 spindlestream: spindlestream,
73 db: db,
74 notifier: notifier,
75 enforcer: enforcer,
76 }
77}
78
79func (rp *Repo) RepoLog(w http.ResponseWriter, r *http.Request) {
80 f, err := rp.repoResolver.Resolve(r)
81 if err != nil {
82 log.Println("failed to fully resolve repo", err)
83 return
84 }
85
86 page := 1
87 if r.URL.Query().Get("page") != "" {
88 page, err = strconv.Atoi(r.URL.Query().Get("page"))
89 if err != nil {
90 page = 1
91 }
92 }
93
94 ref := chi.URLParam(r, "ref")
95
96 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
97 if err != nil {
98 log.Println("failed to create unsigned client", err)
99 return
100 }
101
102 repolog, err := us.Log(f.OwnerDid(), f.RepoName, ref, page)
103 if err != nil {
104 log.Println("failed to reach knotserver", err)
105 return
106 }
107
108 tagResult, err := us.Tags(f.OwnerDid(), f.RepoName)
109 if err != nil {
110 log.Println("failed to reach knotserver", err)
111 return
112 }
113
114 tagMap := make(map[string][]string)
115 for _, tag := range tagResult.Tags {
116 hash := tag.Hash
117 if tag.Tag != nil {
118 hash = tag.Tag.Target.String()
119 }
120 tagMap[hash] = append(tagMap[hash], tag.Name)
121 }
122
123 branchResult, err := us.Branches(f.OwnerDid(), f.RepoName)
124 if err != nil {
125 log.Println("failed to reach knotserver", err)
126 return
127 }
128
129 for _, branch := range branchResult.Branches {
130 hash := branch.Hash
131 tagMap[hash] = append(tagMap[hash], branch.Name)
132 }
133
134 user := rp.oauth.GetUser(r)
135
136 emailToDidMap, err := db.GetEmailToDid(rp.db, uniqueEmails(repolog.Commits), true)
137 if err != nil {
138 log.Println("failed to fetch email to did mapping", err)
139 }
140
141 vc, err := commitverify.GetVerifiedObjectCommits(rp.db, emailToDidMap, repolog.Commits)
142 if err != nil {
143 log.Println(err)
144 }
145
146 repoInfo := f.RepoInfo(user)
147
148 var shas []string
149 for _, c := range repolog.Commits {
150 shas = append(shas, c.Hash.String())
151 }
152 pipelines, err := getPipelineStatuses(rp.db, repoInfo, shas)
153 if err != nil {
154 log.Println(err)
155 // non-fatal
156 }
157
158 rp.pages.RepoLog(w, pages.RepoLogParams{
159 LoggedInUser: user,
160 TagMap: tagMap,
161 RepoInfo: repoInfo,
162 RepoLogResponse: *repolog,
163 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
164 VerifiedCommits: vc,
165 Pipelines: pipelines,
166 })
167}
168
169func (rp *Repo) RepoDescriptionEdit(w http.ResponseWriter, r *http.Request) {
170 f, err := rp.repoResolver.Resolve(r)
171 if err != nil {
172 log.Println("failed to get repo and knot", err)
173 w.WriteHeader(http.StatusBadRequest)
174 return
175 }
176
177 user := rp.oauth.GetUser(r)
178 rp.pages.EditRepoDescriptionFragment(w, pages.RepoDescriptionParams{
179 RepoInfo: f.RepoInfo(user),
180 })
181}
182
183func (rp *Repo) RepoDescription(w http.ResponseWriter, r *http.Request) {
184 f, err := rp.repoResolver.Resolve(r)
185 if err != nil {
186 log.Println("failed to get repo and knot", err)
187 w.WriteHeader(http.StatusBadRequest)
188 return
189 }
190
191 repoAt := f.RepoAt
192 rkey := repoAt.RecordKey().String()
193 if rkey == "" {
194 log.Println("invalid aturi for repo", err)
195 w.WriteHeader(http.StatusInternalServerError)
196 return
197 }
198
199 user := rp.oauth.GetUser(r)
200
201 switch r.Method {
202 case http.MethodGet:
203 rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
204 RepoInfo: f.RepoInfo(user),
205 })
206 return
207 case http.MethodPut:
208 newDescription := r.FormValue("description")
209 client, err := rp.oauth.AuthorizedClient(r)
210 if err != nil {
211 log.Println("failed to get client")
212 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
213 return
214 }
215
216 // optimistic update
217 err = db.UpdateDescription(rp.db, string(repoAt), newDescription)
218 if err != nil {
219 log.Println("failed to perferom update-description query", err)
220 rp.pages.Notice(w, "repo-notice", "Failed to update description, try again later.")
221 return
222 }
223
224 // this is a bit of a pain because the golang atproto impl does not allow nil SwapRecord field
225 //
226 // SwapRecord is optional and should happen automagically, but given that it does not, we have to perform two requests
227 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
228 if err != nil {
229 // failed to get record
230 rp.pages.Notice(w, "repo-notice", "Failed to update description, no record found on PDS.")
231 return
232 }
233 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
234 Collection: tangled.RepoNSID,
235 Repo: user.Did,
236 Rkey: rkey,
237 SwapRecord: ex.Cid,
238 Record: &lexutil.LexiconTypeDecoder{
239 Val: &tangled.Repo{
240 Knot: f.Knot,
241 Name: f.RepoName,
242 Owner: user.Did,
243 CreatedAt: f.CreatedAt,
244 Description: &newDescription,
245 Spindle: &f.Spindle,
246 },
247 },
248 })
249
250 if err != nil {
251 log.Println("failed to perferom update-description query", err)
252 // failed to get record
253 rp.pages.Notice(w, "repo-notice", "Failed to update description, unable to save to PDS.")
254 return
255 }
256
257 newRepoInfo := f.RepoInfo(user)
258 newRepoInfo.Description = newDescription
259
260 rp.pages.RepoDescriptionFragment(w, pages.RepoDescriptionParams{
261 RepoInfo: newRepoInfo,
262 })
263 return
264 }
265}
266
267func (rp *Repo) RepoCommit(w http.ResponseWriter, r *http.Request) {
268 f, err := rp.repoResolver.Resolve(r)
269 if err != nil {
270 log.Println("failed to fully resolve repo", err)
271 return
272 }
273 ref := chi.URLParam(r, "ref")
274 protocol := "http"
275 if !rp.config.Core.Dev {
276 protocol = "https"
277 }
278
279 var diffOpts types.DiffOpts
280 if d := r.URL.Query().Get("diff"); d == "split" {
281 diffOpts.Split = true
282 }
283
284 if !plumbing.IsHash(ref) {
285 rp.pages.Error404(w)
286 return
287 }
288
289 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/commit/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref))
290 if err != nil {
291 log.Println("failed to reach knotserver", err)
292 return
293 }
294
295 body, err := io.ReadAll(resp.Body)
296 if err != nil {
297 log.Printf("Error reading response body: %v", err)
298 return
299 }
300
301 var result types.RepoCommitResponse
302 err = json.Unmarshal(body, &result)
303 if err != nil {
304 log.Println("failed to parse response:", err)
305 return
306 }
307
308 emailToDidMap, err := db.GetEmailToDid(rp.db, []string{result.Diff.Commit.Committer.Email, result.Diff.Commit.Author.Email}, true)
309 if err != nil {
310 log.Println("failed to get email to did mapping:", err)
311 }
312
313 vc, err := commitverify.GetVerifiedCommits(rp.db, emailToDidMap, []types.NiceDiff{*result.Diff})
314 if err != nil {
315 log.Println(err)
316 }
317
318 user := rp.oauth.GetUser(r)
319 repoInfo := f.RepoInfo(user)
320 pipelines, err := getPipelineStatuses(rp.db, repoInfo, []string{result.Diff.Commit.This})
321 if err != nil {
322 log.Println(err)
323 // non-fatal
324 }
325 var pipeline *db.Pipeline
326 if p, ok := pipelines[result.Diff.Commit.This]; ok {
327 pipeline = &p
328 }
329
330 rp.pages.RepoCommit(w, pages.RepoCommitParams{
331 LoggedInUser: user,
332 RepoInfo: f.RepoInfo(user),
333 RepoCommitResponse: result,
334 EmailToDidOrHandle: emailToDidOrHandle(rp, emailToDidMap),
335 VerifiedCommit: vc,
336 Pipeline: pipeline,
337 DiffOpts: diffOpts,
338 })
339}
340
341func (rp *Repo) RepoTree(w http.ResponseWriter, r *http.Request) {
342 f, err := rp.repoResolver.Resolve(r)
343 if err != nil {
344 log.Println("failed to fully resolve repo", err)
345 return
346 }
347
348 ref := chi.URLParam(r, "ref")
349 treePath := chi.URLParam(r, "*")
350 protocol := "http"
351 if !rp.config.Core.Dev {
352 protocol = "https"
353 }
354 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/tree/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, treePath))
355 if err != nil {
356 log.Println("failed to reach knotserver", err)
357 return
358 }
359
360 body, err := io.ReadAll(resp.Body)
361 if err != nil {
362 log.Printf("Error reading response body: %v", err)
363 return
364 }
365
366 var result types.RepoTreeResponse
367 err = json.Unmarshal(body, &result)
368 if err != nil {
369 log.Println("failed to parse response:", err)
370 return
371 }
372
373 // redirects tree paths trying to access a blob; in this case the result.Files is unpopulated,
374 // so we can safely redirect to the "parent" (which is the same file).
375 unescapedTreePath, _ := url.PathUnescape(treePath)
376 if len(result.Files) == 0 && result.Parent == unescapedTreePath {
377 http.Redirect(w, r, fmt.Sprintf("/%s/blob/%s/%s", f.OwnerSlashRepo(), ref, result.Parent), http.StatusFound)
378 return
379 }
380
381 user := rp.oauth.GetUser(r)
382
383 var breadcrumbs [][]string
384 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
385 if treePath != "" {
386 for idx, elem := range strings.Split(treePath, "/") {
387 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
388 }
389 }
390
391 sortFiles(result.Files)
392
393 rp.pages.RepoTree(w, pages.RepoTreeParams{
394 LoggedInUser: user,
395 BreadCrumbs: breadcrumbs,
396 TreePath: treePath,
397 RepoInfo: f.RepoInfo(user),
398 RepoTreeResponse: result,
399 })
400}
401
402func (rp *Repo) RepoTags(w http.ResponseWriter, r *http.Request) {
403 f, err := rp.repoResolver.Resolve(r)
404 if err != nil {
405 log.Println("failed to get repo and knot", err)
406 return
407 }
408
409 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
410 if err != nil {
411 log.Println("failed to create unsigned client", err)
412 return
413 }
414
415 result, err := us.Tags(f.OwnerDid(), f.RepoName)
416 if err != nil {
417 log.Println("failed to reach knotserver", err)
418 return
419 }
420
421 artifacts, err := db.GetArtifact(rp.db, db.FilterEq("repo_at", f.RepoAt))
422 if err != nil {
423 log.Println("failed grab artifacts", err)
424 return
425 }
426
427 // convert artifacts to map for easy UI building
428 artifactMap := make(map[plumbing.Hash][]db.Artifact)
429 for _, a := range artifacts {
430 artifactMap[a.Tag] = append(artifactMap[a.Tag], a)
431 }
432
433 var danglingArtifacts []db.Artifact
434 for _, a := range artifacts {
435 found := false
436 for _, t := range result.Tags {
437 if t.Tag != nil {
438 if t.Tag.Hash == a.Tag {
439 found = true
440 }
441 }
442 }
443
444 if !found {
445 danglingArtifacts = append(danglingArtifacts, a)
446 }
447 }
448
449 user := rp.oauth.GetUser(r)
450 rp.pages.RepoTags(w, pages.RepoTagsParams{
451 LoggedInUser: user,
452 RepoInfo: f.RepoInfo(user),
453 RepoTagsResponse: *result,
454 ArtifactMap: artifactMap,
455 DanglingArtifacts: danglingArtifacts,
456 })
457}
458
459func (rp *Repo) RepoBranches(w http.ResponseWriter, r *http.Request) {
460 f, err := rp.repoResolver.Resolve(r)
461 if err != nil {
462 log.Println("failed to get repo and knot", err)
463 return
464 }
465
466 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
467 if err != nil {
468 log.Println("failed to create unsigned client", err)
469 return
470 }
471
472 result, err := us.Branches(f.OwnerDid(), f.RepoName)
473 if err != nil {
474 log.Println("failed to reach knotserver", err)
475 return
476 }
477
478 sortBranches(result.Branches)
479
480 user := rp.oauth.GetUser(r)
481 rp.pages.RepoBranches(w, pages.RepoBranchesParams{
482 LoggedInUser: user,
483 RepoInfo: f.RepoInfo(user),
484 RepoBranchesResponse: *result,
485 })
486}
487
488func (rp *Repo) RepoBlob(w http.ResponseWriter, r *http.Request) {
489 f, err := rp.repoResolver.Resolve(r)
490 if err != nil {
491 log.Println("failed to get repo and knot", err)
492 return
493 }
494
495 ref := chi.URLParam(r, "ref")
496 filePath := chi.URLParam(r, "*")
497 protocol := "http"
498 if !rp.config.Core.Dev {
499 protocol = "https"
500 }
501 resp, err := http.Get(fmt.Sprintf("%s://%s/%s/%s/blob/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath))
502 if err != nil {
503 log.Println("failed to reach knotserver", err)
504 return
505 }
506
507 body, err := io.ReadAll(resp.Body)
508 if err != nil {
509 log.Printf("Error reading response body: %v", err)
510 return
511 }
512
513 var result types.RepoBlobResponse
514 err = json.Unmarshal(body, &result)
515 if err != nil {
516 log.Println("failed to parse response:", err)
517 return
518 }
519
520 var breadcrumbs [][]string
521 breadcrumbs = append(breadcrumbs, []string{f.RepoName, fmt.Sprintf("/%s/tree/%s", f.OwnerSlashRepo(), ref)})
522 if filePath != "" {
523 for idx, elem := range strings.Split(filePath, "/") {
524 breadcrumbs = append(breadcrumbs, []string{elem, fmt.Sprintf("%s/%s", breadcrumbs[idx][1], elem)})
525 }
526 }
527
528 showRendered := false
529 renderToggle := false
530
531 if markup.GetFormat(result.Path) == markup.FormatMarkdown {
532 renderToggle = true
533 showRendered = r.URL.Query().Get("code") != "true"
534 }
535
536 var unsupported bool
537 var isImage bool
538 var isVideo bool
539 var contentSrc string
540
541 if result.IsBinary {
542 ext := strings.ToLower(filepath.Ext(result.Path))
543 switch ext {
544 case ".jpg", ".jpeg", ".png", ".gif", ".svg", ".webp":
545 isImage = true
546 case ".mp4", ".webm", ".ogg", ".mov", ".avi":
547 isVideo = true
548 default:
549 unsupported = true
550 }
551
552 // fetch the actual binary content like in RepoBlobRaw
553
554 blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)
555 contentSrc = blobURL
556 if !rp.config.Core.Dev {
557 contentSrc = markup.GenerateCamoURL(rp.config.Camo.Host, rp.config.Camo.SharedSecret, blobURL)
558 }
559 }
560
561 user := rp.oauth.GetUser(r)
562 rp.pages.RepoBlob(w, pages.RepoBlobParams{
563 LoggedInUser: user,
564 RepoInfo: f.RepoInfo(user),
565 RepoBlobResponse: result,
566 BreadCrumbs: breadcrumbs,
567 ShowRendered: showRendered,
568 RenderToggle: renderToggle,
569 Unsupported: unsupported,
570 IsImage: isImage,
571 IsVideo: isVideo,
572 ContentSrc: contentSrc,
573 })
574}
575
576func (rp *Repo) RepoBlobRaw(w http.ResponseWriter, r *http.Request) {
577 f, err := rp.repoResolver.Resolve(r)
578 if err != nil {
579 log.Println("failed to get repo and knot", err)
580 w.WriteHeader(http.StatusBadRequest)
581 return
582 }
583
584 ref := chi.URLParam(r, "ref")
585 filePath := chi.URLParam(r, "*")
586
587 protocol := "http"
588 if !rp.config.Core.Dev {
589 protocol = "https"
590 }
591 blobURL := fmt.Sprintf("%s://%s/%s/%s/raw/%s/%s", protocol, f.Knot, f.OwnerDid(), f.RepoName, ref, filePath)
592 resp, err := http.Get(blobURL)
593 if err != nil {
594 log.Println("failed to reach knotserver:", err)
595 rp.pages.Error503(w)
596 return
597 }
598 defer resp.Body.Close()
599
600 if resp.StatusCode != http.StatusOK {
601 log.Printf("knotserver returned non-OK status for raw blob %s: %d", blobURL, resp.StatusCode)
602 w.WriteHeader(resp.StatusCode)
603 _, _ = io.Copy(w, resp.Body)
604 return
605 }
606
607 contentType := resp.Header.Get("Content-Type")
608 body, err := io.ReadAll(resp.Body)
609 if err != nil {
610 log.Printf("error reading response body from knotserver: %v", err)
611 w.WriteHeader(http.StatusInternalServerError)
612 return
613 }
614
615 if strings.Contains(contentType, "text/plain") {
616 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
617 w.Write(body)
618 } else if strings.HasPrefix(contentType, "image/") || strings.HasPrefix(contentType, "video/") {
619 w.Header().Set("Content-Type", contentType)
620 w.Write(body)
621 } else {
622 w.WriteHeader(http.StatusUnsupportedMediaType)
623 w.Write([]byte("unsupported content type"))
624 return
625 }
626}
627
628// modify the spindle configured for this repo
629func (rp *Repo) EditSpindle(w http.ResponseWriter, r *http.Request) {
630 f, err := rp.repoResolver.Resolve(r)
631 if err != nil {
632 log.Println("failed to get repo and knot", err)
633 w.WriteHeader(http.StatusBadRequest)
634 return
635 }
636
637 repoAt := f.RepoAt
638 rkey := repoAt.RecordKey().String()
639 if rkey == "" {
640 log.Println("invalid aturi for repo", err)
641 w.WriteHeader(http.StatusInternalServerError)
642 return
643 }
644
645 user := rp.oauth.GetUser(r)
646
647 newSpindle := r.FormValue("spindle")
648 client, err := rp.oauth.AuthorizedClient(r)
649 if err != nil {
650 log.Println("failed to get client")
651 rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
652 return
653 }
654
655 // ensure that this is a valid spindle for this user
656 validSpindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
657 if err != nil {
658 log.Println("failed to get valid spindles")
659 rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
660 return
661 }
662
663 if !slices.Contains(validSpindles, newSpindle) {
664 log.Println("newSpindle not present in validSpindles", "newSpindle", newSpindle, "validSpindles", validSpindles)
665 rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
666 return
667 }
668
669 // optimistic update
670 err = db.UpdateSpindle(rp.db, string(repoAt), newSpindle)
671 if err != nil {
672 log.Println("failed to perform update-spindle query", err)
673 rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, try again later.")
674 return
675 }
676
677 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoNSID, user.Did, rkey)
678 if err != nil {
679 // failed to get record
680 rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, no record found on PDS.")
681 return
682 }
683 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
684 Collection: tangled.RepoNSID,
685 Repo: user.Did,
686 Rkey: rkey,
687 SwapRecord: ex.Cid,
688 Record: &lexutil.LexiconTypeDecoder{
689 Val: &tangled.Repo{
690 Knot: f.Knot,
691 Name: f.RepoName,
692 Owner: user.Did,
693 CreatedAt: f.CreatedAt,
694 Description: &f.Description,
695 Spindle: &newSpindle,
696 },
697 },
698 })
699
700 if err != nil {
701 log.Println("failed to perform update-spindle query", err)
702 // failed to get record
703 rp.pages.Notice(w, "repo-notice", "Failed to configure spindle, unable to save to PDS.")
704 return
705 }
706
707 // add this spindle to spindle stream
708 rp.spindlestream.AddSource(
709 context.Background(),
710 eventconsumer.NewSpindleSource(newSpindle),
711 )
712
713 w.Write(fmt.Append(nil, "spindle set to: ", newSpindle))
714}
715
716func (rp *Repo) AddCollaborator(w http.ResponseWriter, r *http.Request) {
717 f, err := rp.repoResolver.Resolve(r)
718 if err != nil {
719 log.Println("failed to get repo and knot", err)
720 return
721 }
722
723 collaborator := r.FormValue("collaborator")
724 if collaborator == "" {
725 http.Error(w, "malformed form", http.StatusBadRequest)
726 return
727 }
728
729 collaboratorIdent, err := rp.idResolver.ResolveIdent(r.Context(), collaborator)
730 if err != nil {
731 w.Write([]byte("failed to resolve collaborator did to a handle"))
732 return
733 }
734 log.Printf("adding %s to %s\n", collaboratorIdent.Handle.String(), f.Knot)
735
736 // TODO: create an atproto record for this
737
738 secret, err := db.GetRegistrationKey(rp.db, f.Knot)
739 if err != nil {
740 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
741 return
742 }
743
744 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
745 if err != nil {
746 log.Println("failed to create client to ", f.Knot)
747 return
748 }
749
750 ksResp, err := ksClient.AddCollaborator(f.OwnerDid(), f.RepoName, collaboratorIdent.DID.String())
751 if err != nil {
752 log.Printf("failed to make request to %s: %s", f.Knot, err)
753 return
754 }
755
756 if ksResp.StatusCode != http.StatusNoContent {
757 w.Write(fmt.Append(nil, "knotserver failed to add collaborator: ", err))
758 return
759 }
760
761 tx, err := rp.db.BeginTx(r.Context(), nil)
762 if err != nil {
763 log.Println("failed to start tx")
764 w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
765 return
766 }
767 defer func() {
768 tx.Rollback()
769 err = rp.enforcer.E.LoadPolicy()
770 if err != nil {
771 log.Println("failed to rollback policies")
772 }
773 }()
774
775 err = rp.enforcer.AddCollaborator(collaboratorIdent.DID.String(), f.Knot, f.DidSlashRepo())
776 if err != nil {
777 w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
778 return
779 }
780
781 err = db.AddCollaborator(rp.db, collaboratorIdent.DID.String(), f.OwnerDid(), f.RepoName, f.Knot)
782 if err != nil {
783 w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
784 return
785 }
786
787 err = tx.Commit()
788 if err != nil {
789 log.Println("failed to commit changes", err)
790 http.Error(w, err.Error(), http.StatusInternalServerError)
791 return
792 }
793
794 err = rp.enforcer.E.SavePolicy()
795 if err != nil {
796 log.Println("failed to update ACLs", err)
797 http.Error(w, err.Error(), http.StatusInternalServerError)
798 return
799 }
800
801 w.Write(fmt.Append(nil, "added collaborator: ", collaboratorIdent.Handle.String()))
802
803}
804
805func (rp *Repo) DeleteRepo(w http.ResponseWriter, r *http.Request) {
806 user := rp.oauth.GetUser(r)
807
808 f, err := rp.repoResolver.Resolve(r)
809 if err != nil {
810 log.Println("failed to get repo and knot", err)
811 return
812 }
813
814 // remove record from pds
815 xrpcClient, err := rp.oauth.AuthorizedClient(r)
816 if err != nil {
817 log.Println("failed to get authorized client", err)
818 return
819 }
820 repoRkey := f.RepoAt.RecordKey().String()
821 _, err = xrpcClient.RepoDeleteRecord(r.Context(), &comatproto.RepoDeleteRecord_Input{
822 Collection: tangled.RepoNSID,
823 Repo: user.Did,
824 Rkey: repoRkey,
825 })
826 if err != nil {
827 log.Printf("failed to delete record: %s", err)
828 rp.pages.Notice(w, "settings-delete", "Failed to delete repository from PDS.")
829 return
830 }
831 log.Println("removed repo record ", f.RepoAt.String())
832
833 secret, err := db.GetRegistrationKey(rp.db, f.Knot)
834 if err != nil {
835 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
836 return
837 }
838
839 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
840 if err != nil {
841 log.Println("failed to create client to ", f.Knot)
842 return
843 }
844
845 ksResp, err := ksClient.RemoveRepo(f.OwnerDid(), f.RepoName)
846 if err != nil {
847 log.Printf("failed to make request to %s: %s", f.Knot, err)
848 return
849 }
850
851 if ksResp.StatusCode != http.StatusNoContent {
852 log.Println("failed to remove repo from knot, continuing anyway ", f.Knot)
853 } else {
854 log.Println("removed repo from knot ", f.Knot)
855 }
856
857 tx, err := rp.db.BeginTx(r.Context(), nil)
858 if err != nil {
859 log.Println("failed to start tx")
860 w.Write(fmt.Append(nil, "failed to add collaborator: ", err))
861 return
862 }
863 defer func() {
864 tx.Rollback()
865 err = rp.enforcer.E.LoadPolicy()
866 if err != nil {
867 log.Println("failed to rollback policies")
868 }
869 }()
870
871 // remove collaborator RBAC
872 repoCollaborators, err := rp.enforcer.E.GetImplicitUsersForResourceByDomain(f.DidSlashRepo(), f.Knot)
873 if err != nil {
874 rp.pages.Notice(w, "settings-delete", "Failed to remove collaborators")
875 return
876 }
877 for _, c := range repoCollaborators {
878 did := c[0]
879 rp.enforcer.RemoveCollaborator(did, f.Knot, f.DidSlashRepo())
880 }
881 log.Println("removed collaborators")
882
883 // remove repo RBAC
884 err = rp.enforcer.RemoveRepo(f.OwnerDid(), f.Knot, f.DidSlashRepo())
885 if err != nil {
886 rp.pages.Notice(w, "settings-delete", "Failed to update RBAC rules")
887 return
888 }
889
890 // remove repo from db
891 err = db.RemoveRepo(tx, f.OwnerDid(), f.RepoName)
892 if err != nil {
893 rp.pages.Notice(w, "settings-delete", "Failed to update appview")
894 return
895 }
896 log.Println("removed repo from db")
897
898 err = tx.Commit()
899 if err != nil {
900 log.Println("failed to commit changes", err)
901 http.Error(w, err.Error(), http.StatusInternalServerError)
902 return
903 }
904
905 err = rp.enforcer.E.SavePolicy()
906 if err != nil {
907 log.Println("failed to update ACLs", err)
908 http.Error(w, err.Error(), http.StatusInternalServerError)
909 return
910 }
911
912 rp.pages.HxRedirect(w, fmt.Sprintf("/%s", f.OwnerDid()))
913}
914
915func (rp *Repo) SetDefaultBranch(w http.ResponseWriter, r *http.Request) {
916 f, err := rp.repoResolver.Resolve(r)
917 if err != nil {
918 log.Println("failed to get repo and knot", err)
919 return
920 }
921
922 branch := r.FormValue("branch")
923 if branch == "" {
924 http.Error(w, "malformed form", http.StatusBadRequest)
925 return
926 }
927
928 secret, err := db.GetRegistrationKey(rp.db, f.Knot)
929 if err != nil {
930 log.Printf("no key found for domain %s: %s\n", f.Knot, err)
931 return
932 }
933
934 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
935 if err != nil {
936 log.Println("failed to create client to ", f.Knot)
937 return
938 }
939
940 ksResp, err := ksClient.SetDefaultBranch(f.OwnerDid(), f.RepoName, branch)
941 if err != nil {
942 log.Printf("failed to make request to %s: %s", f.Knot, err)
943 return
944 }
945
946 if ksResp.StatusCode != http.StatusNoContent {
947 rp.pages.Notice(w, "repo-settings", "Failed to set default branch. Try again later.")
948 return
949 }
950
951 w.Write(fmt.Append(nil, "default branch set to: ", branch))
952}
953
954func (rp *Repo) Secrets(w http.ResponseWriter, r *http.Request) {
955 f, err := rp.repoResolver.Resolve(r)
956 if err != nil {
957 log.Println("failed to get repo and knot", err)
958 return
959 }
960
961 if f.Spindle == "" {
962 log.Println("empty spindle cannot add/rm secret", err)
963 return
964 }
965
966 lxm := tangled.RepoAddSecretNSID
967 if r.Method == http.MethodDelete {
968 lxm = tangled.RepoRemoveSecretNSID
969 }
970
971 spindleClient, err := rp.oauth.ServiceClient(
972 r,
973 oauth.WithService(f.Spindle),
974 oauth.WithLxm(lxm),
975 oauth.WithDev(rp.config.Core.Dev),
976 )
977 if err != nil {
978 log.Println("failed to create spindle client", err)
979 return
980 }
981
982 key := r.FormValue("key")
983 if key == "" {
984 w.WriteHeader(http.StatusBadRequest)
985 return
986 }
987
988 switch r.Method {
989 case http.MethodPut:
990 value := r.FormValue("value")
991 if key == "" {
992 w.WriteHeader(http.StatusBadRequest)
993 return
994 }
995
996 err = tangled.RepoAddSecret(
997 r.Context(),
998 spindleClient,
999 &tangled.RepoAddSecret_Input{
1000 Repo: f.RepoAt.String(),
1001 Key: key,
1002 Value: value,
1003 },
1004 )
1005 if err != nil {
1006 log.Println("request didnt run", "err", err)
1007 return
1008 }
1009
1010 case http.MethodDelete:
1011 err = tangled.RepoRemoveSecret(
1012 r.Context(),
1013 spindleClient,
1014 &tangled.RepoRemoveSecret_Input{
1015 Repo: f.RepoAt.String(),
1016 Key: key,
1017 },
1018 )
1019 if err != nil {
1020 log.Println("request didnt run", "err", err)
1021 return
1022 }
1023 }
1024}
1025
1026func (rp *Repo) RepoSettings(w http.ResponseWriter, r *http.Request) {
1027 f, err := rp.repoResolver.Resolve(r)
1028 if err != nil {
1029 log.Println("failed to get repo and knot", err)
1030 return
1031 }
1032
1033 switch r.Method {
1034 case http.MethodGet:
1035 // for now, this is just pubkeys
1036 user := rp.oauth.GetUser(r)
1037 repoCollaborators, err := f.Collaborators(r.Context())
1038 if err != nil {
1039 log.Println("failed to get collaborators", err)
1040 }
1041
1042 isCollaboratorInviteAllowed := false
1043 if user != nil {
1044 ok, err := rp.enforcer.IsCollaboratorInviteAllowed(user.Did, f.Knot, f.DidSlashRepo())
1045 if err == nil && ok {
1046 isCollaboratorInviteAllowed = true
1047 }
1048 }
1049
1050 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1051 if err != nil {
1052 log.Println("failed to create unsigned client", err)
1053 return
1054 }
1055
1056 result, err := us.Branches(f.OwnerDid(), f.RepoName)
1057 if err != nil {
1058 log.Println("failed to reach knotserver", err)
1059 return
1060 }
1061
1062 // all spindles that this user is a member of
1063 spindles, err := rp.enforcer.GetSpindlesForUser(user.Did)
1064 if err != nil {
1065 log.Println("failed to fetch spindles", err)
1066 return
1067 }
1068
1069 var secrets []*tangled.RepoListSecrets_Secret
1070 if f.Spindle != "" {
1071 if spindleClient, err := rp.oauth.ServiceClient(
1072 r,
1073 oauth.WithService(f.Spindle),
1074 oauth.WithLxm(tangled.RepoListSecretsNSID),
1075 oauth.WithDev(rp.config.Core.Dev),
1076 ); err != nil {
1077 log.Println("failed to create spindle client", err)
1078 } else if resp, err := tangled.RepoListSecrets(r.Context(), spindleClient, f.RepoAt.String()); err != nil {
1079 log.Println("failed to fetch secrets", err)
1080 } else {
1081 secrets = resp.Secrets
1082 }
1083 }
1084
1085 rp.pages.RepoSettings(w, pages.RepoSettingsParams{
1086 LoggedInUser: user,
1087 RepoInfo: f.RepoInfo(user),
1088 Collaborators: repoCollaborators,
1089 IsCollaboratorInviteAllowed: isCollaboratorInviteAllowed,
1090 Branches: result.Branches,
1091 Spindles: spindles,
1092 CurrentSpindle: f.Spindle,
1093 Secrets: secrets,
1094 })
1095 }
1096}
1097
1098func (rp *Repo) SyncRepoFork(w http.ResponseWriter, r *http.Request) {
1099 user := rp.oauth.GetUser(r)
1100 f, err := rp.repoResolver.Resolve(r)
1101 if err != nil {
1102 log.Printf("failed to resolve source repo: %v", err)
1103 return
1104 }
1105
1106 switch r.Method {
1107 case http.MethodPost:
1108 secret, err := db.GetRegistrationKey(rp.db, f.Knot)
1109 if err != nil {
1110 rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", f.Knot))
1111 return
1112 }
1113
1114 client, err := knotclient.NewSignedClient(f.Knot, secret, rp.config.Core.Dev)
1115 if err != nil {
1116 rp.pages.Notice(w, "repo", "Failed to reach knot server.")
1117 return
1118 }
1119
1120 var uri string
1121 if rp.config.Core.Dev {
1122 uri = "http"
1123 } else {
1124 uri = "https"
1125 }
1126 forkName := fmt.Sprintf("%s", f.RepoName)
1127 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1128
1129 _, err = client.SyncRepoFork(user.Did, forkSourceUrl, forkName, f.Ref)
1130 if err != nil {
1131 rp.pages.Notice(w, "repo", "Failed to sync repository fork.")
1132 return
1133 }
1134
1135 rp.pages.HxRefresh(w)
1136 return
1137 }
1138}
1139
1140func (rp *Repo) ForkRepo(w http.ResponseWriter, r *http.Request) {
1141 user := rp.oauth.GetUser(r)
1142 f, err := rp.repoResolver.Resolve(r)
1143 if err != nil {
1144 log.Printf("failed to resolve source repo: %v", err)
1145 return
1146 }
1147
1148 switch r.Method {
1149 case http.MethodGet:
1150 user := rp.oauth.GetUser(r)
1151 knots, err := rp.enforcer.GetKnotsForUser(user.Did)
1152 if err != nil {
1153 rp.pages.Notice(w, "repo", "Invalid user account.")
1154 return
1155 }
1156
1157 rp.pages.ForkRepo(w, pages.ForkRepoParams{
1158 LoggedInUser: user,
1159 Knots: knots,
1160 RepoInfo: f.RepoInfo(user),
1161 })
1162
1163 case http.MethodPost:
1164
1165 knot := r.FormValue("knot")
1166 if knot == "" {
1167 rp.pages.Notice(w, "repo", "Invalid form submission—missing knot domain.")
1168 return
1169 }
1170
1171 ok, err := rp.enforcer.E.Enforce(user.Did, knot, knot, "repo:create")
1172 if err != nil || !ok {
1173 rp.pages.Notice(w, "repo", "You do not have permission to create a repo in this knot.")
1174 return
1175 }
1176
1177 forkName := fmt.Sprintf("%s", f.RepoName)
1178
1179 // this check is *only* to see if the forked repo name already exists
1180 // in the user's account.
1181 existingRepo, err := db.GetRepo(rp.db, user.Did, f.RepoName)
1182 if err != nil {
1183 if errors.Is(err, sql.ErrNoRows) {
1184 // no existing repo with this name found, we can use the name as is
1185 } else {
1186 log.Println("error fetching existing repo from db", err)
1187 rp.pages.Notice(w, "repo", "Failed to fork this repository. Try again later.")
1188 return
1189 }
1190 } else if existingRepo != nil {
1191 // repo with this name already exists, append random string
1192 forkName = fmt.Sprintf("%s-%s", forkName, randomString(3))
1193 }
1194 secret, err := db.GetRegistrationKey(rp.db, knot)
1195 if err != nil {
1196 rp.pages.Notice(w, "repo", fmt.Sprintf("No registration key found for knot %s.", knot))
1197 return
1198 }
1199
1200 client, err := knotclient.NewSignedClient(knot, secret, rp.config.Core.Dev)
1201 if err != nil {
1202 rp.pages.Notice(w, "repo", "Failed to reach knot server.")
1203 return
1204 }
1205
1206 var uri string
1207 if rp.config.Core.Dev {
1208 uri = "http"
1209 } else {
1210 uri = "https"
1211 }
1212 forkSourceUrl := fmt.Sprintf("%s://%s/%s/%s", uri, f.Knot, f.OwnerDid(), f.RepoName)
1213 sourceAt := f.RepoAt.String()
1214
1215 rkey := tid.TID()
1216 repo := &db.Repo{
1217 Did: user.Did,
1218 Name: forkName,
1219 Knot: knot,
1220 Rkey: rkey,
1221 Source: sourceAt,
1222 }
1223
1224 tx, err := rp.db.BeginTx(r.Context(), nil)
1225 if err != nil {
1226 log.Println(err)
1227 rp.pages.Notice(w, "repo", "Failed to save repository information.")
1228 return
1229 }
1230 defer func() {
1231 tx.Rollback()
1232 err = rp.enforcer.E.LoadPolicy()
1233 if err != nil {
1234 log.Println("failed to rollback policies")
1235 }
1236 }()
1237
1238 resp, err := client.ForkRepo(user.Did, forkSourceUrl, forkName)
1239 if err != nil {
1240 rp.pages.Notice(w, "repo", "Failed to create repository on knot server.")
1241 return
1242 }
1243
1244 switch resp.StatusCode {
1245 case http.StatusConflict:
1246 rp.pages.Notice(w, "repo", "A repository with that name already exists.")
1247 return
1248 case http.StatusInternalServerError:
1249 rp.pages.Notice(w, "repo", "Failed to create repository on knot. Try again later.")
1250 case http.StatusNoContent:
1251 // continue
1252 }
1253
1254 xrpcClient, err := rp.oauth.AuthorizedClient(r)
1255 if err != nil {
1256 log.Println("failed to get authorized client", err)
1257 rp.pages.Notice(w, "repo", "Failed to create repository.")
1258 return
1259 }
1260
1261 createdAt := time.Now().Format(time.RFC3339)
1262 atresp, err := xrpcClient.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1263 Collection: tangled.RepoNSID,
1264 Repo: user.Did,
1265 Rkey: rkey,
1266 Record: &lexutil.LexiconTypeDecoder{
1267 Val: &tangled.Repo{
1268 Knot: repo.Knot,
1269 Name: repo.Name,
1270 CreatedAt: createdAt,
1271 Owner: user.Did,
1272 Source: &sourceAt,
1273 }},
1274 })
1275 if err != nil {
1276 log.Printf("failed to create record: %s", err)
1277 rp.pages.Notice(w, "repo", "Failed to announce repository creation.")
1278 return
1279 }
1280 log.Println("created repo record: ", atresp.Uri)
1281
1282 repo.AtUri = atresp.Uri
1283 err = db.AddRepo(tx, repo)
1284 if err != nil {
1285 log.Println(err)
1286 rp.pages.Notice(w, "repo", "Failed to save repository information.")
1287 return
1288 }
1289
1290 // acls
1291 p, _ := securejoin.SecureJoin(user.Did, forkName)
1292 err = rp.enforcer.AddRepo(user.Did, knot, p)
1293 if err != nil {
1294 log.Println(err)
1295 rp.pages.Notice(w, "repo", "Failed to set up repository permissions.")
1296 return
1297 }
1298
1299 err = tx.Commit()
1300 if err != nil {
1301 log.Println("failed to commit changes", err)
1302 http.Error(w, err.Error(), http.StatusInternalServerError)
1303 return
1304 }
1305
1306 err = rp.enforcer.E.SavePolicy()
1307 if err != nil {
1308 log.Println("failed to update ACLs", err)
1309 http.Error(w, err.Error(), http.StatusInternalServerError)
1310 return
1311 }
1312
1313 rp.pages.HxLocation(w, fmt.Sprintf("/@%s/%s", user.Handle, forkName))
1314 return
1315 }
1316}
1317
1318func (rp *Repo) RepoCompareNew(w http.ResponseWriter, r *http.Request) {
1319 user := rp.oauth.GetUser(r)
1320 f, err := rp.repoResolver.Resolve(r)
1321 if err != nil {
1322 log.Println("failed to get repo and knot", err)
1323 return
1324 }
1325
1326 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1327 if err != nil {
1328 log.Printf("failed to create unsigned client for %s", f.Knot)
1329 rp.pages.Error503(w)
1330 return
1331 }
1332
1333 result, err := us.Branches(f.OwnerDid(), f.RepoName)
1334 if err != nil {
1335 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1336 log.Println("failed to reach knotserver", err)
1337 return
1338 }
1339 branches := result.Branches
1340
1341 sortBranches(branches)
1342
1343 var defaultBranch string
1344 for _, b := range branches {
1345 if b.IsDefault {
1346 defaultBranch = b.Name
1347 }
1348 }
1349
1350 base := defaultBranch
1351 head := defaultBranch
1352
1353 params := r.URL.Query()
1354 queryBase := params.Get("base")
1355 queryHead := params.Get("head")
1356 if queryBase != "" {
1357 base = queryBase
1358 }
1359 if queryHead != "" {
1360 head = queryHead
1361 }
1362
1363 tags, err := us.Tags(f.OwnerDid(), f.RepoName)
1364 if err != nil {
1365 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1366 log.Println("failed to reach knotserver", err)
1367 return
1368 }
1369
1370 repoinfo := f.RepoInfo(user)
1371
1372 rp.pages.RepoCompareNew(w, pages.RepoCompareNewParams{
1373 LoggedInUser: user,
1374 RepoInfo: repoinfo,
1375 Branches: branches,
1376 Tags: tags.Tags,
1377 Base: base,
1378 Head: head,
1379 })
1380}
1381
1382func (rp *Repo) RepoCompare(w http.ResponseWriter, r *http.Request) {
1383 user := rp.oauth.GetUser(r)
1384 f, err := rp.repoResolver.Resolve(r)
1385 if err != nil {
1386 log.Println("failed to get repo and knot", err)
1387 return
1388 }
1389
1390 var diffOpts types.DiffOpts
1391 if d := r.URL.Query().Get("diff"); d == "split" {
1392 diffOpts.Split = true
1393 }
1394
1395 // if user is navigating to one of
1396 // /compare/{base}/{head}
1397 // /compare/{base}...{head}
1398 base := chi.URLParam(r, "base")
1399 head := chi.URLParam(r, "head")
1400 if base == "" && head == "" {
1401 rest := chi.URLParam(r, "*") // master...feature/xyz
1402 parts := strings.SplitN(rest, "...", 2)
1403 if len(parts) == 2 {
1404 base = parts[0]
1405 head = parts[1]
1406 }
1407 }
1408
1409 base, _ = url.PathUnescape(base)
1410 head, _ = url.PathUnescape(head)
1411
1412 if base == "" || head == "" {
1413 log.Printf("invalid comparison")
1414 rp.pages.Error404(w)
1415 return
1416 }
1417
1418 us, err := knotclient.NewUnsignedClient(f.Knot, rp.config.Core.Dev)
1419 if err != nil {
1420 log.Printf("failed to create unsigned client for %s", f.Knot)
1421 rp.pages.Error503(w)
1422 return
1423 }
1424
1425 branches, err := us.Branches(f.OwnerDid(), f.RepoName)
1426 if err != nil {
1427 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1428 log.Println("failed to reach knotserver", err)
1429 return
1430 }
1431
1432 tags, err := us.Tags(f.OwnerDid(), f.RepoName)
1433 if err != nil {
1434 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1435 log.Println("failed to reach knotserver", err)
1436 return
1437 }
1438
1439 formatPatch, err := us.Compare(f.OwnerDid(), f.RepoName, base, head)
1440 if err != nil {
1441 rp.pages.Notice(w, "compare-error", "Failed to produce comparison. Try again later.")
1442 log.Println("failed to compare", err)
1443 return
1444 }
1445 diff := patchutil.AsNiceDiff(formatPatch.Patch, base)
1446
1447 repoinfo := f.RepoInfo(user)
1448
1449 rp.pages.RepoCompare(w, pages.RepoCompareParams{
1450 LoggedInUser: user,
1451 RepoInfo: repoinfo,
1452 Branches: branches.Branches,
1453 Tags: tags.Tags,
1454 Base: base,
1455 Head: head,
1456 Diff: &diff,
1457 DiffOpts: diffOpts,
1458 })
1459
1460}