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