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