Monorepo for Tangled
tangled.org
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 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1244 Collection: tangled.RepoPullNSID,
1245 Repo: user.Did,
1246 Rkey: rkey,
1247 Record: &lexutil.LexiconTypeDecoder{
1248 Val: &tangled.RepoPull{
1249 Title: title,
1250 Target: &tangled.RepoPull_Target{
1251 Repo: string(repo.RepoAt()),
1252 Branch: targetBranch,
1253 },
1254 Patch: patch,
1255 Source: recordPullSource,
1256 CreatedAt: time.Now().Format(time.RFC3339),
1257 },
1258 },
1259 })
1260 if err != nil {
1261 log.Println("failed to create pull request", err)
1262 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1263 return
1264 }
1265
1266 if err = tx.Commit(); err != nil {
1267 log.Println("failed to create pull request", err)
1268 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1269 return
1270 }
1271
1272 s.notifier.NewPull(r.Context(), pull)
1273
1274 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
1275 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pullId))
1276}
1277
1278func (s *Pulls) createStackedPullRequest(
1279 w http.ResponseWriter,
1280 r *http.Request,
1281 repo *models.Repo,
1282 user *oauth.User,
1283 targetBranch string,
1284 patch string,
1285 sourceRev string,
1286 pullSource *models.PullSource,
1287) {
1288 // run some necessary checks for stacked-prs first
1289
1290 // must be branch or fork based
1291 if sourceRev == "" {
1292 log.Println("stacked PR from patch-based pull")
1293 s.pages.Notice(w, "pull", "Stacking is only supported on branch and fork based pull-requests.")
1294 return
1295 }
1296
1297 formatPatches, err := patchutil.ExtractPatches(patch)
1298 if err != nil {
1299 log.Println("failed to extract patches", err)
1300 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
1301 return
1302 }
1303
1304 // must have atleast 1 patch to begin with
1305 if len(formatPatches) == 0 {
1306 log.Println("empty patches")
1307 s.pages.Notice(w, "pull", "No patches found in the generated format-patch.")
1308 return
1309 }
1310
1311 // build a stack out of this patch
1312 stackId := uuid.New()
1313 stack, err := s.newStack(r.Context(), repo, user, targetBranch, patch, pullSource, stackId.String())
1314 if err != nil {
1315 log.Println("failed to create stack", err)
1316 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err))
1317 return
1318 }
1319
1320 client, err := s.oauth.AuthorizedClient(r)
1321 if err != nil {
1322 log.Println("failed to get authorized client", err)
1323 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1324 return
1325 }
1326
1327 // apply all record creations at once
1328 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1329 for _, p := range stack {
1330 record := p.AsRecord()
1331 write := comatproto.RepoApplyWrites_Input_Writes_Elem{
1332 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
1333 Collection: tangled.RepoPullNSID,
1334 Rkey: &p.Rkey,
1335 Value: &lexutil.LexiconTypeDecoder{
1336 Val: &record,
1337 },
1338 },
1339 }
1340 writes = append(writes, &write)
1341 }
1342 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
1343 Repo: user.Did,
1344 Writes: writes,
1345 })
1346 if err != nil {
1347 log.Println("failed to create stacked pull request", err)
1348 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.")
1349 return
1350 }
1351
1352 // create all pulls at once
1353 tx, err := s.db.BeginTx(r.Context(), nil)
1354 if err != nil {
1355 log.Println("failed to start tx")
1356 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1357 return
1358 }
1359 defer tx.Rollback()
1360
1361 for _, p := range stack {
1362 err = db.NewPull(tx, p)
1363 if err != nil {
1364 log.Println("failed to create pull request", err)
1365 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1366 return
1367 }
1368 }
1369
1370 if err = tx.Commit(); err != nil {
1371 log.Println("failed to create pull request", err)
1372 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1373 return
1374 }
1375
1376 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
1377 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", ownerSlashRepo))
1378}
1379
1380func (s *Pulls) ValidatePatch(w http.ResponseWriter, r *http.Request) {
1381 _, err := s.repoResolver.Resolve(r)
1382 if err != nil {
1383 log.Println("failed to get repo and knot", err)
1384 return
1385 }
1386
1387 patch := r.FormValue("patch")
1388 if patch == "" {
1389 s.pages.Notice(w, "patch-error", "Patch is required.")
1390 return
1391 }
1392
1393 if err := s.validator.ValidatePatch(&patch); err != nil {
1394 s.logger.Error("faield to validate patch", "err", err)
1395 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.")
1396 return
1397 }
1398
1399 if patchutil.IsFormatPatch(patch) {
1400 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.")
1401 } else {
1402 s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.")
1403 }
1404}
1405
1406func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
1407 user := s.oauth.GetUser(r)
1408
1409 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
1410 RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1411 })
1412}
1413
1414func (s *Pulls) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) {
1415 user := s.oauth.GetUser(r)
1416 f, err := s.repoResolver.Resolve(r)
1417 if err != nil {
1418 log.Println("failed to get repo and knot", err)
1419 return
1420 }
1421
1422 scheme := "http"
1423 if !s.config.Core.Dev {
1424 scheme = "https"
1425 }
1426 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
1427 xrpcc := &indigoxrpc.Client{
1428 Host: host,
1429 }
1430
1431 repo := fmt.Sprintf("%s/%s", f.Did, f.Name)
1432 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1433 if err != nil {
1434 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1435 log.Println("failed to call XRPC repo.branches", xrpcerr)
1436 s.pages.Error503(w)
1437 return
1438 }
1439 log.Println("failed to fetch branches", err)
1440 return
1441 }
1442
1443 var result types.RepoBranchesResponse
1444 if err := json.Unmarshal(xrpcBytes, &result); err != nil {
1445 log.Println("failed to decode XRPC response", err)
1446 s.pages.Error503(w)
1447 return
1448 }
1449
1450 branches := result.Branches
1451 sort.Slice(branches, func(i int, j int) bool {
1452 return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When)
1453 })
1454
1455 withoutDefault := []types.Branch{}
1456 for _, b := range branches {
1457 if b.IsDefault {
1458 continue
1459 }
1460 withoutDefault = append(withoutDefault, b)
1461 }
1462
1463 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
1464 RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1465 Branches: withoutDefault,
1466 })
1467}
1468
1469func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) {
1470 user := s.oauth.GetUser(r)
1471
1472 forks, err := db.GetForksByDid(s.db, user.Did)
1473 if err != nil {
1474 log.Println("failed to get forks", err)
1475 return
1476 }
1477
1478 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{
1479 RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1480 Forks: forks,
1481 Selected: r.URL.Query().Get("fork"),
1482 })
1483}
1484
1485func (s *Pulls) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) {
1486 user := s.oauth.GetUser(r)
1487
1488 f, err := s.repoResolver.Resolve(r)
1489 if err != nil {
1490 log.Println("failed to get repo and knot", err)
1491 return
1492 }
1493
1494 forkVal := r.URL.Query().Get("fork")
1495 repoString := strings.SplitN(forkVal, "/", 2)
1496 forkOwnerDid := repoString[0]
1497 forkName := repoString[1]
1498 // fork repo
1499 repo, err := db.GetRepo(
1500 s.db,
1501 db.FilterEq("did", forkOwnerDid),
1502 db.FilterEq("name", forkName),
1503 )
1504 if err != nil {
1505 log.Println("failed to get repo", "did", forkOwnerDid, "name", forkName, "err", err)
1506 return
1507 }
1508
1509 sourceScheme := "http"
1510 if !s.config.Core.Dev {
1511 sourceScheme = "https"
1512 }
1513 sourceHost := fmt.Sprintf("%s://%s", sourceScheme, repo.Knot)
1514 sourceXrpcc := &indigoxrpc.Client{
1515 Host: sourceHost,
1516 }
1517
1518 sourceRepo := fmt.Sprintf("%s/%s", forkOwnerDid, repo.Name)
1519 sourceXrpcBytes, err := tangled.RepoBranches(r.Context(), sourceXrpcc, "", 0, sourceRepo)
1520 if err != nil {
1521 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1522 log.Println("failed to call XRPC repo.branches for source", xrpcerr)
1523 s.pages.Error503(w)
1524 return
1525 }
1526 log.Println("failed to fetch source branches", err)
1527 return
1528 }
1529
1530 // Decode source branches
1531 var sourceBranches types.RepoBranchesResponse
1532 if err := json.Unmarshal(sourceXrpcBytes, &sourceBranches); err != nil {
1533 log.Println("failed to decode source branches XRPC response", err)
1534 s.pages.Error503(w)
1535 return
1536 }
1537
1538 targetScheme := "http"
1539 if !s.config.Core.Dev {
1540 targetScheme = "https"
1541 }
1542 targetHost := fmt.Sprintf("%s://%s", targetScheme, f.Knot)
1543 targetXrpcc := &indigoxrpc.Client{
1544 Host: targetHost,
1545 }
1546
1547 targetRepo := fmt.Sprintf("%s/%s", f.Did, f.Name)
1548 targetXrpcBytes, err := tangled.RepoBranches(r.Context(), targetXrpcc, "", 0, targetRepo)
1549 if err != nil {
1550 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1551 log.Println("failed to call XRPC repo.branches for target", xrpcerr)
1552 s.pages.Error503(w)
1553 return
1554 }
1555 log.Println("failed to fetch target branches", err)
1556 return
1557 }
1558
1559 // Decode target branches
1560 var targetBranches types.RepoBranchesResponse
1561 if err := json.Unmarshal(targetXrpcBytes, &targetBranches); err != nil {
1562 log.Println("failed to decode target branches XRPC response", err)
1563 s.pages.Error503(w)
1564 return
1565 }
1566
1567 sort.Slice(sourceBranches.Branches, func(i int, j int) bool {
1568 return sourceBranches.Branches[i].Commit.Committer.When.After(sourceBranches.Branches[j].Commit.Committer.When)
1569 })
1570
1571 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
1572 RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1573 SourceBranches: sourceBranches.Branches,
1574 TargetBranches: targetBranches.Branches,
1575 })
1576}
1577
1578func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) {
1579 user := s.oauth.GetUser(r)
1580
1581 pull, ok := r.Context().Value("pull").(*models.Pull)
1582 if !ok {
1583 log.Println("failed to get pull")
1584 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1585 return
1586 }
1587
1588 switch r.Method {
1589 case http.MethodGet:
1590 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
1591 RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1592 Pull: pull,
1593 })
1594 return
1595 case http.MethodPost:
1596 if pull.IsPatchBased() {
1597 s.resubmitPatch(w, r)
1598 return
1599 } else if pull.IsBranchBased() {
1600 s.resubmitBranch(w, r)
1601 return
1602 } else if pull.IsForkBased() {
1603 s.resubmitFork(w, r)
1604 return
1605 }
1606 }
1607}
1608
1609func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) {
1610 user := s.oauth.GetUser(r)
1611
1612 pull, ok := r.Context().Value("pull").(*models.Pull)
1613 if !ok {
1614 log.Println("failed to get pull")
1615 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1616 return
1617 }
1618
1619 f, err := s.repoResolver.Resolve(r)
1620 if err != nil {
1621 log.Println("failed to get repo and knot", err)
1622 return
1623 }
1624
1625 if user.Did != pull.OwnerDid {
1626 log.Println("unauthorized user")
1627 w.WriteHeader(http.StatusUnauthorized)
1628 return
1629 }
1630
1631 patch := r.FormValue("patch")
1632
1633 s.resubmitPullHelper(w, r, f, user, pull, patch, "", "")
1634}
1635
1636func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) {
1637 user := s.oauth.GetUser(r)
1638
1639 pull, ok := r.Context().Value("pull").(*models.Pull)
1640 if !ok {
1641 log.Println("failed to get pull")
1642 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1643 return
1644 }
1645
1646 f, err := s.repoResolver.Resolve(r)
1647 if err != nil {
1648 log.Println("failed to get repo and knot", err)
1649 return
1650 }
1651
1652 if user.Did != pull.OwnerDid {
1653 log.Println("unauthorized user")
1654 w.WriteHeader(http.StatusUnauthorized)
1655 return
1656 }
1657
1658 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
1659 if !roles.IsPushAllowed() {
1660 log.Println("unauthorized user")
1661 w.WriteHeader(http.StatusUnauthorized)
1662 return
1663 }
1664
1665 scheme := "http"
1666 if !s.config.Core.Dev {
1667 scheme = "https"
1668 }
1669 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
1670 xrpcc := &indigoxrpc.Client{
1671 Host: host,
1672 }
1673
1674 repo := fmt.Sprintf("%s/%s", f.Did, f.Name)
1675 xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, pull.TargetBranch, pull.PullSource.Branch)
1676 if err != nil {
1677 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1678 log.Println("failed to call XRPC repo.compare", xrpcerr)
1679 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1680 return
1681 }
1682 log.Printf("compare request failed: %s", err)
1683 s.pages.Notice(w, "resubmit-error", err.Error())
1684 return
1685 }
1686
1687 var comparison types.RepoFormatPatchResponse
1688 if err := json.Unmarshal(xrpcBytes, &comparison); err != nil {
1689 log.Println("failed to decode XRPC compare response", err)
1690 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1691 return
1692 }
1693
1694 sourceRev := comparison.Rev2
1695 patch := comparison.FormatPatchRaw
1696 combined := comparison.CombinedPatchRaw
1697
1698 s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev)
1699}
1700
1701func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) {
1702 user := s.oauth.GetUser(r)
1703
1704 pull, ok := r.Context().Value("pull").(*models.Pull)
1705 if !ok {
1706 log.Println("failed to get pull")
1707 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1708 return
1709 }
1710
1711 f, err := s.repoResolver.Resolve(r)
1712 if err != nil {
1713 log.Println("failed to get repo and knot", err)
1714 return
1715 }
1716
1717 if user.Did != pull.OwnerDid {
1718 log.Println("unauthorized user")
1719 w.WriteHeader(http.StatusUnauthorized)
1720 return
1721 }
1722
1723 forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
1724 if err != nil {
1725 log.Println("failed to get source repo", err)
1726 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1727 return
1728 }
1729
1730 // update the hidden tracking branch to latest
1731 client, err := s.oauth.ServiceClient(
1732 r,
1733 oauth.WithService(forkRepo.Knot),
1734 oauth.WithLxm(tangled.RepoHiddenRefNSID),
1735 oauth.WithDev(s.config.Core.Dev),
1736 )
1737 if err != nil {
1738 log.Printf("failed to connect to knot server: %v", err)
1739 return
1740 }
1741
1742 resp, err := tangled.RepoHiddenRef(
1743 r.Context(),
1744 client,
1745 &tangled.RepoHiddenRef_Input{
1746 ForkRef: pull.PullSource.Branch,
1747 RemoteRef: pull.TargetBranch,
1748 Repo: forkRepo.RepoAt().String(),
1749 },
1750 )
1751 if err := xrpcclient.HandleXrpcErr(err); err != nil {
1752 s.pages.Notice(w, "resubmit-error", err.Error())
1753 return
1754 }
1755 if !resp.Success {
1756 log.Println("Failed to update tracking ref.", "err", resp.Error)
1757 s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.")
1758 return
1759 }
1760
1761 hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch)
1762 // extract patch by performing compare
1763 forkScheme := "http"
1764 if !s.config.Core.Dev {
1765 forkScheme = "https"
1766 }
1767 forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot)
1768 forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name)
1769 forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, hiddenRef, pull.PullSource.Branch)
1770 if err != nil {
1771 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1772 log.Println("failed to call XRPC repo.compare for fork", xrpcerr)
1773 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1774 return
1775 }
1776 log.Printf("failed to compare branches: %s", err)
1777 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1778 return
1779 }
1780
1781 var forkComparison types.RepoFormatPatchResponse
1782 if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil {
1783 log.Println("failed to decode XRPC compare response for fork", err)
1784 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1785 return
1786 }
1787
1788 // Use the fork comparison we already made
1789 comparison := forkComparison
1790
1791 sourceRev := comparison.Rev2
1792 patch := comparison.FormatPatchRaw
1793 combined := comparison.CombinedPatchRaw
1794
1795 s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev)
1796}
1797
1798func (s *Pulls) resubmitPullHelper(
1799 w http.ResponseWriter,
1800 r *http.Request,
1801 repo *models.Repo,
1802 user *oauth.User,
1803 pull *models.Pull,
1804 patch string,
1805 combined string,
1806 sourceRev string,
1807) {
1808 if pull.IsStacked() {
1809 log.Println("resubmitting stacked PR")
1810 s.resubmitStackedPullHelper(w, r, repo, user, pull, patch, pull.StackId)
1811 return
1812 }
1813
1814 if err := s.validator.ValidatePatch(&patch); err != nil {
1815 s.pages.Notice(w, "resubmit-error", err.Error())
1816 return
1817 }
1818
1819 if patch == pull.LatestPatch() {
1820 s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.")
1821 return
1822 }
1823
1824 // validate sourceRev if branch/fork based
1825 if pull.IsBranchBased() || pull.IsForkBased() {
1826 if sourceRev == pull.LatestSha() {
1827 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1828 return
1829 }
1830 }
1831
1832 tx, err := s.db.BeginTx(r.Context(), nil)
1833 if err != nil {
1834 log.Println("failed to start tx")
1835 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1836 return
1837 }
1838 defer tx.Rollback()
1839
1840 pullAt := pull.AtUri()
1841 newRoundNumber := len(pull.Submissions)
1842 newPatch := patch
1843 newSourceRev := sourceRev
1844 combinedPatch := combined
1845 err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev)
1846 if err != nil {
1847 log.Println("failed to create pull request", err)
1848 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1849 return
1850 }
1851 client, err := s.oauth.AuthorizedClient(r)
1852 if err != nil {
1853 log.Println("failed to authorize client")
1854 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1855 return
1856 }
1857
1858 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1859 if err != nil {
1860 // failed to get record
1861 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1862 return
1863 }
1864
1865 var recordPullSource *tangled.RepoPull_Source
1866 if pull.IsBranchBased() {
1867 recordPullSource = &tangled.RepoPull_Source{
1868 Branch: pull.PullSource.Branch,
1869 Sha: sourceRev,
1870 }
1871 }
1872 if pull.IsForkBased() {
1873 repoAt := pull.PullSource.RepoAt.String()
1874 recordPullSource = &tangled.RepoPull_Source{
1875 Branch: pull.PullSource.Branch,
1876 Repo: &repoAt,
1877 Sha: sourceRev,
1878 }
1879 }
1880
1881 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1882 Collection: tangled.RepoPullNSID,
1883 Repo: user.Did,
1884 Rkey: pull.Rkey,
1885 SwapRecord: ex.Cid,
1886 Record: &lexutil.LexiconTypeDecoder{
1887 Val: &tangled.RepoPull{
1888 Title: pull.Title,
1889 Target: &tangled.RepoPull_Target{
1890 Repo: string(repo.RepoAt()),
1891 Branch: pull.TargetBranch,
1892 },
1893 Patch: patch, // new patch
1894 Source: recordPullSource,
1895 CreatedAt: time.Now().Format(time.RFC3339),
1896 },
1897 },
1898 })
1899 if err != nil {
1900 log.Println("failed to update record", err)
1901 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1902 return
1903 }
1904
1905 if err = tx.Commit(); err != nil {
1906 log.Println("failed to commit transaction", err)
1907 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1908 return
1909 }
1910
1911 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
1912 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
1913}
1914
1915func (s *Pulls) resubmitStackedPullHelper(
1916 w http.ResponseWriter,
1917 r *http.Request,
1918 repo *models.Repo,
1919 user *oauth.User,
1920 pull *models.Pull,
1921 patch string,
1922 stackId string,
1923) {
1924 targetBranch := pull.TargetBranch
1925
1926 origStack, _ := r.Context().Value("stack").(models.Stack)
1927 newStack, err := s.newStack(r.Context(), repo, user, targetBranch, patch, pull.PullSource, stackId)
1928 if err != nil {
1929 log.Println("failed to create resubmitted stack", err)
1930 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1931 return
1932 }
1933
1934 // find the diff between the stacks, first, map them by changeId
1935 origById := make(map[string]*models.Pull)
1936 newById := make(map[string]*models.Pull)
1937 for _, p := range origStack {
1938 origById[p.ChangeId] = p
1939 }
1940 for _, p := range newStack {
1941 newById[p.ChangeId] = p
1942 }
1943
1944 // commits that got deleted: corresponding pull is closed
1945 // commits that got added: new pull is created
1946 // commits that got updated: corresponding pull is resubmitted & new round begins
1947 additions := make(map[string]*models.Pull)
1948 deletions := make(map[string]*models.Pull)
1949 updated := make(map[string]struct{})
1950
1951 // pulls in orignal stack but not in new one
1952 for _, op := range origStack {
1953 if _, ok := newById[op.ChangeId]; !ok {
1954 deletions[op.ChangeId] = op
1955 }
1956 }
1957
1958 // pulls in new stack but not in original one
1959 for _, np := range newStack {
1960 if _, ok := origById[np.ChangeId]; !ok {
1961 additions[np.ChangeId] = np
1962 }
1963 }
1964
1965 // NOTE: this loop can be written in any of above blocks,
1966 // but is written separately in the interest of simpler code
1967 for _, np := range newStack {
1968 if op, ok := origById[np.ChangeId]; ok {
1969 // pull exists in both stacks
1970 updated[op.ChangeId] = struct{}{}
1971 }
1972 }
1973
1974 tx, err := s.db.Begin()
1975 if err != nil {
1976 log.Println("failed to start transaction", err)
1977 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1978 return
1979 }
1980 defer tx.Rollback()
1981
1982 // pds updates to make
1983 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1984
1985 // deleted pulls are marked as deleted in the DB
1986 for _, p := range deletions {
1987 // do not do delete already merged PRs
1988 if p.State == models.PullMerged {
1989 continue
1990 }
1991
1992 err := db.DeletePull(tx, p.RepoAt, p.PullId)
1993 if err != nil {
1994 log.Println("failed to delete pull", err, p.PullId)
1995 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1996 return
1997 }
1998 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1999 RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{
2000 Collection: tangled.RepoPullNSID,
2001 Rkey: p.Rkey,
2002 },
2003 })
2004 }
2005
2006 // new pulls are created
2007 for _, p := range additions {
2008 err := db.NewPull(tx, p)
2009 if err != nil {
2010 log.Println("failed to create pull", err, p.PullId)
2011 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2012 return
2013 }
2014
2015 record := p.AsRecord()
2016 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2017 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
2018 Collection: tangled.RepoPullNSID,
2019 Rkey: &p.Rkey,
2020 Value: &lexutil.LexiconTypeDecoder{
2021 Val: &record,
2022 },
2023 },
2024 })
2025 }
2026
2027 // updated pulls are, well, updated; to start a new round
2028 for id := range updated {
2029 op, _ := origById[id]
2030 np, _ := newById[id]
2031
2032 // do not update already merged PRs
2033 if op.State == models.PullMerged {
2034 continue
2035 }
2036
2037 // resubmit the new pull
2038 pullAt := op.AtUri()
2039 newRoundNumber := len(op.Submissions)
2040 newPatch := np.LatestPatch()
2041 combinedPatch := np.LatestSubmission().Combined
2042 newSourceRev := np.LatestSha()
2043 err := db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev)
2044 if err != nil {
2045 log.Println("failed to update pull", err, op.PullId)
2046 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2047 return
2048 }
2049
2050 record := np.AsRecord()
2051
2052 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2053 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
2054 Collection: tangled.RepoPullNSID,
2055 Rkey: op.Rkey,
2056 Value: &lexutil.LexiconTypeDecoder{
2057 Val: &record,
2058 },
2059 },
2060 })
2061 }
2062
2063 // update parent-change-id relations for the entire stack
2064 for _, p := range newStack {
2065 err := db.SetPullParentChangeId(
2066 tx,
2067 p.ParentChangeId,
2068 // these should be enough filters to be unique per-stack
2069 db.FilterEq("repo_at", p.RepoAt.String()),
2070 db.FilterEq("owner_did", p.OwnerDid),
2071 db.FilterEq("change_id", p.ChangeId),
2072 )
2073
2074 if err != nil {
2075 log.Println("failed to update pull", err, p.PullId)
2076 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2077 return
2078 }
2079 }
2080
2081 err = tx.Commit()
2082 if err != nil {
2083 log.Println("failed to resubmit pull", err)
2084 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2085 return
2086 }
2087
2088 client, err := s.oauth.AuthorizedClient(r)
2089 if err != nil {
2090 log.Println("failed to authorize client")
2091 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
2092 return
2093 }
2094
2095 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
2096 Repo: user.Did,
2097 Writes: writes,
2098 })
2099 if err != nil {
2100 log.Println("failed to create stacked pull request", err)
2101 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.")
2102 return
2103 }
2104
2105 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
2106 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2107}
2108
2109func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
2110 user := s.oauth.GetUser(r)
2111 f, err := s.repoResolver.Resolve(r)
2112 if err != nil {
2113 log.Println("failed to resolve repo:", err)
2114 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
2115 return
2116 }
2117
2118 pull, ok := r.Context().Value("pull").(*models.Pull)
2119 if !ok {
2120 log.Println("failed to get pull")
2121 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
2122 return
2123 }
2124
2125 var pullsToMerge models.Stack
2126 pullsToMerge = append(pullsToMerge, pull)
2127 if pull.IsStacked() {
2128 stack, ok := r.Context().Value("stack").(models.Stack)
2129 if !ok {
2130 log.Println("failed to get stack")
2131 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
2132 return
2133 }
2134
2135 // combine patches of substack
2136 subStack := stack.StrictlyBelow(pull)
2137 // collect the portion of the stack that is mergeable
2138 mergeable := subStack.Mergeable()
2139 // add to total patch
2140 pullsToMerge = append(pullsToMerge, mergeable...)
2141 }
2142
2143 patch := pullsToMerge.CombinedPatch()
2144
2145 ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid)
2146 if err != nil {
2147 log.Printf("resolving identity: %s", err)
2148 w.WriteHeader(http.StatusNotFound)
2149 return
2150 }
2151
2152 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
2153 if err != nil {
2154 log.Printf("failed to get primary email: %s", err)
2155 }
2156
2157 authorName := ident.Handle.String()
2158 mergeInput := &tangled.RepoMerge_Input{
2159 Did: f.Did,
2160 Name: f.Name,
2161 Branch: pull.TargetBranch,
2162 Patch: patch,
2163 CommitMessage: &pull.Title,
2164 AuthorName: &authorName,
2165 }
2166
2167 if pull.Body != "" {
2168 mergeInput.CommitBody = &pull.Body
2169 }
2170
2171 if email.Address != "" {
2172 mergeInput.AuthorEmail = &email.Address
2173 }
2174
2175 client, err := s.oauth.ServiceClient(
2176 r,
2177 oauth.WithService(f.Knot),
2178 oauth.WithLxm(tangled.RepoMergeNSID),
2179 oauth.WithDev(s.config.Core.Dev),
2180 )
2181 if err != nil {
2182 log.Printf("failed to connect to knot server: %v", err)
2183 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
2184 return
2185 }
2186
2187 err = tangled.RepoMerge(r.Context(), client, mergeInput)
2188 if err := xrpcclient.HandleXrpcErr(err); err != nil {
2189 s.pages.Notice(w, "pull-merge-error", err.Error())
2190 return
2191 }
2192
2193 tx, err := s.db.Begin()
2194 if err != nil {
2195 log.Println("failed to start transcation", err)
2196 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
2197 return
2198 }
2199 defer tx.Rollback()
2200
2201 for _, p := range pullsToMerge {
2202 err := db.MergePull(tx, f.RepoAt(), p.PullId)
2203 if err != nil {
2204 log.Printf("failed to update pull request status in database: %s", err)
2205 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
2206 return
2207 }
2208 p.State = models.PullMerged
2209 }
2210
2211 err = tx.Commit()
2212 if err != nil {
2213 // TODO: this is unsound, we should also revert the merge from the knotserver here
2214 log.Printf("failed to update pull request status in database: %s", err)
2215 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
2216 return
2217 }
2218
2219 // notify about the pull merge
2220 for _, p := range pullsToMerge {
2221 s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2222 }
2223
2224 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
2225 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2226}
2227
2228func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) {
2229 user := s.oauth.GetUser(r)
2230
2231 f, err := s.repoResolver.Resolve(r)
2232 if err != nil {
2233 log.Println("malformed middleware")
2234 return
2235 }
2236
2237 pull, ok := r.Context().Value("pull").(*models.Pull)
2238 if !ok {
2239 log.Println("failed to get pull")
2240 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
2241 return
2242 }
2243
2244 // auth filter: only owner or collaborators can close
2245 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
2246 isOwner := roles.IsOwner()
2247 isCollaborator := roles.IsCollaborator()
2248 isPullAuthor := user.Did == pull.OwnerDid
2249 isCloseAllowed := isOwner || isCollaborator || isPullAuthor
2250 if !isCloseAllowed {
2251 log.Println("failed to close pull")
2252 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
2253 return
2254 }
2255
2256 // Start a transaction
2257 tx, err := s.db.BeginTx(r.Context(), nil)
2258 if err != nil {
2259 log.Println("failed to start transaction", err)
2260 s.pages.Notice(w, "pull-close", "Failed to close pull.")
2261 return
2262 }
2263 defer tx.Rollback()
2264
2265 var pullsToClose []*models.Pull
2266 pullsToClose = append(pullsToClose, pull)
2267
2268 // if this PR is stacked, then we want to close all PRs below this one on the stack
2269 if pull.IsStacked() {
2270 stack := r.Context().Value("stack").(models.Stack)
2271 subStack := stack.StrictlyBelow(pull)
2272 pullsToClose = append(pullsToClose, subStack...)
2273 }
2274
2275 for _, p := range pullsToClose {
2276 // Close the pull in the database
2277 err = db.ClosePull(tx, f.RepoAt(), p.PullId)
2278 if err != nil {
2279 log.Println("failed to close pull", err)
2280 s.pages.Notice(w, "pull-close", "Failed to close pull.")
2281 return
2282 }
2283 p.State = models.PullClosed
2284 }
2285
2286 // Commit the transaction
2287 if err = tx.Commit(); err != nil {
2288 log.Println("failed to commit transaction", err)
2289 s.pages.Notice(w, "pull-close", "Failed to close pull.")
2290 return
2291 }
2292
2293 for _, p := range pullsToClose {
2294 s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2295 }
2296
2297 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
2298 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2299}
2300
2301func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) {
2302 user := s.oauth.GetUser(r)
2303
2304 f, err := s.repoResolver.Resolve(r)
2305 if err != nil {
2306 log.Println("failed to resolve repo", err)
2307 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2308 return
2309 }
2310
2311 pull, ok := r.Context().Value("pull").(*models.Pull)
2312 if !ok {
2313 log.Println("failed to get pull")
2314 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
2315 return
2316 }
2317
2318 // auth filter: only owner or collaborators can close
2319 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
2320 isOwner := roles.IsOwner()
2321 isCollaborator := roles.IsCollaborator()
2322 isPullAuthor := user.Did == pull.OwnerDid
2323 isCloseAllowed := isOwner || isCollaborator || isPullAuthor
2324 if !isCloseAllowed {
2325 log.Println("failed to close pull")
2326 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
2327 return
2328 }
2329
2330 // Start a transaction
2331 tx, err := s.db.BeginTx(r.Context(), nil)
2332 if err != nil {
2333 log.Println("failed to start transaction", err)
2334 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2335 return
2336 }
2337 defer tx.Rollback()
2338
2339 var pullsToReopen []*models.Pull
2340 pullsToReopen = append(pullsToReopen, pull)
2341
2342 // if this PR is stacked, then we want to reopen all PRs above this one on the stack
2343 if pull.IsStacked() {
2344 stack := r.Context().Value("stack").(models.Stack)
2345 subStack := stack.StrictlyAbove(pull)
2346 pullsToReopen = append(pullsToReopen, subStack...)
2347 }
2348
2349 for _, p := range pullsToReopen {
2350 // Close the pull in the database
2351 err = db.ReopenPull(tx, f.RepoAt(), p.PullId)
2352 if err != nil {
2353 log.Println("failed to close pull", err)
2354 s.pages.Notice(w, "pull-close", "Failed to close pull.")
2355 return
2356 }
2357 p.State = models.PullOpen
2358 }
2359
2360 // Commit the transaction
2361 if err = tx.Commit(); err != nil {
2362 log.Println("failed to commit transaction", err)
2363 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2364 return
2365 }
2366
2367 for _, p := range pullsToReopen {
2368 s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2369 }
2370
2371 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
2372 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2373}
2374
2375func (s *Pulls) newStack(ctx context.Context, repo *models.Repo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) {
2376 formatPatches, err := patchutil.ExtractPatches(patch)
2377 if err != nil {
2378 return nil, fmt.Errorf("Failed to extract patches: %v", err)
2379 }
2380
2381 // must have atleast 1 patch to begin with
2382 if len(formatPatches) == 0 {
2383 return nil, fmt.Errorf("No patches found in the generated format-patch.")
2384 }
2385
2386 // the stack is identified by a UUID
2387 var stack models.Stack
2388 parentChangeId := ""
2389 for _, fp := range formatPatches {
2390 // all patches must have a jj change-id
2391 changeId, err := fp.ChangeId()
2392 if err != nil {
2393 return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.")
2394 }
2395
2396 title := fp.Title
2397 body := fp.Body
2398 rkey := tid.TID()
2399
2400 mentions, references := s.refResolver.Resolve(ctx, body)
2401
2402 initialSubmission := models.PullSubmission{
2403 Patch: fp.Raw,
2404 SourceRev: fp.SHA,
2405 Combined: fp.Raw,
2406 }
2407 pull := models.Pull{
2408 Title: title,
2409 Body: body,
2410 TargetBranch: targetBranch,
2411 OwnerDid: user.Did,
2412 RepoAt: repo.RepoAt(),
2413 Rkey: rkey,
2414 Mentions: mentions,
2415 References: references,
2416 Submissions: []*models.PullSubmission{
2417 &initialSubmission,
2418 },
2419 PullSource: pullSource,
2420 Created: time.Now(),
2421
2422 StackId: stackId,
2423 ChangeId: changeId,
2424 ParentChangeId: parentChangeId,
2425 }
2426
2427 stack = append(stack, &pull)
2428
2429 parentChangeId = changeId
2430 }
2431
2432 return stack, nil
2433}