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