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