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