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