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