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