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: string(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 _, 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 if err != nil {
897 log.Println("failed to create pull request", err)
898 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
899 return
900 }
901
902 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
903}
904
905func (s *State) ValidatePatch(w http.ResponseWriter, r *http.Request) {
906 _, err := fullyResolvedRepo(r)
907 if err != nil {
908 log.Println("failed to get repo and knot", err)
909 return
910 }
911
912 patch := r.FormValue("patch")
913 if patch == "" {
914 s.pages.Notice(w, "patch-error", "Patch is required.")
915 return
916 }
917
918 if patch == "" || !patchutil.IsPatchValid(patch) {
919 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.")
920 return
921 }
922
923 if patchutil.IsFormatPatch(patch) {
924 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.")
925 } else {
926 s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.")
927 }
928}
929
930func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
931 user := s.auth.GetUser(r)
932 f, err := fullyResolvedRepo(r)
933 if err != nil {
934 log.Println("failed to get repo and knot", err)
935 return
936 }
937
938 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
939 RepoInfo: f.RepoInfo(s, user),
940 })
941}
942
943func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) {
944 user := s.auth.GetUser(r)
945 f, err := fullyResolvedRepo(r)
946 if err != nil {
947 log.Println("failed to get repo and knot", err)
948 return
949 }
950
951 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
952 if err != nil {
953 log.Printf("failed to create unsigned client for %s", f.Knot)
954 s.pages.Error503(w)
955 return
956 }
957
958 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
959 if err != nil {
960 log.Println("failed to reach knotserver", err)
961 return
962 }
963
964 body, err := io.ReadAll(resp.Body)
965 if err != nil {
966 log.Printf("Error reading response body: %v", err)
967 return
968 }
969
970 var result types.RepoBranchesResponse
971 err = json.Unmarshal(body, &result)
972 if err != nil {
973 log.Println("failed to parse response:", err)
974 return
975 }
976
977 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
978 RepoInfo: f.RepoInfo(s, user),
979 Branches: result.Branches,
980 })
981}
982
983func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) {
984 user := s.auth.GetUser(r)
985 f, err := fullyResolvedRepo(r)
986 if err != nil {
987 log.Println("failed to get repo and knot", err)
988 return
989 }
990
991 forks, err := db.GetForksByDid(s.db, user.Did)
992 if err != nil {
993 log.Println("failed to get forks", err)
994 return
995 }
996
997 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{
998 RepoInfo: f.RepoInfo(s, user),
999 Forks: forks,
1000 })
1001}
1002
1003func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) {
1004 user := s.auth.GetUser(r)
1005
1006 f, err := fullyResolvedRepo(r)
1007 if err != nil {
1008 log.Println("failed to get repo and knot", err)
1009 return
1010 }
1011
1012 forkVal := r.URL.Query().Get("fork")
1013
1014 // fork repo
1015 repo, err := db.GetRepo(s.db, user.Did, forkVal)
1016 if err != nil {
1017 log.Println("failed to get repo", user.Did, forkVal)
1018 return
1019 }
1020
1021 sourceBranchesClient, err := NewUnsignedClient(repo.Knot, s.config.Dev)
1022 if err != nil {
1023 log.Printf("failed to create unsigned client for %s", repo.Knot)
1024 s.pages.Error503(w)
1025 return
1026 }
1027
1028 sourceResp, err := sourceBranchesClient.Branches(user.Did, repo.Name)
1029 if err != nil {
1030 log.Println("failed to reach knotserver for source branches", err)
1031 return
1032 }
1033
1034 sourceBody, err := io.ReadAll(sourceResp.Body)
1035 if err != nil {
1036 log.Println("failed to read source response body", err)
1037 return
1038 }
1039 defer sourceResp.Body.Close()
1040
1041 var sourceResult types.RepoBranchesResponse
1042 err = json.Unmarshal(sourceBody, &sourceResult)
1043 if err != nil {
1044 log.Println("failed to parse source branches response:", err)
1045 return
1046 }
1047
1048 targetBranchesClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
1049 if err != nil {
1050 log.Printf("failed to create unsigned client for target knot %s", f.Knot)
1051 s.pages.Error503(w)
1052 return
1053 }
1054
1055 targetResp, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName)
1056 if err != nil {
1057 log.Println("failed to reach knotserver for target branches", err)
1058 return
1059 }
1060
1061 targetBody, err := io.ReadAll(targetResp.Body)
1062 if err != nil {
1063 log.Println("failed to read target response body", err)
1064 return
1065 }
1066 defer targetResp.Body.Close()
1067
1068 var targetResult types.RepoBranchesResponse
1069 err = json.Unmarshal(targetBody, &targetResult)
1070 if err != nil {
1071 log.Println("failed to parse target branches response:", err)
1072 return
1073 }
1074
1075 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
1076 RepoInfo: f.RepoInfo(s, user),
1077 SourceBranches: sourceResult.Branches,
1078 TargetBranches: targetResult.Branches,
1079 })
1080}
1081
1082func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) {
1083 user := s.auth.GetUser(r)
1084 f, err := fullyResolvedRepo(r)
1085 if err != nil {
1086 log.Println("failed to get repo and knot", err)
1087 return
1088 }
1089
1090 pull, ok := r.Context().Value("pull").(*db.Pull)
1091 if !ok {
1092 log.Println("failed to get pull")
1093 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1094 return
1095 }
1096
1097 switch r.Method {
1098 case http.MethodGet:
1099 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
1100 RepoInfo: f.RepoInfo(s, user),
1101 Pull: pull,
1102 })
1103 return
1104 case http.MethodPost:
1105 if pull.IsPatchBased() {
1106 s.resubmitPatch(w, r)
1107 return
1108 } else if pull.IsBranchBased() {
1109 s.resubmitBranch(w, r)
1110 return
1111 } else if pull.IsForkBased() {
1112 s.resubmitFork(w, r)
1113 return
1114 }
1115 }
1116}
1117
1118func (s *State) resubmitPatch(w http.ResponseWriter, r *http.Request) {
1119 user := s.auth.GetUser(r)
1120
1121 pull, ok := r.Context().Value("pull").(*db.Pull)
1122 if !ok {
1123 log.Println("failed to get pull")
1124 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1125 return
1126 }
1127
1128 f, err := fullyResolvedRepo(r)
1129 if err != nil {
1130 log.Println("failed to get repo and knot", err)
1131 return
1132 }
1133
1134 if user.Did != pull.OwnerDid {
1135 log.Println("unauthorized user")
1136 w.WriteHeader(http.StatusUnauthorized)
1137 return
1138 }
1139
1140 patch := r.FormValue("patch")
1141
1142 if err = validateResubmittedPatch(pull, patch); err != nil {
1143 s.pages.Notice(w, "resubmit-error", err.Error())
1144 return
1145 }
1146
1147 tx, err := s.db.BeginTx(r.Context(), nil)
1148 if err != nil {
1149 log.Println("failed to start tx")
1150 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1151 return
1152 }
1153 defer tx.Rollback()
1154
1155 err = db.ResubmitPull(tx, pull, patch, "")
1156 if err != nil {
1157 log.Println("failed to resubmit pull request", err)
1158 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull request. Try again later.")
1159 return
1160 }
1161 client, _ := s.auth.AuthorizedClient(r)
1162
1163 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1164 if err != nil {
1165 // failed to get record
1166 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1167 return
1168 }
1169
1170 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1171 Collection: tangled.RepoPullNSID,
1172 Repo: user.Did,
1173 Rkey: pull.Rkey,
1174 SwapRecord: ex.Cid,
1175 Record: &lexutil.LexiconTypeDecoder{
1176 Val: &tangled.RepoPull{
1177 Title: pull.Title,
1178 PullId: int64(pull.PullId),
1179 TargetRepo: string(f.RepoAt),
1180 TargetBranch: pull.TargetBranch,
1181 Patch: patch, // new patch
1182 },
1183 },
1184 })
1185 if err != nil {
1186 log.Println("failed to update record", err)
1187 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1188 return
1189 }
1190
1191 if err = tx.Commit(); err != nil {
1192 log.Println("failed to commit transaction", err)
1193 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1194 return
1195 }
1196
1197 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1198 return
1199}
1200
1201func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) {
1202 user := s.auth.GetUser(r)
1203
1204 pull, ok := r.Context().Value("pull").(*db.Pull)
1205 if !ok {
1206 log.Println("failed to get pull")
1207 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1208 return
1209 }
1210
1211 f, err := fullyResolvedRepo(r)
1212 if err != nil {
1213 log.Println("failed to get repo and knot", err)
1214 return
1215 }
1216
1217 if user.Did != pull.OwnerDid {
1218 log.Println("unauthorized user")
1219 w.WriteHeader(http.StatusUnauthorized)
1220 return
1221 }
1222
1223 if !f.RepoInfo(s, user).Roles.IsPushAllowed() {
1224 log.Println("unauthorized user")
1225 w.WriteHeader(http.StatusUnauthorized)
1226 return
1227 }
1228
1229 ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
1230 if err != nil {
1231 log.Printf("failed to create client for %s: %s", f.Knot, err)
1232 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1233 return
1234 }
1235
1236 comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch)
1237 if err != nil {
1238 log.Printf("compare request failed: %s", err)
1239 s.pages.Notice(w, "resubmit-error", err.Error())
1240 return
1241 }
1242
1243 sourceRev := comparison.Rev2
1244 patch := comparison.Patch
1245
1246 if err = validateResubmittedPatch(pull, patch); err != nil {
1247 s.pages.Notice(w, "resubmit-error", err.Error())
1248 return
1249 }
1250
1251 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1252 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1253 return
1254 }
1255
1256 tx, err := s.db.BeginTx(r.Context(), nil)
1257 if err != nil {
1258 log.Println("failed to start tx")
1259 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1260 return
1261 }
1262 defer tx.Rollback()
1263
1264 err = db.ResubmitPull(tx, pull, patch, sourceRev)
1265 if err != nil {
1266 log.Println("failed to create pull request", err)
1267 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1268 return
1269 }
1270 client, _ := s.auth.AuthorizedClient(r)
1271
1272 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1273 if err != nil {
1274 // failed to get record
1275 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1276 return
1277 }
1278
1279 recordPullSource := &tangled.RepoPull_Source{
1280 Branch: pull.PullSource.Branch,
1281 }
1282 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1283 Collection: tangled.RepoPullNSID,
1284 Repo: user.Did,
1285 Rkey: pull.Rkey,
1286 SwapRecord: ex.Cid,
1287 Record: &lexutil.LexiconTypeDecoder{
1288 Val: &tangled.RepoPull{
1289 Title: pull.Title,
1290 PullId: int64(pull.PullId),
1291 TargetRepo: string(f.RepoAt),
1292 TargetBranch: pull.TargetBranch,
1293 Patch: patch, // new patch
1294 Source: recordPullSource,
1295 },
1296 },
1297 })
1298 if err != nil {
1299 log.Println("failed to update record", err)
1300 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1301 return
1302 }
1303
1304 if err = tx.Commit(); err != nil {
1305 log.Println("failed to commit transaction", err)
1306 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1307 return
1308 }
1309
1310 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1311 return
1312}
1313
1314func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) {
1315 user := s.auth.GetUser(r)
1316
1317 pull, ok := r.Context().Value("pull").(*db.Pull)
1318 if !ok {
1319 log.Println("failed to get pull")
1320 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1321 return
1322 }
1323
1324 f, err := fullyResolvedRepo(r)
1325 if err != nil {
1326 log.Println("failed to get repo and knot", err)
1327 return
1328 }
1329
1330 if user.Did != pull.OwnerDid {
1331 log.Println("unauthorized user")
1332 w.WriteHeader(http.StatusUnauthorized)
1333 return
1334 }
1335
1336 forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
1337 if err != nil {
1338 log.Println("failed to get source repo", err)
1339 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1340 return
1341 }
1342
1343 // extract patch by performing compare
1344 ksClient, err := NewUnsignedClient(forkRepo.Knot, s.config.Dev)
1345 if err != nil {
1346 log.Printf("failed to create client for %s: %s", forkRepo.Knot, err)
1347 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1348 return
1349 }
1350
1351 secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot)
1352 if err != nil {
1353 log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err)
1354 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1355 return
1356 }
1357
1358 // update the hidden tracking branch to latest
1359 signedClient, err := NewSignedClient(forkRepo.Knot, secret, s.config.Dev)
1360 if err != nil {
1361 log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err)
1362 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1363 return
1364 }
1365
1366 resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch)
1367 if err != nil || resp.StatusCode != http.StatusNoContent {
1368 log.Printf("failed to update tracking branch: %s", err)
1369 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1370 return
1371 }
1372
1373 hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch))
1374 comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch)
1375 if err != nil {
1376 log.Printf("failed to compare branches: %s", err)
1377 s.pages.Notice(w, "resubmit-error", err.Error())
1378 return
1379 }
1380
1381 sourceRev := comparison.Rev2
1382 patch := comparison.Patch
1383
1384 if err = validateResubmittedPatch(pull, patch); err != nil {
1385 s.pages.Notice(w, "resubmit-error", err.Error())
1386 return
1387 }
1388
1389 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1390 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1391 return
1392 }
1393
1394 tx, err := s.db.BeginTx(r.Context(), nil)
1395 if err != nil {
1396 log.Println("failed to start tx")
1397 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1398 return
1399 }
1400 defer tx.Rollback()
1401
1402 err = db.ResubmitPull(tx, pull, patch, sourceRev)
1403 if err != nil {
1404 log.Println("failed to create pull request", err)
1405 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1406 return
1407 }
1408 client, _ := s.auth.AuthorizedClient(r)
1409
1410 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1411 if err != nil {
1412 // failed to get record
1413 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1414 return
1415 }
1416
1417 repoAt := pull.PullSource.RepoAt.String()
1418 recordPullSource := &tangled.RepoPull_Source{
1419 Branch: pull.PullSource.Branch,
1420 Repo: &repoAt,
1421 }
1422 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1423 Collection: tangled.RepoPullNSID,
1424 Repo: user.Did,
1425 Rkey: pull.Rkey,
1426 SwapRecord: ex.Cid,
1427 Record: &lexutil.LexiconTypeDecoder{
1428 Val: &tangled.RepoPull{
1429 Title: pull.Title,
1430 PullId: int64(pull.PullId),
1431 TargetRepo: string(f.RepoAt),
1432 TargetBranch: pull.TargetBranch,
1433 Patch: patch, // new patch
1434 Source: recordPullSource,
1435 },
1436 },
1437 })
1438 if err != nil {
1439 log.Println("failed to update record", err)
1440 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1441 return
1442 }
1443
1444 if err = tx.Commit(); err != nil {
1445 log.Println("failed to commit transaction", err)
1446 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1447 return
1448 }
1449
1450 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1451 return
1452}
1453
1454// validate a resubmission against a pull request
1455func validateResubmittedPatch(pull *db.Pull, patch string) error {
1456 if patch == "" {
1457 return fmt.Errorf("Patch is empty.")
1458 }
1459
1460 if patch == pull.LatestPatch() {
1461 return fmt.Errorf("Patch is identical to previous submission.")
1462 }
1463
1464 if !patchutil.IsPatchValid(patch) {
1465 return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
1466 }
1467
1468 return nil
1469}
1470
1471func (s *State) MergePull(w http.ResponseWriter, r *http.Request) {
1472 f, err := fullyResolvedRepo(r)
1473 if err != nil {
1474 log.Println("failed to resolve repo:", err)
1475 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1476 return
1477 }
1478
1479 pull, ok := r.Context().Value("pull").(*db.Pull)
1480 if !ok {
1481 log.Println("failed to get pull")
1482 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1483 return
1484 }
1485
1486 secret, err := db.GetRegistrationKey(s.db, f.Knot)
1487 if err != nil {
1488 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
1489 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1490 return
1491 }
1492
1493 ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid)
1494 if err != nil {
1495 log.Printf("resolving identity: %s", err)
1496 w.WriteHeader(http.StatusNotFound)
1497 return
1498 }
1499
1500 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
1501 if err != nil {
1502 log.Printf("failed to get primary email: %s", err)
1503 }
1504
1505 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
1506 if err != nil {
1507 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
1508 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1509 return
1510 }
1511
1512 // Merge the pull request
1513 resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
1514 if err != nil {
1515 log.Printf("failed to merge pull request: %s", err)
1516 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1517 return
1518 }
1519
1520 if resp.StatusCode == http.StatusOK {
1521 err := db.MergePull(s.db, f.RepoAt, pull.PullId)
1522 if err != nil {
1523 log.Printf("failed to update pull request status in database: %s", err)
1524 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1525 return
1526 }
1527 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
1528 } else {
1529 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
1530 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1531 }
1532}
1533
1534func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) {
1535 user := s.auth.GetUser(r)
1536
1537 f, err := fullyResolvedRepo(r)
1538 if err != nil {
1539 log.Println("malformed middleware")
1540 return
1541 }
1542
1543 pull, ok := r.Context().Value("pull").(*db.Pull)
1544 if !ok {
1545 log.Println("failed to get pull")
1546 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1547 return
1548 }
1549
1550 // auth filter: only owner or collaborators can close
1551 roles := RolesInRepo(s, user, f)
1552 isCollaborator := roles.IsCollaborator()
1553 isPullAuthor := user.Did == pull.OwnerDid
1554 isCloseAllowed := isCollaborator || isPullAuthor
1555 if !isCloseAllowed {
1556 log.Println("failed to close pull")
1557 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
1558 return
1559 }
1560
1561 // Start a transaction
1562 tx, err := s.db.BeginTx(r.Context(), nil)
1563 if err != nil {
1564 log.Println("failed to start transaction", err)
1565 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1566 return
1567 }
1568
1569 // Close the pull in the database
1570 err = db.ClosePull(tx, f.RepoAt, pull.PullId)
1571 if err != nil {
1572 log.Println("failed to close pull", err)
1573 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1574 return
1575 }
1576
1577 // Commit the transaction
1578 if err = tx.Commit(); err != nil {
1579 log.Println("failed to commit transaction", err)
1580 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1581 return
1582 }
1583
1584 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1585 return
1586}
1587
1588func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) {
1589 user := s.auth.GetUser(r)
1590
1591 f, err := fullyResolvedRepo(r)
1592 if err != nil {
1593 log.Println("failed to resolve repo", err)
1594 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1595 return
1596 }
1597
1598 pull, ok := r.Context().Value("pull").(*db.Pull)
1599 if !ok {
1600 log.Println("failed to get pull")
1601 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1602 return
1603 }
1604
1605 // auth filter: only owner or collaborators can close
1606 roles := RolesInRepo(s, user, f)
1607 isCollaborator := roles.IsCollaborator()
1608 isPullAuthor := user.Did == pull.OwnerDid
1609 isCloseAllowed := isCollaborator || isPullAuthor
1610 if !isCloseAllowed {
1611 log.Println("failed to close pull")
1612 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
1613 return
1614 }
1615
1616 // Start a transaction
1617 tx, err := s.db.BeginTx(r.Context(), nil)
1618 if err != nil {
1619 log.Println("failed to start transaction", err)
1620 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1621 return
1622 }
1623
1624 // Reopen the pull in the database
1625 err = db.ReopenPull(tx, f.RepoAt, pull.PullId)
1626 if err != nil {
1627 log.Println("failed to reopen pull", err)
1628 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1629 return
1630 }
1631
1632 // Commit the transaction
1633 if err = tx.Commit(); err != nil {
1634 log.Println("failed to commit transaction", err)
1635 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1636 return
1637 }
1638
1639 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1640 return
1641}