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