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
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 Target: &tangled.RepoPull_Target{
1039 Repo: string(f.RepoAt()),
1040 Branch: targetBranch,
1041 },
1042 Patch: patch,
1043 Source: recordPullSource,
1044 },
1045 },
1046 })
1047 if err != nil {
1048 log.Println("failed to create pull request", err)
1049 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1050 return
1051 }
1052
1053 if err = tx.Commit(); err != nil {
1054 log.Println("failed to create pull request", err)
1055 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1056 return
1057 }
1058
1059 s.notifier.NewPull(r.Context(), pull)
1060
1061 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
1062}
1063
1064func (s *Pulls) createStackedPullRequest(
1065 w http.ResponseWriter,
1066 r *http.Request,
1067 f *reporesolver.ResolvedRepo,
1068 user *oauth.User,
1069 targetBranch string,
1070 patch string,
1071 sourceRev string,
1072 pullSource *db.PullSource,
1073) {
1074 // run some necessary checks for stacked-prs first
1075
1076 // must be branch or fork based
1077 if sourceRev == "" {
1078 log.Println("stacked PR from patch-based pull")
1079 s.pages.Notice(w, "pull", "Stacking is only supported on branch and fork based pull-requests.")
1080 return
1081 }
1082
1083 formatPatches, err := patchutil.ExtractPatches(patch)
1084 if err != nil {
1085 log.Println("failed to extract patches", err)
1086 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
1087 return
1088 }
1089
1090 // must have atleast 1 patch to begin with
1091 if len(formatPatches) == 0 {
1092 log.Println("empty patches")
1093 s.pages.Notice(w, "pull", "No patches found in the generated format-patch.")
1094 return
1095 }
1096
1097 // build a stack out of this patch
1098 stackId := uuid.New()
1099 stack, err := newStack(f, user, targetBranch, patch, pullSource, stackId.String())
1100 if err != nil {
1101 log.Println("failed to create stack", err)
1102 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err))
1103 return
1104 }
1105
1106 client, err := s.oauth.AuthorizedClient(r)
1107 if err != nil {
1108 log.Println("failed to get authorized client", err)
1109 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1110 return
1111 }
1112
1113 // apply all record creations at once
1114 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1115 for _, p := range stack {
1116 record := p.AsRecord()
1117 write := comatproto.RepoApplyWrites_Input_Writes_Elem{
1118 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
1119 Collection: tangled.RepoPullNSID,
1120 Rkey: &p.Rkey,
1121 Value: &lexutil.LexiconTypeDecoder{
1122 Val: &record,
1123 },
1124 },
1125 }
1126 writes = append(writes, &write)
1127 }
1128 _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{
1129 Repo: user.Did,
1130 Writes: writes,
1131 })
1132 if err != nil {
1133 log.Println("failed to create stacked pull request", err)
1134 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.")
1135 return
1136 }
1137
1138 // create all pulls at once
1139 tx, err := s.db.BeginTx(r.Context(), nil)
1140 if err != nil {
1141 log.Println("failed to start tx")
1142 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1143 return
1144 }
1145 defer tx.Rollback()
1146
1147 for _, p := range stack {
1148 err = db.NewPull(tx, p)
1149 if err != nil {
1150 log.Println("failed to create pull request", err)
1151 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1152 return
1153 }
1154 }
1155
1156 if err = tx.Commit(); err != nil {
1157 log.Println("failed to create pull request", err)
1158 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1159 return
1160 }
1161
1162 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", f.OwnerSlashRepo()))
1163}
1164
1165func (s *Pulls) ValidatePatch(w http.ResponseWriter, r *http.Request) {
1166 _, err := s.repoResolver.Resolve(r)
1167 if err != nil {
1168 log.Println("failed to get repo and knot", err)
1169 return
1170 }
1171
1172 patch := r.FormValue("patch")
1173 if patch == "" {
1174 s.pages.Notice(w, "patch-error", "Patch is required.")
1175 return
1176 }
1177
1178 if patch == "" || !patchutil.IsPatchValid(patch) {
1179 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.")
1180 return
1181 }
1182
1183 if patchutil.IsFormatPatch(patch) {
1184 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.")
1185 } else {
1186 s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.")
1187 }
1188}
1189
1190func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
1191 user := s.oauth.GetUser(r)
1192 f, err := s.repoResolver.Resolve(r)
1193 if err != nil {
1194 log.Println("failed to get repo and knot", err)
1195 return
1196 }
1197
1198 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
1199 RepoInfo: f.RepoInfo(user),
1200 })
1201}
1202
1203func (s *Pulls) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) {
1204 user := s.oauth.GetUser(r)
1205 f, err := s.repoResolver.Resolve(r)
1206 if err != nil {
1207 log.Println("failed to get repo and knot", err)
1208 return
1209 }
1210
1211 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1212 if err != nil {
1213 log.Printf("failed to create unsigned client for %s", f.Knot)
1214 s.pages.Error503(w)
1215 return
1216 }
1217
1218 result, err := us.Branches(f.OwnerDid(), f.Name)
1219 if err != nil {
1220 log.Println("failed to reach knotserver", err)
1221 return
1222 }
1223
1224 branches := result.Branches
1225 sort.Slice(branches, func(i int, j int) bool {
1226 return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When)
1227 })
1228
1229 withoutDefault := []types.Branch{}
1230 for _, b := range branches {
1231 if b.IsDefault {
1232 continue
1233 }
1234 withoutDefault = append(withoutDefault, b)
1235 }
1236
1237 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
1238 RepoInfo: f.RepoInfo(user),
1239 Branches: withoutDefault,
1240 })
1241}
1242
1243func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) {
1244 user := s.oauth.GetUser(r)
1245 f, err := s.repoResolver.Resolve(r)
1246 if err != nil {
1247 log.Println("failed to get repo and knot", err)
1248 return
1249 }
1250
1251 forks, err := db.GetForksByDid(s.db, user.Did)
1252 if err != nil {
1253 log.Println("failed to get forks", err)
1254 return
1255 }
1256
1257 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{
1258 RepoInfo: f.RepoInfo(user),
1259 Forks: forks,
1260 Selected: r.URL.Query().Get("fork"),
1261 })
1262}
1263
1264func (s *Pulls) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) {
1265 user := s.oauth.GetUser(r)
1266
1267 f, err := s.repoResolver.Resolve(r)
1268 if err != nil {
1269 log.Println("failed to get repo and knot", err)
1270 return
1271 }
1272
1273 forkVal := r.URL.Query().Get("fork")
1274
1275 // fork repo
1276 repo, err := db.GetRepo(s.db, user.Did, forkVal)
1277 if err != nil {
1278 log.Println("failed to get repo", user.Did, forkVal)
1279 return
1280 }
1281
1282 sourceBranchesClient, err := knotclient.NewUnsignedClient(repo.Knot, s.config.Core.Dev)
1283 if err != nil {
1284 log.Printf("failed to create unsigned client for %s", repo.Knot)
1285 s.pages.Error503(w)
1286 return
1287 }
1288
1289 sourceResult, err := sourceBranchesClient.Branches(user.Did, repo.Name)
1290 if err != nil {
1291 log.Println("failed to reach knotserver for source branches", err)
1292 return
1293 }
1294
1295 targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1296 if err != nil {
1297 log.Printf("failed to create unsigned client for target knot %s", f.Knot)
1298 s.pages.Error503(w)
1299 return
1300 }
1301
1302 targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.Name)
1303 if err != nil {
1304 log.Println("failed to reach knotserver for target branches", err)
1305 return
1306 }
1307
1308 sourceBranches := sourceResult.Branches
1309 sort.Slice(sourceBranches, func(i int, j int) bool {
1310 return sourceBranches[i].Commit.Committer.When.After(sourceBranches[j].Commit.Committer.When)
1311 })
1312
1313 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
1314 RepoInfo: f.RepoInfo(user),
1315 SourceBranches: sourceBranches,
1316 TargetBranches: targetResult.Branches,
1317 })
1318}
1319
1320func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) {
1321 user := s.oauth.GetUser(r)
1322 f, err := s.repoResolver.Resolve(r)
1323 if err != nil {
1324 log.Println("failed to get repo and knot", err)
1325 return
1326 }
1327
1328 pull, ok := r.Context().Value("pull").(*db.Pull)
1329 if !ok {
1330 log.Println("failed to get pull")
1331 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1332 return
1333 }
1334
1335 switch r.Method {
1336 case http.MethodGet:
1337 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
1338 RepoInfo: f.RepoInfo(user),
1339 Pull: pull,
1340 })
1341 return
1342 case http.MethodPost:
1343 if pull.IsPatchBased() {
1344 s.resubmitPatch(w, r)
1345 return
1346 } else if pull.IsBranchBased() {
1347 s.resubmitBranch(w, r)
1348 return
1349 } else if pull.IsForkBased() {
1350 s.resubmitFork(w, r)
1351 return
1352 }
1353 }
1354}
1355
1356func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) {
1357 user := s.oauth.GetUser(r)
1358
1359 pull, ok := r.Context().Value("pull").(*db.Pull)
1360 if !ok {
1361 log.Println("failed to get pull")
1362 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1363 return
1364 }
1365
1366 f, err := s.repoResolver.Resolve(r)
1367 if err != nil {
1368 log.Println("failed to get repo and knot", err)
1369 return
1370 }
1371
1372 if user.Did != pull.OwnerDid {
1373 log.Println("unauthorized user")
1374 w.WriteHeader(http.StatusUnauthorized)
1375 return
1376 }
1377
1378 patch := r.FormValue("patch")
1379
1380 s.resubmitPullHelper(w, r, f, user, pull, patch, "")
1381}
1382
1383func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) {
1384 user := s.oauth.GetUser(r)
1385
1386 pull, ok := r.Context().Value("pull").(*db.Pull)
1387 if !ok {
1388 log.Println("failed to get pull")
1389 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1390 return
1391 }
1392
1393 f, err := s.repoResolver.Resolve(r)
1394 if err != nil {
1395 log.Println("failed to get repo and knot", err)
1396 return
1397 }
1398
1399 if user.Did != pull.OwnerDid {
1400 log.Println("unauthorized user")
1401 w.WriteHeader(http.StatusUnauthorized)
1402 return
1403 }
1404
1405 if !f.RepoInfo(user).Roles.IsPushAllowed() {
1406 log.Println("unauthorized user")
1407 w.WriteHeader(http.StatusUnauthorized)
1408 return
1409 }
1410
1411 ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1412 if err != nil {
1413 log.Printf("failed to create client for %s: %s", f.Knot, err)
1414 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1415 return
1416 }
1417
1418 comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, pull.TargetBranch, pull.PullSource.Branch)
1419 if err != nil {
1420 log.Printf("compare request failed: %s", err)
1421 s.pages.Notice(w, "resubmit-error", err.Error())
1422 return
1423 }
1424
1425 sourceRev := comparison.Rev2
1426 patch := comparison.Patch
1427
1428 s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev)
1429}
1430
1431func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) {
1432 user := s.oauth.GetUser(r)
1433
1434 pull, ok := r.Context().Value("pull").(*db.Pull)
1435 if !ok {
1436 log.Println("failed to get pull")
1437 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1438 return
1439 }
1440
1441 f, err := s.repoResolver.Resolve(r)
1442 if err != nil {
1443 log.Println("failed to get repo and knot", err)
1444 return
1445 }
1446
1447 if user.Did != pull.OwnerDid {
1448 log.Println("unauthorized user")
1449 w.WriteHeader(http.StatusUnauthorized)
1450 return
1451 }
1452
1453 forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
1454 if err != nil {
1455 log.Println("failed to get source repo", err)
1456 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1457 return
1458 }
1459
1460 // extract patch by performing compare
1461 ksClient, err := knotclient.NewUnsignedClient(forkRepo.Knot, s.config.Core.Dev)
1462 if err != nil {
1463 log.Printf("failed to create client for %s: %s", forkRepo.Knot, err)
1464 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1465 return
1466 }
1467
1468 // update the hidden tracking branch to latest
1469 client, err := s.oauth.ServiceClient(
1470 r,
1471 oauth.WithService(forkRepo.Knot),
1472 oauth.WithLxm(tangled.RepoHiddenRefNSID),
1473 oauth.WithDev(s.config.Core.Dev),
1474 )
1475 if err != nil {
1476 log.Printf("failed to connect to knot server: %v", err)
1477 return
1478 }
1479
1480 resp, err := tangled.RepoHiddenRef(
1481 r.Context(),
1482 client,
1483 &tangled.RepoHiddenRef_Input{
1484 ForkRef: pull.PullSource.Branch,
1485 RemoteRef: pull.TargetBranch,
1486 Repo: forkRepo.RepoAt().String(),
1487 },
1488 )
1489 if err := xrpcclient.HandleXrpcErr(err); err != nil {
1490 s.pages.Notice(w, "resubmit-error", err.Error())
1491 return
1492 }
1493 if !resp.Success {
1494 log.Println("Failed to update tracking ref.", "err", resp.Error)
1495 s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.")
1496 return
1497 }
1498
1499 hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch)
1500 comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch)
1501 if err != nil {
1502 log.Printf("failed to compare branches: %s", err)
1503 s.pages.Notice(w, "resubmit-error", err.Error())
1504 return
1505 }
1506
1507 sourceRev := comparison.Rev2
1508 patch := comparison.Patch
1509
1510 s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev)
1511}
1512
1513// validate a resubmission against a pull request
1514func validateResubmittedPatch(pull *db.Pull, patch string) error {
1515 if patch == "" {
1516 return fmt.Errorf("Patch is empty.")
1517 }
1518
1519 if patch == pull.LatestPatch() {
1520 return fmt.Errorf("Patch is identical to previous submission.")
1521 }
1522
1523 if !patchutil.IsPatchValid(patch) {
1524 return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
1525 }
1526
1527 return nil
1528}
1529
1530func (s *Pulls) resubmitPullHelper(
1531 w http.ResponseWriter,
1532 r *http.Request,
1533 f *reporesolver.ResolvedRepo,
1534 user *oauth.User,
1535 pull *db.Pull,
1536 patch string,
1537 sourceRev string,
1538) {
1539 if pull.IsStacked() {
1540 log.Println("resubmitting stacked PR")
1541 s.resubmitStackedPullHelper(w, r, f, user, pull, patch, pull.StackId)
1542 return
1543 }
1544
1545 if err := validateResubmittedPatch(pull, patch); err != nil {
1546 s.pages.Notice(w, "resubmit-error", err.Error())
1547 return
1548 }
1549
1550 // validate sourceRev if branch/fork based
1551 if pull.IsBranchBased() || pull.IsForkBased() {
1552 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1553 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1554 return
1555 }
1556 }
1557
1558 tx, err := s.db.BeginTx(r.Context(), nil)
1559 if err != nil {
1560 log.Println("failed to start tx")
1561 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1562 return
1563 }
1564 defer tx.Rollback()
1565
1566 err = db.ResubmitPull(tx, pull, patch, sourceRev)
1567 if err != nil {
1568 log.Println("failed to create pull request", err)
1569 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1570 return
1571 }
1572 client, err := s.oauth.AuthorizedClient(r)
1573 if err != nil {
1574 log.Println("failed to authorize client")
1575 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1576 return
1577 }
1578
1579 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1580 if err != nil {
1581 // failed to get record
1582 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1583 return
1584 }
1585
1586 var recordPullSource *tangled.RepoPull_Source
1587 if pull.IsBranchBased() {
1588 recordPullSource = &tangled.RepoPull_Source{
1589 Branch: pull.PullSource.Branch,
1590 Sha: sourceRev,
1591 }
1592 }
1593 if pull.IsForkBased() {
1594 repoAt := pull.PullSource.RepoAt.String()
1595 recordPullSource = &tangled.RepoPull_Source{
1596 Branch: pull.PullSource.Branch,
1597 Repo: &repoAt,
1598 Sha: sourceRev,
1599 }
1600 }
1601
1602 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1603 Collection: tangled.RepoPullNSID,
1604 Repo: user.Did,
1605 Rkey: pull.Rkey,
1606 SwapRecord: ex.Cid,
1607 Record: &lexutil.LexiconTypeDecoder{
1608 Val: &tangled.RepoPull{
1609 Title: pull.Title,
1610 Target: &tangled.RepoPull_Target{
1611 Repo: string(f.RepoAt()),
1612 Branch: pull.TargetBranch,
1613 },
1614 Patch: patch, // new patch
1615 Source: recordPullSource,
1616 },
1617 },
1618 })
1619 if err != nil {
1620 log.Println("failed to update record", err)
1621 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1622 return
1623 }
1624
1625 if err = tx.Commit(); err != nil {
1626 log.Println("failed to commit transaction", err)
1627 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1628 return
1629 }
1630
1631 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1632}
1633
1634func (s *Pulls) resubmitStackedPullHelper(
1635 w http.ResponseWriter,
1636 r *http.Request,
1637 f *reporesolver.ResolvedRepo,
1638 user *oauth.User,
1639 pull *db.Pull,
1640 patch string,
1641 stackId string,
1642) {
1643 targetBranch := pull.TargetBranch
1644
1645 origStack, _ := r.Context().Value("stack").(db.Stack)
1646 newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId)
1647 if err != nil {
1648 log.Println("failed to create resubmitted stack", err)
1649 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1650 return
1651 }
1652
1653 // find the diff between the stacks, first, map them by changeId
1654 origById := make(map[string]*db.Pull)
1655 newById := make(map[string]*db.Pull)
1656 for _, p := range origStack {
1657 origById[p.ChangeId] = p
1658 }
1659 for _, p := range newStack {
1660 newById[p.ChangeId] = p
1661 }
1662
1663 // commits that got deleted: corresponding pull is closed
1664 // commits that got added: new pull is created
1665 // commits that got updated: corresponding pull is resubmitted & new round begins
1666 //
1667 // for commits that were unchanged: no changes, parent-change-id is updated as necessary
1668 additions := make(map[string]*db.Pull)
1669 deletions := make(map[string]*db.Pull)
1670 unchanged := make(map[string]struct{})
1671 updated := make(map[string]struct{})
1672
1673 // pulls in orignal stack but not in new one
1674 for _, op := range origStack {
1675 if _, ok := newById[op.ChangeId]; !ok {
1676 deletions[op.ChangeId] = op
1677 }
1678 }
1679
1680 // pulls in new stack but not in original one
1681 for _, np := range newStack {
1682 if _, ok := origById[np.ChangeId]; !ok {
1683 additions[np.ChangeId] = np
1684 }
1685 }
1686
1687 // NOTE: this loop can be written in any of above blocks,
1688 // but is written separately in the interest of simpler code
1689 for _, np := range newStack {
1690 if op, ok := origById[np.ChangeId]; ok {
1691 // pull exists in both stacks
1692 // TODO: can we avoid reparse?
1693 origFiles, origHeaderStr, _ := gitdiff.Parse(strings.NewReader(op.LatestPatch()))
1694 newFiles, newHeaderStr, _ := gitdiff.Parse(strings.NewReader(np.LatestPatch()))
1695
1696 origHeader, _ := gitdiff.ParsePatchHeader(origHeaderStr)
1697 newHeader, _ := gitdiff.ParsePatchHeader(newHeaderStr)
1698
1699 patchutil.SortPatch(newFiles)
1700 patchutil.SortPatch(origFiles)
1701
1702 // text content of patch may be identical, but a jj rebase might have forwarded it
1703 //
1704 // we still need to update the hash in submission.Patch and submission.SourceRev
1705 if patchutil.Equal(newFiles, origFiles) &&
1706 origHeader.Title == newHeader.Title &&
1707 origHeader.Body == newHeader.Body {
1708 unchanged[op.ChangeId] = struct{}{}
1709 } else {
1710 updated[op.ChangeId] = struct{}{}
1711 }
1712 }
1713 }
1714
1715 tx, err := s.db.Begin()
1716 if err != nil {
1717 log.Println("failed to start transaction", err)
1718 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1719 return
1720 }
1721 defer tx.Rollback()
1722
1723 // pds updates to make
1724 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1725
1726 // deleted pulls are marked as deleted in the DB
1727 for _, p := range deletions {
1728 // do not do delete already merged PRs
1729 if p.State == db.PullMerged {
1730 continue
1731 }
1732
1733 err := db.DeletePull(tx, p.RepoAt, p.PullId)
1734 if err != nil {
1735 log.Println("failed to delete pull", err, p.PullId)
1736 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1737 return
1738 }
1739 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1740 RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{
1741 Collection: tangled.RepoPullNSID,
1742 Rkey: p.Rkey,
1743 },
1744 })
1745 }
1746
1747 // new pulls are created
1748 for _, p := range additions {
1749 err := db.NewPull(tx, p)
1750 if err != nil {
1751 log.Println("failed to create pull", err, p.PullId)
1752 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1753 return
1754 }
1755
1756 record := p.AsRecord()
1757 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1758 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
1759 Collection: tangled.RepoPullNSID,
1760 Rkey: &p.Rkey,
1761 Value: &lexutil.LexiconTypeDecoder{
1762 Val: &record,
1763 },
1764 },
1765 })
1766 }
1767
1768 // updated pulls are, well, updated; to start a new round
1769 for id := range updated {
1770 op, _ := origById[id]
1771 np, _ := newById[id]
1772
1773 // do not update already merged PRs
1774 if op.State == db.PullMerged {
1775 continue
1776 }
1777
1778 submission := np.Submissions[np.LastRoundNumber()]
1779
1780 // resubmit the old pull
1781 err := db.ResubmitPull(tx, op, submission.Patch, submission.SourceRev)
1782
1783 if err != nil {
1784 log.Println("failed to update pull", err, op.PullId)
1785 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1786 return
1787 }
1788
1789 record := op.AsRecord()
1790 record.Patch = submission.Patch
1791
1792 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1793 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
1794 Collection: tangled.RepoPullNSID,
1795 Rkey: op.Rkey,
1796 Value: &lexutil.LexiconTypeDecoder{
1797 Val: &record,
1798 },
1799 },
1800 })
1801 }
1802
1803 // unchanged pulls are edited without starting a new round
1804 //
1805 // update source-revs & patches without advancing rounds
1806 for changeId := range unchanged {
1807 op, _ := origById[changeId]
1808 np, _ := newById[changeId]
1809
1810 origSubmission := op.Submissions[op.LastRoundNumber()]
1811 newSubmission := np.Submissions[np.LastRoundNumber()]
1812
1813 log.Println("moving unchanged change id : ", changeId)
1814
1815 err := db.UpdatePull(
1816 tx,
1817 newSubmission.Patch,
1818 newSubmission.SourceRev,
1819 db.FilterEq("id", origSubmission.ID),
1820 )
1821
1822 if err != nil {
1823 log.Println("failed to update pull", err, op.PullId)
1824 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1825 return
1826 }
1827
1828 record := op.AsRecord()
1829 record.Patch = newSubmission.Patch
1830
1831 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1832 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
1833 Collection: tangled.RepoPullNSID,
1834 Rkey: op.Rkey,
1835 Value: &lexutil.LexiconTypeDecoder{
1836 Val: &record,
1837 },
1838 },
1839 })
1840 }
1841
1842 // update parent-change-id relations for the entire stack
1843 for _, p := range newStack {
1844 err := db.SetPullParentChangeId(
1845 tx,
1846 p.ParentChangeId,
1847 // these should be enough filters to be unique per-stack
1848 db.FilterEq("repo_at", p.RepoAt.String()),
1849 db.FilterEq("owner_did", p.OwnerDid),
1850 db.FilterEq("change_id", p.ChangeId),
1851 )
1852
1853 if err != nil {
1854 log.Println("failed to update pull", err, p.PullId)
1855 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1856 return
1857 }
1858 }
1859
1860 err = tx.Commit()
1861 if err != nil {
1862 log.Println("failed to resubmit pull", err)
1863 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1864 return
1865 }
1866
1867 client, err := s.oauth.AuthorizedClient(r)
1868 if err != nil {
1869 log.Println("failed to authorize client")
1870 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1871 return
1872 }
1873
1874 _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{
1875 Repo: user.Did,
1876 Writes: writes,
1877 })
1878 if err != nil {
1879 log.Println("failed to create stacked pull request", err)
1880 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.")
1881 return
1882 }
1883
1884 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1885}
1886
1887func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
1888 f, err := s.repoResolver.Resolve(r)
1889 if err != nil {
1890 log.Println("failed to resolve repo:", err)
1891 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1892 return
1893 }
1894
1895 pull, ok := r.Context().Value("pull").(*db.Pull)
1896 if !ok {
1897 log.Println("failed to get pull")
1898 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
1899 return
1900 }
1901
1902 var pullsToMerge db.Stack
1903 pullsToMerge = append(pullsToMerge, pull)
1904 if pull.IsStacked() {
1905 stack, ok := r.Context().Value("stack").(db.Stack)
1906 if !ok {
1907 log.Println("failed to get stack")
1908 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
1909 return
1910 }
1911
1912 // combine patches of substack
1913 subStack := stack.StrictlyBelow(pull)
1914 // collect the portion of the stack that is mergeable
1915 mergeable := subStack.Mergeable()
1916 // add to total patch
1917 pullsToMerge = append(pullsToMerge, mergeable...)
1918 }
1919
1920 patch := pullsToMerge.CombinedPatch()
1921
1922 ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid)
1923 if err != nil {
1924 log.Printf("resolving identity: %s", err)
1925 w.WriteHeader(http.StatusNotFound)
1926 return
1927 }
1928
1929 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
1930 if err != nil {
1931 log.Printf("failed to get primary email: %s", err)
1932 }
1933
1934 authorName := ident.Handle.String()
1935 mergeInput := &tangled.RepoMerge_Input{
1936 Did: f.OwnerDid(),
1937 Name: f.Name,
1938 Branch: pull.TargetBranch,
1939 Patch: patch,
1940 CommitMessage: &pull.Title,
1941 AuthorName: &authorName,
1942 }
1943
1944 if pull.Body != "" {
1945 mergeInput.CommitBody = &pull.Body
1946 }
1947
1948 if email.Address != "" {
1949 mergeInput.AuthorEmail = &email.Address
1950 }
1951
1952 client, err := s.oauth.ServiceClient(
1953 r,
1954 oauth.WithService(f.Knot),
1955 oauth.WithLxm(tangled.RepoMergeNSID),
1956 oauth.WithDev(s.config.Core.Dev),
1957 )
1958 if err != nil {
1959 log.Printf("failed to connect to knot server: %v", err)
1960 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1961 return
1962 }
1963
1964 err = tangled.RepoMerge(r.Context(), client, mergeInput)
1965 if err := xrpcclient.HandleXrpcErr(err); err != nil {
1966 s.pages.Notice(w, "pull-merge-error", err.Error())
1967 return
1968 }
1969
1970 tx, err := s.db.Begin()
1971 if err != nil {
1972 log.Println("failed to start transcation", err)
1973 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1974 return
1975 }
1976 defer tx.Rollback()
1977
1978 for _, p := range pullsToMerge {
1979 err := db.MergePull(tx, f.RepoAt(), p.PullId)
1980 if err != nil {
1981 log.Printf("failed to update pull request status in database: %s", err)
1982 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1983 return
1984 }
1985 }
1986
1987 err = tx.Commit()
1988 if err != nil {
1989 // TODO: this is unsound, we should also revert the merge from the knotserver here
1990 log.Printf("failed to update pull request status in database: %s", err)
1991 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1992 return
1993 }
1994
1995 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
1996}
1997
1998func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) {
1999 user := s.oauth.GetUser(r)
2000
2001 f, err := s.repoResolver.Resolve(r)
2002 if err != nil {
2003 log.Println("malformed middleware")
2004 return
2005 }
2006
2007 pull, ok := r.Context().Value("pull").(*db.Pull)
2008 if !ok {
2009 log.Println("failed to get pull")
2010 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
2011 return
2012 }
2013
2014 // auth filter: only owner or collaborators can close
2015 roles := f.RolesInRepo(user)
2016 isOwner := roles.IsOwner()
2017 isCollaborator := roles.IsCollaborator()
2018 isPullAuthor := user.Did == pull.OwnerDid
2019 isCloseAllowed := isOwner || isCollaborator || isPullAuthor
2020 if !isCloseAllowed {
2021 log.Println("failed to close pull")
2022 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
2023 return
2024 }
2025
2026 // Start a transaction
2027 tx, err := s.db.BeginTx(r.Context(), nil)
2028 if err != nil {
2029 log.Println("failed to start transaction", err)
2030 s.pages.Notice(w, "pull-close", "Failed to close pull.")
2031 return
2032 }
2033 defer tx.Rollback()
2034
2035 var pullsToClose []*db.Pull
2036 pullsToClose = append(pullsToClose, pull)
2037
2038 // if this PR is stacked, then we want to close all PRs below this one on the stack
2039 if pull.IsStacked() {
2040 stack := r.Context().Value("stack").(db.Stack)
2041 subStack := stack.StrictlyBelow(pull)
2042 pullsToClose = append(pullsToClose, subStack...)
2043 }
2044
2045 for _, p := range pullsToClose {
2046 // Close the pull in the database
2047 err = db.ClosePull(tx, f.RepoAt(), p.PullId)
2048 if err != nil {
2049 log.Println("failed to close pull", err)
2050 s.pages.Notice(w, "pull-close", "Failed to close pull.")
2051 return
2052 }
2053 }
2054
2055 // Commit the transaction
2056 if err = tx.Commit(); err != nil {
2057 log.Println("failed to commit transaction", err)
2058 s.pages.Notice(w, "pull-close", "Failed to close pull.")
2059 return
2060 }
2061
2062 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2063}
2064
2065func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) {
2066 user := s.oauth.GetUser(r)
2067
2068 f, err := s.repoResolver.Resolve(r)
2069 if err != nil {
2070 log.Println("failed to resolve repo", err)
2071 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2072 return
2073 }
2074
2075 pull, ok := r.Context().Value("pull").(*db.Pull)
2076 if !ok {
2077 log.Println("failed to get pull")
2078 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
2079 return
2080 }
2081
2082 // auth filter: only owner or collaborators can close
2083 roles := f.RolesInRepo(user)
2084 isOwner := roles.IsOwner()
2085 isCollaborator := roles.IsCollaborator()
2086 isPullAuthor := user.Did == pull.OwnerDid
2087 isCloseAllowed := isOwner || isCollaborator || isPullAuthor
2088 if !isCloseAllowed {
2089 log.Println("failed to close pull")
2090 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
2091 return
2092 }
2093
2094 // Start a transaction
2095 tx, err := s.db.BeginTx(r.Context(), nil)
2096 if err != nil {
2097 log.Println("failed to start transaction", err)
2098 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2099 return
2100 }
2101 defer tx.Rollback()
2102
2103 var pullsToReopen []*db.Pull
2104 pullsToReopen = append(pullsToReopen, pull)
2105
2106 // if this PR is stacked, then we want to reopen all PRs above this one on the stack
2107 if pull.IsStacked() {
2108 stack := r.Context().Value("stack").(db.Stack)
2109 subStack := stack.StrictlyAbove(pull)
2110 pullsToReopen = append(pullsToReopen, subStack...)
2111 }
2112
2113 for _, p := range pullsToReopen {
2114 // Close the pull in the database
2115 err = db.ReopenPull(tx, f.RepoAt(), p.PullId)
2116 if err != nil {
2117 log.Println("failed to close pull", err)
2118 s.pages.Notice(w, "pull-close", "Failed to close pull.")
2119 return
2120 }
2121 }
2122
2123 // Commit the transaction
2124 if err = tx.Commit(); err != nil {
2125 log.Println("failed to commit transaction", err)
2126 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2127 return
2128 }
2129
2130 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2131}
2132
2133func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) {
2134 formatPatches, err := patchutil.ExtractPatches(patch)
2135 if err != nil {
2136 return nil, fmt.Errorf("Failed to extract patches: %v", err)
2137 }
2138
2139 // must have atleast 1 patch to begin with
2140 if len(formatPatches) == 0 {
2141 return nil, fmt.Errorf("No patches found in the generated format-patch.")
2142 }
2143
2144 // the stack is identified by a UUID
2145 var stack db.Stack
2146 parentChangeId := ""
2147 for _, fp := range formatPatches {
2148 // all patches must have a jj change-id
2149 changeId, err := fp.ChangeId()
2150 if err != nil {
2151 return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.")
2152 }
2153
2154 title := fp.Title
2155 body := fp.Body
2156 rkey := tid.TID()
2157
2158 initialSubmission := db.PullSubmission{
2159 Patch: fp.Raw,
2160 SourceRev: fp.SHA,
2161 }
2162 pull := db.Pull{
2163 Title: title,
2164 Body: body,
2165 TargetBranch: targetBranch,
2166 OwnerDid: user.Did,
2167 RepoAt: f.RepoAt(),
2168 Rkey: rkey,
2169 Submissions: []*db.PullSubmission{
2170 &initialSubmission,
2171 },
2172 PullSource: pullSource,
2173 Created: time.Now(),
2174
2175 StackId: stackId,
2176 ChangeId: changeId,
2177 ParentChangeId: parentChangeId,
2178 }
2179
2180 stack = append(stack, &pull)
2181
2182 parentChangeId = changeId
2183 }
2184
2185 return stack, nil
2186}