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