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