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