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