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