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