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