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