this repo has no description
1package state
2
3import (
4 "database/sql"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "log"
10 "net/http"
11 "net/url"
12 "strconv"
13 "strings"
14 "time"
15
16 "github.com/go-chi/chi/v5"
17 "tangled.sh/tangled.sh/core/api/tangled"
18 "tangled.sh/tangled.sh/core/appview/auth"
19 "tangled.sh/tangled.sh/core/appview/db"
20 "tangled.sh/tangled.sh/core/appview/pages"
21 "tangled.sh/tangled.sh/core/types"
22
23 comatproto "github.com/bluesky-social/indigo/api/atproto"
24 "github.com/bluesky-social/indigo/atproto/syntax"
25 lexutil "github.com/bluesky-social/indigo/lex/util"
26)
27
28// htmx fragment
29func (s *State) PullActions(w http.ResponseWriter, r *http.Request) {
30 switch r.Method {
31 case http.MethodGet:
32 user := s.auth.GetUser(r)
33 f, err := fullyResolvedRepo(r)
34 if err != nil {
35 log.Println("failed to get repo and knot", err)
36 return
37 }
38
39 pull, ok := r.Context().Value("pull").(*db.Pull)
40 if !ok {
41 log.Println("failed to get pull")
42 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
43 return
44 }
45
46 roundNumberStr := chi.URLParam(r, "round")
47 roundNumber, err := strconv.Atoi(roundNumberStr)
48 if err != nil {
49 roundNumber = pull.LastRoundNumber()
50 }
51 if roundNumber >= len(pull.Submissions) {
52 http.Error(w, "bad round id", http.StatusBadRequest)
53 log.Println("failed to parse round id", err)
54 return
55 }
56
57 mergeCheckResponse := s.mergeCheck(f, pull)
58 var resubmitResult pages.ResubmitResult
59 if user.Did == pull.OwnerDid {
60 resubmitResult = s.resubmitCheck(f, pull)
61 }
62
63 s.pages.PullActionsFragment(w, pages.PullActionsParams{
64 LoggedInUser: user,
65 RepoInfo: f.RepoInfo(s, user),
66 Pull: pull,
67 RoundNumber: roundNumber,
68 MergeCheck: mergeCheckResponse,
69 ResubmitCheck: resubmitResult,
70 })
71 return
72 }
73}
74
75func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) {
76 user := s.auth.GetUser(r)
77 f, err := fullyResolvedRepo(r)
78 if err != nil {
79 log.Println("failed to get repo and knot", err)
80 return
81 }
82
83 pull, ok := r.Context().Value("pull").(*db.Pull)
84 if !ok {
85 log.Println("failed to get pull")
86 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
87 return
88 }
89
90 totalIdents := 1
91 for _, submission := range pull.Submissions {
92 totalIdents += len(submission.Comments)
93 }
94
95 identsToResolve := make([]string, totalIdents)
96
97 // populate idents
98 identsToResolve[0] = pull.OwnerDid
99 idx := 1
100 for _, submission := range pull.Submissions {
101 for _, comment := range submission.Comments {
102 identsToResolve[idx] = comment.OwnerDid
103 idx += 1
104 }
105 }
106
107 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
108 didHandleMap := make(map[string]string)
109 for _, identity := range resolvedIds {
110 if !identity.Handle.IsInvalidHandle() {
111 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
112 } else {
113 didHandleMap[identity.DID.String()] = identity.DID.String()
114 }
115 }
116
117 mergeCheckResponse := s.mergeCheck(f, pull)
118 var resubmitResult pages.ResubmitResult
119 if user.Did == pull.OwnerDid {
120 resubmitResult = s.resubmitCheck(f, pull)
121 }
122
123 var pullSourceRepo *db.Repo
124 if pull.PullSource != nil {
125 if pull.PullSource.Repo != nil {
126 pullSourceRepo, err = db.GetRepoByAtUri(s.db, pull.PullSource.Repo.String())
127 if err != nil {
128 log.Printf("failed to get repo by at uri: %v", err)
129 return
130 }
131 }
132 }
133
134 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
135 LoggedInUser: user,
136 RepoInfo: f.RepoInfo(s, user),
137 DidHandleMap: didHandleMap,
138 Pull: pull,
139 PullSourceRepo: pullSourceRepo,
140 MergeCheck: mergeCheckResponse,
141 ResubmitCheck: resubmitResult,
142 })
143}
144
145func (s *State) mergeCheck(f *FullyResolvedRepo, pull *db.Pull) types.MergeCheckResponse {
146 if pull.State == db.PullMerged {
147 return types.MergeCheckResponse{}
148 }
149
150 secret, err := db.GetRegistrationKey(s.db, f.Knot)
151 if err != nil {
152 log.Printf("failed to get registration key: %v", err)
153 return types.MergeCheckResponse{
154 Error: "failed to check merge status: this knot is unregistered",
155 }
156 }
157
158 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
159 if err != nil {
160 log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err)
161 return types.MergeCheckResponse{
162 Error: "failed to check merge status",
163 }
164 }
165
166 resp, err := ksClient.MergeCheck([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch)
167 if err != nil {
168 log.Println("failed to check for mergeability:", err)
169 return types.MergeCheckResponse{
170 Error: "failed to check merge status",
171 }
172 }
173 switch resp.StatusCode {
174 case 404:
175 return types.MergeCheckResponse{
176 Error: "failed to check merge status: this knot does not support PRs",
177 }
178 case 400:
179 return types.MergeCheckResponse{
180 Error: "failed to check merge status: does this knot support PRs?",
181 }
182 }
183
184 respBody, err := io.ReadAll(resp.Body)
185 if err != nil {
186 log.Println("failed to read merge check response body")
187 return types.MergeCheckResponse{
188 Error: "failed to check merge status: knot is not speaking the right language",
189 }
190 }
191 defer resp.Body.Close()
192
193 var mergeCheckResponse types.MergeCheckResponse
194 err = json.Unmarshal(respBody, &mergeCheckResponse)
195 if err != nil {
196 log.Println("failed to unmarshal merge check response", err)
197 return types.MergeCheckResponse{
198 Error: "failed to check merge status: knot is not speaking the right language",
199 }
200 }
201
202 return mergeCheckResponse
203}
204
205func (s *State) resubmitCheck(f *FullyResolvedRepo, pull *db.Pull) pages.ResubmitResult {
206 if pull.State == db.PullMerged || pull.PullSource == nil {
207 return pages.Unknown
208 }
209
210 var knot, ownerDid, repoName string
211
212 if pull.PullSource.Repo != nil {
213 // fork-based pulls
214 sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.Repo.String())
215 if err != nil {
216 log.Println("failed to get source repo", err)
217 return pages.Unknown
218 }
219
220 knot = sourceRepo.Knot
221 ownerDid = sourceRepo.Did
222 repoName = sourceRepo.Name
223 } else {
224 // pulls within the same repo
225 knot = f.Knot
226 ownerDid = f.OwnerDid()
227 repoName = f.RepoName
228 }
229
230 us, err := NewUnsignedClient(knot, s.config.Dev)
231 if err != nil {
232 log.Printf("failed to setup client for %s; ignoring: %v", knot, err)
233 return pages.Unknown
234 }
235
236 resp, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch)
237 if err != nil {
238 log.Println("failed to reach knotserver", err)
239 return pages.Unknown
240 }
241
242 body, err := io.ReadAll(resp.Body)
243 if err != nil {
244 log.Printf("error reading response body: %v", err)
245 return pages.Unknown
246 }
247 defer resp.Body.Close()
248
249 var result types.RepoBranchResponse
250 if err := json.Unmarshal(body, &result); err != nil {
251 log.Println("failed to parse response:", err)
252 return pages.Unknown
253 }
254
255 latestSubmission := pull.Submissions[pull.LastRoundNumber()]
256 if latestSubmission.SourceRev != result.Branch.Hash {
257 return pages.ShouldResubmit
258 }
259
260 return pages.ShouldNotResubmit
261}
262
263func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
264 user := s.auth.GetUser(r)
265 f, err := fullyResolvedRepo(r)
266 if err != nil {
267 log.Println("failed to get repo and knot", err)
268 return
269 }
270
271 pull, ok := r.Context().Value("pull").(*db.Pull)
272 if !ok {
273 log.Println("failed to get pull")
274 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
275 return
276 }
277
278 roundId := chi.URLParam(r, "round")
279 roundIdInt, err := strconv.Atoi(roundId)
280 if err != nil || roundIdInt >= len(pull.Submissions) {
281 http.Error(w, "bad round id", http.StatusBadRequest)
282 log.Println("failed to parse round id", err)
283 return
284 }
285
286 identsToResolve := []string{pull.OwnerDid}
287 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
288 didHandleMap := make(map[string]string)
289 for _, identity := range resolvedIds {
290 if !identity.Handle.IsInvalidHandle() {
291 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
292 } else {
293 didHandleMap[identity.DID.String()] = identity.DID.String()
294 }
295 }
296
297 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
298 LoggedInUser: user,
299 DidHandleMap: didHandleMap,
300 RepoInfo: f.RepoInfo(s, user),
301 Pull: pull,
302 Round: roundIdInt,
303 Submission: pull.Submissions[roundIdInt],
304 Diff: pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch),
305 })
306
307}
308
309func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
310 pull, ok := r.Context().Value("pull").(*db.Pull)
311 if !ok {
312 log.Println("failed to get pull")
313 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
314 return
315 }
316
317 roundId := chi.URLParam(r, "round")
318 roundIdInt, err := strconv.Atoi(roundId)
319 if err != nil || roundIdInt >= len(pull.Submissions) {
320 http.Error(w, "bad round id", http.StatusBadRequest)
321 log.Println("failed to parse round id", err)
322 return
323 }
324
325 identsToResolve := []string{pull.OwnerDid}
326 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
327 didHandleMap := make(map[string]string)
328 for _, identity := range resolvedIds {
329 if !identity.Handle.IsInvalidHandle() {
330 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
331 } else {
332 didHandleMap[identity.DID.String()] = identity.DID.String()
333 }
334 }
335
336 w.Header().Set("Content-Type", "text/plain")
337 w.Write([]byte(pull.Submissions[roundIdInt].Patch))
338}
339
340func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) {
341 user := s.auth.GetUser(r)
342 params := r.URL.Query()
343
344 state := db.PullOpen
345 switch params.Get("state") {
346 case "closed":
347 state = db.PullClosed
348 case "merged":
349 state = db.PullMerged
350 }
351
352 f, err := fullyResolvedRepo(r)
353 if err != nil {
354 log.Println("failed to get repo and knot", err)
355 return
356 }
357
358 pulls, err := db.GetPulls(s.db, f.RepoAt, state)
359 if err != nil {
360 log.Println("failed to get pulls", err)
361 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
362 return
363 }
364
365 identsToResolve := make([]string, len(pulls))
366 for i, pull := range pulls {
367 identsToResolve[i] = pull.OwnerDid
368 }
369 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
370 didHandleMap := make(map[string]string)
371 for _, identity := range resolvedIds {
372 if !identity.Handle.IsInvalidHandle() {
373 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
374 } else {
375 didHandleMap[identity.DID.String()] = identity.DID.String()
376 }
377 }
378
379 s.pages.RepoPulls(w, pages.RepoPullsParams{
380 LoggedInUser: s.auth.GetUser(r),
381 RepoInfo: f.RepoInfo(s, user),
382 Pulls: pulls,
383 DidHandleMap: didHandleMap,
384 FilteringBy: state,
385 })
386 return
387}
388
389func (s *State) PullComment(w http.ResponseWriter, r *http.Request) {
390 user := s.auth.GetUser(r)
391 f, err := fullyResolvedRepo(r)
392 if err != nil {
393 log.Println("failed to get repo and knot", err)
394 return
395 }
396
397 pull, ok := r.Context().Value("pull").(*db.Pull)
398 if !ok {
399 log.Println("failed to get pull")
400 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
401 return
402 }
403
404 roundNumberStr := chi.URLParam(r, "round")
405 roundNumber, err := strconv.Atoi(roundNumberStr)
406 if err != nil || roundNumber >= len(pull.Submissions) {
407 http.Error(w, "bad round id", http.StatusBadRequest)
408 log.Println("failed to parse round id", err)
409 return
410 }
411
412 switch r.Method {
413 case http.MethodGet:
414 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{
415 LoggedInUser: user,
416 RepoInfo: f.RepoInfo(s, user),
417 Pull: pull,
418 RoundNumber: roundNumber,
419 })
420 return
421 case http.MethodPost:
422 body := r.FormValue("body")
423 if body == "" {
424 s.pages.Notice(w, "pull", "Comment body is required")
425 return
426 }
427
428 // Start a transaction
429 tx, err := s.db.BeginTx(r.Context(), nil)
430 if err != nil {
431 log.Println("failed to start transaction", err)
432 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
433 return
434 }
435 defer tx.Rollback()
436
437 createdAt := time.Now().Format(time.RFC3339)
438 ownerDid := user.Did
439
440 pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId)
441 if err != nil {
442 log.Println("failed to get pull at", err)
443 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
444 return
445 }
446
447 atUri := f.RepoAt.String()
448 client, _ := s.auth.AuthorizedClient(r)
449 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
450 Collection: tangled.RepoPullCommentNSID,
451 Repo: user.Did,
452 Rkey: s.TID(),
453 Record: &lexutil.LexiconTypeDecoder{
454 Val: &tangled.RepoPullComment{
455 Repo: &atUri,
456 Pull: pullAt,
457 Owner: &ownerDid,
458 Body: &body,
459 CreatedAt: &createdAt,
460 },
461 },
462 })
463 if err != nil {
464 log.Println("failed to create pull comment", err)
465 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
466 return
467 }
468
469 // Create the pull comment in the database with the commentAt field
470 commentId, err := db.NewPullComment(tx, &db.PullComment{
471 OwnerDid: user.Did,
472 RepoAt: f.RepoAt.String(),
473 PullId: pull.PullId,
474 Body: body,
475 CommentAt: atResp.Uri,
476 SubmissionId: pull.Submissions[roundNumber].ID,
477 })
478 if err != nil {
479 log.Println("failed to create pull comment", err)
480 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
481 return
482 }
483
484 // Commit the transaction
485 if err = tx.Commit(); err != nil {
486 log.Println("failed to commit transaction", err)
487 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
488 return
489 }
490
491 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
492 return
493 }
494}
495
496func (s *State) NewPull(w http.ResponseWriter, r *http.Request) {
497 user := s.auth.GetUser(r)
498 f, err := fullyResolvedRepo(r)
499 if err != nil {
500 log.Println("failed to get repo and knot", err)
501 return
502 }
503
504 forks, err := db.GetForksByDid(s.db, user.Did)
505 if err != nil {
506 log.Println("failed to get forks", err)
507 return
508 }
509
510 switch r.Method {
511 case http.MethodGet:
512 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
513 if err != nil {
514 log.Printf("failed to create unsigned client for %s", f.Knot)
515 s.pages.Error503(w)
516 return
517 }
518
519 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
520 if err != nil {
521 log.Println("failed to reach knotserver", err)
522 return
523 }
524
525 body, err := io.ReadAll(resp.Body)
526 if err != nil {
527 log.Printf("Error reading response body: %v", err)
528 return
529 }
530
531 var result types.RepoBranchesResponse
532 err = json.Unmarshal(body, &result)
533 if err != nil {
534 log.Println("failed to parse response:", err)
535 return
536 }
537
538 s.pages.RepoNewPull(w, pages.RepoNewPullParams{
539 LoggedInUser: user,
540 RepoInfo: f.RepoInfo(s, user),
541 Forks: forks,
542 Branches: result.Branches,
543 })
544 case http.MethodPost:
545 title := r.FormValue("title")
546 body := r.FormValue("body")
547 targetBranch := r.FormValue("targetBranch")
548 fromFork := r.FormValue("fork")
549 sourceBranch := r.FormValue("sourceBranch")
550 patch := r.FormValue("patch")
551
552 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed()
553 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == ""
554 isPatchBased := patch != ""
555 isForkBased := fromFork != "" && sourceBranch != ""
556
557 if !isBranchBased && !isPatchBased && !isForkBased {
558 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.")
559 return
560 }
561
562 if isBranchBased && isPatchBased {
563 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.")
564 return
565 }
566
567 if title == "" || body == "" || targetBranch == "" {
568 s.pages.Notice(w, "pull", "Title, body and target branch are required.")
569 return
570 }
571
572 if isBranchBased {
573 s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch)
574 } else if isPatchBased {
575 s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch)
576 } else if isForkBased {
577 s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch)
578 }
579 return
580 }
581}
582
583func (s *State) handleBranchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, sourceBranch string) {
584 pullSource := &db.PullSource{
585 Branch: sourceBranch,
586 }
587 recordPullSource := &tangled.RepoPull_Source{
588 Branch: sourceBranch,
589 }
590
591 // Generate a patch using /compare
592 ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
593 if err != nil {
594 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
595 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
596 return
597 }
598
599 resp, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
600 switch resp.StatusCode {
601 case 404:
602 case 400:
603 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
604 return
605 }
606
607 respBody, err := io.ReadAll(resp.Body)
608 if err != nil {
609 log.Println("failed to compare across branches")
610 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
611 return
612 }
613 defer resp.Body.Close()
614
615 var diffTreeResponse types.RepoDiffTreeResponse
616 err = json.Unmarshal(respBody, &diffTreeResponse)
617 if err != nil {
618 log.Println("failed to unmarshal diff tree response", err)
619 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
620 return
621 }
622
623 sourceRev := diffTreeResponse.DiffTree.Rev2
624 patch := diffTreeResponse.DiffTree.Patch
625
626 if !isPatchValid(patch) {
627 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
628 return
629 }
630
631 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource)
632}
633
634func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, patch string) {
635 if !isPatchValid(patch) {
636 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
637 return
638 }
639
640 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil)
641}
642
643func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, forkRepo string, title, body, targetBranch, sourceBranch string) {
644 fork, err := db.GetForkByDid(s.db, user.Did, forkRepo)
645 if errors.Is(err, sql.ErrNoRows) {
646 s.pages.Notice(w, "pull", "No such fork.")
647 return
648 } else if err != nil {
649 log.Println("failed to fetch fork:", err)
650 s.pages.Notice(w, "pull", "Failed to fetch fork.")
651 return
652 }
653
654 secret, err := db.GetRegistrationKey(s.db, fork.Knot)
655 if err != nil {
656 log.Println("failed to fetch registration key:", err)
657 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
658 return
659 }
660
661 sc, err := NewSignedClient(fork.Knot, secret, s.config.Dev)
662 if err != nil {
663 log.Println("failed to create signed client:", err)
664 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
665 return
666 }
667
668 us, err := NewUnsignedClient(fork.Knot, s.config.Dev)
669 if err != nil {
670 log.Println("failed to create unsigned client:", err)
671 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
672 return
673 }
674
675 resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch)
676 if err != nil {
677 log.Println("failed to create hidden ref:", err, resp.StatusCode)
678 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
679 return
680 }
681
682 switch resp.StatusCode {
683 case 404:
684 case 400:
685 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
686 return
687 }
688
689 hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch))
690 // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking
691 // the targetBranch on the target repository. This code is a bit confusing, but here's an example:
692 // hiddenRef: hidden/feature-1/main (on repo-fork)
693 // targetBranch: main (on repo-1)
694 // sourceBranch: feature-1 (on repo-fork)
695 diffResp, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch)
696 if err != nil {
697 log.Println("failed to compare across branches", err)
698 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
699 return
700 }
701
702 respBody, err := io.ReadAll(diffResp.Body)
703 if err != nil {
704 log.Println("failed to read response body", err)
705 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
706 return
707 }
708
709 defer resp.Body.Close()
710
711 var diffTreeResponse types.RepoDiffTreeResponse
712 err = json.Unmarshal(respBody, &diffTreeResponse)
713 if err != nil {
714 log.Println("failed to unmarshal diff tree response", err)
715 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
716 return
717 }
718
719 sourceRev := diffTreeResponse.DiffTree.Rev2
720 patch := diffTreeResponse.DiffTree.Patch
721
722 if !isPatchValid(patch) {
723 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
724 return
725 }
726
727 forkAtUri, err := syntax.ParseATURI(fork.AtUri)
728 if err != nil {
729 log.Println("failed to parse fork AT URI", err)
730 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
731 return
732 }
733
734 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{
735 Branch: sourceBranch,
736 Repo: &forkAtUri,
737 }, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri})
738}
739
740func (s *State) createPullRequest(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, patch, sourceRev string, pullSource *db.PullSource, recordPullSource *tangled.RepoPull_Source) {
741 tx, err := s.db.BeginTx(r.Context(), nil)
742 if err != nil {
743 log.Println("failed to start tx")
744 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
745 return
746 }
747 defer tx.Rollback()
748
749 rkey := s.TID()
750 initialSubmission := db.PullSubmission{
751 Patch: patch,
752 SourceRev: sourceRev,
753 }
754 err = db.NewPull(tx, &db.Pull{
755 Title: title,
756 Body: body,
757 TargetBranch: targetBranch,
758 OwnerDid: user.Did,
759 RepoAt: f.RepoAt,
760 Rkey: rkey,
761 Submissions: []*db.PullSubmission{
762 &initialSubmission,
763 },
764 PullSource: pullSource,
765 })
766 if err != nil {
767 log.Println("failed to create pull request", err)
768 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
769 return
770 }
771 client, _ := s.auth.AuthorizedClient(r)
772 pullId, err := db.NextPullId(s.db, f.RepoAt)
773 if err != nil {
774 log.Println("failed to get pull id", err)
775 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
776 return
777 }
778
779 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
780 Collection: tangled.RepoPullNSID,
781 Repo: user.Did,
782 Rkey: rkey,
783 Record: &lexutil.LexiconTypeDecoder{
784 Val: &tangled.RepoPull{
785 Title: title,
786 PullId: int64(pullId),
787 TargetRepo: string(f.RepoAt),
788 TargetBranch: targetBranch,
789 Patch: patch,
790 Source: recordPullSource,
791 },
792 },
793 })
794
795 err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri)
796 if err != nil {
797 log.Println("failed to get pull id", err)
798 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
799 return
800 }
801
802 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
803}
804
805func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
806 user := s.auth.GetUser(r)
807 f, err := fullyResolvedRepo(r)
808 if err != nil {
809 log.Println("failed to get repo and knot", err)
810 return
811 }
812
813 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
814 RepoInfo: f.RepoInfo(s, user),
815 })
816}
817
818func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) {
819 user := s.auth.GetUser(r)
820 f, err := fullyResolvedRepo(r)
821 if err != nil {
822 log.Println("failed to get repo and knot", err)
823 return
824 }
825
826 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
827 if err != nil {
828 log.Printf("failed to create unsigned client for %s", f.Knot)
829 s.pages.Error503(w)
830 return
831 }
832
833 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
834 if err != nil {
835 log.Println("failed to reach knotserver", err)
836 return
837 }
838
839 body, err := io.ReadAll(resp.Body)
840 if err != nil {
841 log.Printf("Error reading response body: %v", err)
842 return
843 }
844
845 var result types.RepoBranchesResponse
846 err = json.Unmarshal(body, &result)
847 if err != nil {
848 log.Println("failed to parse response:", err)
849 return
850 }
851
852 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
853 RepoInfo: f.RepoInfo(s, user),
854 Branches: result.Branches,
855 })
856}
857
858func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) {
859 user := s.auth.GetUser(r)
860 f, err := fullyResolvedRepo(r)
861 if err != nil {
862 log.Println("failed to get repo and knot", err)
863 return
864 }
865
866 forks, err := db.GetForksByDid(s.db, user.Did)
867 if err != nil {
868 log.Println("failed to get forks", err)
869 return
870 }
871
872 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{
873 RepoInfo: f.RepoInfo(s, user),
874 Forks: forks,
875 })
876}
877
878func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) {
879 user := s.auth.GetUser(r)
880
881 f, err := fullyResolvedRepo(r)
882 if err != nil {
883 log.Println("failed to get repo and knot", err)
884 return
885 }
886
887 forkVal := r.URL.Query().Get("fork")
888
889 // fork repo
890 repo, err := db.GetRepo(s.db, user.Did, forkVal)
891 if err != nil {
892 log.Println("failed to get repo", user.Did, forkVal)
893 return
894 }
895
896 sourceBranchesClient, err := NewUnsignedClient(repo.Knot, s.config.Dev)
897 if err != nil {
898 log.Printf("failed to create unsigned client for %s", repo.Knot)
899 s.pages.Error503(w)
900 return
901 }
902
903 sourceResp, err := sourceBranchesClient.Branches(user.Did, repo.Name)
904 if err != nil {
905 log.Println("failed to reach knotserver for source branches", err)
906 return
907 }
908
909 sourceBody, err := io.ReadAll(sourceResp.Body)
910 if err != nil {
911 log.Println("failed to read source response body", err)
912 return
913 }
914 defer sourceResp.Body.Close()
915
916 var sourceResult types.RepoBranchesResponse
917 err = json.Unmarshal(sourceBody, &sourceResult)
918 if err != nil {
919 log.Println("failed to parse source branches response:", err)
920 return
921 }
922
923 targetBranchesClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
924 if err != nil {
925 log.Printf("failed to create unsigned client for target knot %s", f.Knot)
926 s.pages.Error503(w)
927 return
928 }
929
930 targetResp, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName)
931 if err != nil {
932 log.Println("failed to reach knotserver for target branches", err)
933 return
934 }
935
936 targetBody, err := io.ReadAll(targetResp.Body)
937 if err != nil {
938 log.Println("failed to read target response body", err)
939 return
940 }
941 defer targetResp.Body.Close()
942
943 var targetResult types.RepoBranchesResponse
944 err = json.Unmarshal(targetBody, &targetResult)
945 if err != nil {
946 log.Println("failed to parse target branches response:", err)
947 return
948 }
949
950 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
951 RepoInfo: f.RepoInfo(s, user),
952 SourceBranches: sourceResult.Branches,
953 TargetBranches: targetResult.Branches,
954 })
955}
956
957func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) {
958 user := s.auth.GetUser(r)
959 f, err := fullyResolvedRepo(r)
960 if err != nil {
961 log.Println("failed to get repo and knot", err)
962 return
963 }
964
965 pull, ok := r.Context().Value("pull").(*db.Pull)
966 if !ok {
967 log.Println("failed to get pull")
968 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
969 return
970 }
971
972 switch r.Method {
973 case http.MethodGet:
974 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
975 RepoInfo: f.RepoInfo(s, user),
976 Pull: pull,
977 })
978 return
979 case http.MethodPost:
980 patch := r.FormValue("patch")
981 var sourceRev string
982 var recordPullSource *tangled.RepoPull_Source
983
984 var ownerDid, repoName, knotName string
985 var isSameRepo bool = pull.IsSameRepoBranch()
986 sourceBranch := pull.PullSource.Branch
987 targetBranch := pull.TargetBranch
988 recordPullSource = &tangled.RepoPull_Source{
989 Branch: sourceBranch,
990 }
991
992 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed()
993 if isSameRepo && isPushAllowed {
994 ownerDid = f.OwnerDid()
995 repoName = f.RepoName
996 knotName = f.Knot
997 } else if !isSameRepo {
998 sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.Repo.String())
999 if err != nil {
1000 log.Println("failed to get source repo", err)
1001 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1002 return
1003 }
1004 ownerDid = sourceRepo.Did
1005 repoName = sourceRepo.Name
1006 knotName = sourceRepo.Knot
1007 }
1008
1009 if sourceBranch != "" && knotName != "" {
1010 // extract patch by performing compare
1011 ksClient, err := NewUnsignedClient(knotName, s.config.Dev)
1012 if err != nil {
1013 log.Printf("failed to create client for %s: %s", knotName, err)
1014 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1015 return
1016 }
1017
1018 if !isSameRepo {
1019 secret, err := db.GetRegistrationKey(s.db, knotName)
1020 if err != nil {
1021 log.Printf("failed to get registration key for %s: %s", knotName, err)
1022 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1023 return
1024 }
1025 // update the hidden tracking branch to latest
1026 signedClient, err := NewSignedClient(knotName, secret, s.config.Dev)
1027 if err != nil {
1028 log.Printf("failed to create signed client for %s: %s", knotName, err)
1029 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1030 return
1031 }
1032 resp, err := signedClient.NewHiddenRef(ownerDid, repoName, sourceBranch, targetBranch)
1033 if err != nil || resp.StatusCode != http.StatusNoContent {
1034 log.Printf("failed to update tracking branch: %s", err)
1035 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1036 return
1037 }
1038 }
1039
1040 var compareResp *http.Response
1041 if !isSameRepo {
1042 hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch))
1043 compareResp, err = ksClient.Compare(ownerDid, repoName, hiddenRef, sourceBranch)
1044 } else {
1045 compareResp, err = ksClient.Compare(ownerDid, repoName, targetBranch, sourceBranch)
1046 }
1047 if err != nil {
1048 log.Printf("failed to compare branches: %s", err)
1049 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1050 return
1051 }
1052 defer compareResp.Body.Close()
1053
1054 switch compareResp.StatusCode {
1055 case 404:
1056 case 400:
1057 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
1058 return
1059 }
1060
1061 respBody, err := io.ReadAll(compareResp.Body)
1062 if err != nil {
1063 log.Println("failed to compare across branches")
1064 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1065 return
1066 }
1067 defer compareResp.Body.Close()
1068
1069 var diffTreeResponse types.RepoDiffTreeResponse
1070 err = json.Unmarshal(respBody, &diffTreeResponse)
1071 if err != nil {
1072 log.Println("failed to unmarshal diff tree response", err)
1073 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1074 return
1075 }
1076
1077 sourceRev = diffTreeResponse.DiffTree.Rev2
1078 patch = diffTreeResponse.DiffTree.Patch
1079 }
1080
1081 if patch == "" {
1082 s.pages.Notice(w, "resubmit-error", "Patch is empty.")
1083 return
1084 }
1085
1086 if patch == pull.LatestPatch() {
1087 s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.")
1088 return
1089 }
1090
1091 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1092 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1093 return
1094 }
1095
1096 if !isPatchValid(patch) {
1097 s.pages.Notice(w, "resubmit-error", "Invalid patch format. Please provide a valid diff.")
1098 return
1099 }
1100
1101 tx, err := s.db.BeginTx(r.Context(), nil)
1102 if err != nil {
1103 log.Println("failed to start tx")
1104 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1105 return
1106 }
1107 defer tx.Rollback()
1108
1109 err = db.ResubmitPull(tx, pull, patch, sourceRev)
1110 if err != nil {
1111 log.Println("failed to create pull request", err)
1112 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1113 return
1114 }
1115 client, _ := s.auth.AuthorizedClient(r)
1116
1117 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1118 if err != nil {
1119 // failed to get record
1120 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1121 return
1122 }
1123
1124 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1125 Collection: tangled.RepoPullNSID,
1126 Repo: user.Did,
1127 Rkey: pull.Rkey,
1128 SwapRecord: ex.Cid,
1129 Record: &lexutil.LexiconTypeDecoder{
1130 Val: &tangled.RepoPull{
1131 Title: pull.Title,
1132 PullId: int64(pull.PullId),
1133 TargetRepo: string(f.RepoAt),
1134 TargetBranch: pull.TargetBranch,
1135 Patch: patch, // new patch
1136 Source: recordPullSource,
1137 },
1138 },
1139 })
1140 if err != nil {
1141 log.Println("failed to update record", err)
1142 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1143 return
1144 }
1145
1146 if err = tx.Commit(); err != nil {
1147 log.Println("failed to commit transaction", err)
1148 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1149 return
1150 }
1151
1152 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1153 return
1154 }
1155}
1156
1157func (s *State) MergePull(w http.ResponseWriter, r *http.Request) {
1158 f, err := fullyResolvedRepo(r)
1159 if err != nil {
1160 log.Println("failed to resolve repo:", err)
1161 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1162 return
1163 }
1164
1165 pull, ok := r.Context().Value("pull").(*db.Pull)
1166 if !ok {
1167 log.Println("failed to get pull")
1168 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1169 return
1170 }
1171
1172 secret, err := db.GetRegistrationKey(s.db, f.Knot)
1173 if err != nil {
1174 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
1175 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1176 return
1177 }
1178
1179 ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid)
1180 if err != nil {
1181 log.Printf("resolving identity: %s", err)
1182 w.WriteHeader(http.StatusNotFound)
1183 return
1184 }
1185
1186 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
1187 if err != nil {
1188 log.Printf("failed to get primary email: %s", err)
1189 }
1190
1191 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
1192 if err != nil {
1193 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
1194 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1195 return
1196 }
1197
1198 // Merge the pull request
1199 resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
1200 if err != nil {
1201 log.Printf("failed to merge pull request: %s", err)
1202 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1203 return
1204 }
1205
1206 if resp.StatusCode == http.StatusOK {
1207 err := db.MergePull(s.db, f.RepoAt, pull.PullId)
1208 if err != nil {
1209 log.Printf("failed to update pull request status in database: %s", err)
1210 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1211 return
1212 }
1213 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
1214 } else {
1215 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
1216 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1217 }
1218}
1219
1220func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) {
1221 user := s.auth.GetUser(r)
1222
1223 f, err := fullyResolvedRepo(r)
1224 if err != nil {
1225 log.Println("malformed middleware")
1226 return
1227 }
1228
1229 pull, ok := r.Context().Value("pull").(*db.Pull)
1230 if !ok {
1231 log.Println("failed to get pull")
1232 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1233 return
1234 }
1235
1236 // auth filter: only owner or collaborators can close
1237 roles := RolesInRepo(s, user, f)
1238 isCollaborator := roles.IsCollaborator()
1239 isPullAuthor := user.Did == pull.OwnerDid
1240 isCloseAllowed := isCollaborator || isPullAuthor
1241 if !isCloseAllowed {
1242 log.Println("failed to close pull")
1243 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
1244 return
1245 }
1246
1247 // Start a transaction
1248 tx, err := s.db.BeginTx(r.Context(), nil)
1249 if err != nil {
1250 log.Println("failed to start transaction", err)
1251 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1252 return
1253 }
1254
1255 // Close the pull in the database
1256 err = db.ClosePull(tx, f.RepoAt, pull.PullId)
1257 if err != nil {
1258 log.Println("failed to close pull", err)
1259 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1260 return
1261 }
1262
1263 // Commit the transaction
1264 if err = tx.Commit(); err != nil {
1265 log.Println("failed to commit transaction", err)
1266 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1267 return
1268 }
1269
1270 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1271 return
1272}
1273
1274func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) {
1275 user := s.auth.GetUser(r)
1276
1277 f, err := fullyResolvedRepo(r)
1278 if err != nil {
1279 log.Println("failed to resolve repo", err)
1280 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1281 return
1282 }
1283
1284 pull, ok := r.Context().Value("pull").(*db.Pull)
1285 if !ok {
1286 log.Println("failed to get pull")
1287 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1288 return
1289 }
1290
1291 // auth filter: only owner or collaborators can close
1292 roles := RolesInRepo(s, user, f)
1293 isCollaborator := roles.IsCollaborator()
1294 isPullAuthor := user.Did == pull.OwnerDid
1295 isCloseAllowed := isCollaborator || isPullAuthor
1296 if !isCloseAllowed {
1297 log.Println("failed to close pull")
1298 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
1299 return
1300 }
1301
1302 // Start a transaction
1303 tx, err := s.db.BeginTx(r.Context(), nil)
1304 if err != nil {
1305 log.Println("failed to start transaction", err)
1306 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1307 return
1308 }
1309
1310 // Reopen the pull in the database
1311 err = db.ReopenPull(tx, f.RepoAt, pull.PullId)
1312 if err != nil {
1313 log.Println("failed to reopen pull", err)
1314 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1315 return
1316 }
1317
1318 // Commit the transaction
1319 if err = tx.Commit(); err != nil {
1320 log.Println("failed to commit transaction", err)
1321 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1322 return
1323 }
1324
1325 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1326 return
1327}
1328
1329// Very basic validation to check if it looks like a diff/patch
1330// A valid patch usually starts with diff or --- lines
1331func isPatchValid(patch string) bool {
1332 // Basic validation to check if it looks like a diff/patch
1333 // A valid patch usually starts with diff or --- lines
1334 if len(patch) == 0 {
1335 return false
1336 }
1337
1338 lines := strings.Split(patch, "\n")
1339 if len(lines) < 2 {
1340 return false
1341 }
1342
1343 // Check for common patch format markers
1344 firstLine := strings.TrimSpace(lines[0])
1345 return strings.HasPrefix(firstLine, "diff ") ||
1346 strings.HasPrefix(firstLine, "--- ") ||
1347 strings.HasPrefix(firstLine, "Index: ") ||
1348 strings.HasPrefix(firstLine, "+++ ") ||
1349 strings.HasPrefix(firstLine, "@@ ")
1350}