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