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