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