1package pulls
2
3import (
4 "database/sql"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "log"
10 "net/http"
11 "sort"
12 "strconv"
13 "strings"
14 "time"
15
16 "tangled.sh/tangled.sh/core/api/tangled"
17 "tangled.sh/tangled.sh/core/appview"
18 "tangled.sh/tangled.sh/core/appview/db"
19 "tangled.sh/tangled.sh/core/appview/oauth"
20 "tangled.sh/tangled.sh/core/appview/pages"
21 "tangled.sh/tangled.sh/core/appview/reporesolver"
22 "tangled.sh/tangled.sh/core/knotclient"
23 "tangled.sh/tangled.sh/core/patchutil"
24 "tangled.sh/tangled.sh/core/types"
25
26 "github.com/bluekeyes/go-gitdiff/gitdiff"
27 comatproto "github.com/bluesky-social/indigo/api/atproto"
28 "github.com/bluesky-social/indigo/atproto/syntax"
29 lexutil "github.com/bluesky-social/indigo/lex/util"
30 "github.com/go-chi/chi/v5"
31 "github.com/google/uuid"
32 "github.com/posthog/posthog-go"
33)
34
35type Pulls struct {
36 oauth *oauth.OAuth
37 repoResolver *reporesolver.RepoResolver
38 pages *pages.Pages
39 resolver *appview.Resolver
40 db *db.DB
41 config *appview.Config
42 posthog posthog.Client
43}
44
45func New(oauth *oauth.OAuth, repoResolver *reporesolver.RepoResolver, pages *pages.Pages, resolver *appview.Resolver, db *db.DB, config *appview.Config) *Pulls {
46 return &Pulls{oauth: oauth, repoResolver: repoResolver, pages: pages, resolver: resolver, db: db, config: config}
47}
48
49// htmx fragment
50func (s *Pulls) PullActions(w http.ResponseWriter, r *http.Request) {
51 switch r.Method {
52 case http.MethodGet:
53 user := s.oauth.GetUser(r)
54 f, err := s.repoResolver.Resolve(r)
55 if err != nil {
56 log.Println("failed to get repo and knot", err)
57 return
58 }
59
60 pull, ok := r.Context().Value("pull").(*db.Pull)
61 if !ok {
62 log.Println("failed to get pull")
63 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
64 return
65 }
66
67 // can be nil if this pull is not stacked
68 stack, _ := r.Context().Value("stack").(db.Stack)
69
70 roundNumberStr := chi.URLParam(r, "round")
71 roundNumber, err := strconv.Atoi(roundNumberStr)
72 if err != nil {
73 roundNumber = pull.LastRoundNumber()
74 }
75 if roundNumber >= len(pull.Submissions) {
76 http.Error(w, "bad round id", http.StatusBadRequest)
77 log.Println("failed to parse round id", err)
78 return
79 }
80
81 mergeCheckResponse := s.mergeCheck(f, pull, stack)
82 resubmitResult := pages.Unknown
83 if user.Did == pull.OwnerDid {
84 resubmitResult = s.resubmitCheck(f, pull, stack)
85 }
86
87 s.pages.PullActionsFragment(w, pages.PullActionsParams{
88 LoggedInUser: user,
89 RepoInfo: f.RepoInfo(user),
90 Pull: pull,
91 RoundNumber: roundNumber,
92 MergeCheck: mergeCheckResponse,
93 ResubmitCheck: resubmitResult,
94 Stack: stack,
95 })
96 return
97 }
98}
99
100func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) {
101 user := s.oauth.GetUser(r)
102 f, err := s.repoResolver.Resolve(r)
103 if err != nil {
104 log.Println("failed to get repo and knot", err)
105 return
106 }
107
108 pull, ok := r.Context().Value("pull").(*db.Pull)
109 if !ok {
110 log.Println("failed to get pull")
111 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
112 return
113 }
114
115 // can be nil if this pull is not stacked
116 stack, _ := r.Context().Value("stack").(db.Stack)
117 abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*db.Pull)
118
119 totalIdents := 1
120 for _, submission := range pull.Submissions {
121 totalIdents += len(submission.Comments)
122 }
123
124 identsToResolve := make([]string, totalIdents)
125
126 // populate idents
127 identsToResolve[0] = pull.OwnerDid
128 idx := 1
129 for _, submission := range pull.Submissions {
130 for _, comment := range submission.Comments {
131 identsToResolve[idx] = comment.OwnerDid
132 idx += 1
133 }
134 }
135
136 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
137 didHandleMap := make(map[string]string)
138 for _, identity := range resolvedIds {
139 if !identity.Handle.IsInvalidHandle() {
140 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
141 } else {
142 didHandleMap[identity.DID.String()] = identity.DID.String()
143 }
144 }
145
146 mergeCheckResponse := s.mergeCheck(f, pull, stack)
147 resubmitResult := pages.Unknown
148 if user != nil && user.Did == pull.OwnerDid {
149 resubmitResult = s.resubmitCheck(f, pull, stack)
150 }
151
152 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
153 LoggedInUser: user,
154 RepoInfo: f.RepoInfo(user),
155 DidHandleMap: didHandleMap,
156 Pull: pull,
157 Stack: stack,
158 AbandonedPulls: abandonedPulls,
159 MergeCheck: mergeCheckResponse,
160 ResubmitCheck: resubmitResult,
161 })
162}
163
164func (s *Pulls) mergeCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse {
165 if pull.State == db.PullMerged {
166 return types.MergeCheckResponse{}
167 }
168
169 secret, err := db.GetRegistrationKey(s.db, f.Knot)
170 if err != nil {
171 log.Printf("failed to get registration key: %v", err)
172 return types.MergeCheckResponse{
173 Error: "failed to check merge status: this knot is unregistered",
174 }
175 }
176
177 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
178 if err != nil {
179 log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err)
180 return types.MergeCheckResponse{
181 Error: "failed to check merge status",
182 }
183 }
184
185 patch := pull.LatestPatch()
186 if pull.IsStacked() {
187 // combine patches of substack
188 subStack := stack.Below(pull)
189 // collect the portion of the stack that is mergeable
190 mergeable := subStack.Mergeable()
191 // combine each patch
192 patch = mergeable.CombinedPatch()
193 }
194
195 resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch)
196 if err != nil {
197 log.Println("failed to check for mergeability:", err)
198 return types.MergeCheckResponse{
199 Error: "failed to check merge status",
200 }
201 }
202 switch resp.StatusCode {
203 case 404:
204 return types.MergeCheckResponse{
205 Error: "failed to check merge status: this knot does not support PRs",
206 }
207 case 400:
208 return types.MergeCheckResponse{
209 Error: "failed to check merge status: does this knot support PRs?",
210 }
211 }
212
213 respBody, err := io.ReadAll(resp.Body)
214 if err != nil {
215 log.Println("failed to read merge check response body")
216 return types.MergeCheckResponse{
217 Error: "failed to check merge status: knot is not speaking the right language",
218 }
219 }
220 defer resp.Body.Close()
221
222 var mergeCheckResponse types.MergeCheckResponse
223 err = json.Unmarshal(respBody, &mergeCheckResponse)
224 if err != nil {
225 log.Println("failed to unmarshal merge check response", err)
226 return types.MergeCheckResponse{
227 Error: "failed to check merge status: knot is not speaking the right language",
228 }
229 }
230
231 return mergeCheckResponse
232}
233
234func (s *Pulls) resubmitCheck(f *reporesolver.ResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult {
235 if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil {
236 return pages.Unknown
237 }
238
239 var knot, ownerDid, repoName string
240
241 if pull.PullSource.RepoAt != nil {
242 // fork-based pulls
243 sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
244 if err != nil {
245 log.Println("failed to get source repo", err)
246 return pages.Unknown
247 }
248
249 knot = sourceRepo.Knot
250 ownerDid = sourceRepo.Did
251 repoName = sourceRepo.Name
252 } else {
253 // pulls within the same repo
254 knot = f.Knot
255 ownerDid = f.OwnerDid()
256 repoName = f.RepoName
257 }
258
259 us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev)
260 if err != nil {
261 log.Printf("failed to setup client for %s; ignoring: %v", knot, err)
262 return pages.Unknown
263 }
264
265 result, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch)
266 if err != nil {
267 log.Println("failed to reach knotserver", err)
268 return pages.Unknown
269 }
270
271 latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev
272
273 if pull.IsStacked() && stack != nil {
274 top := stack[0]
275 latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev
276 }
277
278 log.Println(latestSourceRev, result.Branch.Hash)
279
280 if latestSourceRev != result.Branch.Hash {
281 return pages.ShouldResubmit
282 }
283
284 return pages.ShouldNotResubmit
285}
286
287func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
288 user := s.oauth.GetUser(r)
289 f, err := s.repoResolver.Resolve(r)
290 if err != nil {
291 log.Println("failed to get repo and knot", err)
292 return
293 }
294
295 pull, ok := r.Context().Value("pull").(*db.Pull)
296 if !ok {
297 log.Println("failed to get pull")
298 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
299 return
300 }
301
302 stack, _ := r.Context().Value("stack").(db.Stack)
303
304 roundId := chi.URLParam(r, "round")
305 roundIdInt, err := strconv.Atoi(roundId)
306 if err != nil || roundIdInt >= len(pull.Submissions) {
307 http.Error(w, "bad round id", http.StatusBadRequest)
308 log.Println("failed to parse round id", err)
309 return
310 }
311
312 identsToResolve := []string{pull.OwnerDid}
313 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
314 didHandleMap := make(map[string]string)
315 for _, identity := range resolvedIds {
316 if !identity.Handle.IsInvalidHandle() {
317 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
318 } else {
319 didHandleMap[identity.DID.String()] = identity.DID.String()
320 }
321 }
322
323 patch := pull.Submissions[roundIdInt].Patch
324 diff := patchutil.AsNiceDiff(patch, pull.TargetBranch)
325
326 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
327 LoggedInUser: user,
328 DidHandleMap: didHandleMap,
329 RepoInfo: f.RepoInfo(user),
330 Pull: pull,
331 Stack: stack,
332 Round: roundIdInt,
333 Submission: pull.Submissions[roundIdInt],
334 Diff: &diff,
335 })
336
337}
338
339func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) {
340 user := s.oauth.GetUser(r)
341
342 f, err := s.repoResolver.Resolve(r)
343 if err != nil {
344 log.Println("failed to get repo and knot", err)
345 return
346 }
347
348 pull, ok := r.Context().Value("pull").(*db.Pull)
349 if !ok {
350 log.Println("failed to get pull")
351 s.pages.Notice(w, "pull-error", "Failed to get pull.")
352 return
353 }
354
355 roundId := chi.URLParam(r, "round")
356 roundIdInt, err := strconv.Atoi(roundId)
357 if err != nil || roundIdInt >= len(pull.Submissions) {
358 http.Error(w, "bad round id", http.StatusBadRequest)
359 log.Println("failed to parse round id", err)
360 return
361 }
362
363 if roundIdInt == 0 {
364 http.Error(w, "bad round id", http.StatusBadRequest)
365 log.Println("cannot interdiff initial submission")
366 return
367 }
368
369 identsToResolve := []string{pull.OwnerDid}
370 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
371 didHandleMap := make(map[string]string)
372 for _, identity := range resolvedIds {
373 if !identity.Handle.IsInvalidHandle() {
374 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
375 } else {
376 didHandleMap[identity.DID.String()] = identity.DID.String()
377 }
378 }
379
380 currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].Patch)
381 if err != nil {
382 log.Println("failed to interdiff; current patch malformed")
383 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.")
384 return
385 }
386
387 previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].Patch)
388 if err != nil {
389 log.Println("failed to interdiff; previous patch malformed")
390 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.")
391 return
392 }
393
394 interdiff := patchutil.Interdiff(previousPatch, currentPatch)
395
396 s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{
397 LoggedInUser: s.oauth.GetUser(r),
398 RepoInfo: f.RepoInfo(user),
399 Pull: pull,
400 Round: roundIdInt,
401 DidHandleMap: didHandleMap,
402 Interdiff: interdiff,
403 })
404 return
405}
406
407func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
408 pull, ok := r.Context().Value("pull").(*db.Pull)
409 if !ok {
410 log.Println("failed to get pull")
411 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
412 return
413 }
414
415 roundId := chi.URLParam(r, "round")
416 roundIdInt, err := strconv.Atoi(roundId)
417 if err != nil || roundIdInt >= len(pull.Submissions) {
418 http.Error(w, "bad round id", http.StatusBadRequest)
419 log.Println("failed to parse round id", err)
420 return
421 }
422
423 identsToResolve := []string{pull.OwnerDid}
424 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
425 didHandleMap := make(map[string]string)
426 for _, identity := range resolvedIds {
427 if !identity.Handle.IsInvalidHandle() {
428 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
429 } else {
430 didHandleMap[identity.DID.String()] = identity.DID.String()
431 }
432 }
433
434 w.Header().Set("Content-Type", "text/plain")
435 w.Write([]byte(pull.Submissions[roundIdInt].Patch))
436}
437
438func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) {
439 user := s.oauth.GetUser(r)
440 params := r.URL.Query()
441
442 state := db.PullOpen
443 switch params.Get("state") {
444 case "closed":
445 state = db.PullClosed
446 case "merged":
447 state = db.PullMerged
448 }
449
450 f, err := s.repoResolver.Resolve(r)
451 if err != nil {
452 log.Println("failed to get repo and knot", err)
453 return
454 }
455
456 pulls, err := db.GetPulls(
457 s.db,
458 db.FilterEq("repo_at", f.RepoAt),
459 db.FilterEq("state", state),
460 )
461 if err != nil {
462 log.Println("failed to get pulls", err)
463 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
464 return
465 }
466
467 for _, p := range pulls {
468 var pullSourceRepo *db.Repo
469 if p.PullSource != nil {
470 if p.PullSource.RepoAt != nil {
471 pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String())
472 if err != nil {
473 log.Printf("failed to get repo by at uri: %v", err)
474 continue
475 } else {
476 p.PullSource.Repo = pullSourceRepo
477 }
478 }
479 }
480 }
481
482 identsToResolve := make([]string, len(pulls))
483 for i, pull := range pulls {
484 identsToResolve[i] = pull.OwnerDid
485 }
486 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
487 didHandleMap := make(map[string]string)
488 for _, identity := range resolvedIds {
489 if !identity.Handle.IsInvalidHandle() {
490 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
491 } else {
492 didHandleMap[identity.DID.String()] = identity.DID.String()
493 }
494 }
495
496 s.pages.RepoPulls(w, pages.RepoPullsParams{
497 LoggedInUser: s.oauth.GetUser(r),
498 RepoInfo: f.RepoInfo(user),
499 Pulls: pulls,
500 DidHandleMap: didHandleMap,
501 FilteringBy: state,
502 })
503 return
504}
505
506func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) {
507 user := s.oauth.GetUser(r)
508 f, err := s.repoResolver.Resolve(r)
509 if err != nil {
510 log.Println("failed to get repo and knot", err)
511 return
512 }
513
514 pull, ok := r.Context().Value("pull").(*db.Pull)
515 if !ok {
516 log.Println("failed to get pull")
517 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
518 return
519 }
520
521 roundNumberStr := chi.URLParam(r, "round")
522 roundNumber, err := strconv.Atoi(roundNumberStr)
523 if err != nil || roundNumber >= len(pull.Submissions) {
524 http.Error(w, "bad round id", http.StatusBadRequest)
525 log.Println("failed to parse round id", err)
526 return
527 }
528
529 switch r.Method {
530 case http.MethodGet:
531 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{
532 LoggedInUser: user,
533 RepoInfo: f.RepoInfo(user),
534 Pull: pull,
535 RoundNumber: roundNumber,
536 })
537 return
538 case http.MethodPost:
539 body := r.FormValue("body")
540 if body == "" {
541 s.pages.Notice(w, "pull", "Comment body is required")
542 return
543 }
544
545 // Start a transaction
546 tx, err := s.db.BeginTx(r.Context(), nil)
547 if err != nil {
548 log.Println("failed to start transaction", err)
549 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
550 return
551 }
552 defer tx.Rollback()
553
554 createdAt := time.Now().Format(time.RFC3339)
555 ownerDid := user.Did
556
557 pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId)
558 if err != nil {
559 log.Println("failed to get pull at", err)
560 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
561 return
562 }
563
564 atUri := f.RepoAt.String()
565 client, err := s.oauth.AuthorizedClient(r)
566 if err != nil {
567 log.Println("failed to get authorized client", err)
568 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
569 return
570 }
571 atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
572 Collection: tangled.RepoPullCommentNSID,
573 Repo: user.Did,
574 Rkey: appview.TID(),
575 Record: &lexutil.LexiconTypeDecoder{
576 Val: &tangled.RepoPullComment{
577 Repo: &atUri,
578 Pull: string(pullAt),
579 Owner: &ownerDid,
580 Body: body,
581 CreatedAt: createdAt,
582 },
583 },
584 })
585 if err != nil {
586 log.Println("failed to create pull comment", err)
587 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
588 return
589 }
590
591 // Create the pull comment in the database with the commentAt field
592 commentId, err := db.NewPullComment(tx, &db.PullComment{
593 OwnerDid: user.Did,
594 RepoAt: f.RepoAt.String(),
595 PullId: pull.PullId,
596 Body: body,
597 CommentAt: atResp.Uri,
598 SubmissionId: pull.Submissions[roundNumber].ID,
599 })
600 if err != nil {
601 log.Println("failed to create pull comment", err)
602 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
603 return
604 }
605
606 // Commit the transaction
607 if err = tx.Commit(); err != nil {
608 log.Println("failed to commit transaction", err)
609 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
610 return
611 }
612
613 if !s.config.Core.Dev {
614 err = s.posthog.Enqueue(posthog.Capture{
615 DistinctId: user.Did,
616 Event: "new_pull_comment",
617 Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pull.PullId},
618 })
619 if err != nil {
620 log.Println("failed to enqueue posthog event:", err)
621 }
622 }
623
624 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
625 return
626 }
627}
628
629func (s *Pulls) NewPull(w http.ResponseWriter, r *http.Request) {
630 user := s.oauth.GetUser(r)
631 f, err := s.repoResolver.Resolve(r)
632 if err != nil {
633 log.Println("failed to get repo and knot", err)
634 return
635 }
636
637 switch r.Method {
638 case http.MethodGet:
639 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
640 if err != nil {
641 log.Printf("failed to create unsigned client for %s", f.Knot)
642 s.pages.Error503(w)
643 return
644 }
645
646 result, err := us.Branches(f.OwnerDid(), f.RepoName)
647 if err != nil {
648 log.Println("failed to fetch branches", err)
649 return
650 }
651
652 // can be one of "patch", "branch" or "fork"
653 strategy := r.URL.Query().Get("strategy")
654 // ignored if strategy is "patch"
655 sourceBranch := r.URL.Query().Get("sourceBranch")
656 targetBranch := r.URL.Query().Get("targetBranch")
657
658 s.pages.RepoNewPull(w, pages.RepoNewPullParams{
659 LoggedInUser: user,
660 RepoInfo: f.RepoInfo(user),
661 Branches: result.Branches,
662 Strategy: strategy,
663 SourceBranch: sourceBranch,
664 TargetBranch: targetBranch,
665 Title: r.URL.Query().Get("title"),
666 Body: r.URL.Query().Get("body"),
667 })
668
669 case http.MethodPost:
670 title := r.FormValue("title")
671 body := r.FormValue("body")
672 targetBranch := r.FormValue("targetBranch")
673 fromFork := r.FormValue("fork")
674 sourceBranch := r.FormValue("sourceBranch")
675 patch := r.FormValue("patch")
676
677 if targetBranch == "" {
678 s.pages.Notice(w, "pull", "Target branch is required.")
679 return
680 }
681
682 // Determine PR type based on input parameters
683 isPushAllowed := f.RepoInfo(user).Roles.IsPushAllowed()
684 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == ""
685 isForkBased := fromFork != "" && sourceBranch != ""
686 isPatchBased := patch != "" && !isBranchBased && !isForkBased
687 isStacked := r.FormValue("isStacked") == "on"
688
689 if isPatchBased && !patchutil.IsFormatPatch(patch) {
690 if title == "" {
691 s.pages.Notice(w, "pull", "Title is required for git-diff patches.")
692 return
693 }
694 }
695
696 // Validate we have at least one valid PR creation method
697 if !isBranchBased && !isPatchBased && !isForkBased {
698 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.")
699 return
700 }
701
702 // Can't mix branch-based and patch-based approaches
703 if isBranchBased && patch != "" {
704 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.")
705 return
706 }
707
708 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
709 if err != nil {
710 log.Printf("failed to create unsigned client to %s: %v", f.Knot, err)
711 s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
712 return
713 }
714
715 caps, err := us.Capabilities()
716 if err != nil {
717 log.Println("error fetching knot caps", f.Knot, err)
718 s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
719 return
720 }
721
722 if !caps.PullRequests.FormatPatch {
723 s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.")
724 return
725 }
726
727 // Handle the PR creation based on the type
728 if isBranchBased {
729 if !caps.PullRequests.BranchSubmissions {
730 s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?")
731 return
732 }
733 s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch, isStacked)
734 } else if isForkBased {
735 if !caps.PullRequests.ForkSubmissions {
736 s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?")
737 return
738 }
739 s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch, isStacked)
740 } else if isPatchBased {
741 if !caps.PullRequests.PatchSubmissions {
742 s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.")
743 return
744 }
745 s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch, isStacked)
746 }
747 return
748 }
749}
750
751func (s *Pulls) handleBranchBasedPull(
752 w http.ResponseWriter,
753 r *http.Request,
754 f *reporesolver.ResolvedRepo,
755 user *oauth.User,
756 title,
757 body,
758 targetBranch,
759 sourceBranch string,
760 isStacked bool,
761) {
762 pullSource := &db.PullSource{
763 Branch: sourceBranch,
764 }
765 recordPullSource := &tangled.RepoPull_Source{
766 Branch: sourceBranch,
767 }
768
769 // Generate a patch using /compare
770 ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
771 if err != nil {
772 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
773 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
774 return
775 }
776
777 comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
778 if err != nil {
779 log.Println("failed to compare", err)
780 s.pages.Notice(w, "pull", err.Error())
781 return
782 }
783
784 sourceRev := comparison.Rev2
785 patch := comparison.Patch
786
787 if !patchutil.IsPatchValid(patch) {
788 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
789 return
790 }
791
792 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked)
793}
794
795func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) {
796 if !patchutil.IsPatchValid(patch) {
797 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
798 return
799 }
800
801 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil, isStacked)
802}
803
804func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) {
805 fork, err := db.GetForkByDid(s.db, user.Did, forkRepo)
806 if errors.Is(err, sql.ErrNoRows) {
807 s.pages.Notice(w, "pull", "No such fork.")
808 return
809 } else if err != nil {
810 log.Println("failed to fetch fork:", err)
811 s.pages.Notice(w, "pull", "Failed to fetch fork.")
812 return
813 }
814
815 secret, err := db.GetRegistrationKey(s.db, fork.Knot)
816 if err != nil {
817 log.Println("failed to fetch registration key:", err)
818 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
819 return
820 }
821
822 sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev)
823 if err != nil {
824 log.Println("failed to create signed client:", err)
825 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
826 return
827 }
828
829 us, err := knotclient.NewUnsignedClient(fork.Knot, s.config.Core.Dev)
830 if err != nil {
831 log.Println("failed to create unsigned client:", err)
832 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
833 return
834 }
835
836 resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch)
837 if err != nil {
838 log.Println("failed to create hidden ref:", err, resp.StatusCode)
839 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
840 return
841 }
842
843 switch resp.StatusCode {
844 case 404:
845 case 400:
846 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
847 return
848 }
849
850 hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch)
851 // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking
852 // the targetBranch on the target repository. This code is a bit confusing, but here's an example:
853 // hiddenRef: hidden/feature-1/main (on repo-fork)
854 // targetBranch: main (on repo-1)
855 // sourceBranch: feature-1 (on repo-fork)
856 comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch)
857 if err != nil {
858 log.Println("failed to compare across branches", err)
859 s.pages.Notice(w, "pull", err.Error())
860 return
861 }
862
863 sourceRev := comparison.Rev2
864 patch := comparison.Patch
865
866 if !patchutil.IsPatchValid(patch) {
867 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
868 return
869 }
870
871 forkAtUri, err := syntax.ParseATURI(fork.AtUri)
872 if err != nil {
873 log.Println("failed to parse fork AT URI", err)
874 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
875 return
876 }
877
878 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{
879 Branch: sourceBranch,
880 RepoAt: &forkAtUri,
881 }, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri}, isStacked)
882}
883
884func (s *Pulls) createPullRequest(
885 w http.ResponseWriter,
886 r *http.Request,
887 f *reporesolver.ResolvedRepo,
888 user *oauth.User,
889 title, body, targetBranch string,
890 patch string,
891 sourceRev string,
892 pullSource *db.PullSource,
893 recordPullSource *tangled.RepoPull_Source,
894 isStacked bool,
895) {
896 if isStacked {
897 // creates a series of PRs, each linking to the previous, identified by jj's change-id
898 s.createStackedPulLRequest(
899 w,
900 r,
901 f,
902 user,
903 targetBranch,
904 patch,
905 sourceRev,
906 pullSource,
907 )
908 return
909 }
910
911 client, err := s.oauth.AuthorizedClient(r)
912 if err != nil {
913 log.Println("failed to get authorized client", err)
914 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
915 return
916 }
917
918 tx, err := s.db.BeginTx(r.Context(), nil)
919 if err != nil {
920 log.Println("failed to start tx")
921 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
922 return
923 }
924 defer tx.Rollback()
925
926 // We've already checked earlier if it's diff-based and title is empty,
927 // so if it's still empty now, it's intentionally skipped owing to format-patch.
928 if title == "" {
929 formatPatches, err := patchutil.ExtractPatches(patch)
930 if err != nil {
931 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
932 return
933 }
934 if len(formatPatches) == 0 {
935 s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.")
936 return
937 }
938
939 title = formatPatches[0].Title
940 body = formatPatches[0].Body
941 }
942
943 rkey := appview.TID()
944 initialSubmission := db.PullSubmission{
945 Patch: patch,
946 SourceRev: sourceRev,
947 }
948 err = db.NewPull(tx, &db.Pull{
949 Title: title,
950 Body: body,
951 TargetBranch: targetBranch,
952 OwnerDid: user.Did,
953 RepoAt: f.RepoAt,
954 Rkey: rkey,
955 Submissions: []*db.PullSubmission{
956 &initialSubmission,
957 },
958 PullSource: pullSource,
959 })
960 if err != nil {
961 log.Println("failed to create pull request", err)
962 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
963 return
964 }
965 pullId, err := db.NextPullId(tx, f.RepoAt)
966 if err != nil {
967 log.Println("failed to get pull id", err)
968 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
969 return
970 }
971
972 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
973 Collection: tangled.RepoPullNSID,
974 Repo: user.Did,
975 Rkey: rkey,
976 Record: &lexutil.LexiconTypeDecoder{
977 Val: &tangled.RepoPull{
978 Title: title,
979 PullId: int64(pullId),
980 TargetRepo: string(f.RepoAt),
981 TargetBranch: targetBranch,
982 Patch: patch,
983 Source: recordPullSource,
984 },
985 },
986 })
987 if err != nil {
988 log.Println("failed to create pull request", err)
989 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
990 return
991 }
992
993 if err = tx.Commit(); err != nil {
994 log.Println("failed to create pull request", err)
995 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
996 return
997 }
998
999 if !s.config.Core.Dev {
1000 err = s.posthog.Enqueue(posthog.Capture{
1001 DistinctId: user.Did,
1002 Event: "new_pull",
1003 Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pullId},
1004 })
1005 if err != nil {
1006 log.Println("failed to enqueue posthog event:", err)
1007 }
1008 }
1009
1010 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
1011}
1012
1013func (s *Pulls) createStackedPulLRequest(
1014 w http.ResponseWriter,
1015 r *http.Request,
1016 f *reporesolver.ResolvedRepo,
1017 user *oauth.User,
1018 targetBranch string,
1019 patch string,
1020 sourceRev string,
1021 pullSource *db.PullSource,
1022) {
1023 // run some necessary checks for stacked-prs first
1024
1025 // must be branch or fork based
1026 if sourceRev == "" {
1027 log.Println("stacked PR from patch-based pull")
1028 s.pages.Notice(w, "pull", "Stacking is only supported on branch and fork based pull-requests.")
1029 return
1030 }
1031
1032 formatPatches, err := patchutil.ExtractPatches(patch)
1033 if err != nil {
1034 log.Println("failed to extract patches", err)
1035 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
1036 return
1037 }
1038
1039 // must have atleast 1 patch to begin with
1040 if len(formatPatches) == 0 {
1041 log.Println("empty patches")
1042 s.pages.Notice(w, "pull", "No patches found in the generated format-patch.")
1043 return
1044 }
1045
1046 // build a stack out of this patch
1047 stackId := uuid.New()
1048 stack, err := newStack(f, user, targetBranch, patch, pullSource, stackId.String())
1049 if err != nil {
1050 log.Println("failed to create stack", err)
1051 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err))
1052 return
1053 }
1054
1055 client, err := s.oauth.AuthorizedClient(r)
1056 if err != nil {
1057 log.Println("failed to get authorized client", err)
1058 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1059 return
1060 }
1061
1062 // apply all record creations at once
1063 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1064 for _, p := range stack {
1065 record := p.AsRecord()
1066 write := comatproto.RepoApplyWrites_Input_Writes_Elem{
1067 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
1068 Collection: tangled.RepoPullNSID,
1069 Rkey: &p.Rkey,
1070 Value: &lexutil.LexiconTypeDecoder{
1071 Val: &record,
1072 },
1073 },
1074 }
1075 writes = append(writes, &write)
1076 }
1077 _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{
1078 Repo: user.Did,
1079 Writes: writes,
1080 })
1081 if err != nil {
1082 log.Println("failed to create stacked pull request", err)
1083 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.")
1084 return
1085 }
1086
1087 // create all pulls at once
1088 tx, err := s.db.BeginTx(r.Context(), nil)
1089 if err != nil {
1090 log.Println("failed to start tx")
1091 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1092 return
1093 }
1094 defer tx.Rollback()
1095
1096 for _, p := range stack {
1097 err = db.NewPull(tx, p)
1098 if err != nil {
1099 log.Println("failed to create pull request", err)
1100 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1101 return
1102 }
1103 }
1104
1105 if err = tx.Commit(); err != nil {
1106 log.Println("failed to create pull request", err)
1107 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1108 return
1109 }
1110
1111 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", f.OwnerSlashRepo()))
1112}
1113
1114func (s *Pulls) ValidatePatch(w http.ResponseWriter, r *http.Request) {
1115 _, err := s.repoResolver.Resolve(r)
1116 if err != nil {
1117 log.Println("failed to get repo and knot", err)
1118 return
1119 }
1120
1121 patch := r.FormValue("patch")
1122 if patch == "" {
1123 s.pages.Notice(w, "patch-error", "Patch is required.")
1124 return
1125 }
1126
1127 if patch == "" || !patchutil.IsPatchValid(patch) {
1128 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.")
1129 return
1130 }
1131
1132 if patchutil.IsFormatPatch(patch) {
1133 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.")
1134 } else {
1135 s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.")
1136 }
1137}
1138
1139func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
1140 user := s.oauth.GetUser(r)
1141 f, err := s.repoResolver.Resolve(r)
1142 if err != nil {
1143 log.Println("failed to get repo and knot", err)
1144 return
1145 }
1146
1147 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
1148 RepoInfo: f.RepoInfo(user),
1149 })
1150}
1151
1152func (s *Pulls) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) {
1153 user := s.oauth.GetUser(r)
1154 f, err := s.repoResolver.Resolve(r)
1155 if err != nil {
1156 log.Println("failed to get repo and knot", err)
1157 return
1158 }
1159
1160 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1161 if err != nil {
1162 log.Printf("failed to create unsigned client for %s", f.Knot)
1163 s.pages.Error503(w)
1164 return
1165 }
1166
1167 result, err := us.Branches(f.OwnerDid(), f.RepoName)
1168 if err != nil {
1169 log.Println("failed to reach knotserver", err)
1170 return
1171 }
1172
1173 branches := result.Branches
1174 sort.Slice(branches, func(i int, j int) bool {
1175 return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When)
1176 })
1177
1178 withoutDefault := []types.Branch{}
1179 for _, b := range branches {
1180 if b.IsDefault {
1181 continue
1182 }
1183 withoutDefault = append(withoutDefault, b)
1184 }
1185
1186 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
1187 RepoInfo: f.RepoInfo(user),
1188 Branches: withoutDefault,
1189 })
1190}
1191
1192func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) {
1193 user := s.oauth.GetUser(r)
1194 f, err := s.repoResolver.Resolve(r)
1195 if err != nil {
1196 log.Println("failed to get repo and knot", err)
1197 return
1198 }
1199
1200 forks, err := db.GetForksByDid(s.db, user.Did)
1201 if err != nil {
1202 log.Println("failed to get forks", err)
1203 return
1204 }
1205
1206 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{
1207 RepoInfo: f.RepoInfo(user),
1208 Forks: forks,
1209 Selected: r.URL.Query().Get("fork"),
1210 })
1211}
1212
1213func (s *Pulls) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) {
1214 user := s.oauth.GetUser(r)
1215
1216 f, err := s.repoResolver.Resolve(r)
1217 if err != nil {
1218 log.Println("failed to get repo and knot", err)
1219 return
1220 }
1221
1222 forkVal := r.URL.Query().Get("fork")
1223
1224 // fork repo
1225 repo, err := db.GetRepo(s.db, user.Did, forkVal)
1226 if err != nil {
1227 log.Println("failed to get repo", user.Did, forkVal)
1228 return
1229 }
1230
1231 sourceBranchesClient, err := knotclient.NewUnsignedClient(repo.Knot, s.config.Core.Dev)
1232 if err != nil {
1233 log.Printf("failed to create unsigned client for %s", repo.Knot)
1234 s.pages.Error503(w)
1235 return
1236 }
1237
1238 sourceResult, err := sourceBranchesClient.Branches(user.Did, repo.Name)
1239 if err != nil {
1240 log.Println("failed to reach knotserver for source branches", err)
1241 return
1242 }
1243
1244 targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1245 if err != nil {
1246 log.Printf("failed to create unsigned client for target knot %s", f.Knot)
1247 s.pages.Error503(w)
1248 return
1249 }
1250
1251 targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName)
1252 if err != nil {
1253 log.Println("failed to reach knotserver for target branches", err)
1254 return
1255 }
1256
1257 sourceBranches := sourceResult.Branches
1258 sort.Slice(sourceBranches, func(i int, j int) bool {
1259 return sourceBranches[i].Commit.Committer.When.After(sourceBranches[j].Commit.Committer.When)
1260 })
1261
1262 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
1263 RepoInfo: f.RepoInfo(user),
1264 SourceBranches: sourceBranches,
1265 TargetBranches: targetResult.Branches,
1266 })
1267}
1268
1269func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) {
1270 user := s.oauth.GetUser(r)
1271 f, err := s.repoResolver.Resolve(r)
1272 if err != nil {
1273 log.Println("failed to get repo and knot", err)
1274 return
1275 }
1276
1277 pull, ok := r.Context().Value("pull").(*db.Pull)
1278 if !ok {
1279 log.Println("failed to get pull")
1280 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1281 return
1282 }
1283
1284 switch r.Method {
1285 case http.MethodGet:
1286 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
1287 RepoInfo: f.RepoInfo(user),
1288 Pull: pull,
1289 })
1290 return
1291 case http.MethodPost:
1292 if pull.IsPatchBased() {
1293 s.resubmitPatch(w, r)
1294 return
1295 } else if pull.IsBranchBased() {
1296 s.resubmitBranch(w, r)
1297 return
1298 } else if pull.IsForkBased() {
1299 s.resubmitFork(w, r)
1300 return
1301 }
1302 }
1303}
1304
1305func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) {
1306 user := s.oauth.GetUser(r)
1307
1308 pull, ok := r.Context().Value("pull").(*db.Pull)
1309 if !ok {
1310 log.Println("failed to get pull")
1311 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1312 return
1313 }
1314
1315 f, err := s.repoResolver.Resolve(r)
1316 if err != nil {
1317 log.Println("failed to get repo and knot", err)
1318 return
1319 }
1320
1321 if user.Did != pull.OwnerDid {
1322 log.Println("unauthorized user")
1323 w.WriteHeader(http.StatusUnauthorized)
1324 return
1325 }
1326
1327 patch := r.FormValue("patch")
1328
1329 s.resubmitPullHelper(w, r, f, user, pull, patch, "")
1330}
1331
1332func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) {
1333 user := s.oauth.GetUser(r)
1334
1335 pull, ok := r.Context().Value("pull").(*db.Pull)
1336 if !ok {
1337 log.Println("failed to get pull")
1338 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1339 return
1340 }
1341
1342 f, err := s.repoResolver.Resolve(r)
1343 if err != nil {
1344 log.Println("failed to get repo and knot", err)
1345 return
1346 }
1347
1348 if user.Did != pull.OwnerDid {
1349 log.Println("unauthorized user")
1350 w.WriteHeader(http.StatusUnauthorized)
1351 return
1352 }
1353
1354 if !f.RepoInfo(user).Roles.IsPushAllowed() {
1355 log.Println("unauthorized user")
1356 w.WriteHeader(http.StatusUnauthorized)
1357 return
1358 }
1359
1360 ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1361 if err != nil {
1362 log.Printf("failed to create client for %s: %s", f.Knot, err)
1363 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1364 return
1365 }
1366
1367 comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch)
1368 if err != nil {
1369 log.Printf("compare request failed: %s", err)
1370 s.pages.Notice(w, "resubmit-error", err.Error())
1371 return
1372 }
1373
1374 sourceRev := comparison.Rev2
1375 patch := comparison.Patch
1376
1377 s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev)
1378}
1379
1380func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) {
1381 user := s.oauth.GetUser(r)
1382
1383 pull, ok := r.Context().Value("pull").(*db.Pull)
1384 if !ok {
1385 log.Println("failed to get pull")
1386 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1387 return
1388 }
1389
1390 f, err := s.repoResolver.Resolve(r)
1391 if err != nil {
1392 log.Println("failed to get repo and knot", err)
1393 return
1394 }
1395
1396 if user.Did != pull.OwnerDid {
1397 log.Println("unauthorized user")
1398 w.WriteHeader(http.StatusUnauthorized)
1399 return
1400 }
1401
1402 forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
1403 if err != nil {
1404 log.Println("failed to get source repo", err)
1405 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1406 return
1407 }
1408
1409 // extract patch by performing compare
1410 ksClient, err := knotclient.NewUnsignedClient(forkRepo.Knot, s.config.Core.Dev)
1411 if err != nil {
1412 log.Printf("failed to create client for %s: %s", forkRepo.Knot, err)
1413 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1414 return
1415 }
1416
1417 secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot)
1418 if err != nil {
1419 log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err)
1420 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1421 return
1422 }
1423
1424 // update the hidden tracking branch to latest
1425 signedClient, err := knotclient.NewSignedClient(forkRepo.Knot, secret, s.config.Core.Dev)
1426 if err != nil {
1427 log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err)
1428 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1429 return
1430 }
1431
1432 resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch)
1433 if err != nil || resp.StatusCode != http.StatusNoContent {
1434 log.Printf("failed to update tracking branch: %s", err)
1435 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1436 return
1437 }
1438
1439 hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch)
1440 comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch)
1441 if err != nil {
1442 log.Printf("failed to compare branches: %s", err)
1443 s.pages.Notice(w, "resubmit-error", err.Error())
1444 return
1445 }
1446
1447 sourceRev := comparison.Rev2
1448 patch := comparison.Patch
1449
1450 s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev)
1451}
1452
1453// validate a resubmission against a pull request
1454func validateResubmittedPatch(pull *db.Pull, patch string) error {
1455 if patch == "" {
1456 return fmt.Errorf("Patch is empty.")
1457 }
1458
1459 if patch == pull.LatestPatch() {
1460 return fmt.Errorf("Patch is identical to previous submission.")
1461 }
1462
1463 if !patchutil.IsPatchValid(patch) {
1464 return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
1465 }
1466
1467 return nil
1468}
1469
1470func (s *Pulls) resubmitPullHelper(
1471 w http.ResponseWriter,
1472 r *http.Request,
1473 f *reporesolver.ResolvedRepo,
1474 user *oauth.User,
1475 pull *db.Pull,
1476 patch string,
1477 sourceRev string,
1478) {
1479 if pull.IsStacked() {
1480 log.Println("resubmitting stacked PR")
1481 s.resubmitStackedPullHelper(w, r, f, user, pull, patch, pull.StackId)
1482 return
1483 }
1484
1485 if err := validateResubmittedPatch(pull, patch); err != nil {
1486 s.pages.Notice(w, "resubmit-error", err.Error())
1487 return
1488 }
1489
1490 // validate sourceRev if branch/fork based
1491 if pull.IsBranchBased() || pull.IsForkBased() {
1492 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1493 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1494 return
1495 }
1496 }
1497
1498 tx, err := s.db.BeginTx(r.Context(), nil)
1499 if err != nil {
1500 log.Println("failed to start tx")
1501 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1502 return
1503 }
1504 defer tx.Rollback()
1505
1506 err = db.ResubmitPull(tx, pull, patch, sourceRev)
1507 if err != nil {
1508 log.Println("failed to create pull request", err)
1509 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1510 return
1511 }
1512 client, err := s.oauth.AuthorizedClient(r)
1513 if err != nil {
1514 log.Println("failed to authorize client")
1515 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1516 return
1517 }
1518
1519 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1520 if err != nil {
1521 // failed to get record
1522 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1523 return
1524 }
1525
1526 var recordPullSource *tangled.RepoPull_Source
1527 if pull.IsBranchBased() {
1528 recordPullSource = &tangled.RepoPull_Source{
1529 Branch: pull.PullSource.Branch,
1530 }
1531 }
1532 if pull.IsForkBased() {
1533 repoAt := pull.PullSource.RepoAt.String()
1534 recordPullSource = &tangled.RepoPull_Source{
1535 Branch: pull.PullSource.Branch,
1536 Repo: &repoAt,
1537 }
1538 }
1539
1540 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1541 Collection: tangled.RepoPullNSID,
1542 Repo: user.Did,
1543 Rkey: pull.Rkey,
1544 SwapRecord: ex.Cid,
1545 Record: &lexutil.LexiconTypeDecoder{
1546 Val: &tangled.RepoPull{
1547 Title: pull.Title,
1548 PullId: int64(pull.PullId),
1549 TargetRepo: string(f.RepoAt),
1550 TargetBranch: pull.TargetBranch,
1551 Patch: patch, // new patch
1552 Source: recordPullSource,
1553 },
1554 },
1555 })
1556 if err != nil {
1557 log.Println("failed to update record", err)
1558 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1559 return
1560 }
1561
1562 if err = tx.Commit(); err != nil {
1563 log.Println("failed to commit transaction", err)
1564 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1565 return
1566 }
1567
1568 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1569 return
1570}
1571
1572func (s *Pulls) resubmitStackedPullHelper(
1573 w http.ResponseWriter,
1574 r *http.Request,
1575 f *reporesolver.ResolvedRepo,
1576 user *oauth.User,
1577 pull *db.Pull,
1578 patch string,
1579 stackId string,
1580) {
1581 targetBranch := pull.TargetBranch
1582
1583 origStack, _ := r.Context().Value("stack").(db.Stack)
1584 newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId)
1585 if err != nil {
1586 log.Println("failed to create resubmitted stack", err)
1587 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1588 return
1589 }
1590
1591 // find the diff between the stacks, first, map them by changeId
1592 origById := make(map[string]*db.Pull)
1593 newById := make(map[string]*db.Pull)
1594 for _, p := range origStack {
1595 origById[p.ChangeId] = p
1596 }
1597 for _, p := range newStack {
1598 newById[p.ChangeId] = p
1599 }
1600
1601 // commits that got deleted: corresponding pull is closed
1602 // commits that got added: new pull is created
1603 // commits that got updated: corresponding pull is resubmitted & new round begins
1604 //
1605 // for commits that were unchanged: no changes, parent-change-id is updated as necessary
1606 additions := make(map[string]*db.Pull)
1607 deletions := make(map[string]*db.Pull)
1608 unchanged := make(map[string]struct{})
1609 updated := make(map[string]struct{})
1610
1611 // pulls in orignal stack but not in new one
1612 for _, op := range origStack {
1613 if _, ok := newById[op.ChangeId]; !ok {
1614 deletions[op.ChangeId] = op
1615 }
1616 }
1617
1618 // pulls in new stack but not in original one
1619 for _, np := range newStack {
1620 if _, ok := origById[np.ChangeId]; !ok {
1621 additions[np.ChangeId] = np
1622 }
1623 }
1624
1625 // NOTE: this loop can be written in any of above blocks,
1626 // but is written separately in the interest of simpler code
1627 for _, np := range newStack {
1628 if op, ok := origById[np.ChangeId]; ok {
1629 // pull exists in both stacks
1630 // TODO: can we avoid reparse?
1631 origFiles, origHeaderStr, _ := gitdiff.Parse(strings.NewReader(op.LatestPatch()))
1632 newFiles, newHeaderStr, _ := gitdiff.Parse(strings.NewReader(np.LatestPatch()))
1633
1634 origHeader, _ := gitdiff.ParsePatchHeader(origHeaderStr)
1635 newHeader, _ := gitdiff.ParsePatchHeader(newHeaderStr)
1636
1637 patchutil.SortPatch(newFiles)
1638 patchutil.SortPatch(origFiles)
1639
1640 // text content of patch may be identical, but a jj rebase might have forwarded it
1641 //
1642 // we still need to update the hash in submission.Patch and submission.SourceRev
1643 if patchutil.Equal(newFiles, origFiles) &&
1644 origHeader.Title == newHeader.Title &&
1645 origHeader.Body == newHeader.Body {
1646 unchanged[op.ChangeId] = struct{}{}
1647 } else {
1648 updated[op.ChangeId] = struct{}{}
1649 }
1650 }
1651 }
1652
1653 tx, err := s.db.Begin()
1654 if err != nil {
1655 log.Println("failed to start transaction", err)
1656 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1657 return
1658 }
1659 defer tx.Rollback()
1660
1661 // pds updates to make
1662 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1663
1664 // deleted pulls are marked as deleted in the DB
1665 for _, p := range deletions {
1666 err := db.DeletePull(tx, p.RepoAt, p.PullId)
1667 if err != nil {
1668 log.Println("failed to delete pull", err, p.PullId)
1669 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1670 return
1671 }
1672 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1673 RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{
1674 Collection: tangled.RepoPullNSID,
1675 Rkey: p.Rkey,
1676 },
1677 })
1678 }
1679
1680 // new pulls are created
1681 for _, p := range additions {
1682 err := db.NewPull(tx, p)
1683 if err != nil {
1684 log.Println("failed to create pull", err, p.PullId)
1685 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1686 return
1687 }
1688
1689 record := p.AsRecord()
1690 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1691 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
1692 Collection: tangled.RepoPullNSID,
1693 Rkey: &p.Rkey,
1694 Value: &lexutil.LexiconTypeDecoder{
1695 Val: &record,
1696 },
1697 },
1698 })
1699 }
1700
1701 // updated pulls are, well, updated; to start a new round
1702 for id := range updated {
1703 op, _ := origById[id]
1704 np, _ := newById[id]
1705
1706 submission := np.Submissions[np.LastRoundNumber()]
1707
1708 // resubmit the old pull
1709 err := db.ResubmitPull(tx, op, submission.Patch, submission.SourceRev)
1710
1711 if err != nil {
1712 log.Println("failed to update pull", err, op.PullId)
1713 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1714 return
1715 }
1716
1717 record := op.AsRecord()
1718 record.Patch = submission.Patch
1719
1720 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1721 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
1722 Collection: tangled.RepoPullNSID,
1723 Rkey: op.Rkey,
1724 Value: &lexutil.LexiconTypeDecoder{
1725 Val: &record,
1726 },
1727 },
1728 })
1729 }
1730
1731 // unchanged pulls are edited without starting a new round
1732 //
1733 // update source-revs & patches without advancing rounds
1734 for changeId := range unchanged {
1735 op, _ := origById[changeId]
1736 np, _ := newById[changeId]
1737
1738 origSubmission := op.Submissions[op.LastRoundNumber()]
1739 newSubmission := np.Submissions[np.LastRoundNumber()]
1740
1741 log.Println("moving unchanged change id : ", changeId)
1742
1743 err := db.UpdatePull(
1744 tx,
1745 newSubmission.Patch,
1746 newSubmission.SourceRev,
1747 db.FilterEq("id", origSubmission.ID),
1748 )
1749
1750 if err != nil {
1751 log.Println("failed to update pull", err, op.PullId)
1752 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1753 return
1754 }
1755
1756 record := op.AsRecord()
1757 record.Patch = newSubmission.Patch
1758
1759 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1760 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
1761 Collection: tangled.RepoPullNSID,
1762 Rkey: op.Rkey,
1763 Value: &lexutil.LexiconTypeDecoder{
1764 Val: &record,
1765 },
1766 },
1767 })
1768 }
1769
1770 // update parent-change-id relations for the entire stack
1771 for _, p := range newStack {
1772 err := db.SetPullParentChangeId(
1773 tx,
1774 p.ParentChangeId,
1775 // these should be enough filters to be unique per-stack
1776 db.FilterEq("repo_at", p.RepoAt.String()),
1777 db.FilterEq("owner_did", p.OwnerDid),
1778 db.FilterEq("change_id", p.ChangeId),
1779 )
1780
1781 if err != nil {
1782 log.Println("failed to update pull", err, p.PullId)
1783 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1784 return
1785 }
1786 }
1787
1788 err = tx.Commit()
1789 if err != nil {
1790 log.Println("failed to resubmit pull", err)
1791 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1792 return
1793 }
1794
1795 client, err := s.oauth.AuthorizedClient(r)
1796 if err != nil {
1797 log.Println("failed to authorize client")
1798 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1799 return
1800 }
1801
1802 _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{
1803 Repo: user.Did,
1804 Writes: writes,
1805 })
1806 if err != nil {
1807 log.Println("failed to create stacked pull request", err)
1808 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.")
1809 return
1810 }
1811
1812 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1813 return
1814}
1815
1816func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
1817 f, err := s.repoResolver.Resolve(r)
1818 if err != nil {
1819 log.Println("failed to resolve repo:", err)
1820 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1821 return
1822 }
1823
1824 pull, ok := r.Context().Value("pull").(*db.Pull)
1825 if !ok {
1826 log.Println("failed to get pull")
1827 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
1828 return
1829 }
1830
1831 var pullsToMerge db.Stack
1832 pullsToMerge = append(pullsToMerge, pull)
1833 if pull.IsStacked() {
1834 stack, ok := r.Context().Value("stack").(db.Stack)
1835 if !ok {
1836 log.Println("failed to get stack")
1837 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
1838 return
1839 }
1840
1841 // combine patches of substack
1842 subStack := stack.StrictlyBelow(pull)
1843 // collect the portion of the stack that is mergeable
1844 mergeable := subStack.Mergeable()
1845 // add to total patch
1846 pullsToMerge = append(pullsToMerge, mergeable...)
1847 }
1848
1849 patch := pullsToMerge.CombinedPatch()
1850
1851 secret, err := db.GetRegistrationKey(s.db, f.Knot)
1852 if err != nil {
1853 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
1854 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1855 return
1856 }
1857
1858 ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid)
1859 if err != nil {
1860 log.Printf("resolving identity: %s", err)
1861 w.WriteHeader(http.StatusNotFound)
1862 return
1863 }
1864
1865 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
1866 if err != nil {
1867 log.Printf("failed to get primary email: %s", err)
1868 }
1869
1870 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
1871 if err != nil {
1872 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
1873 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1874 return
1875 }
1876
1877 // Merge the pull request
1878 resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
1879 if err != nil {
1880 log.Printf("failed to merge pull request: %s", err)
1881 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1882 return
1883 }
1884
1885 if resp.StatusCode != http.StatusOK {
1886 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
1887 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1888 return
1889 }
1890
1891 tx, err := s.db.Begin()
1892 if err != nil {
1893 log.Println("failed to start transcation", err)
1894 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1895 return
1896 }
1897 defer tx.Rollback()
1898
1899 for _, p := range pullsToMerge {
1900 err := db.MergePull(tx, f.RepoAt, p.PullId)
1901 if err != nil {
1902 log.Printf("failed to update pull request status in database: %s", err)
1903 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1904 return
1905 }
1906 }
1907
1908 err = tx.Commit()
1909 if err != nil {
1910 // TODO: this is unsound, we should also revert the merge from the knotserver here
1911 log.Printf("failed to update pull request status in database: %s", err)
1912 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1913 return
1914 }
1915
1916 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
1917}
1918
1919func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) {
1920 user := s.oauth.GetUser(r)
1921
1922 f, err := s.repoResolver.Resolve(r)
1923 if err != nil {
1924 log.Println("malformed middleware")
1925 return
1926 }
1927
1928 pull, ok := r.Context().Value("pull").(*db.Pull)
1929 if !ok {
1930 log.Println("failed to get pull")
1931 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1932 return
1933 }
1934
1935 // auth filter: only owner or collaborators can close
1936 roles := f.RolesInRepo(user)
1937 isCollaborator := roles.IsCollaborator()
1938 isPullAuthor := user.Did == pull.OwnerDid
1939 isCloseAllowed := isCollaborator || isPullAuthor
1940 if !isCloseAllowed {
1941 log.Println("failed to close pull")
1942 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
1943 return
1944 }
1945
1946 // Start a transaction
1947 tx, err := s.db.BeginTx(r.Context(), nil)
1948 if err != nil {
1949 log.Println("failed to start transaction", err)
1950 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1951 return
1952 }
1953 defer tx.Rollback()
1954
1955 var pullsToClose []*db.Pull
1956 pullsToClose = append(pullsToClose, pull)
1957
1958 // if this PR is stacked, then we want to close all PRs below this one on the stack
1959 if pull.IsStacked() {
1960 stack := r.Context().Value("stack").(db.Stack)
1961 subStack := stack.StrictlyBelow(pull)
1962 pullsToClose = append(pullsToClose, subStack...)
1963 }
1964
1965 for _, p := range pullsToClose {
1966 // Close the pull in the database
1967 err = db.ClosePull(tx, f.RepoAt, p.PullId)
1968 if err != nil {
1969 log.Println("failed to close pull", err)
1970 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1971 return
1972 }
1973 }
1974
1975 // Commit the transaction
1976 if err = tx.Commit(); err != nil {
1977 log.Println("failed to commit transaction", err)
1978 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1979 return
1980 }
1981
1982 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1983 return
1984}
1985
1986func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) {
1987 user := s.oauth.GetUser(r)
1988
1989 f, err := s.repoResolver.Resolve(r)
1990 if err != nil {
1991 log.Println("failed to resolve repo", err)
1992 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1993 return
1994 }
1995
1996 pull, ok := r.Context().Value("pull").(*db.Pull)
1997 if !ok {
1998 log.Println("failed to get pull")
1999 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
2000 return
2001 }
2002
2003 // auth filter: only owner or collaborators can close
2004 roles := f.RolesInRepo(user)
2005 isCollaborator := roles.IsCollaborator()
2006 isPullAuthor := user.Did == pull.OwnerDid
2007 isCloseAllowed := isCollaborator || isPullAuthor
2008 if !isCloseAllowed {
2009 log.Println("failed to close pull")
2010 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
2011 return
2012 }
2013
2014 // Start a transaction
2015 tx, err := s.db.BeginTx(r.Context(), nil)
2016 if err != nil {
2017 log.Println("failed to start transaction", err)
2018 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2019 return
2020 }
2021 defer tx.Rollback()
2022
2023 var pullsToReopen []*db.Pull
2024 pullsToReopen = append(pullsToReopen, pull)
2025
2026 // if this PR is stacked, then we want to reopen all PRs above this one on the stack
2027 if pull.IsStacked() {
2028 stack := r.Context().Value("stack").(db.Stack)
2029 subStack := stack.StrictlyAbove(pull)
2030 pullsToReopen = append(pullsToReopen, subStack...)
2031 }
2032
2033 for _, p := range pullsToReopen {
2034 // Close the pull in the database
2035 err = db.ReopenPull(tx, f.RepoAt, p.PullId)
2036 if err != nil {
2037 log.Println("failed to close pull", err)
2038 s.pages.Notice(w, "pull-close", "Failed to close pull.")
2039 return
2040 }
2041 }
2042
2043 // Commit the transaction
2044 if err = tx.Commit(); err != nil {
2045 log.Println("failed to commit transaction", err)
2046 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2047 return
2048 }
2049
2050 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2051 return
2052}
2053
2054func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) {
2055 formatPatches, err := patchutil.ExtractPatches(patch)
2056 if err != nil {
2057 return nil, fmt.Errorf("Failed to extract patches: %v", err)
2058 }
2059
2060 // must have atleast 1 patch to begin with
2061 if len(formatPatches) == 0 {
2062 return nil, fmt.Errorf("No patches found in the generated format-patch.")
2063 }
2064
2065 // the stack is identified by a UUID
2066 var stack db.Stack
2067 parentChangeId := ""
2068 for _, fp := range formatPatches {
2069 // all patches must have a jj change-id
2070 changeId, err := fp.ChangeId()
2071 if err != nil {
2072 return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.")
2073 }
2074
2075 title := fp.Title
2076 body := fp.Body
2077 rkey := appview.TID()
2078
2079 initialSubmission := db.PullSubmission{
2080 Patch: fp.Raw,
2081 SourceRev: fp.SHA,
2082 }
2083 pull := db.Pull{
2084 Title: title,
2085 Body: body,
2086 TargetBranch: targetBranch,
2087 OwnerDid: user.Did,
2088 RepoAt: f.RepoAt,
2089 Rkey: rkey,
2090 Submissions: []*db.PullSubmission{
2091 &initialSubmission,
2092 },
2093 PullSource: pullSource,
2094 Created: time.Now(),
2095
2096 StackId: stackId,
2097 ChangeId: changeId,
2098 ParentChangeId: parentChangeId,
2099 }
2100
2101 stack = append(stack, &pull)
2102
2103 parentChangeId = changeId
2104 }
2105
2106 return stack, nil
2107}