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 "time"
14
15 "tangled.sh/tangled.sh/core/api/tangled"
16 "tangled.sh/tangled.sh/core/appview"
17 "tangled.sh/tangled.sh/core/appview/auth"
18 "tangled.sh/tangled.sh/core/appview/db"
19 "tangled.sh/tangled.sh/core/appview/pages"
20 "tangled.sh/tangled.sh/core/patchutil"
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 "github.com/go-chi/chi/v5"
27)
28
29// htmx fragment
30func (s *State) PullActions(w http.ResponseWriter, r *http.Request) {
31 switch r.Method {
32 case http.MethodGet:
33 user := s.auth.GetUser(r)
34 f, err := fullyResolvedRepo(r)
35 if err != nil {
36 log.Println("failed to get repo and knot", err)
37 return
38 }
39
40 pull, ok := r.Context().Value("pull").(*db.Pull)
41 if !ok {
42 log.Println("failed to get pull")
43 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
44 return
45 }
46
47 roundNumberStr := chi.URLParam(r, "round")
48 roundNumber, err := strconv.Atoi(roundNumberStr)
49 if err != nil {
50 roundNumber = pull.LastRoundNumber()
51 }
52 if roundNumber >= len(pull.Submissions) {
53 http.Error(w, "bad round id", http.StatusBadRequest)
54 log.Println("failed to parse round id", err)
55 return
56 }
57
58 mergeCheckResponse := s.mergeCheck(f, pull)
59 resubmitResult := pages.Unknown
60 if user.Did == pull.OwnerDid {
61 resubmitResult = s.resubmitCheck(f, pull)
62 }
63
64 s.pages.PullActionsFragment(w, pages.PullActionsParams{
65 LoggedInUser: user,
66 RepoInfo: f.RepoInfo(s, user),
67 Pull: pull,
68 RoundNumber: roundNumber,
69 MergeCheck: mergeCheckResponse,
70 ResubmitCheck: resubmitResult,
71 })
72 return
73 }
74}
75
76func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) {
77 user := s.auth.GetUser(r)
78 f, err := fullyResolvedRepo(r)
79 if err != nil {
80 log.Println("failed to get repo and knot", err)
81 return
82 }
83
84 pull, ok := r.Context().Value("pull").(*db.Pull)
85 if !ok {
86 log.Println("failed to get pull")
87 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
88 return
89 }
90
91 totalIdents := 1
92 for _, submission := range pull.Submissions {
93 totalIdents += len(submission.Comments)
94 }
95
96 identsToResolve := make([]string, totalIdents)
97
98 // populate idents
99 identsToResolve[0] = pull.OwnerDid
100 idx := 1
101 for _, submission := range pull.Submissions {
102 for _, comment := range submission.Comments {
103 identsToResolve[idx] = comment.OwnerDid
104 idx += 1
105 }
106 }
107
108 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
109 didHandleMap := make(map[string]string)
110 for _, identity := range resolvedIds {
111 if !identity.Handle.IsInvalidHandle() {
112 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
113 } else {
114 didHandleMap[identity.DID.String()] = identity.DID.String()
115 }
116 }
117
118 mergeCheckResponse := s.mergeCheck(f, pull)
119 resubmitResult := pages.Unknown
120 if user != nil && user.Did == pull.OwnerDid {
121 resubmitResult = s.resubmitCheck(f, pull)
122 }
123
124 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
125 LoggedInUser: user,
126 RepoInfo: f.RepoInfo(s, user),
127 DidHandleMap: didHandleMap,
128 Pull: pull,
129 MergeCheck: mergeCheckResponse,
130 ResubmitCheck: resubmitResult,
131 })
132}
133
134func (s *State) mergeCheck(f *FullyResolvedRepo, pull *db.Pull) types.MergeCheckResponse {
135 if pull.State == db.PullMerged {
136 return types.MergeCheckResponse{}
137 }
138
139 secret, err := db.GetRegistrationKey(s.db, f.Knot)
140 if err != nil {
141 log.Printf("failed to get registration key: %v", err)
142 return types.MergeCheckResponse{
143 Error: "failed to check merge status: this knot is unregistered",
144 }
145 }
146
147 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
148 if err != nil {
149 log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err)
150 return types.MergeCheckResponse{
151 Error: "failed to check merge status",
152 }
153 }
154
155 resp, err := ksClient.MergeCheck([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch)
156 if err != nil {
157 log.Println("failed to check for mergeability:", err)
158 return types.MergeCheckResponse{
159 Error: "failed to check merge status",
160 }
161 }
162 switch resp.StatusCode {
163 case 404:
164 return types.MergeCheckResponse{
165 Error: "failed to check merge status: this knot does not support PRs",
166 }
167 case 400:
168 return types.MergeCheckResponse{
169 Error: "failed to check merge status: does this knot support PRs?",
170 }
171 }
172
173 respBody, err := io.ReadAll(resp.Body)
174 if err != nil {
175 log.Println("failed to read merge check response body")
176 return types.MergeCheckResponse{
177 Error: "failed to check merge status: knot is not speaking the right language",
178 }
179 }
180 defer resp.Body.Close()
181
182 var mergeCheckResponse types.MergeCheckResponse
183 err = json.Unmarshal(respBody, &mergeCheckResponse)
184 if err != nil {
185 log.Println("failed to unmarshal merge check response", err)
186 return types.MergeCheckResponse{
187 Error: "failed to check merge status: knot is not speaking the right language",
188 }
189 }
190
191 return mergeCheckResponse
192}
193
194func (s *State) resubmitCheck(f *FullyResolvedRepo, pull *db.Pull) pages.ResubmitResult {
195 if pull.State == db.PullMerged || pull.PullSource == nil {
196 return pages.Unknown
197 }
198
199 var knot, ownerDid, repoName string
200
201 if pull.PullSource.RepoAt != nil {
202 // fork-based pulls
203 sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
204 if err != nil {
205 log.Println("failed to get source repo", err)
206 return pages.Unknown
207 }
208
209 knot = sourceRepo.Knot
210 ownerDid = sourceRepo.Did
211 repoName = sourceRepo.Name
212 } else {
213 // pulls within the same repo
214 knot = f.Knot
215 ownerDid = f.OwnerDid()
216 repoName = f.RepoName
217 }
218
219 us, err := NewUnsignedClient(knot, s.config.Dev)
220 if err != nil {
221 log.Printf("failed to setup client for %s; ignoring: %v", knot, err)
222 return pages.Unknown
223 }
224
225 resp, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch)
226 if err != nil {
227 log.Println("failed to reach knotserver", err)
228 return pages.Unknown
229 }
230
231 body, err := io.ReadAll(resp.Body)
232 if err != nil {
233 log.Printf("error reading response body: %v", err)
234 return pages.Unknown
235 }
236 defer resp.Body.Close()
237
238 var result types.RepoBranchResponse
239 if err := json.Unmarshal(body, &result); err != nil {
240 log.Println("failed to parse response:", err)
241 return pages.Unknown
242 }
243
244 latestSubmission := pull.Submissions[pull.LastRoundNumber()]
245 if latestSubmission.SourceRev != result.Branch.Hash {
246 fmt.Println(latestSubmission.SourceRev, result.Branch.Hash)
247 return pages.ShouldResubmit
248 }
249
250 return pages.ShouldNotResubmit
251}
252
253func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
254 user := s.auth.GetUser(r)
255 f, err := fullyResolvedRepo(r)
256 if err != nil {
257 log.Println("failed to get repo and knot", err)
258 return
259 }
260
261 pull, ok := r.Context().Value("pull").(*db.Pull)
262 if !ok {
263 log.Println("failed to get pull")
264 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
265 return
266 }
267
268 roundId := chi.URLParam(r, "round")
269 roundIdInt, err := strconv.Atoi(roundId)
270 if err != nil || roundIdInt >= len(pull.Submissions) {
271 http.Error(w, "bad round id", http.StatusBadRequest)
272 log.Println("failed to parse round id", err)
273 return
274 }
275
276 identsToResolve := []string{pull.OwnerDid}
277 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
278 didHandleMap := make(map[string]string)
279 for _, identity := range resolvedIds {
280 if !identity.Handle.IsInvalidHandle() {
281 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
282 } else {
283 didHandleMap[identity.DID.String()] = identity.DID.String()
284 }
285 }
286
287 diff := pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch)
288
289 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
290 LoggedInUser: user,
291 DidHandleMap: didHandleMap,
292 RepoInfo: f.RepoInfo(s, user),
293 Pull: pull,
294 Round: roundIdInt,
295 Submission: pull.Submissions[roundIdInt],
296 Diff: &diff,
297 })
298
299}
300
301func (s *State) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) {
302 user := s.auth.GetUser(r)
303
304 f, err := fullyResolvedRepo(r)
305 if err != nil {
306 log.Println("failed to get repo and knot", err)
307 return
308 }
309
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 get pull.")
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 if roundIdInt == 0 {
326 http.Error(w, "bad round id", http.StatusBadRequest)
327 log.Println("cannot interdiff initial submission")
328 return
329 }
330
331 identsToResolve := []string{pull.OwnerDid}
332 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
333 didHandleMap := make(map[string]string)
334 for _, identity := range resolvedIds {
335 if !identity.Handle.IsInvalidHandle() {
336 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
337 } else {
338 didHandleMap[identity.DID.String()] = identity.DID.String()
339 }
340 }
341
342 currentPatch, err := pull.Submissions[roundIdInt].AsDiff(pull.TargetBranch)
343 if err != nil {
344 log.Println("failed to interdiff; current patch malformed")
345 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.")
346 return
347 }
348
349 previousPatch, err := pull.Submissions[roundIdInt-1].AsDiff(pull.TargetBranch)
350 if err != nil {
351 log.Println("failed to interdiff; previous patch malformed")
352 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.")
353 return
354 }
355
356 interdiff := patchutil.Interdiff(previousPatch, currentPatch)
357
358 s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{
359 LoggedInUser: s.auth.GetUser(r),
360 RepoInfo: f.RepoInfo(s, user),
361 Pull: pull,
362 Round: roundIdInt,
363 DidHandleMap: didHandleMap,
364 Interdiff: interdiff,
365 })
366 return
367}
368
369func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
370 pull, ok := r.Context().Value("pull").(*db.Pull)
371 if !ok {
372 log.Println("failed to get pull")
373 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
374 return
375 }
376
377 roundId := chi.URLParam(r, "round")
378 roundIdInt, err := strconv.Atoi(roundId)
379 if err != nil || roundIdInt >= len(pull.Submissions) {
380 http.Error(w, "bad round id", http.StatusBadRequest)
381 log.Println("failed to parse round id", err)
382 return
383 }
384
385 identsToResolve := []string{pull.OwnerDid}
386 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
387 didHandleMap := make(map[string]string)
388 for _, identity := range resolvedIds {
389 if !identity.Handle.IsInvalidHandle() {
390 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
391 } else {
392 didHandleMap[identity.DID.String()] = identity.DID.String()
393 }
394 }
395
396 w.Header().Set("Content-Type", "text/plain")
397 w.Write([]byte(pull.Submissions[roundIdInt].Patch))
398}
399
400func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) {
401 user := s.auth.GetUser(r)
402 params := r.URL.Query()
403
404 state := db.PullOpen
405 switch params.Get("state") {
406 case "closed":
407 state = db.PullClosed
408 case "merged":
409 state = db.PullMerged
410 }
411
412 f, err := fullyResolvedRepo(r)
413 if err != nil {
414 log.Println("failed to get repo and knot", err)
415 return
416 }
417
418 pulls, err := db.GetPulls(s.db, f.RepoAt, state)
419 if err != nil {
420 log.Println("failed to get pulls", err)
421 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
422 return
423 }
424
425 for _, p := range pulls {
426 var pullSourceRepo *db.Repo
427 if p.PullSource != nil {
428 if p.PullSource.RepoAt != nil {
429 pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String())
430 if err != nil {
431 log.Printf("failed to get repo by at uri: %v", err)
432 continue
433 } else {
434 p.PullSource.Repo = pullSourceRepo
435 }
436 }
437 }
438 }
439
440 identsToResolve := make([]string, len(pulls))
441 for i, pull := range pulls {
442 identsToResolve[i] = pull.OwnerDid
443 }
444 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
445 didHandleMap := make(map[string]string)
446 for _, identity := range resolvedIds {
447 if !identity.Handle.IsInvalidHandle() {
448 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
449 } else {
450 didHandleMap[identity.DID.String()] = identity.DID.String()
451 }
452 }
453
454 s.pages.RepoPulls(w, pages.RepoPullsParams{
455 LoggedInUser: s.auth.GetUser(r),
456 RepoInfo: f.RepoInfo(s, user),
457 Pulls: pulls,
458 DidHandleMap: didHandleMap,
459 FilteringBy: state,
460 })
461 return
462}
463
464func (s *State) PullComment(w http.ResponseWriter, r *http.Request) {
465 user := s.auth.GetUser(r)
466 f, err := fullyResolvedRepo(r)
467 if err != nil {
468 log.Println("failed to get repo and knot", err)
469 return
470 }
471
472 pull, ok := r.Context().Value("pull").(*db.Pull)
473 if !ok {
474 log.Println("failed to get pull")
475 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
476 return
477 }
478
479 roundNumberStr := chi.URLParam(r, "round")
480 roundNumber, err := strconv.Atoi(roundNumberStr)
481 if err != nil || roundNumber >= len(pull.Submissions) {
482 http.Error(w, "bad round id", http.StatusBadRequest)
483 log.Println("failed to parse round id", err)
484 return
485 }
486
487 switch r.Method {
488 case http.MethodGet:
489 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{
490 LoggedInUser: user,
491 RepoInfo: f.RepoInfo(s, user),
492 Pull: pull,
493 RoundNumber: roundNumber,
494 })
495 return
496 case http.MethodPost:
497 body := r.FormValue("body")
498 if body == "" {
499 s.pages.Notice(w, "pull", "Comment body is required")
500 return
501 }
502
503 // Start a transaction
504 tx, err := s.db.BeginTx(r.Context(), nil)
505 if err != nil {
506 log.Println("failed to start transaction", err)
507 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
508 return
509 }
510 defer tx.Rollback()
511
512 createdAt := time.Now().Format(time.RFC3339)
513 ownerDid := user.Did
514
515 pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId)
516 if err != nil {
517 log.Println("failed to get pull at", err)
518 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
519 return
520 }
521
522 atUri := f.RepoAt.String()
523 client, _ := s.auth.AuthorizedClient(r)
524 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
525 Collection: tangled.RepoPullCommentNSID,
526 Repo: user.Did,
527 Rkey: appview.TID(),
528 Record: &lexutil.LexiconTypeDecoder{
529 Val: &tangled.RepoPullComment{
530 Repo: &atUri,
531 Pull: pullAt,
532 Owner: &ownerDid,
533 Body: &body,
534 CreatedAt: &createdAt,
535 },
536 },
537 })
538 if err != nil {
539 log.Println("failed to create pull comment", err)
540 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
541 return
542 }
543
544 // Create the pull comment in the database with the commentAt field
545 commentId, err := db.NewPullComment(tx, &db.PullComment{
546 OwnerDid: user.Did,
547 RepoAt: f.RepoAt.String(),
548 PullId: pull.PullId,
549 Body: body,
550 CommentAt: atResp.Uri,
551 SubmissionId: pull.Submissions[roundNumber].ID,
552 })
553 if err != nil {
554 log.Println("failed to create pull comment", err)
555 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
556 return
557 }
558
559 // Commit the transaction
560 if err = tx.Commit(); err != nil {
561 log.Println("failed to commit transaction", err)
562 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
563 return
564 }
565
566 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
567 return
568 }
569}
570
571func (s *State) NewPull(w http.ResponseWriter, r *http.Request) {
572 user := s.auth.GetUser(r)
573 f, err := fullyResolvedRepo(r)
574 if err != nil {
575 log.Println("failed to get repo and knot", err)
576 return
577 }
578
579 switch r.Method {
580 case http.MethodGet:
581 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
582 if err != nil {
583 log.Printf("failed to create unsigned client for %s", f.Knot)
584 s.pages.Error503(w)
585 return
586 }
587
588 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
589 if err != nil {
590 log.Println("failed to reach knotserver", err)
591 return
592 }
593
594 body, err := io.ReadAll(resp.Body)
595 if err != nil {
596 log.Printf("Error reading response body: %v", err)
597 return
598 }
599
600 var result types.RepoBranchesResponse
601 err = json.Unmarshal(body, &result)
602 if err != nil {
603 log.Println("failed to parse response:", err)
604 return
605 }
606
607 s.pages.RepoNewPull(w, pages.RepoNewPullParams{
608 LoggedInUser: user,
609 RepoInfo: f.RepoInfo(s, user),
610 Branches: result.Branches,
611 })
612 case http.MethodPost:
613 title := r.FormValue("title")
614 body := r.FormValue("body")
615 targetBranch := r.FormValue("targetBranch")
616 fromFork := r.FormValue("fork")
617 sourceBranch := r.FormValue("sourceBranch")
618 patch := r.FormValue("patch")
619
620 if targetBranch == "" {
621 s.pages.Notice(w, "pull", "Target branch is required.")
622 return
623 }
624
625 // Determine PR type based on input parameters
626 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed()
627 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == ""
628 isForkBased := fromFork != "" && sourceBranch != ""
629 isPatchBased := patch != "" && !isBranchBased && !isForkBased
630
631 if isPatchBased && !patchutil.IsFormatPatch(patch) {
632 if title == "" {
633 s.pages.Notice(w, "pull", "Title is required for git-diff patches.")
634 return
635 }
636 }
637
638 // Validate we have at least one valid PR creation method
639 if !isBranchBased && !isPatchBased && !isForkBased {
640 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.")
641 return
642 }
643
644 // Can't mix branch-based and patch-based approaches
645 if isBranchBased && patch != "" {
646 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.")
647 return
648 }
649
650 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
651 if err != nil {
652 log.Printf("failed to create unsigned client to %s: %v", f.Knot, err)
653 s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
654 return
655 }
656
657 caps, err := us.Capabilities()
658 if err != nil {
659 log.Println("error fetching knot caps", f.Knot, err)
660 s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
661 return
662 }
663
664 if !caps.PullRequests.FormatPatch {
665 s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.")
666 return
667 }
668
669 // Handle the PR creation based on the type
670 if isBranchBased {
671 if !caps.PullRequests.BranchSubmissions {
672 s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?")
673 return
674 }
675 s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch)
676 } else if isForkBased {
677 if !caps.PullRequests.ForkSubmissions {
678 s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?")
679 return
680 }
681 s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch)
682 } else if isPatchBased {
683 if !caps.PullRequests.PatchSubmissions {
684 s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.")
685 return
686 }
687 s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch)
688 }
689 return
690 }
691}
692
693func (s *State) handleBranchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, sourceBranch string) {
694 pullSource := &db.PullSource{
695 Branch: sourceBranch,
696 }
697 recordPullSource := &tangled.RepoPull_Source{
698 Branch: sourceBranch,
699 }
700
701 // Generate a patch using /compare
702 ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
703 if err != nil {
704 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
705 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
706 return
707 }
708
709 comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
710 if err != nil {
711 log.Println("failed to compare", err)
712 s.pages.Notice(w, "pull", err.Error())
713 return
714 }
715
716 sourceRev := comparison.Rev2
717 patch := comparison.Patch
718
719 if !patchutil.IsPatchValid(patch) {
720 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
721 return
722 }
723
724 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource)
725}
726
727func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, patch string) {
728 if !patchutil.IsPatchValid(patch) {
729 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
730 return
731 }
732
733 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil)
734}
735
736func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, forkRepo string, title, body, targetBranch, sourceBranch string) {
737 fork, err := db.GetForkByDid(s.db, user.Did, forkRepo)
738 if errors.Is(err, sql.ErrNoRows) {
739 s.pages.Notice(w, "pull", "No such fork.")
740 return
741 } else if err != nil {
742 log.Println("failed to fetch fork:", err)
743 s.pages.Notice(w, "pull", "Failed to fetch fork.")
744 return
745 }
746
747 secret, err := db.GetRegistrationKey(s.db, fork.Knot)
748 if err != nil {
749 log.Println("failed to fetch registration key:", err)
750 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
751 return
752 }
753
754 sc, err := NewSignedClient(fork.Knot, secret, s.config.Dev)
755 if err != nil {
756 log.Println("failed to create signed client:", err)
757 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
758 return
759 }
760
761 us, err := NewUnsignedClient(fork.Knot, s.config.Dev)
762 if err != nil {
763 log.Println("failed to create unsigned client:", err)
764 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
765 return
766 }
767
768 resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch)
769 if err != nil {
770 log.Println("failed to create hidden ref:", err, resp.StatusCode)
771 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
772 return
773 }
774
775 switch resp.StatusCode {
776 case 404:
777 case 400:
778 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
779 return
780 }
781
782 hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch))
783 // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking
784 // the targetBranch on the target repository. This code is a bit confusing, but here's an example:
785 // hiddenRef: hidden/feature-1/main (on repo-fork)
786 // targetBranch: main (on repo-1)
787 // sourceBranch: feature-1 (on repo-fork)
788 comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch)
789 if err != nil {
790 log.Println("failed to compare across branches", err)
791 s.pages.Notice(w, "pull", err.Error())
792 return
793 }
794
795 sourceRev := comparison.Rev2
796 patch := comparison.Patch
797
798 if !patchutil.IsPatchValid(patch) {
799 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
800 return
801 }
802
803 forkAtUri, err := syntax.ParseATURI(fork.AtUri)
804 if err != nil {
805 log.Println("failed to parse fork AT URI", err)
806 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
807 return
808 }
809
810 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{
811 Branch: sourceBranch,
812 RepoAt: &forkAtUri,
813 }, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri})
814}
815
816func (s *State) createPullRequest(
817 w http.ResponseWriter,
818 r *http.Request,
819 f *FullyResolvedRepo,
820 user *auth.User,
821 title, body, targetBranch string,
822 patch string,
823 sourceRev string,
824 pullSource *db.PullSource,
825 recordPullSource *tangled.RepoPull_Source,
826) {
827 tx, err := s.db.BeginTx(r.Context(), nil)
828 if err != nil {
829 log.Println("failed to start tx")
830 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
831 return
832 }
833 defer tx.Rollback()
834
835 // We've already checked earlier if it's diff-based and title is empty,
836 // so if it's still empty now, it's intentionally skipped owing to format-patch.
837 if title == "" {
838 formatPatches, err := patchutil.ExtractPatches(patch)
839 if err != nil {
840 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
841 return
842 }
843 if len(formatPatches) == 0 {
844 s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.")
845 return
846 }
847
848 title = formatPatches[0].Title
849 body = formatPatches[0].Body
850 }
851
852 rkey := appview.TID()
853 initialSubmission := db.PullSubmission{
854 Patch: patch,
855 SourceRev: sourceRev,
856 }
857 err = db.NewPull(tx, &db.Pull{
858 Title: title,
859 Body: body,
860 TargetBranch: targetBranch,
861 OwnerDid: user.Did,
862 RepoAt: f.RepoAt,
863 Rkey: rkey,
864 Submissions: []*db.PullSubmission{
865 &initialSubmission,
866 },
867 PullSource: pullSource,
868 })
869 if err != nil {
870 log.Println("failed to create pull request", err)
871 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
872 return
873 }
874 client, _ := s.auth.AuthorizedClient(r)
875 pullId, err := db.NextPullId(s.db, f.RepoAt)
876 if err != nil {
877 log.Println("failed to get pull id", err)
878 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
879 return
880 }
881
882 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
883 Collection: tangled.RepoPullNSID,
884 Repo: user.Did,
885 Rkey: rkey,
886 Record: &lexutil.LexiconTypeDecoder{
887 Val: &tangled.RepoPull{
888 Title: title,
889 PullId: int64(pullId),
890 TargetRepo: string(f.RepoAt),
891 TargetBranch: targetBranch,
892 Patch: patch,
893 Source: recordPullSource,
894 },
895 },
896 })
897
898 err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri)
899 if err != nil {
900 log.Println("failed to get pull id", err)
901 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
902 return
903 }
904
905 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
906}
907
908func (s *State) ValidatePatch(w http.ResponseWriter, r *http.Request) {
909 _, err := fullyResolvedRepo(r)
910 if err != nil {
911 log.Println("failed to get repo and knot", err)
912 return
913 }
914
915 patch := r.FormValue("patch")
916 if patch == "" {
917 s.pages.Notice(w, "patch-error", "Patch is required.")
918 return
919 }
920
921 if patch == "" || !patchutil.IsPatchValid(patch) {
922 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.")
923 return
924 }
925
926 if patchutil.IsFormatPatch(patch) {
927 s.pages.Notice(w, "patch-preview", "git-format-patch detected. Title and description are optional; if left out, they will be extracted from the first commit.")
928 } else {
929 s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.")
930 }
931}
932
933func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
934 user := s.auth.GetUser(r)
935 f, err := fullyResolvedRepo(r)
936 if err != nil {
937 log.Println("failed to get repo and knot", err)
938 return
939 }
940
941 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
942 RepoInfo: f.RepoInfo(s, user),
943 })
944}
945
946func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) {
947 user := s.auth.GetUser(r)
948 f, err := fullyResolvedRepo(r)
949 if err != nil {
950 log.Println("failed to get repo and knot", err)
951 return
952 }
953
954 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
955 if err != nil {
956 log.Printf("failed to create unsigned client for %s", f.Knot)
957 s.pages.Error503(w)
958 return
959 }
960
961 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
962 if err != nil {
963 log.Println("failed to reach knotserver", err)
964 return
965 }
966
967 body, err := io.ReadAll(resp.Body)
968 if err != nil {
969 log.Printf("Error reading response body: %v", err)
970 return
971 }
972
973 var result types.RepoBranchesResponse
974 err = json.Unmarshal(body, &result)
975 if err != nil {
976 log.Println("failed to parse response:", err)
977 return
978 }
979
980 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
981 RepoInfo: f.RepoInfo(s, user),
982 Branches: result.Branches,
983 })
984}
985
986func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) {
987 user := s.auth.GetUser(r)
988 f, err := fullyResolvedRepo(r)
989 if err != nil {
990 log.Println("failed to get repo and knot", err)
991 return
992 }
993
994 forks, err := db.GetForksByDid(s.db, user.Did)
995 if err != nil {
996 log.Println("failed to get forks", err)
997 return
998 }
999
1000 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{
1001 RepoInfo: f.RepoInfo(s, user),
1002 Forks: forks,
1003 })
1004}
1005
1006func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) {
1007 user := s.auth.GetUser(r)
1008
1009 f, err := fullyResolvedRepo(r)
1010 if err != nil {
1011 log.Println("failed to get repo and knot", err)
1012 return
1013 }
1014
1015 forkVal := r.URL.Query().Get("fork")
1016
1017 // fork repo
1018 repo, err := db.GetRepo(s.db, user.Did, forkVal)
1019 if err != nil {
1020 log.Println("failed to get repo", user.Did, forkVal)
1021 return
1022 }
1023
1024 sourceBranchesClient, err := NewUnsignedClient(repo.Knot, s.config.Dev)
1025 if err != nil {
1026 log.Printf("failed to create unsigned client for %s", repo.Knot)
1027 s.pages.Error503(w)
1028 return
1029 }
1030
1031 sourceResp, err := sourceBranchesClient.Branches(user.Did, repo.Name)
1032 if err != nil {
1033 log.Println("failed to reach knotserver for source branches", err)
1034 return
1035 }
1036
1037 sourceBody, err := io.ReadAll(sourceResp.Body)
1038 if err != nil {
1039 log.Println("failed to read source response body", err)
1040 return
1041 }
1042 defer sourceResp.Body.Close()
1043
1044 var sourceResult types.RepoBranchesResponse
1045 err = json.Unmarshal(sourceBody, &sourceResult)
1046 if err != nil {
1047 log.Println("failed to parse source branches response:", err)
1048 return
1049 }
1050
1051 targetBranchesClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
1052 if err != nil {
1053 log.Printf("failed to create unsigned client for target knot %s", f.Knot)
1054 s.pages.Error503(w)
1055 return
1056 }
1057
1058 targetResp, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName)
1059 if err != nil {
1060 log.Println("failed to reach knotserver for target branches", err)
1061 return
1062 }
1063
1064 targetBody, err := io.ReadAll(targetResp.Body)
1065 if err != nil {
1066 log.Println("failed to read target response body", err)
1067 return
1068 }
1069 defer targetResp.Body.Close()
1070
1071 var targetResult types.RepoBranchesResponse
1072 err = json.Unmarshal(targetBody, &targetResult)
1073 if err != nil {
1074 log.Println("failed to parse target branches response:", err)
1075 return
1076 }
1077
1078 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
1079 RepoInfo: f.RepoInfo(s, user),
1080 SourceBranches: sourceResult.Branches,
1081 TargetBranches: targetResult.Branches,
1082 })
1083}
1084
1085func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) {
1086 user := s.auth.GetUser(r)
1087 f, err := fullyResolvedRepo(r)
1088 if err != nil {
1089 log.Println("failed to get repo and knot", err)
1090 return
1091 }
1092
1093 pull, ok := r.Context().Value("pull").(*db.Pull)
1094 if !ok {
1095 log.Println("failed to get pull")
1096 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1097 return
1098 }
1099
1100 switch r.Method {
1101 case http.MethodGet:
1102 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
1103 RepoInfo: f.RepoInfo(s, user),
1104 Pull: pull,
1105 })
1106 return
1107 case http.MethodPost:
1108 if pull.IsPatchBased() {
1109 s.resubmitPatch(w, r)
1110 return
1111 } else if pull.IsBranchBased() {
1112 s.resubmitBranch(w, r)
1113 return
1114 } else if pull.IsForkBased() {
1115 s.resubmitFork(w, r)
1116 return
1117 }
1118 }
1119}
1120
1121func (s *State) resubmitPatch(w http.ResponseWriter, r *http.Request) {
1122 user := s.auth.GetUser(r)
1123
1124 pull, ok := r.Context().Value("pull").(*db.Pull)
1125 if !ok {
1126 log.Println("failed to get pull")
1127 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1128 return
1129 }
1130
1131 f, err := fullyResolvedRepo(r)
1132 if err != nil {
1133 log.Println("failed to get repo and knot", err)
1134 return
1135 }
1136
1137 if user.Did != pull.OwnerDid {
1138 log.Println("unauthorized user")
1139 w.WriteHeader(http.StatusUnauthorized)
1140 return
1141 }
1142
1143 patch := r.FormValue("patch")
1144
1145 if err = validateResubmittedPatch(pull, patch); err != nil {
1146 s.pages.Notice(w, "resubmit-error", err.Error())
1147 return
1148 }
1149
1150 tx, err := s.db.BeginTx(r.Context(), nil)
1151 if err != nil {
1152 log.Println("failed to start tx")
1153 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1154 return
1155 }
1156 defer tx.Rollback()
1157
1158 err = db.ResubmitPull(tx, pull, patch, "")
1159 if err != nil {
1160 log.Println("failed to resubmit pull request", err)
1161 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull request. Try again later.")
1162 return
1163 }
1164 client, _ := s.auth.AuthorizedClient(r)
1165
1166 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1167 if err != nil {
1168 // failed to get record
1169 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1170 return
1171 }
1172
1173 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1174 Collection: tangled.RepoPullNSID,
1175 Repo: user.Did,
1176 Rkey: pull.Rkey,
1177 SwapRecord: ex.Cid,
1178 Record: &lexutil.LexiconTypeDecoder{
1179 Val: &tangled.RepoPull{
1180 Title: pull.Title,
1181 PullId: int64(pull.PullId),
1182 TargetRepo: string(f.RepoAt),
1183 TargetBranch: pull.TargetBranch,
1184 Patch: patch, // new patch
1185 },
1186 },
1187 })
1188 if err != nil {
1189 log.Println("failed to update record", err)
1190 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1191 return
1192 }
1193
1194 if err = tx.Commit(); err != nil {
1195 log.Println("failed to commit transaction", err)
1196 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1197 return
1198 }
1199
1200 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1201 return
1202}
1203
1204func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) {
1205 user := s.auth.GetUser(r)
1206
1207 pull, ok := r.Context().Value("pull").(*db.Pull)
1208 if !ok {
1209 log.Println("failed to get pull")
1210 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1211 return
1212 }
1213
1214 f, err := fullyResolvedRepo(r)
1215 if err != nil {
1216 log.Println("failed to get repo and knot", err)
1217 return
1218 }
1219
1220 if user.Did != pull.OwnerDid {
1221 log.Println("unauthorized user")
1222 w.WriteHeader(http.StatusUnauthorized)
1223 return
1224 }
1225
1226 if !f.RepoInfo(s, user).Roles.IsPushAllowed() {
1227 log.Println("unauthorized user")
1228 w.WriteHeader(http.StatusUnauthorized)
1229 return
1230 }
1231
1232 ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
1233 if err != nil {
1234 log.Printf("failed to create client for %s: %s", f.Knot, err)
1235 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1236 return
1237 }
1238
1239 comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch)
1240 if err != nil {
1241 log.Printf("compare request failed: %s", err)
1242 s.pages.Notice(w, "resubmit-error", err.Error())
1243 return
1244 }
1245
1246 sourceRev := comparison.Rev2
1247 patch := comparison.Patch
1248
1249 if err = validateResubmittedPatch(pull, patch); err != nil {
1250 s.pages.Notice(w, "resubmit-error", err.Error())
1251 return
1252 }
1253
1254 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1255 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1256 return
1257 }
1258
1259 tx, err := s.db.BeginTx(r.Context(), nil)
1260 if err != nil {
1261 log.Println("failed to start tx")
1262 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1263 return
1264 }
1265 defer tx.Rollback()
1266
1267 err = db.ResubmitPull(tx, pull, patch, sourceRev)
1268 if err != nil {
1269 log.Println("failed to create pull request", err)
1270 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1271 return
1272 }
1273 client, _ := s.auth.AuthorizedClient(r)
1274
1275 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1276 if err != nil {
1277 // failed to get record
1278 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1279 return
1280 }
1281
1282 recordPullSource := &tangled.RepoPull_Source{
1283 Branch: pull.PullSource.Branch,
1284 }
1285 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1286 Collection: tangled.RepoPullNSID,
1287 Repo: user.Did,
1288 Rkey: pull.Rkey,
1289 SwapRecord: ex.Cid,
1290 Record: &lexutil.LexiconTypeDecoder{
1291 Val: &tangled.RepoPull{
1292 Title: pull.Title,
1293 PullId: int64(pull.PullId),
1294 TargetRepo: string(f.RepoAt),
1295 TargetBranch: pull.TargetBranch,
1296 Patch: patch, // new patch
1297 Source: recordPullSource,
1298 },
1299 },
1300 })
1301 if err != nil {
1302 log.Println("failed to update record", err)
1303 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1304 return
1305 }
1306
1307 if err = tx.Commit(); err != nil {
1308 log.Println("failed to commit transaction", err)
1309 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1310 return
1311 }
1312
1313 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1314 return
1315}
1316
1317func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) {
1318 user := s.auth.GetUser(r)
1319
1320 pull, ok := r.Context().Value("pull").(*db.Pull)
1321 if !ok {
1322 log.Println("failed to get pull")
1323 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1324 return
1325 }
1326
1327 f, err := fullyResolvedRepo(r)
1328 if err != nil {
1329 log.Println("failed to get repo and knot", err)
1330 return
1331 }
1332
1333 if user.Did != pull.OwnerDid {
1334 log.Println("unauthorized user")
1335 w.WriteHeader(http.StatusUnauthorized)
1336 return
1337 }
1338
1339 forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
1340 if err != nil {
1341 log.Println("failed to get source repo", err)
1342 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1343 return
1344 }
1345
1346 // extract patch by performing compare
1347 ksClient, err := NewUnsignedClient(forkRepo.Knot, s.config.Dev)
1348 if err != nil {
1349 log.Printf("failed to create client for %s: %s", forkRepo.Knot, err)
1350 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1351 return
1352 }
1353
1354 secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot)
1355 if err != nil {
1356 log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err)
1357 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1358 return
1359 }
1360
1361 // update the hidden tracking branch to latest
1362 signedClient, err := NewSignedClient(forkRepo.Knot, secret, s.config.Dev)
1363 if err != nil {
1364 log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err)
1365 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1366 return
1367 }
1368
1369 resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch)
1370 if err != nil || resp.StatusCode != http.StatusNoContent {
1371 log.Printf("failed to update tracking branch: %s", err)
1372 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1373 return
1374 }
1375
1376 hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch))
1377 comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch)
1378 if err != nil {
1379 log.Printf("failed to compare branches: %s", err)
1380 s.pages.Notice(w, "resubmit-error", err.Error())
1381 return
1382 }
1383
1384 sourceRev := comparison.Rev2
1385 patch := comparison.Patch
1386
1387 if err = validateResubmittedPatch(pull, patch); err != nil {
1388 s.pages.Notice(w, "resubmit-error", err.Error())
1389 return
1390 }
1391
1392 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1393 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1394 return
1395 }
1396
1397 tx, err := s.db.BeginTx(r.Context(), nil)
1398 if err != nil {
1399 log.Println("failed to start tx")
1400 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1401 return
1402 }
1403 defer tx.Rollback()
1404
1405 err = db.ResubmitPull(tx, pull, patch, sourceRev)
1406 if err != nil {
1407 log.Println("failed to create pull request", err)
1408 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1409 return
1410 }
1411 client, _ := s.auth.AuthorizedClient(r)
1412
1413 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1414 if err != nil {
1415 // failed to get record
1416 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1417 return
1418 }
1419
1420 repoAt := pull.PullSource.RepoAt.String()
1421 recordPullSource := &tangled.RepoPull_Source{
1422 Branch: pull.PullSource.Branch,
1423 Repo: &repoAt,
1424 }
1425 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1426 Collection: tangled.RepoPullNSID,
1427 Repo: user.Did,
1428 Rkey: pull.Rkey,
1429 SwapRecord: ex.Cid,
1430 Record: &lexutil.LexiconTypeDecoder{
1431 Val: &tangled.RepoPull{
1432 Title: pull.Title,
1433 PullId: int64(pull.PullId),
1434 TargetRepo: string(f.RepoAt),
1435 TargetBranch: pull.TargetBranch,
1436 Patch: patch, // new patch
1437 Source: recordPullSource,
1438 },
1439 },
1440 })
1441 if err != nil {
1442 log.Println("failed to update record", err)
1443 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1444 return
1445 }
1446
1447 if err = tx.Commit(); err != nil {
1448 log.Println("failed to commit transaction", err)
1449 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1450 return
1451 }
1452
1453 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1454 return
1455}
1456
1457// validate a resubmission against a pull request
1458func validateResubmittedPatch(pull *db.Pull, patch string) error {
1459 if patch == "" {
1460 return fmt.Errorf("Patch is empty.")
1461 }
1462
1463 if patch == pull.LatestPatch() {
1464 return fmt.Errorf("Patch is identical to previous submission.")
1465 }
1466
1467 if !patchutil.IsPatchValid(patch) {
1468 return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
1469 }
1470
1471 return nil
1472}
1473
1474func (s *State) MergePull(w http.ResponseWriter, r *http.Request) {
1475 f, err := fullyResolvedRepo(r)
1476 if err != nil {
1477 log.Println("failed to resolve repo:", err)
1478 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1479 return
1480 }
1481
1482 pull, ok := r.Context().Value("pull").(*db.Pull)
1483 if !ok {
1484 log.Println("failed to get pull")
1485 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1486 return
1487 }
1488
1489 secret, err := db.GetRegistrationKey(s.db, f.Knot)
1490 if err != nil {
1491 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
1492 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1493 return
1494 }
1495
1496 ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid)
1497 if err != nil {
1498 log.Printf("resolving identity: %s", err)
1499 w.WriteHeader(http.StatusNotFound)
1500 return
1501 }
1502
1503 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
1504 if err != nil {
1505 log.Printf("failed to get primary email: %s", err)
1506 }
1507
1508 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
1509 if err != nil {
1510 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
1511 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1512 return
1513 }
1514
1515 // Merge the pull request
1516 resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
1517 if err != nil {
1518 log.Printf("failed to merge pull request: %s", err)
1519 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1520 return
1521 }
1522
1523 if resp.StatusCode == http.StatusOK {
1524 err := db.MergePull(s.db, f.RepoAt, pull.PullId)
1525 if err != nil {
1526 log.Printf("failed to update pull request status in database: %s", err)
1527 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1528 return
1529 }
1530 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
1531 } else {
1532 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
1533 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1534 }
1535}
1536
1537func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) {
1538 user := s.auth.GetUser(r)
1539
1540 f, err := fullyResolvedRepo(r)
1541 if err != nil {
1542 log.Println("malformed middleware")
1543 return
1544 }
1545
1546 pull, ok := r.Context().Value("pull").(*db.Pull)
1547 if !ok {
1548 log.Println("failed to get pull")
1549 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1550 return
1551 }
1552
1553 // auth filter: only owner or collaborators can close
1554 roles := RolesInRepo(s, user, f)
1555 isCollaborator := roles.IsCollaborator()
1556 isPullAuthor := user.Did == pull.OwnerDid
1557 isCloseAllowed := isCollaborator || isPullAuthor
1558 if !isCloseAllowed {
1559 log.Println("failed to close pull")
1560 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
1561 return
1562 }
1563
1564 // Start a transaction
1565 tx, err := s.db.BeginTx(r.Context(), nil)
1566 if err != nil {
1567 log.Println("failed to start transaction", err)
1568 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1569 return
1570 }
1571
1572 // Close the pull in the database
1573 err = db.ClosePull(tx, f.RepoAt, pull.PullId)
1574 if err != nil {
1575 log.Println("failed to close pull", err)
1576 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1577 return
1578 }
1579
1580 // Commit the transaction
1581 if err = tx.Commit(); err != nil {
1582 log.Println("failed to commit transaction", err)
1583 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1584 return
1585 }
1586
1587 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1588 return
1589}
1590
1591func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) {
1592 user := s.auth.GetUser(r)
1593
1594 f, err := fullyResolvedRepo(r)
1595 if err != nil {
1596 log.Println("failed to resolve repo", err)
1597 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1598 return
1599 }
1600
1601 pull, ok := r.Context().Value("pull").(*db.Pull)
1602 if !ok {
1603 log.Println("failed to get pull")
1604 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1605 return
1606 }
1607
1608 // auth filter: only owner or collaborators can close
1609 roles := RolesInRepo(s, user, f)
1610 isCollaborator := roles.IsCollaborator()
1611 isPullAuthor := user.Did == pull.OwnerDid
1612 isCloseAllowed := isCollaborator || isPullAuthor
1613 if !isCloseAllowed {
1614 log.Println("failed to close pull")
1615 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
1616 return
1617 }
1618
1619 // Start a transaction
1620 tx, err := s.db.BeginTx(r.Context(), nil)
1621 if err != nil {
1622 log.Println("failed to start transaction", err)
1623 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1624 return
1625 }
1626
1627 // Reopen the pull in the database
1628 err = db.ReopenPull(tx, f.RepoAt, pull.PullId)
1629 if err != nil {
1630 log.Println("failed to reopen pull", err)
1631 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1632 return
1633 }
1634
1635 // Commit the transaction
1636 if err = tx.Commit(); err != nil {
1637 log.Println("failed to commit transaction", err)
1638 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1639 return
1640 }
1641
1642 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1643 return
1644}