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