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