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