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