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