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