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