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