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