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