this repo has no description
1package pulls
2
3import (
4 "database/sql"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "log"
9 "log/slog"
10 "net/http"
11 "slices"
12 "sort"
13 "strconv"
14 "strings"
15 "time"
16
17 "tangled.org/core/api/tangled"
18 "tangled.org/core/appview/config"
19 "tangled.org/core/appview/db"
20 pulls_indexer "tangled.org/core/appview/indexer/pulls"
21 "tangled.org/core/appview/models"
22 "tangled.org/core/appview/notify"
23 "tangled.org/core/appview/oauth"
24 "tangled.org/core/appview/pages"
25 "tangled.org/core/appview/pages/markup"
26 "tangled.org/core/appview/reporesolver"
27 "tangled.org/core/appview/validator"
28 "tangled.org/core/appview/xrpcclient"
29 "tangled.org/core/idresolver"
30 "tangled.org/core/patchutil"
31 "tangled.org/core/rbac"
32 "tangled.org/core/tid"
33 "tangled.org/core/types"
34
35 comatproto "github.com/bluesky-social/indigo/api/atproto"
36 "github.com/bluesky-social/indigo/atproto/syntax"
37 lexutil "github.com/bluesky-social/indigo/lex/util"
38 indigoxrpc "github.com/bluesky-social/indigo/xrpc"
39 "github.com/go-chi/chi/v5"
40 "github.com/google/uuid"
41)
42
43type Pulls struct {
44 oauth *oauth.OAuth
45 repoResolver *reporesolver.RepoResolver
46 pages *pages.Pages
47 idResolver *idresolver.Resolver
48 db *db.DB
49 config *config.Config
50 notifier notify.Notifier
51 enforcer *rbac.Enforcer
52 logger *slog.Logger
53 validator *validator.Validator
54 indexer *pulls_indexer.Indexer
55}
56
57func New(
58 oauth *oauth.OAuth,
59 repoResolver *reporesolver.RepoResolver,
60 pages *pages.Pages,
61 resolver *idresolver.Resolver,
62 db *db.DB,
63 config *config.Config,
64 notifier notify.Notifier,
65 enforcer *rbac.Enforcer,
66 validator *validator.Validator,
67 indexer *pulls_indexer.Indexer,
68 logger *slog.Logger,
69) *Pulls {
70 return &Pulls{
71 oauth: oauth,
72 repoResolver: repoResolver,
73 pages: pages,
74 idResolver: resolver,
75 db: db,
76 config: config,
77 notifier: notifier,
78 enforcer: enforcer,
79 logger: logger,
80 validator: validator,
81 indexer: indexer,
82 }
83}
84
85// htmx fragment
86func (s *Pulls) PullActions(w http.ResponseWriter, r *http.Request) {
87 switch r.Method {
88 case http.MethodGet:
89 user := s.oauth.GetUser(r)
90 f, err := s.repoResolver.Resolve(r)
91 if err != nil {
92 log.Println("failed to get repo and knot", err)
93 return
94 }
95
96 pull, ok := r.Context().Value("pull").(*models.Pull)
97 if !ok {
98 log.Println("failed to get pull")
99 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
100 return
101 }
102
103 // can be nil if this pull is not stacked
104 stack, _ := r.Context().Value("stack").(models.Stack)
105
106 roundNumberStr := chi.URLParam(r, "round")
107 roundNumber, err := strconv.Atoi(roundNumberStr)
108 if err != nil {
109 roundNumber = pull.LastRoundNumber()
110 }
111 if roundNumber >= len(pull.Submissions) {
112 http.Error(w, "bad round id", http.StatusBadRequest)
113 log.Println("failed to parse round id", err)
114 return
115 }
116
117 mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
118 branchDeleteStatus := s.branchDeleteStatus(r, f, pull)
119 resubmitResult := pages.Unknown
120 if user.Did == pull.OwnerDid {
121 resubmitResult = s.resubmitCheck(r, f, pull, stack)
122 }
123
124 s.pages.PullActionsFragment(w, pages.PullActionsParams{
125 LoggedInUser: user,
126 RepoInfo: f.RepoInfo(user),
127 Pull: pull,
128 RoundNumber: roundNumber,
129 MergeCheck: mergeCheckResponse,
130 ResubmitCheck: resubmitResult,
131 BranchDeleteStatus: branchDeleteStatus,
132 Stack: stack,
133 })
134 return
135 }
136}
137
138func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) {
139 user := s.oauth.GetUser(r)
140 f, err := s.repoResolver.Resolve(r)
141 if err != nil {
142 log.Println("failed to get repo and knot", err)
143 return
144 }
145
146 pull, ok := r.Context().Value("pull").(*models.Pull)
147 if !ok {
148 log.Println("failed to get pull")
149 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
150 return
151 }
152
153 // can be nil if this pull is not stacked
154 stack, _ := r.Context().Value("stack").(models.Stack)
155 abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull)
156
157 mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
158 branchDeleteStatus := s.branchDeleteStatus(r, f, pull)
159 resubmitResult := pages.Unknown
160 if user != nil && user.Did == pull.OwnerDid {
161 resubmitResult = s.resubmitCheck(r, f, pull, stack)
162 }
163
164 repoInfo := f.RepoInfo(user)
165
166 m := make(map[string]models.Pipeline)
167
168 var shas []string
169 for _, s := range pull.Submissions {
170 shas = append(shas, s.SourceRev)
171 }
172 for _, p := range stack {
173 shas = append(shas, p.LatestSha())
174 }
175 for _, p := range abandonedPulls {
176 shas = append(shas, p.LatestSha())
177 }
178
179 ps, err := db.GetPipelineStatuses(
180 s.db,
181 len(shas),
182 db.FilterEq("repo_owner", repoInfo.OwnerDid),
183 db.FilterEq("repo_name", repoInfo.Name),
184 db.FilterEq("knot", repoInfo.Knot),
185 db.FilterIn("sha", shas),
186 )
187 if err != nil {
188 log.Printf("failed to fetch pipeline statuses: %s", err)
189 // non-fatal
190 }
191
192 for _, p := range ps {
193 m[p.Sha] = p
194 }
195
196 reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri())
197 if err != nil {
198 log.Println("failed to get pull reactions")
199 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.")
200 }
201
202 userReactions := map[models.ReactionKind]bool{}
203 if user != nil {
204 userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.AtUri())
205 }
206
207 labelDefs, err := db.GetLabelDefinitions(
208 s.db,
209 db.FilterIn("at_uri", f.Repo.Labels),
210 db.FilterContains("scope", tangled.RepoPullNSID),
211 )
212 if err != nil {
213 log.Println("failed to fetch labels", err)
214 s.pages.Error503(w)
215 return
216 }
217
218 defs := make(map[string]*models.LabelDefinition)
219 for _, l := range labelDefs {
220 defs[l.AtUri().String()] = &l
221 }
222
223 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
224 LoggedInUser: user,
225 RepoInfo: repoInfo,
226 Pull: pull,
227 Stack: stack,
228 AbandonedPulls: abandonedPulls,
229 BranchDeleteStatus: branchDeleteStatus,
230 MergeCheck: mergeCheckResponse,
231 ResubmitCheck: resubmitResult,
232 Pipelines: m,
233
234 OrderedReactionKinds: models.OrderedReactionKinds,
235 Reactions: reactionMap,
236 UserReacted: userReactions,
237
238 LabelDefs: defs,
239 })
240}
241
242func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse {
243 if pull.State == models.PullMerged {
244 return types.MergeCheckResponse{}
245 }
246
247 scheme := "https"
248 if s.config.Core.Dev {
249 scheme = "http"
250 }
251 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
252
253 xrpcc := indigoxrpc.Client{
254 Host: host,
255 }
256
257 patch := pull.LatestPatch()
258 if pull.IsStacked() {
259 // combine patches of substack
260 subStack := stack.Below(pull)
261 // collect the portion of the stack that is mergeable
262 mergeable := subStack.Mergeable()
263 // combine each patch
264 patch = mergeable.CombinedPatch()
265 }
266
267 resp, xe := tangled.RepoMergeCheck(
268 r.Context(),
269 &xrpcc,
270 &tangled.RepoMergeCheck_Input{
271 Did: f.Did,
272 Name: f.Name,
273 Branch: pull.TargetBranch,
274 Patch: patch,
275 },
276 )
277 if err := xrpcclient.HandleXrpcErr(xe); err != nil {
278 log.Println("failed to check for mergeability", "err", err)
279 return types.MergeCheckResponse{
280 Error: fmt.Sprintf("failed to check merge status: %s", err.Error()),
281 }
282 }
283
284 // convert xrpc response to internal types
285 conflicts := make([]types.ConflictInfo, len(resp.Conflicts))
286 for i, conflict := range resp.Conflicts {
287 conflicts[i] = types.ConflictInfo{
288 Filename: conflict.Filename,
289 Reason: conflict.Reason,
290 }
291 }
292
293 result := types.MergeCheckResponse{
294 IsConflicted: resp.Is_conflicted,
295 Conflicts: conflicts,
296 }
297
298 if resp.Message != nil {
299 result.Message = *resp.Message
300 }
301
302 if resp.Error != nil {
303 result.Error = *resp.Error
304 }
305
306 return result
307}
308
309func (s *Pulls) branchDeleteStatus(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull) *models.BranchDeleteStatus {
310 if pull.State != models.PullMerged {
311 return nil
312 }
313
314 user := s.oauth.GetUser(r)
315 if user == nil {
316 return nil
317 }
318
319 var branch string
320 var repo *models.Repo
321 // check if the branch exists
322 // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates
323 if pull.IsBranchBased() {
324 branch = pull.PullSource.Branch
325 repo = &f.Repo
326 } else if pull.IsForkBased() {
327 branch = pull.PullSource.Branch
328 repo = pull.PullSource.Repo
329 } else {
330 return nil
331 }
332
333 // deleted fork
334 if repo == nil {
335 return nil
336 }
337
338 // user can only delete branch if they are a collaborator in the repo that the branch belongs to
339 perms := s.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.DidSlashRepo())
340 if !slices.Contains(perms, "repo:push") {
341 return nil
342 }
343
344 scheme := "http"
345 if !s.config.Core.Dev {
346 scheme = "https"
347 }
348 host := fmt.Sprintf("%s://%s", scheme, repo.Knot)
349 xrpcc := &indigoxrpc.Client{
350 Host: host,
351 }
352
353 resp, err := tangled.RepoBranch(r.Context(), xrpcc, branch, fmt.Sprintf("%s/%s", repo.Did, repo.Name))
354 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
355 return nil
356 }
357
358 return &models.BranchDeleteStatus{
359 Repo: repo,
360 Branch: resp.Name,
361 }
362}
363
364func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult {
365 if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil {
366 return pages.Unknown
367 }
368
369 var knot, ownerDid, repoName string
370
371 if pull.PullSource.RepoAt != nil {
372 // fork-based pulls
373 sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
374 if err != nil {
375 log.Println("failed to get source repo", err)
376 return pages.Unknown
377 }
378
379 knot = sourceRepo.Knot
380 ownerDid = sourceRepo.Did
381 repoName = sourceRepo.Name
382 } else {
383 // pulls within the same repo
384 knot = f.Knot
385 ownerDid = f.Did
386 repoName = f.Name
387 }
388
389 scheme := "http"
390 if !s.config.Core.Dev {
391 scheme = "https"
392 }
393 host := fmt.Sprintf("%s://%s", scheme, knot)
394 xrpcc := &indigoxrpc.Client{
395 Host: host,
396 }
397
398 repo := fmt.Sprintf("%s/%s", ownerDid, repoName)
399 branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, repo)
400 if err != nil {
401 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
402 log.Println("failed to call XRPC repo.branches", xrpcerr)
403 return pages.Unknown
404 }
405 log.Println("failed to reach knotserver", err)
406 return pages.Unknown
407 }
408
409 targetBranch := branchResp
410
411 latestSourceRev := pull.LatestSha()
412
413 if pull.IsStacked() && stack != nil {
414 top := stack[0]
415 latestSourceRev = top.LatestSha()
416 }
417
418 if latestSourceRev != targetBranch.Hash {
419 return pages.ShouldResubmit
420 }
421
422 return pages.ShouldNotResubmit
423}
424
425func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
426 user := s.oauth.GetUser(r)
427 f, err := s.repoResolver.Resolve(r)
428 if err != nil {
429 log.Println("failed to get repo and knot", err)
430 return
431 }
432
433 var diffOpts types.DiffOpts
434 if d := r.URL.Query().Get("diff"); d == "split" {
435 diffOpts.Split = true
436 }
437
438 pull, ok := r.Context().Value("pull").(*models.Pull)
439 if !ok {
440 log.Println("failed to get pull")
441 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
442 return
443 }
444
445 stack, _ := r.Context().Value("stack").(models.Stack)
446
447 roundId := chi.URLParam(r, "round")
448 roundIdInt, err := strconv.Atoi(roundId)
449 if err != nil || roundIdInt >= len(pull.Submissions) {
450 http.Error(w, "bad round id", http.StatusBadRequest)
451 log.Println("failed to parse round id", err)
452 return
453 }
454
455 patch := pull.Submissions[roundIdInt].CombinedPatch()
456 diff := patchutil.AsNiceDiff(patch, pull.TargetBranch)
457
458 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
459 LoggedInUser: user,
460 RepoInfo: f.RepoInfo(user),
461 Pull: pull,
462 Stack: stack,
463 Round: roundIdInt,
464 Submission: pull.Submissions[roundIdInt],
465 Diff: &diff,
466 DiffOpts: diffOpts,
467 })
468
469}
470
471func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) {
472 user := s.oauth.GetUser(r)
473
474 f, err := s.repoResolver.Resolve(r)
475 if err != nil {
476 log.Println("failed to get repo and knot", err)
477 return
478 }
479
480 var diffOpts types.DiffOpts
481 if d := r.URL.Query().Get("diff"); d == "split" {
482 diffOpts.Split = true
483 }
484
485 pull, ok := r.Context().Value("pull").(*models.Pull)
486 if !ok {
487 log.Println("failed to get pull")
488 s.pages.Notice(w, "pull-error", "Failed to get pull.")
489 return
490 }
491
492 roundId := chi.URLParam(r, "round")
493 roundIdInt, err := strconv.Atoi(roundId)
494 if err != nil || roundIdInt >= len(pull.Submissions) {
495 http.Error(w, "bad round id", http.StatusBadRequest)
496 log.Println("failed to parse round id", err)
497 return
498 }
499
500 if roundIdInt == 0 {
501 http.Error(w, "bad round id", http.StatusBadRequest)
502 log.Println("cannot interdiff initial submission")
503 return
504 }
505
506 currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch())
507 if err != nil {
508 log.Println("failed to interdiff; current patch malformed")
509 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.")
510 return
511 }
512
513 previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch())
514 if err != nil {
515 log.Println("failed to interdiff; previous patch malformed")
516 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.")
517 return
518 }
519
520 interdiff := patchutil.Interdiff(previousPatch, currentPatch)
521
522 s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{
523 LoggedInUser: s.oauth.GetUser(r),
524 RepoInfo: f.RepoInfo(user),
525 Pull: pull,
526 Round: roundIdInt,
527 Interdiff: interdiff,
528 DiffOpts: diffOpts,
529 })
530}
531
532func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
533 pull, ok := r.Context().Value("pull").(*models.Pull)
534 if !ok {
535 log.Println("failed to get pull")
536 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
537 return
538 }
539
540 roundId := chi.URLParam(r, "round")
541 roundIdInt, err := strconv.Atoi(roundId)
542 if err != nil || roundIdInt >= len(pull.Submissions) {
543 http.Error(w, "bad round id", http.StatusBadRequest)
544 log.Println("failed to parse round id", err)
545 return
546 }
547
548 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
549 w.Write([]byte(pull.Submissions[roundIdInt].Patch))
550}
551
552func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) {
553 l := s.logger.With("handler", "RepoPulls")
554
555 user := s.oauth.GetUser(r)
556 params := r.URL.Query()
557
558 state := models.PullOpen
559 switch params.Get("state") {
560 case "closed":
561 state = models.PullClosed
562 case "merged":
563 state = models.PullMerged
564 }
565
566 f, err := s.repoResolver.Resolve(r)
567 if err != nil {
568 log.Println("failed to get repo and knot", err)
569 return
570 }
571
572 keyword := params.Get("q")
573
574 var ids []int64
575 searchOpts := models.PullSearchOptions{
576 Keyword: keyword,
577 RepoAt: f.RepoAt().String(),
578 State: state,
579 // Page: page,
580 }
581 l.Debug("searching with", "searchOpts", searchOpts)
582 if keyword != "" {
583 res, err := s.indexer.Search(r.Context(), searchOpts)
584 if err != nil {
585 l.Error("failed to search for pulls", "err", err)
586 return
587 }
588 ids = res.Hits
589 l.Debug("searched pulls with indexer", "count", len(ids))
590 } else {
591 ids, err = db.GetPullIDs(s.db, searchOpts)
592 if err != nil {
593 l.Error("failed to get all pull ids", "err", err)
594 return
595 }
596 l.Debug("indexed all pulls from the db", "count", len(ids))
597 }
598
599 pulls, err := db.GetPulls(
600 s.db,
601 db.FilterIn("id", ids),
602 )
603 if err != nil {
604 log.Println("failed to get pulls", err)
605 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
606 return
607 }
608
609 for _, p := range pulls {
610 var pullSourceRepo *models.Repo
611 if p.PullSource != nil {
612 if p.PullSource.RepoAt != nil {
613 pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String())
614 if err != nil {
615 log.Printf("failed to get repo by at uri: %v", err)
616 continue
617 } else {
618 p.PullSource.Repo = pullSourceRepo
619 }
620 }
621 }
622 }
623
624 // we want to group all stacked PRs into just one list
625 stacks := make(map[string]models.Stack)
626 var shas []string
627 n := 0
628 for _, p := range pulls {
629 // store the sha for later
630 shas = append(shas, p.LatestSha())
631 // this PR is stacked
632 if p.StackId != "" {
633 // we have already seen this PR stack
634 if _, seen := stacks[p.StackId]; seen {
635 stacks[p.StackId] = append(stacks[p.StackId], p)
636 // skip this PR
637 } else {
638 stacks[p.StackId] = nil
639 pulls[n] = p
640 n++
641 }
642 } else {
643 pulls[n] = p
644 n++
645 }
646 }
647 pulls = pulls[:n]
648
649 repoInfo := f.RepoInfo(user)
650 ps, err := db.GetPipelineStatuses(
651 s.db,
652 len(shas),
653 db.FilterEq("repo_owner", repoInfo.OwnerDid),
654 db.FilterEq("repo_name", repoInfo.Name),
655 db.FilterEq("knot", repoInfo.Knot),
656 db.FilterIn("sha", shas),
657 )
658 if err != nil {
659 log.Printf("failed to fetch pipeline statuses: %s", err)
660 // non-fatal
661 }
662 m := make(map[string]models.Pipeline)
663 for _, p := range ps {
664 m[p.Sha] = p
665 }
666
667 labelDefs, err := db.GetLabelDefinitions(
668 s.db,
669 db.FilterIn("at_uri", f.Repo.Labels),
670 db.FilterContains("scope", tangled.RepoPullNSID),
671 )
672 if err != nil {
673 log.Println("failed to fetch labels", err)
674 s.pages.Error503(w)
675 return
676 }
677
678 defs := make(map[string]*models.LabelDefinition)
679 for _, l := range labelDefs {
680 defs[l.AtUri().String()] = &l
681 }
682
683 s.pages.RepoPulls(w, pages.RepoPullsParams{
684 LoggedInUser: s.oauth.GetUser(r),
685 RepoInfo: f.RepoInfo(user),
686 Pulls: pulls,
687 LabelDefs: defs,
688 FilteringBy: state,
689 FilterQuery: keyword,
690 Stacks: stacks,
691 Pipelines: m,
692 })
693}
694
695func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) {
696 l := s.logger.With("handler", "PullComment")
697 user := s.oauth.GetUser(r)
698 f, err := s.repoResolver.Resolve(r)
699 if err != nil {
700 log.Println("failed to get repo and knot", err)
701 return
702 }
703
704 pull, ok := r.Context().Value("pull").(*models.Pull)
705 if !ok {
706 log.Println("failed to get pull")
707 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
708 return
709 }
710
711 roundNumberStr := chi.URLParam(r, "round")
712 roundNumber, err := strconv.Atoi(roundNumberStr)
713 if err != nil || roundNumber >= len(pull.Submissions) {
714 http.Error(w, "bad round id", http.StatusBadRequest)
715 log.Println("failed to parse round id", err)
716 return
717 }
718
719 switch r.Method {
720 case http.MethodGet:
721 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{
722 LoggedInUser: user,
723 RepoInfo: f.RepoInfo(user),
724 Pull: pull,
725 RoundNumber: roundNumber,
726 })
727 return
728 case http.MethodPost:
729 body := r.FormValue("body")
730 if body == "" {
731 s.pages.Notice(w, "pull", "Comment body is required")
732 return
733 }
734
735 // Start a transaction
736 tx, err := s.db.BeginTx(r.Context(), nil)
737 if err != nil {
738 log.Println("failed to start transaction", err)
739 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
740 return
741 }
742 defer tx.Rollback()
743
744 createdAt := time.Now().Format(time.RFC3339)
745
746 client, err := s.oauth.AuthorizedClient(r)
747 if err != nil {
748 log.Println("failed to get authorized client", err)
749 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
750 return
751 }
752 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
753 Collection: tangled.RepoPullCommentNSID,
754 Repo: user.Did,
755 Rkey: tid.TID(),
756 Record: &lexutil.LexiconTypeDecoder{
757 Val: &tangled.RepoPullComment{
758 Pull: pull.AtUri().String(),
759 Body: body,
760 CreatedAt: createdAt,
761 },
762 },
763 })
764 if err != nil {
765 log.Println("failed to create pull comment", err)
766 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
767 return
768 }
769
770 comment := &models.PullComment{
771 OwnerDid: user.Did,
772 RepoAt: f.RepoAt().String(),
773 PullId: pull.PullId,
774 Body: body,
775 CommentAt: atResp.Uri,
776 SubmissionId: pull.Submissions[roundNumber].ID,
777 }
778
779 // Create the pull comment in the database with the commentAt field
780 commentId, err := db.NewPullComment(tx, comment)
781 if err != nil {
782 log.Println("failed to create pull comment", err)
783 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
784 return
785 }
786
787 // Commit the transaction
788 if err = tx.Commit(); err != nil {
789 log.Println("failed to commit transaction", err)
790 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
791 return
792 }
793
794 rawMentions := markup.FindUserMentions(comment.Body)
795 idents := s.idResolver.ResolveIdents(r.Context(), rawMentions)
796 l.Debug("parsed mentions", "raw", rawMentions, "idents", idents)
797 var mentions []syntax.DID
798 for _, ident := range idents {
799 if ident != nil && !ident.Handle.IsInvalidHandle() {
800 mentions = append(mentions, ident.DID)
801 }
802 }
803 s.notifier.NewPullComment(r.Context(), comment, mentions)
804
805 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, &f.Repo)
806 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId))
807 return
808 }
809}
810
811func (s *Pulls) NewPull(w http.ResponseWriter, r *http.Request) {
812 user := s.oauth.GetUser(r)
813 f, err := s.repoResolver.Resolve(r)
814 if err != nil {
815 log.Println("failed to get repo and knot", err)
816 return
817 }
818
819 switch r.Method {
820 case http.MethodGet:
821 scheme := "http"
822 if !s.config.Core.Dev {
823 scheme = "https"
824 }
825 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
826 xrpcc := &indigoxrpc.Client{
827 Host: host,
828 }
829
830 repo := fmt.Sprintf("%s/%s", f.Did, f.Name)
831 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
832 if err != nil {
833 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
834 log.Println("failed to call XRPC repo.branches", xrpcerr)
835 s.pages.Error503(w)
836 return
837 }
838 log.Println("failed to fetch branches", err)
839 return
840 }
841
842 var result types.RepoBranchesResponse
843 if err := json.Unmarshal(xrpcBytes, &result); err != nil {
844 log.Println("failed to decode XRPC response", err)
845 s.pages.Error503(w)
846 return
847 }
848
849 // can be one of "patch", "branch" or "fork"
850 strategy := r.URL.Query().Get("strategy")
851 // ignored if strategy is "patch"
852 sourceBranch := r.URL.Query().Get("sourceBranch")
853 targetBranch := r.URL.Query().Get("targetBranch")
854
855 s.pages.RepoNewPull(w, pages.RepoNewPullParams{
856 LoggedInUser: user,
857 RepoInfo: f.RepoInfo(user),
858 Branches: result.Branches,
859 Strategy: strategy,
860 SourceBranch: sourceBranch,
861 TargetBranch: targetBranch,
862 Title: r.URL.Query().Get("title"),
863 Body: r.URL.Query().Get("body"),
864 })
865
866 case http.MethodPost:
867 title := r.FormValue("title")
868 body := r.FormValue("body")
869 targetBranch := r.FormValue("targetBranch")
870 fromFork := r.FormValue("fork")
871 sourceBranch := r.FormValue("sourceBranch")
872 patch := r.FormValue("patch")
873
874 if targetBranch == "" {
875 s.pages.Notice(w, "pull", "Target branch is required.")
876 return
877 }
878
879 // Determine PR type based on input parameters
880 isPushAllowed := f.RepoInfo(user).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 if !f.RepoInfo(user).Roles.IsPushAllowed() {
1677 log.Println("unauthorized user")
1678 w.WriteHeader(http.StatusUnauthorized)
1679 return
1680 }
1681
1682 scheme := "http"
1683 if !s.config.Core.Dev {
1684 scheme = "https"
1685 }
1686 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
1687 xrpcc := &indigoxrpc.Client{
1688 Host: host,
1689 }
1690
1691 repo := fmt.Sprintf("%s/%s", f.Did, f.Name)
1692 xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, pull.TargetBranch, pull.PullSource.Branch)
1693 if err != nil {
1694 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1695 log.Println("failed to call XRPC repo.compare", xrpcerr)
1696 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1697 return
1698 }
1699 log.Printf("compare request failed: %s", err)
1700 s.pages.Notice(w, "resubmit-error", err.Error())
1701 return
1702 }
1703
1704 var comparison types.RepoFormatPatchResponse
1705 if err := json.Unmarshal(xrpcBytes, &comparison); err != nil {
1706 log.Println("failed to decode XRPC compare response", err)
1707 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1708 return
1709 }
1710
1711 sourceRev := comparison.Rev2
1712 patch := comparison.FormatPatchRaw
1713 combined := comparison.CombinedPatchRaw
1714
1715 s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev)
1716}
1717
1718func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) {
1719 user := s.oauth.GetUser(r)
1720
1721 pull, ok := r.Context().Value("pull").(*models.Pull)
1722 if !ok {
1723 log.Println("failed to get pull")
1724 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1725 return
1726 }
1727
1728 f, err := s.repoResolver.Resolve(r)
1729 if err != nil {
1730 log.Println("failed to get repo and knot", err)
1731 return
1732 }
1733
1734 if user.Did != pull.OwnerDid {
1735 log.Println("unauthorized user")
1736 w.WriteHeader(http.StatusUnauthorized)
1737 return
1738 }
1739
1740 forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
1741 if err != nil {
1742 log.Println("failed to get source repo", err)
1743 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1744 return
1745 }
1746
1747 // update the hidden tracking branch to latest
1748 client, err := s.oauth.ServiceClient(
1749 r,
1750 oauth.WithService(forkRepo.Knot),
1751 oauth.WithLxm(tangled.RepoHiddenRefNSID),
1752 oauth.WithDev(s.config.Core.Dev),
1753 )
1754 if err != nil {
1755 log.Printf("failed to connect to knot server: %v", err)
1756 return
1757 }
1758
1759 resp, err := tangled.RepoHiddenRef(
1760 r.Context(),
1761 client,
1762 &tangled.RepoHiddenRef_Input{
1763 ForkRef: pull.PullSource.Branch,
1764 RemoteRef: pull.TargetBranch,
1765 Repo: forkRepo.RepoAt().String(),
1766 },
1767 )
1768 if err := xrpcclient.HandleXrpcErr(err); err != nil {
1769 s.pages.Notice(w, "resubmit-error", err.Error())
1770 return
1771 }
1772 if !resp.Success {
1773 log.Println("Failed to update tracking ref.", "err", resp.Error)
1774 s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.")
1775 return
1776 }
1777
1778 hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch)
1779 // extract patch by performing compare
1780 forkScheme := "http"
1781 if !s.config.Core.Dev {
1782 forkScheme = "https"
1783 }
1784 forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot)
1785 forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name)
1786 forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, hiddenRef, pull.PullSource.Branch)
1787 if err != nil {
1788 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1789 log.Println("failed to call XRPC repo.compare for fork", xrpcerr)
1790 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1791 return
1792 }
1793 log.Printf("failed to compare branches: %s", err)
1794 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1795 return
1796 }
1797
1798 var forkComparison types.RepoFormatPatchResponse
1799 if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil {
1800 log.Println("failed to decode XRPC compare response for fork", err)
1801 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1802 return
1803 }
1804
1805 // Use the fork comparison we already made
1806 comparison := forkComparison
1807
1808 sourceRev := comparison.Rev2
1809 patch := comparison.FormatPatchRaw
1810 combined := comparison.CombinedPatchRaw
1811
1812 s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev)
1813}
1814
1815func (s *Pulls) resubmitPullHelper(
1816 w http.ResponseWriter,
1817 r *http.Request,
1818 f *reporesolver.ResolvedRepo,
1819 user *oauth.User,
1820 pull *models.Pull,
1821 patch string,
1822 combined string,
1823 sourceRev string,
1824) {
1825 if pull.IsStacked() {
1826 log.Println("resubmitting stacked PR")
1827 s.resubmitStackedPullHelper(w, r, f, user, pull, patch, pull.StackId)
1828 return
1829 }
1830
1831 if err := s.validator.ValidatePatch(&patch); err != nil {
1832 s.pages.Notice(w, "resubmit-error", err.Error())
1833 return
1834 }
1835
1836 if patch == pull.LatestPatch() {
1837 s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.")
1838 return
1839 }
1840
1841 // validate sourceRev if branch/fork based
1842 if pull.IsBranchBased() || pull.IsForkBased() {
1843 if sourceRev == pull.LatestSha() {
1844 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1845 return
1846 }
1847 }
1848
1849 tx, err := s.db.BeginTx(r.Context(), nil)
1850 if err != nil {
1851 log.Println("failed to start tx")
1852 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1853 return
1854 }
1855 defer tx.Rollback()
1856
1857 pullAt := pull.AtUri()
1858 newRoundNumber := len(pull.Submissions)
1859 newPatch := patch
1860 newSourceRev := sourceRev
1861 combinedPatch := combined
1862 err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev)
1863 if err != nil {
1864 log.Println("failed to create pull request", err)
1865 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1866 return
1867 }
1868 client, err := s.oauth.AuthorizedClient(r)
1869 if err != nil {
1870 log.Println("failed to authorize client")
1871 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1872 return
1873 }
1874
1875 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1876 if err != nil {
1877 // failed to get record
1878 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1879 return
1880 }
1881
1882 var recordPullSource *tangled.RepoPull_Source
1883 if pull.IsBranchBased() {
1884 recordPullSource = &tangled.RepoPull_Source{
1885 Branch: pull.PullSource.Branch,
1886 Sha: sourceRev,
1887 }
1888 }
1889 if pull.IsForkBased() {
1890 repoAt := pull.PullSource.RepoAt.String()
1891 recordPullSource = &tangled.RepoPull_Source{
1892 Branch: pull.PullSource.Branch,
1893 Repo: &repoAt,
1894 Sha: sourceRev,
1895 }
1896 }
1897
1898 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1899 Collection: tangled.RepoPullNSID,
1900 Repo: user.Did,
1901 Rkey: pull.Rkey,
1902 SwapRecord: ex.Cid,
1903 Record: &lexutil.LexiconTypeDecoder{
1904 Val: &tangled.RepoPull{
1905 Title: pull.Title,
1906 Target: &tangled.RepoPull_Target{
1907 Repo: string(f.RepoAt()),
1908 Branch: pull.TargetBranch,
1909 },
1910 Patch: patch, // new patch
1911 Source: recordPullSource,
1912 CreatedAt: time.Now().Format(time.RFC3339),
1913 },
1914 },
1915 })
1916 if err != nil {
1917 log.Println("failed to update record", err)
1918 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1919 return
1920 }
1921
1922 if err = tx.Commit(); err != nil {
1923 log.Println("failed to commit transaction", err)
1924 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1925 return
1926 }
1927
1928 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, &f.Repo)
1929 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
1930}
1931
1932func (s *Pulls) resubmitStackedPullHelper(
1933 w http.ResponseWriter,
1934 r *http.Request,
1935 f *reporesolver.ResolvedRepo,
1936 user *oauth.User,
1937 pull *models.Pull,
1938 patch string,
1939 stackId string,
1940) {
1941 targetBranch := pull.TargetBranch
1942
1943 origStack, _ := r.Context().Value("stack").(models.Stack)
1944 newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId)
1945 if err != nil {
1946 log.Println("failed to create resubmitted stack", err)
1947 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1948 return
1949 }
1950
1951 // find the diff between the stacks, first, map them by changeId
1952 origById := make(map[string]*models.Pull)
1953 newById := make(map[string]*models.Pull)
1954 for _, p := range origStack {
1955 origById[p.ChangeId] = p
1956 }
1957 for _, p := range newStack {
1958 newById[p.ChangeId] = p
1959 }
1960
1961 // commits that got deleted: corresponding pull is closed
1962 // commits that got added: new pull is created
1963 // commits that got updated: corresponding pull is resubmitted & new round begins
1964 additions := make(map[string]*models.Pull)
1965 deletions := make(map[string]*models.Pull)
1966 updated := make(map[string]struct{})
1967
1968 // pulls in orignal stack but not in new one
1969 for _, op := range origStack {
1970 if _, ok := newById[op.ChangeId]; !ok {
1971 deletions[op.ChangeId] = op
1972 }
1973 }
1974
1975 // pulls in new stack but not in original one
1976 for _, np := range newStack {
1977 if _, ok := origById[np.ChangeId]; !ok {
1978 additions[np.ChangeId] = np
1979 }
1980 }
1981
1982 // NOTE: this loop can be written in any of above blocks,
1983 // but is written separately in the interest of simpler code
1984 for _, np := range newStack {
1985 if op, ok := origById[np.ChangeId]; ok {
1986 // pull exists in both stacks
1987 updated[op.ChangeId] = struct{}{}
1988 }
1989 }
1990
1991 tx, err := s.db.Begin()
1992 if err != nil {
1993 log.Println("failed to start transaction", err)
1994 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1995 return
1996 }
1997 defer tx.Rollback()
1998
1999 // pds updates to make
2000 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
2001
2002 // deleted pulls are marked as deleted in the DB
2003 for _, p := range deletions {
2004 // do not do delete already merged PRs
2005 if p.State == models.PullMerged {
2006 continue
2007 }
2008
2009 err := db.DeletePull(tx, p.RepoAt, p.PullId)
2010 if err != nil {
2011 log.Println("failed to delete pull", err, p.PullId)
2012 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2013 return
2014 }
2015 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2016 RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{
2017 Collection: tangled.RepoPullNSID,
2018 Rkey: p.Rkey,
2019 },
2020 })
2021 }
2022
2023 // new pulls are created
2024 for _, p := range additions {
2025 err := db.NewPull(tx, p)
2026 if err != nil {
2027 log.Println("failed to create pull", err, p.PullId)
2028 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2029 return
2030 }
2031
2032 record := p.AsRecord()
2033 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2034 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
2035 Collection: tangled.RepoPullNSID,
2036 Rkey: &p.Rkey,
2037 Value: &lexutil.LexiconTypeDecoder{
2038 Val: &record,
2039 },
2040 },
2041 })
2042 }
2043
2044 // updated pulls are, well, updated; to start a new round
2045 for id := range updated {
2046 op, _ := origById[id]
2047 np, _ := newById[id]
2048
2049 // do not update already merged PRs
2050 if op.State == models.PullMerged {
2051 continue
2052 }
2053
2054 // resubmit the new pull
2055 pullAt := op.AtUri()
2056 newRoundNumber := len(op.Submissions)
2057 newPatch := np.LatestPatch()
2058 combinedPatch := np.LatestSubmission().Combined
2059 newSourceRev := np.LatestSha()
2060 err := db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev)
2061 if err != nil {
2062 log.Println("failed to update pull", err, op.PullId)
2063 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2064 return
2065 }
2066
2067 record := np.AsRecord()
2068
2069 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2070 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
2071 Collection: tangled.RepoPullNSID,
2072 Rkey: op.Rkey,
2073 Value: &lexutil.LexiconTypeDecoder{
2074 Val: &record,
2075 },
2076 },
2077 })
2078 }
2079
2080 // update parent-change-id relations for the entire stack
2081 for _, p := range newStack {
2082 err := db.SetPullParentChangeId(
2083 tx,
2084 p.ParentChangeId,
2085 // these should be enough filters to be unique per-stack
2086 db.FilterEq("repo_at", p.RepoAt.String()),
2087 db.FilterEq("owner_did", p.OwnerDid),
2088 db.FilterEq("change_id", p.ChangeId),
2089 )
2090
2091 if err != nil {
2092 log.Println("failed to update pull", err, p.PullId)
2093 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2094 return
2095 }
2096 }
2097
2098 err = tx.Commit()
2099 if err != nil {
2100 log.Println("failed to resubmit pull", err)
2101 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2102 return
2103 }
2104
2105 client, err := s.oauth.AuthorizedClient(r)
2106 if err != nil {
2107 log.Println("failed to authorize client")
2108 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
2109 return
2110 }
2111
2112 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
2113 Repo: user.Did,
2114 Writes: writes,
2115 })
2116 if err != nil {
2117 log.Println("failed to create stacked pull request", err)
2118 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.")
2119 return
2120 }
2121
2122 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, &f.Repo)
2123 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2124}
2125
2126func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
2127 user := s.oauth.GetUser(r)
2128 f, err := s.repoResolver.Resolve(r)
2129 if err != nil {
2130 log.Println("failed to resolve repo:", err)
2131 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
2132 return
2133 }
2134
2135 pull, ok := r.Context().Value("pull").(*models.Pull)
2136 if !ok {
2137 log.Println("failed to get pull")
2138 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
2139 return
2140 }
2141
2142 var pullsToMerge models.Stack
2143 pullsToMerge = append(pullsToMerge, pull)
2144 if pull.IsStacked() {
2145 stack, ok := r.Context().Value("stack").(models.Stack)
2146 if !ok {
2147 log.Println("failed to get stack")
2148 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
2149 return
2150 }
2151
2152 // combine patches of substack
2153 subStack := stack.StrictlyBelow(pull)
2154 // collect the portion of the stack that is mergeable
2155 mergeable := subStack.Mergeable()
2156 // add to total patch
2157 pullsToMerge = append(pullsToMerge, mergeable...)
2158 }
2159
2160 patch := pullsToMerge.CombinedPatch()
2161
2162 ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid)
2163 if err != nil {
2164 log.Printf("resolving identity: %s", err)
2165 w.WriteHeader(http.StatusNotFound)
2166 return
2167 }
2168
2169 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
2170 if err != nil {
2171 log.Printf("failed to get primary email: %s", err)
2172 }
2173
2174 authorName := ident.Handle.String()
2175 mergeInput := &tangled.RepoMerge_Input{
2176 Did: f.Did,
2177 Name: f.Name,
2178 Branch: pull.TargetBranch,
2179 Patch: patch,
2180 CommitMessage: &pull.Title,
2181 AuthorName: &authorName,
2182 }
2183
2184 if pull.Body != "" {
2185 mergeInput.CommitBody = &pull.Body
2186 }
2187
2188 if email.Address != "" {
2189 mergeInput.AuthorEmail = &email.Address
2190 }
2191
2192 client, err := s.oauth.ServiceClient(
2193 r,
2194 oauth.WithService(f.Knot),
2195 oauth.WithLxm(tangled.RepoMergeNSID),
2196 oauth.WithDev(s.config.Core.Dev),
2197 )
2198 if err != nil {
2199 log.Printf("failed to connect to knot server: %v", err)
2200 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
2201 return
2202 }
2203
2204 err = tangled.RepoMerge(r.Context(), client, mergeInput)
2205 if err := xrpcclient.HandleXrpcErr(err); err != nil {
2206 s.pages.Notice(w, "pull-merge-error", err.Error())
2207 return
2208 }
2209
2210 tx, err := s.db.Begin()
2211 if err != nil {
2212 log.Println("failed to start transcation", err)
2213 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
2214 return
2215 }
2216 defer tx.Rollback()
2217
2218 for _, p := range pullsToMerge {
2219 err := db.MergePull(tx, f.RepoAt(), p.PullId)
2220 if err != nil {
2221 log.Printf("failed to update pull request status in database: %s", err)
2222 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
2223 return
2224 }
2225 p.State = models.PullMerged
2226 }
2227
2228 err = tx.Commit()
2229 if err != nil {
2230 // TODO: this is unsound, we should also revert the merge from the knotserver here
2231 log.Printf("failed to update pull request status in database: %s", err)
2232 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
2233 return
2234 }
2235
2236 // notify about the pull merge
2237 for _, p := range pullsToMerge {
2238 s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2239 }
2240
2241 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, &f.Repo)
2242 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2243}
2244
2245func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) {
2246 user := s.oauth.GetUser(r)
2247
2248 f, err := s.repoResolver.Resolve(r)
2249 if err != nil {
2250 log.Println("malformed middleware")
2251 return
2252 }
2253
2254 pull, ok := r.Context().Value("pull").(*models.Pull)
2255 if !ok {
2256 log.Println("failed to get pull")
2257 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
2258 return
2259 }
2260
2261 // auth filter: only owner or collaborators can close
2262 roles := f.RolesInRepo(user)
2263 isOwner := roles.IsOwner()
2264 isCollaborator := roles.IsCollaborator()
2265 isPullAuthor := user.Did == pull.OwnerDid
2266 isCloseAllowed := isOwner || isCollaborator || isPullAuthor
2267 if !isCloseAllowed {
2268 log.Println("failed to close pull")
2269 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
2270 return
2271 }
2272
2273 // Start a transaction
2274 tx, err := s.db.BeginTx(r.Context(), nil)
2275 if err != nil {
2276 log.Println("failed to start transaction", err)
2277 s.pages.Notice(w, "pull-close", "Failed to close pull.")
2278 return
2279 }
2280 defer tx.Rollback()
2281
2282 var pullsToClose []*models.Pull
2283 pullsToClose = append(pullsToClose, pull)
2284
2285 // if this PR is stacked, then we want to close all PRs below this one on the stack
2286 if pull.IsStacked() {
2287 stack := r.Context().Value("stack").(models.Stack)
2288 subStack := stack.StrictlyBelow(pull)
2289 pullsToClose = append(pullsToClose, subStack...)
2290 }
2291
2292 for _, p := range pullsToClose {
2293 // Close the pull in the database
2294 err = db.ClosePull(tx, f.RepoAt(), p.PullId)
2295 if err != nil {
2296 log.Println("failed to close pull", err)
2297 s.pages.Notice(w, "pull-close", "Failed to close pull.")
2298 return
2299 }
2300 p.State = models.PullClosed
2301 }
2302
2303 // Commit the transaction
2304 if err = tx.Commit(); err != nil {
2305 log.Println("failed to commit transaction", err)
2306 s.pages.Notice(w, "pull-close", "Failed to close pull.")
2307 return
2308 }
2309
2310 for _, p := range pullsToClose {
2311 s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2312 }
2313
2314 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, &f.Repo)
2315 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2316}
2317
2318func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) {
2319 user := s.oauth.GetUser(r)
2320
2321 f, err := s.repoResolver.Resolve(r)
2322 if err != nil {
2323 log.Println("failed to resolve repo", err)
2324 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2325 return
2326 }
2327
2328 pull, ok := r.Context().Value("pull").(*models.Pull)
2329 if !ok {
2330 log.Println("failed to get pull")
2331 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
2332 return
2333 }
2334
2335 // auth filter: only owner or collaborators can close
2336 roles := f.RolesInRepo(user)
2337 isOwner := roles.IsOwner()
2338 isCollaborator := roles.IsCollaborator()
2339 isPullAuthor := user.Did == pull.OwnerDid
2340 isCloseAllowed := isOwner || isCollaborator || isPullAuthor
2341 if !isCloseAllowed {
2342 log.Println("failed to close pull")
2343 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
2344 return
2345 }
2346
2347 // Start a transaction
2348 tx, err := s.db.BeginTx(r.Context(), nil)
2349 if err != nil {
2350 log.Println("failed to start transaction", err)
2351 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2352 return
2353 }
2354 defer tx.Rollback()
2355
2356 var pullsToReopen []*models.Pull
2357 pullsToReopen = append(pullsToReopen, pull)
2358
2359 // if this PR is stacked, then we want to reopen all PRs above this one on the stack
2360 if pull.IsStacked() {
2361 stack := r.Context().Value("stack").(models.Stack)
2362 subStack := stack.StrictlyAbove(pull)
2363 pullsToReopen = append(pullsToReopen, subStack...)
2364 }
2365
2366 for _, p := range pullsToReopen {
2367 // Close the pull in the database
2368 err = db.ReopenPull(tx, f.RepoAt(), p.PullId)
2369 if err != nil {
2370 log.Println("failed to close pull", err)
2371 s.pages.Notice(w, "pull-close", "Failed to close pull.")
2372 return
2373 }
2374 p.State = models.PullOpen
2375 }
2376
2377 // Commit the transaction
2378 if err = tx.Commit(); err != nil {
2379 log.Println("failed to commit transaction", err)
2380 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2381 return
2382 }
2383
2384 for _, p := range pullsToReopen {
2385 s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2386 }
2387
2388 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, &f.Repo)
2389 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2390}
2391
2392func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) {
2393 formatPatches, err := patchutil.ExtractPatches(patch)
2394 if err != nil {
2395 return nil, fmt.Errorf("Failed to extract patches: %v", err)
2396 }
2397
2398 // must have atleast 1 patch to begin with
2399 if len(formatPatches) == 0 {
2400 return nil, fmt.Errorf("No patches found in the generated format-patch.")
2401 }
2402
2403 // the stack is identified by a UUID
2404 var stack models.Stack
2405 parentChangeId := ""
2406 for _, fp := range formatPatches {
2407 // all patches must have a jj change-id
2408 changeId, err := fp.ChangeId()
2409 if err != nil {
2410 return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.")
2411 }
2412
2413 title := fp.Title
2414 body := fp.Body
2415 rkey := tid.TID()
2416
2417 initialSubmission := models.PullSubmission{
2418 Patch: fp.Raw,
2419 SourceRev: fp.SHA,
2420 Combined: fp.Raw,
2421 }
2422 pull := models.Pull{
2423 Title: title,
2424 Body: body,
2425 TargetBranch: targetBranch,
2426 OwnerDid: user.Did,
2427 RepoAt: f.RepoAt(),
2428 Rkey: rkey,
2429 Submissions: []*models.PullSubmission{
2430 &initialSubmission,
2431 },
2432 PullSource: pullSource,
2433 Created: time.Now(),
2434
2435 StackId: stackId,
2436 ChangeId: changeId,
2437 ParentChangeId: parentChangeId,
2438 }
2439
2440 stack = append(stack, &pull)
2441
2442 parentChangeId = changeId
2443 }
2444
2445 return stack, nil
2446}