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 repoString := strings.SplitN(forkRepo, "/", 2)
854 forkOwnerDid := repoString[0]
855 repoName := repoString[1]
856 fork, err := db.GetForkByDid(s.db, forkOwnerDid, repoName)
857 if errors.Is(err, sql.ErrNoRows) {
858 s.pages.Notice(w, "pull", "No such fork.")
859 return
860 } else if err != nil {
861 log.Println("failed to fetch fork:", err)
862 s.pages.Notice(w, "pull", "Failed to fetch fork.")
863 return
864 }
865
866 client, err := s.oauth.ServiceClient(
867 r,
868 oauth.WithService(fork.Knot),
869 oauth.WithLxm(tangled.RepoHiddenRefNSID),
870 oauth.WithDev(s.config.Core.Dev),
871 )
872 if err != nil {
873 log.Printf("failed to connect to knot server: %v", err)
874 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
875 return
876 }
877
878 us, err := knotclient.NewUnsignedClient(fork.Knot, s.config.Core.Dev)
879 if err != nil {
880 log.Println("failed to create unsigned client:", err)
881 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
882 return
883 }
884
885 resp, err := tangled.RepoHiddenRef(
886 r.Context(),
887 client,
888 &tangled.RepoHiddenRef_Input{
889 ForkRef: sourceBranch,
890 RemoteRef: targetBranch,
891 Repo: fork.RepoAt().String(),
892 },
893 )
894 if err := xrpcclient.HandleXrpcErr(err); err != nil {
895 s.pages.Notice(w, "pull", err.Error())
896 return
897 }
898
899 if !resp.Success {
900 errorMsg := "Failed to create pull request"
901 if resp.Error != nil {
902 errorMsg = fmt.Sprintf("Failed to create pull request: %s", *resp.Error)
903 }
904 s.pages.Notice(w, "pull", errorMsg)
905 return
906 }
907
908 hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch)
909 // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking
910 // the targetBranch on the target repository. This code is a bit confusing, but here's an example:
911 // hiddenRef: hidden/feature-1/main (on repo-fork)
912 // targetBranch: main (on repo-1)
913 // sourceBranch: feature-1 (on repo-fork)
914 comparison, err := us.Compare(fork.Did, fork.Name, hiddenRef, sourceBranch)
915 if err != nil {
916 log.Println("failed to compare across branches", err)
917 s.pages.Notice(w, "pull", err.Error())
918 return
919 }
920
921 sourceRev := comparison.Rev2
922 patch := comparison.Patch
923
924 if !patchutil.IsPatchValid(patch) {
925 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
926 return
927 }
928
929 forkAtUri := fork.RepoAt()
930 forkAtUriStr := forkAtUri.String()
931
932 pullSource := &db.PullSource{
933 Branch: sourceBranch,
934 RepoAt: &forkAtUri,
935 }
936 recordPullSource := &tangled.RepoPull_Source{
937 Branch: sourceBranch,
938 Repo: &forkAtUriStr,
939 Sha: sourceRev,
940 }
941
942 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked)
943}
944
945func (s *Pulls) createPullRequest(
946 w http.ResponseWriter,
947 r *http.Request,
948 f *reporesolver.ResolvedRepo,
949 user *oauth.User,
950 title, body, targetBranch string,
951 patch string,
952 sourceRev string,
953 pullSource *db.PullSource,
954 recordPullSource *tangled.RepoPull_Source,
955 isStacked bool,
956) {
957 if isStacked {
958 // creates a series of PRs, each linking to the previous, identified by jj's change-id
959 s.createStackedPullRequest(
960 w,
961 r,
962 f,
963 user,
964 targetBranch,
965 patch,
966 sourceRev,
967 pullSource,
968 )
969 return
970 }
971
972 client, err := s.oauth.AuthorizedClient(r)
973 if err != nil {
974 log.Println("failed to get authorized client", err)
975 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
976 return
977 }
978
979 tx, err := s.db.BeginTx(r.Context(), nil)
980 if err != nil {
981 log.Println("failed to start tx")
982 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
983 return
984 }
985 defer tx.Rollback()
986
987 // We've already checked earlier if it's diff-based and title is empty,
988 // so if it's still empty now, it's intentionally skipped owing to format-patch.
989 if title == "" {
990 formatPatches, err := patchutil.ExtractPatches(patch)
991 if err != nil {
992 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
993 return
994 }
995 if len(formatPatches) == 0 {
996 s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.")
997 return
998 }
999
1000 title = formatPatches[0].Title
1001 body = formatPatches[0].Body
1002 }
1003
1004 rkey := tid.TID()
1005 initialSubmission := db.PullSubmission{
1006 Patch: patch,
1007 SourceRev: sourceRev,
1008 }
1009 pull := &db.Pull{
1010 Title: title,
1011 Body: body,
1012 TargetBranch: targetBranch,
1013 OwnerDid: user.Did,
1014 RepoAt: f.RepoAt(),
1015 Rkey: rkey,
1016 Submissions: []*db.PullSubmission{
1017 &initialSubmission,
1018 },
1019 PullSource: pullSource,
1020 }
1021 err = db.NewPull(tx, pull)
1022 if err != nil {
1023 log.Println("failed to create pull request", err)
1024 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1025 return
1026 }
1027 pullId, err := db.NextPullId(tx, f.RepoAt())
1028 if err != nil {
1029 log.Println("failed to get pull id", err)
1030 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1031 return
1032 }
1033
1034 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1035 Collection: tangled.RepoPullNSID,
1036 Repo: user.Did,
1037 Rkey: rkey,
1038 Record: &lexutil.LexiconTypeDecoder{
1039 Val: &tangled.RepoPull{
1040 Title: title,
1041 Target: &tangled.RepoPull_Target{
1042 Repo: string(f.RepoAt()),
1043 Branch: targetBranch,
1044 },
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 repoString := strings.SplitN(forkVal, "/", 2)
1278 forkOwnerDid := repoString[0]
1279 forkName := repoString[1]
1280 // fork repo
1281 repo, err := db.GetRepo(s.db, forkOwnerDid, forkName)
1282 if err != nil {
1283 log.Println("failed to get repo", user.Did, forkVal)
1284 return
1285 }
1286
1287 sourceBranchesClient, err := knotclient.NewUnsignedClient(repo.Knot, s.config.Core.Dev)
1288 if err != nil {
1289 log.Printf("failed to create unsigned client for %s", repo.Knot)
1290 s.pages.Error503(w)
1291 return
1292 }
1293
1294 sourceResult, err := sourceBranchesClient.Branches(forkOwnerDid, repo.Name)
1295 if err != nil {
1296 log.Println("failed to reach knotserver for source branches", err)
1297 return
1298 }
1299
1300 targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1301 if err != nil {
1302 log.Printf("failed to create unsigned client for target knot %s", f.Knot)
1303 s.pages.Error503(w)
1304 return
1305 }
1306
1307 targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.Name)
1308 if err != nil {
1309 log.Println("failed to reach knotserver for target branches", err)
1310 return
1311 }
1312
1313 sourceBranches := sourceResult.Branches
1314 sort.Slice(sourceBranches, func(i int, j int) bool {
1315 return sourceBranches[i].Commit.Committer.When.After(sourceBranches[j].Commit.Committer.When)
1316 })
1317
1318 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
1319 RepoInfo: f.RepoInfo(user),
1320 SourceBranches: sourceBranches,
1321 TargetBranches: targetResult.Branches,
1322 })
1323}
1324
1325func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) {
1326 user := s.oauth.GetUser(r)
1327 f, err := s.repoResolver.Resolve(r)
1328 if err != nil {
1329 log.Println("failed to get repo and knot", err)
1330 return
1331 }
1332
1333 pull, ok := r.Context().Value("pull").(*db.Pull)
1334 if !ok {
1335 log.Println("failed to get pull")
1336 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1337 return
1338 }
1339
1340 switch r.Method {
1341 case http.MethodGet:
1342 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
1343 RepoInfo: f.RepoInfo(user),
1344 Pull: pull,
1345 })
1346 return
1347 case http.MethodPost:
1348 if pull.IsPatchBased() {
1349 s.resubmitPatch(w, r)
1350 return
1351 } else if pull.IsBranchBased() {
1352 s.resubmitBranch(w, r)
1353 return
1354 } else if pull.IsForkBased() {
1355 s.resubmitFork(w, r)
1356 return
1357 }
1358 }
1359}
1360
1361func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) {
1362 user := s.oauth.GetUser(r)
1363
1364 pull, ok := r.Context().Value("pull").(*db.Pull)
1365 if !ok {
1366 log.Println("failed to get pull")
1367 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1368 return
1369 }
1370
1371 f, err := s.repoResolver.Resolve(r)
1372 if err != nil {
1373 log.Println("failed to get repo and knot", err)
1374 return
1375 }
1376
1377 if user.Did != pull.OwnerDid {
1378 log.Println("unauthorized user")
1379 w.WriteHeader(http.StatusUnauthorized)
1380 return
1381 }
1382
1383 patch := r.FormValue("patch")
1384
1385 s.resubmitPullHelper(w, r, f, user, pull, patch, "")
1386}
1387
1388func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) {
1389 user := s.oauth.GetUser(r)
1390
1391 pull, ok := r.Context().Value("pull").(*db.Pull)
1392 if !ok {
1393 log.Println("failed to get pull")
1394 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1395 return
1396 }
1397
1398 f, err := s.repoResolver.Resolve(r)
1399 if err != nil {
1400 log.Println("failed to get repo and knot", err)
1401 return
1402 }
1403
1404 if user.Did != pull.OwnerDid {
1405 log.Println("unauthorized user")
1406 w.WriteHeader(http.StatusUnauthorized)
1407 return
1408 }
1409
1410 if !f.RepoInfo(user).Roles.IsPushAllowed() {
1411 log.Println("unauthorized user")
1412 w.WriteHeader(http.StatusUnauthorized)
1413 return
1414 }
1415
1416 ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1417 if err != nil {
1418 log.Printf("failed to create client for %s: %s", f.Knot, err)
1419 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1420 return
1421 }
1422
1423 comparison, err := ksClient.Compare(f.OwnerDid(), f.Name, pull.TargetBranch, pull.PullSource.Branch)
1424 if err != nil {
1425 log.Printf("compare request failed: %s", err)
1426 s.pages.Notice(w, "resubmit-error", err.Error())
1427 return
1428 }
1429
1430 sourceRev := comparison.Rev2
1431 patch := comparison.Patch
1432
1433 s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev)
1434}
1435
1436func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) {
1437 user := s.oauth.GetUser(r)
1438
1439 pull, ok := r.Context().Value("pull").(*db.Pull)
1440 if !ok {
1441 log.Println("failed to get pull")
1442 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1443 return
1444 }
1445
1446 f, err := s.repoResolver.Resolve(r)
1447 if err != nil {
1448 log.Println("failed to get repo and knot", err)
1449 return
1450 }
1451
1452 if user.Did != pull.OwnerDid {
1453 log.Println("unauthorized user")
1454 w.WriteHeader(http.StatusUnauthorized)
1455 return
1456 }
1457
1458 forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
1459 if err != nil {
1460 log.Println("failed to get source repo", err)
1461 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1462 return
1463 }
1464
1465 // extract patch by performing compare
1466 ksClient, err := knotclient.NewUnsignedClient(forkRepo.Knot, s.config.Core.Dev)
1467 if err != nil {
1468 log.Printf("failed to create client for %s: %s", forkRepo.Knot, err)
1469 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1470 return
1471 }
1472
1473 // update the hidden tracking branch to latest
1474 client, err := s.oauth.ServiceClient(
1475 r,
1476 oauth.WithService(forkRepo.Knot),
1477 oauth.WithLxm(tangled.RepoHiddenRefNSID),
1478 oauth.WithDev(s.config.Core.Dev),
1479 )
1480 if err != nil {
1481 log.Printf("failed to connect to knot server: %v", err)
1482 return
1483 }
1484
1485 resp, err := tangled.RepoHiddenRef(
1486 r.Context(),
1487 client,
1488 &tangled.RepoHiddenRef_Input{
1489 ForkRef: pull.PullSource.Branch,
1490 RemoteRef: pull.TargetBranch,
1491 Repo: forkRepo.RepoAt().String(),
1492 },
1493 )
1494 if err := xrpcclient.HandleXrpcErr(err); err != nil {
1495 s.pages.Notice(w, "resubmit-error", err.Error())
1496 return
1497 }
1498 if !resp.Success {
1499 log.Println("Failed to update tracking ref.", "err", resp.Error)
1500 s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.")
1501 return
1502 }
1503
1504 hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch)
1505 comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch)
1506 if err != nil {
1507 log.Printf("failed to compare branches: %s", err)
1508 s.pages.Notice(w, "resubmit-error", err.Error())
1509 return
1510 }
1511
1512 sourceRev := comparison.Rev2
1513 patch := comparison.Patch
1514
1515 s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev)
1516}
1517
1518// validate a resubmission against a pull request
1519func validateResubmittedPatch(pull *db.Pull, patch string) error {
1520 if patch == "" {
1521 return fmt.Errorf("Patch is empty.")
1522 }
1523
1524 if patch == pull.LatestPatch() {
1525 return fmt.Errorf("Patch is identical to previous submission.")
1526 }
1527
1528 if !patchutil.IsPatchValid(patch) {
1529 return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
1530 }
1531
1532 return nil
1533}
1534
1535func (s *Pulls) resubmitPullHelper(
1536 w http.ResponseWriter,
1537 r *http.Request,
1538 f *reporesolver.ResolvedRepo,
1539 user *oauth.User,
1540 pull *db.Pull,
1541 patch string,
1542 sourceRev string,
1543) {
1544 if pull.IsStacked() {
1545 log.Println("resubmitting stacked PR")
1546 s.resubmitStackedPullHelper(w, r, f, user, pull, patch, pull.StackId)
1547 return
1548 }
1549
1550 if err := validateResubmittedPatch(pull, patch); err != nil {
1551 s.pages.Notice(w, "resubmit-error", err.Error())
1552 return
1553 }
1554
1555 // validate sourceRev if branch/fork based
1556 if pull.IsBranchBased() || pull.IsForkBased() {
1557 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1558 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1559 return
1560 }
1561 }
1562
1563 tx, err := s.db.BeginTx(r.Context(), nil)
1564 if err != nil {
1565 log.Println("failed to start tx")
1566 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1567 return
1568 }
1569 defer tx.Rollback()
1570
1571 err = db.ResubmitPull(tx, pull, patch, sourceRev)
1572 if err != nil {
1573 log.Println("failed to create pull request", err)
1574 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1575 return
1576 }
1577 client, err := s.oauth.AuthorizedClient(r)
1578 if err != nil {
1579 log.Println("failed to authorize client")
1580 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1581 return
1582 }
1583
1584 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1585 if err != nil {
1586 // failed to get record
1587 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1588 return
1589 }
1590
1591 var recordPullSource *tangled.RepoPull_Source
1592 if pull.IsBranchBased() {
1593 recordPullSource = &tangled.RepoPull_Source{
1594 Branch: pull.PullSource.Branch,
1595 Sha: sourceRev,
1596 }
1597 }
1598 if pull.IsForkBased() {
1599 repoAt := pull.PullSource.RepoAt.String()
1600 recordPullSource = &tangled.RepoPull_Source{
1601 Branch: pull.PullSource.Branch,
1602 Repo: &repoAt,
1603 Sha: sourceRev,
1604 }
1605 }
1606
1607 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1608 Collection: tangled.RepoPullNSID,
1609 Repo: user.Did,
1610 Rkey: pull.Rkey,
1611 SwapRecord: ex.Cid,
1612 Record: &lexutil.LexiconTypeDecoder{
1613 Val: &tangled.RepoPull{
1614 Title: pull.Title,
1615 Target: &tangled.RepoPull_Target{
1616 Repo: string(f.RepoAt()),
1617 Branch: pull.TargetBranch,
1618 },
1619 Patch: patch, // new patch
1620 Source: recordPullSource,
1621 },
1622 },
1623 })
1624 if err != nil {
1625 log.Println("failed to update record", err)
1626 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1627 return
1628 }
1629
1630 if err = tx.Commit(); err != nil {
1631 log.Println("failed to commit transaction", err)
1632 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1633 return
1634 }
1635
1636 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1637}
1638
1639func (s *Pulls) resubmitStackedPullHelper(
1640 w http.ResponseWriter,
1641 r *http.Request,
1642 f *reporesolver.ResolvedRepo,
1643 user *oauth.User,
1644 pull *db.Pull,
1645 patch string,
1646 stackId string,
1647) {
1648 targetBranch := pull.TargetBranch
1649
1650 origStack, _ := r.Context().Value("stack").(db.Stack)
1651 newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId)
1652 if err != nil {
1653 log.Println("failed to create resubmitted stack", err)
1654 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1655 return
1656 }
1657
1658 // find the diff between the stacks, first, map them by changeId
1659 origById := make(map[string]*db.Pull)
1660 newById := make(map[string]*db.Pull)
1661 for _, p := range origStack {
1662 origById[p.ChangeId] = p
1663 }
1664 for _, p := range newStack {
1665 newById[p.ChangeId] = p
1666 }
1667
1668 // commits that got deleted: corresponding pull is closed
1669 // commits that got added: new pull is created
1670 // commits that got updated: corresponding pull is resubmitted & new round begins
1671 //
1672 // for commits that were unchanged: no changes, parent-change-id is updated as necessary
1673 additions := make(map[string]*db.Pull)
1674 deletions := make(map[string]*db.Pull)
1675 unchanged := make(map[string]struct{})
1676 updated := make(map[string]struct{})
1677
1678 // pulls in orignal stack but not in new one
1679 for _, op := range origStack {
1680 if _, ok := newById[op.ChangeId]; !ok {
1681 deletions[op.ChangeId] = op
1682 }
1683 }
1684
1685 // pulls in new stack but not in original one
1686 for _, np := range newStack {
1687 if _, ok := origById[np.ChangeId]; !ok {
1688 additions[np.ChangeId] = np
1689 }
1690 }
1691
1692 // NOTE: this loop can be written in any of above blocks,
1693 // but is written separately in the interest of simpler code
1694 for _, np := range newStack {
1695 if op, ok := origById[np.ChangeId]; ok {
1696 // pull exists in both stacks
1697 // TODO: can we avoid reparse?
1698 origFiles, origHeaderStr, _ := gitdiff.Parse(strings.NewReader(op.LatestPatch()))
1699 newFiles, newHeaderStr, _ := gitdiff.Parse(strings.NewReader(np.LatestPatch()))
1700
1701 origHeader, _ := gitdiff.ParsePatchHeader(origHeaderStr)
1702 newHeader, _ := gitdiff.ParsePatchHeader(newHeaderStr)
1703
1704 patchutil.SortPatch(newFiles)
1705 patchutil.SortPatch(origFiles)
1706
1707 // text content of patch may be identical, but a jj rebase might have forwarded it
1708 //
1709 // we still need to update the hash in submission.Patch and submission.SourceRev
1710 if patchutil.Equal(newFiles, origFiles) &&
1711 origHeader.Title == newHeader.Title &&
1712 origHeader.Body == newHeader.Body {
1713 unchanged[op.ChangeId] = struct{}{}
1714 } else {
1715 updated[op.ChangeId] = struct{}{}
1716 }
1717 }
1718 }
1719
1720 tx, err := s.db.Begin()
1721 if err != nil {
1722 log.Println("failed to start transaction", err)
1723 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1724 return
1725 }
1726 defer tx.Rollback()
1727
1728 // pds updates to make
1729 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1730
1731 // deleted pulls are marked as deleted in the DB
1732 for _, p := range deletions {
1733 // do not do delete already merged PRs
1734 if p.State == db.PullMerged {
1735 continue
1736 }
1737
1738 err := db.DeletePull(tx, p.RepoAt, p.PullId)
1739 if err != nil {
1740 log.Println("failed to delete pull", err, p.PullId)
1741 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1742 return
1743 }
1744 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1745 RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{
1746 Collection: tangled.RepoPullNSID,
1747 Rkey: p.Rkey,
1748 },
1749 })
1750 }
1751
1752 // new pulls are created
1753 for _, p := range additions {
1754 err := db.NewPull(tx, p)
1755 if err != nil {
1756 log.Println("failed to create pull", err, p.PullId)
1757 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1758 return
1759 }
1760
1761 record := p.AsRecord()
1762 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1763 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
1764 Collection: tangled.RepoPullNSID,
1765 Rkey: &p.Rkey,
1766 Value: &lexutil.LexiconTypeDecoder{
1767 Val: &record,
1768 },
1769 },
1770 })
1771 }
1772
1773 // updated pulls are, well, updated; to start a new round
1774 for id := range updated {
1775 op, _ := origById[id]
1776 np, _ := newById[id]
1777
1778 // do not update already merged PRs
1779 if op.State == db.PullMerged {
1780 continue
1781 }
1782
1783 submission := np.Submissions[np.LastRoundNumber()]
1784
1785 // resubmit the old pull
1786 err := db.ResubmitPull(tx, op, submission.Patch, submission.SourceRev)
1787
1788 if err != nil {
1789 log.Println("failed to update pull", err, op.PullId)
1790 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1791 return
1792 }
1793
1794 record := op.AsRecord()
1795 record.Patch = submission.Patch
1796
1797 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1798 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
1799 Collection: tangled.RepoPullNSID,
1800 Rkey: op.Rkey,
1801 Value: &lexutil.LexiconTypeDecoder{
1802 Val: &record,
1803 },
1804 },
1805 })
1806 }
1807
1808 // unchanged pulls are edited without starting a new round
1809 //
1810 // update source-revs & patches without advancing rounds
1811 for changeId := range unchanged {
1812 op, _ := origById[changeId]
1813 np, _ := newById[changeId]
1814
1815 origSubmission := op.Submissions[op.LastRoundNumber()]
1816 newSubmission := np.Submissions[np.LastRoundNumber()]
1817
1818 log.Println("moving unchanged change id : ", changeId)
1819
1820 err := db.UpdatePull(
1821 tx,
1822 newSubmission.Patch,
1823 newSubmission.SourceRev,
1824 db.FilterEq("id", origSubmission.ID),
1825 )
1826
1827 if err != nil {
1828 log.Println("failed to update pull", err, op.PullId)
1829 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1830 return
1831 }
1832
1833 record := op.AsRecord()
1834 record.Patch = newSubmission.Patch
1835
1836 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1837 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
1838 Collection: tangled.RepoPullNSID,
1839 Rkey: op.Rkey,
1840 Value: &lexutil.LexiconTypeDecoder{
1841 Val: &record,
1842 },
1843 },
1844 })
1845 }
1846
1847 // update parent-change-id relations for the entire stack
1848 for _, p := range newStack {
1849 err := db.SetPullParentChangeId(
1850 tx,
1851 p.ParentChangeId,
1852 // these should be enough filters to be unique per-stack
1853 db.FilterEq("repo_at", p.RepoAt.String()),
1854 db.FilterEq("owner_did", p.OwnerDid),
1855 db.FilterEq("change_id", p.ChangeId),
1856 )
1857
1858 if err != nil {
1859 log.Println("failed to update pull", err, p.PullId)
1860 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1861 return
1862 }
1863 }
1864
1865 err = tx.Commit()
1866 if err != nil {
1867 log.Println("failed to resubmit pull", err)
1868 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1869 return
1870 }
1871
1872 client, err := s.oauth.AuthorizedClient(r)
1873 if err != nil {
1874 log.Println("failed to authorize client")
1875 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1876 return
1877 }
1878
1879 _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{
1880 Repo: user.Did,
1881 Writes: writes,
1882 })
1883 if err != nil {
1884 log.Println("failed to create stacked pull request", err)
1885 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.")
1886 return
1887 }
1888
1889 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1890}
1891
1892func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
1893 f, err := s.repoResolver.Resolve(r)
1894 if err != nil {
1895 log.Println("failed to resolve repo:", err)
1896 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1897 return
1898 }
1899
1900 pull, ok := r.Context().Value("pull").(*db.Pull)
1901 if !ok {
1902 log.Println("failed to get pull")
1903 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
1904 return
1905 }
1906
1907 var pullsToMerge db.Stack
1908 pullsToMerge = append(pullsToMerge, pull)
1909 if pull.IsStacked() {
1910 stack, ok := r.Context().Value("stack").(db.Stack)
1911 if !ok {
1912 log.Println("failed to get stack")
1913 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
1914 return
1915 }
1916
1917 // combine patches of substack
1918 subStack := stack.StrictlyBelow(pull)
1919 // collect the portion of the stack that is mergeable
1920 mergeable := subStack.Mergeable()
1921 // add to total patch
1922 pullsToMerge = append(pullsToMerge, mergeable...)
1923 }
1924
1925 patch := pullsToMerge.CombinedPatch()
1926
1927 ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid)
1928 if err != nil {
1929 log.Printf("resolving identity: %s", err)
1930 w.WriteHeader(http.StatusNotFound)
1931 return
1932 }
1933
1934 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
1935 if err != nil {
1936 log.Printf("failed to get primary email: %s", err)
1937 }
1938
1939 authorName := ident.Handle.String()
1940 mergeInput := &tangled.RepoMerge_Input{
1941 Did: f.OwnerDid(),
1942 Name: f.Name,
1943 Branch: pull.TargetBranch,
1944 Patch: patch,
1945 CommitMessage: &pull.Title,
1946 AuthorName: &authorName,
1947 }
1948
1949 if pull.Body != "" {
1950 mergeInput.CommitBody = &pull.Body
1951 }
1952
1953 if email.Address != "" {
1954 mergeInput.AuthorEmail = &email.Address
1955 }
1956
1957 client, err := s.oauth.ServiceClient(
1958 r,
1959 oauth.WithService(f.Knot),
1960 oauth.WithLxm(tangled.RepoMergeNSID),
1961 oauth.WithDev(s.config.Core.Dev),
1962 )
1963 if err != nil {
1964 log.Printf("failed to connect to knot server: %v", err)
1965 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1966 return
1967 }
1968
1969 err = tangled.RepoMerge(r.Context(), client, mergeInput)
1970 if err := xrpcclient.HandleXrpcErr(err); err != nil {
1971 s.pages.Notice(w, "pull-merge-error", err.Error())
1972 return
1973 }
1974
1975 tx, err := s.db.Begin()
1976 if err != nil {
1977 log.Println("failed to start transcation", err)
1978 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1979 return
1980 }
1981 defer tx.Rollback()
1982
1983 for _, p := range pullsToMerge {
1984 err := db.MergePull(tx, f.RepoAt(), p.PullId)
1985 if err != nil {
1986 log.Printf("failed to update pull request status in database: %s", err)
1987 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1988 return
1989 }
1990 }
1991
1992 err = tx.Commit()
1993 if err != nil {
1994 // TODO: this is unsound, we should also revert the merge from the knotserver here
1995 log.Printf("failed to update pull request status in database: %s", err)
1996 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1997 return
1998 }
1999
2000 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId))
2001}
2002
2003func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) {
2004 user := s.oauth.GetUser(r)
2005
2006 f, err := s.repoResolver.Resolve(r)
2007 if err != nil {
2008 log.Println("malformed middleware")
2009 return
2010 }
2011
2012 pull, ok := r.Context().Value("pull").(*db.Pull)
2013 if !ok {
2014 log.Println("failed to get pull")
2015 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
2016 return
2017 }
2018
2019 // auth filter: only owner or collaborators can close
2020 roles := f.RolesInRepo(user)
2021 isOwner := roles.IsOwner()
2022 isCollaborator := roles.IsCollaborator()
2023 isPullAuthor := user.Did == pull.OwnerDid
2024 isCloseAllowed := isOwner || isCollaborator || isPullAuthor
2025 if !isCloseAllowed {
2026 log.Println("failed to close pull")
2027 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
2028 return
2029 }
2030
2031 // Start a transaction
2032 tx, err := s.db.BeginTx(r.Context(), nil)
2033 if err != nil {
2034 log.Println("failed to start transaction", err)
2035 s.pages.Notice(w, "pull-close", "Failed to close pull.")
2036 return
2037 }
2038 defer tx.Rollback()
2039
2040 var pullsToClose []*db.Pull
2041 pullsToClose = append(pullsToClose, pull)
2042
2043 // if this PR is stacked, then we want to close all PRs below this one on the stack
2044 if pull.IsStacked() {
2045 stack := r.Context().Value("stack").(db.Stack)
2046 subStack := stack.StrictlyBelow(pull)
2047 pullsToClose = append(pullsToClose, subStack...)
2048 }
2049
2050 for _, p := range pullsToClose {
2051 // Close the pull in the database
2052 err = db.ClosePull(tx, f.RepoAt(), p.PullId)
2053 if err != nil {
2054 log.Println("failed to close pull", err)
2055 s.pages.Notice(w, "pull-close", "Failed to close pull.")
2056 return
2057 }
2058 }
2059
2060 // Commit the transaction
2061 if err = tx.Commit(); err != nil {
2062 log.Println("failed to commit transaction", err)
2063 s.pages.Notice(w, "pull-close", "Failed to close pull.")
2064 return
2065 }
2066
2067 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2068}
2069
2070func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) {
2071 user := s.oauth.GetUser(r)
2072
2073 f, err := s.repoResolver.Resolve(r)
2074 if err != nil {
2075 log.Println("failed to resolve repo", err)
2076 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2077 return
2078 }
2079
2080 pull, ok := r.Context().Value("pull").(*db.Pull)
2081 if !ok {
2082 log.Println("failed to get pull")
2083 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
2084 return
2085 }
2086
2087 // auth filter: only owner or collaborators can close
2088 roles := f.RolesInRepo(user)
2089 isOwner := roles.IsOwner()
2090 isCollaborator := roles.IsCollaborator()
2091 isPullAuthor := user.Did == pull.OwnerDid
2092 isCloseAllowed := isOwner || isCollaborator || isPullAuthor
2093 if !isCloseAllowed {
2094 log.Println("failed to close pull")
2095 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
2096 return
2097 }
2098
2099 // Start a transaction
2100 tx, err := s.db.BeginTx(r.Context(), nil)
2101 if err != nil {
2102 log.Println("failed to start transaction", err)
2103 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2104 return
2105 }
2106 defer tx.Rollback()
2107
2108 var pullsToReopen []*db.Pull
2109 pullsToReopen = append(pullsToReopen, pull)
2110
2111 // if this PR is stacked, then we want to reopen all PRs above this one on the stack
2112 if pull.IsStacked() {
2113 stack := r.Context().Value("stack").(db.Stack)
2114 subStack := stack.StrictlyAbove(pull)
2115 pullsToReopen = append(pullsToReopen, subStack...)
2116 }
2117
2118 for _, p := range pullsToReopen {
2119 // Close the pull in the database
2120 err = db.ReopenPull(tx, f.RepoAt(), p.PullId)
2121 if err != nil {
2122 log.Println("failed to close pull", err)
2123 s.pages.Notice(w, "pull-close", "Failed to close pull.")
2124 return
2125 }
2126 }
2127
2128 // Commit the transaction
2129 if err = tx.Commit(); err != nil {
2130 log.Println("failed to commit transaction", err)
2131 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2132 return
2133 }
2134
2135 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2136}
2137
2138func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) {
2139 formatPatches, err := patchutil.ExtractPatches(patch)
2140 if err != nil {
2141 return nil, fmt.Errorf("Failed to extract patches: %v", err)
2142 }
2143
2144 // must have atleast 1 patch to begin with
2145 if len(formatPatches) == 0 {
2146 return nil, fmt.Errorf("No patches found in the generated format-patch.")
2147 }
2148
2149 // the stack is identified by a UUID
2150 var stack db.Stack
2151 parentChangeId := ""
2152 for _, fp := range formatPatches {
2153 // all patches must have a jj change-id
2154 changeId, err := fp.ChangeId()
2155 if err != nil {
2156 return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.")
2157 }
2158
2159 title := fp.Title
2160 body := fp.Body
2161 rkey := tid.TID()
2162
2163 initialSubmission := db.PullSubmission{
2164 Patch: fp.Raw,
2165 SourceRev: fp.SHA,
2166 }
2167 pull := db.Pull{
2168 Title: title,
2169 Body: body,
2170 TargetBranch: targetBranch,
2171 OwnerDid: user.Did,
2172 RepoAt: f.RepoAt(),
2173 Rkey: rkey,
2174 Submissions: []*db.PullSubmission{
2175 &initialSubmission,
2176 },
2177 PullSource: pullSource,
2178 Created: time.Now(),
2179
2180 StackId: stackId,
2181 ChangeId: changeId,
2182 ParentChangeId: parentChangeId,
2183 }
2184
2185 stack = append(stack, &pull)
2186
2187 parentChangeId = changeId
2188 }
2189
2190 return stack, nil
2191}