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