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