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