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