forked from
tangled.org/core
fork
Configure Feed
Select the types of activity you want to include in your feed.
Monorepo for Tangled
fork
Configure Feed
Select the types of activity you want to include in your feed.
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}