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