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