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