this repo has no description
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/mentions"
23 "tangled.org/core/appview/models"
24 "tangled.org/core/appview/notify"
25 "tangled.org/core/appview/oauth"
26 "tangled.org/core/appview/pages"
27 "tangled.org/core/appview/pages/markup"
28 "tangled.org/core/appview/pages/repoinfo"
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/orm"
34 "tangled.org/core/patchutil"
35 "tangled.org/core/rbac"
36 "tangled.org/core/tid"
37 "tangled.org/core/types"
38
39 comatproto "github.com/bluesky-social/indigo/api/atproto"
40 "github.com/bluesky-social/indigo/atproto/syntax"
41 lexutil "github.com/bluesky-social/indigo/lex/util"
42 indigoxrpc "github.com/bluesky-social/indigo/xrpc"
43 "github.com/go-chi/chi/v5"
44 "github.com/google/uuid"
45)
46
47type Pulls struct {
48 oauth *oauth.OAuth
49 repoResolver *reporesolver.RepoResolver
50 pages *pages.Pages
51 idResolver *idresolver.Resolver
52 mentionsResolver *mentions.Resolver
53 db *db.DB
54 config *config.Config
55 notifier notify.Notifier
56 enforcer *rbac.Enforcer
57 logger *slog.Logger
58 validator *validator.Validator
59 indexer *pulls_indexer.Indexer
60}
61
62func New(
63 oauth *oauth.OAuth,
64 repoResolver *reporesolver.RepoResolver,
65 pages *pages.Pages,
66 resolver *idresolver.Resolver,
67 mentionsResolver *mentions.Resolver,
68 db *db.DB,
69 config *config.Config,
70 notifier notify.Notifier,
71 enforcer *rbac.Enforcer,
72 validator *validator.Validator,
73 indexer *pulls_indexer.Indexer,
74 logger *slog.Logger,
75) *Pulls {
76 return &Pulls{
77 oauth: oauth,
78 repoResolver: repoResolver,
79 pages: pages,
80 idResolver: resolver,
81 mentionsResolver: mentionsResolver,
82 db: db,
83 config: config,
84 notifier: notifier,
85 enforcer: enforcer,
86 logger: logger,
87 validator: validator,
88 indexer: indexer,
89 }
90}
91
92// htmx fragment
93func (s *Pulls) PullActions(w http.ResponseWriter, r *http.Request) {
94 switch r.Method {
95 case http.MethodGet:
96 user := s.oauth.GetUser(r)
97 f, err := s.repoResolver.Resolve(r)
98 if err != nil {
99 log.Println("failed to get repo and knot", err)
100 return
101 }
102
103 pull, ok := r.Context().Value("pull").(*models.Pull)
104 if !ok {
105 log.Println("failed to get pull")
106 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
107 return
108 }
109
110 // can be nil if this pull is not stacked
111 stack, _ := r.Context().Value("stack").(models.Stack)
112
113 roundNumberStr := chi.URLParam(r, "round")
114 roundNumber, err := strconv.Atoi(roundNumberStr)
115 if err != nil {
116 roundNumber = pull.LastRoundNumber()
117 }
118 if roundNumber >= len(pull.Submissions) {
119 http.Error(w, "bad round id", http.StatusBadRequest)
120 log.Println("failed to parse round id", err)
121 return
122 }
123
124 mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
125 branchDeleteStatus := s.branchDeleteStatus(r, f, pull)
126 resubmitResult := pages.Unknown
127 if user.Did == pull.OwnerDid {
128 resubmitResult = s.resubmitCheck(r, f, pull, stack)
129 }
130
131 s.pages.PullActionsFragment(w, pages.PullActionsParams{
132 LoggedInUser: user,
133 RepoInfo: s.repoResolver.GetRepoInfo(r, user),
134 Pull: pull,
135 RoundNumber: roundNumber,
136 MergeCheck: mergeCheckResponse,
137 ResubmitCheck: resubmitResult,
138 BranchDeleteStatus: branchDeleteStatus,
139 Stack: stack,
140 })
141 return
142 }
143}
144
145func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) {
146 user := s.oauth.GetUser(r)
147 f, err := s.repoResolver.Resolve(r)
148 if err != nil {
149 log.Println("failed to get repo and knot", err)
150 return
151 }
152
153 pull, ok := r.Context().Value("pull").(*models.Pull)
154 if !ok {
155 log.Println("failed to get pull")
156 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
157 return
158 }
159
160 backlinks, err := db.GetBacklinks(s.db, pull.AtUri())
161 if err != nil {
162 log.Println("failed to get pull backlinks", err)
163 s.pages.Notice(w, "pull-error", "Failed to get pull. Try again later.")
164 return
165 }
166
167 // can be nil if this pull is not stacked
168 stack, _ := r.Context().Value("stack").(models.Stack)
169 abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull)
170
171 mergeCheckResponse := s.mergeCheck(r, f, pull, stack)
172 branchDeleteStatus := s.branchDeleteStatus(r, f, pull)
173 resubmitResult := pages.Unknown
174 if user != nil && user.Did == pull.OwnerDid {
175 resubmitResult = s.resubmitCheck(r, f, pull, stack)
176 }
177
178 m := make(map[string]models.Pipeline)
179
180 var shas []string
181 for _, s := range pull.Submissions {
182 shas = append(shas, s.SourceRev)
183 }
184 for _, p := range stack {
185 shas = append(shas, p.LatestSha())
186 }
187 for _, p := range abandonedPulls {
188 shas = append(shas, p.LatestSha())
189 }
190
191 ps, err := db.GetPipelineStatuses(
192 s.db,
193 len(shas),
194 orm.FilterEq("repo_owner", f.Did),
195 orm.FilterEq("repo_name", f.Name),
196 orm.FilterEq("knot", f.Knot),
197 orm.FilterIn("sha", shas),
198 )
199 if err != nil {
200 log.Printf("failed to fetch pipeline statuses: %s", err)
201 // non-fatal
202 }
203
204 for _, p := range ps {
205 m[p.Sha] = p
206 }
207
208 reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri())
209 if err != nil {
210 log.Println("failed to get pull reactions")
211 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.")
212 }
213
214 userReactions := map[models.ReactionKind]bool{}
215 if user != nil {
216 userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.AtUri())
217 }
218
219 labelDefs, err := db.GetLabelDefinitions(
220 s.db,
221 orm.FilterIn("at_uri", f.Labels),
222 orm.FilterContains("scope", tangled.RepoPullNSID),
223 )
224 if err != nil {
225 log.Println("failed to fetch labels", err)
226 s.pages.Error503(w)
227 return
228 }
229
230 defs := make(map[string]*models.LabelDefinition)
231 for _, l := range labelDefs {
232 defs[l.AtUri().String()] = &l
233 }
234
235 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
236 LoggedInUser: user,
237 RepoInfo: s.repoResolver.GetRepoInfo(r, user),
238 Pull: pull,
239 Stack: stack,
240 AbandonedPulls: abandonedPulls,
241 Backlinks: backlinks,
242 BranchDeleteStatus: branchDeleteStatus,
243 MergeCheck: mergeCheckResponse,
244 ResubmitCheck: resubmitResult,
245 Pipelines: m,
246
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 orm.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 orm.FilterEq("repo_owner", f.Did),
652 orm.FilterEq("repo_name", f.Name),
653 orm.FilterEq("knot", f.Knot),
654 orm.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 orm.FilterIn("at_uri", f.Labels),
668 orm.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.mentionsResolver.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.mentionsResolver.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
1385 if err = tx.Commit(); err != nil {
1386 log.Println("failed to create pull request", err)
1387 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1388 return
1389 }
1390
1391 // notify about each pull
1392 //
1393 // this is performed after tx.Commit, because it could result in a locked DB otherwise
1394 for _, p := range stack {
1395 s.notifier.NewPull(r.Context(), p)
1396 }
1397
1398 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
1399 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", ownerSlashRepo))
1400}
1401
1402func (s *Pulls) ValidatePatch(w http.ResponseWriter, r *http.Request) {
1403 _, err := s.repoResolver.Resolve(r)
1404 if err != nil {
1405 log.Println("failed to get repo and knot", err)
1406 return
1407 }
1408
1409 patch := r.FormValue("patch")
1410 if patch == "" {
1411 s.pages.Notice(w, "patch-error", "Patch is required.")
1412 return
1413 }
1414
1415 if err := s.validator.ValidatePatch(&patch); err != nil {
1416 s.logger.Error("faield to validate patch", "err", err)
1417 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.")
1418 return
1419 }
1420
1421 if patchutil.IsFormatPatch(patch) {
1422 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.")
1423 } else {
1424 s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.")
1425 }
1426}
1427
1428func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
1429 user := s.oauth.GetUser(r)
1430
1431 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
1432 RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1433 })
1434}
1435
1436func (s *Pulls) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) {
1437 user := s.oauth.GetUser(r)
1438 f, err := s.repoResolver.Resolve(r)
1439 if err != nil {
1440 log.Println("failed to get repo and knot", err)
1441 return
1442 }
1443
1444 scheme := "http"
1445 if !s.config.Core.Dev {
1446 scheme = "https"
1447 }
1448 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
1449 xrpcc := &indigoxrpc.Client{
1450 Host: host,
1451 }
1452
1453 repo := fmt.Sprintf("%s/%s", f.Did, f.Name)
1454 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo)
1455 if err != nil {
1456 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1457 log.Println("failed to call XRPC repo.branches", xrpcerr)
1458 s.pages.Error503(w)
1459 return
1460 }
1461 log.Println("failed to fetch branches", err)
1462 return
1463 }
1464
1465 var result types.RepoBranchesResponse
1466 if err := json.Unmarshal(xrpcBytes, &result); err != nil {
1467 log.Println("failed to decode XRPC response", err)
1468 s.pages.Error503(w)
1469 return
1470 }
1471
1472 branches := result.Branches
1473 sort.Slice(branches, func(i int, j int) bool {
1474 return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When)
1475 })
1476
1477 withoutDefault := []types.Branch{}
1478 for _, b := range branches {
1479 if b.IsDefault {
1480 continue
1481 }
1482 withoutDefault = append(withoutDefault, b)
1483 }
1484
1485 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
1486 RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1487 Branches: withoutDefault,
1488 })
1489}
1490
1491func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) {
1492 user := s.oauth.GetUser(r)
1493
1494 forks, err := db.GetForksByDid(s.db, user.Did)
1495 if err != nil {
1496 log.Println("failed to get forks", err)
1497 return
1498 }
1499
1500 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{
1501 RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1502 Forks: forks,
1503 Selected: r.URL.Query().Get("fork"),
1504 })
1505}
1506
1507func (s *Pulls) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) {
1508 user := s.oauth.GetUser(r)
1509
1510 f, err := s.repoResolver.Resolve(r)
1511 if err != nil {
1512 log.Println("failed to get repo and knot", err)
1513 return
1514 }
1515
1516 forkVal := r.URL.Query().Get("fork")
1517 repoString := strings.SplitN(forkVal, "/", 2)
1518 forkOwnerDid := repoString[0]
1519 forkName := repoString[1]
1520 // fork repo
1521 repo, err := db.GetRepo(
1522 s.db,
1523 orm.FilterEq("did", forkOwnerDid),
1524 orm.FilterEq("name", forkName),
1525 )
1526 if err != nil {
1527 log.Println("failed to get repo", "did", forkOwnerDid, "name", forkName, "err", err)
1528 return
1529 }
1530
1531 sourceScheme := "http"
1532 if !s.config.Core.Dev {
1533 sourceScheme = "https"
1534 }
1535 sourceHost := fmt.Sprintf("%s://%s", sourceScheme, repo.Knot)
1536 sourceXrpcc := &indigoxrpc.Client{
1537 Host: sourceHost,
1538 }
1539
1540 sourceRepo := fmt.Sprintf("%s/%s", forkOwnerDid, repo.Name)
1541 sourceXrpcBytes, err := tangled.RepoBranches(r.Context(), sourceXrpcc, "", 0, sourceRepo)
1542 if err != nil {
1543 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1544 log.Println("failed to call XRPC repo.branches for source", xrpcerr)
1545 s.pages.Error503(w)
1546 return
1547 }
1548 log.Println("failed to fetch source branches", err)
1549 return
1550 }
1551
1552 // Decode source branches
1553 var sourceBranches types.RepoBranchesResponse
1554 if err := json.Unmarshal(sourceXrpcBytes, &sourceBranches); err != nil {
1555 log.Println("failed to decode source branches XRPC response", err)
1556 s.pages.Error503(w)
1557 return
1558 }
1559
1560 targetScheme := "http"
1561 if !s.config.Core.Dev {
1562 targetScheme = "https"
1563 }
1564 targetHost := fmt.Sprintf("%s://%s", targetScheme, f.Knot)
1565 targetXrpcc := &indigoxrpc.Client{
1566 Host: targetHost,
1567 }
1568
1569 targetRepo := fmt.Sprintf("%s/%s", f.Did, f.Name)
1570 targetXrpcBytes, err := tangled.RepoBranches(r.Context(), targetXrpcc, "", 0, targetRepo)
1571 if err != nil {
1572 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1573 log.Println("failed to call XRPC repo.branches for target", xrpcerr)
1574 s.pages.Error503(w)
1575 return
1576 }
1577 log.Println("failed to fetch target branches", err)
1578 return
1579 }
1580
1581 // Decode target branches
1582 var targetBranches types.RepoBranchesResponse
1583 if err := json.Unmarshal(targetXrpcBytes, &targetBranches); err != nil {
1584 log.Println("failed to decode target branches XRPC response", err)
1585 s.pages.Error503(w)
1586 return
1587 }
1588
1589 sort.Slice(sourceBranches.Branches, func(i int, j int) bool {
1590 return sourceBranches.Branches[i].Commit.Committer.When.After(sourceBranches.Branches[j].Commit.Committer.When)
1591 })
1592
1593 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
1594 RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1595 SourceBranches: sourceBranches.Branches,
1596 TargetBranches: targetBranches.Branches,
1597 })
1598}
1599
1600func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) {
1601 user := s.oauth.GetUser(r)
1602
1603 pull, ok := r.Context().Value("pull").(*models.Pull)
1604 if !ok {
1605 log.Println("failed to get pull")
1606 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1607 return
1608 }
1609
1610 switch r.Method {
1611 case http.MethodGet:
1612 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
1613 RepoInfo: s.repoResolver.GetRepoInfo(r, user),
1614 Pull: pull,
1615 })
1616 return
1617 case http.MethodPost:
1618 if pull.IsPatchBased() {
1619 s.resubmitPatch(w, r)
1620 return
1621 } else if pull.IsBranchBased() {
1622 s.resubmitBranch(w, r)
1623 return
1624 } else if pull.IsForkBased() {
1625 s.resubmitFork(w, r)
1626 return
1627 }
1628 }
1629}
1630
1631func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) {
1632 user := s.oauth.GetUser(r)
1633
1634 pull, ok := r.Context().Value("pull").(*models.Pull)
1635 if !ok {
1636 log.Println("failed to get pull")
1637 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1638 return
1639 }
1640
1641 f, err := s.repoResolver.Resolve(r)
1642 if err != nil {
1643 log.Println("failed to get repo and knot", err)
1644 return
1645 }
1646
1647 if user.Did != pull.OwnerDid {
1648 log.Println("unauthorized user")
1649 w.WriteHeader(http.StatusUnauthorized)
1650 return
1651 }
1652
1653 patch := r.FormValue("patch")
1654
1655 s.resubmitPullHelper(w, r, f, user, pull, patch, "", "")
1656}
1657
1658func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) {
1659 user := s.oauth.GetUser(r)
1660
1661 pull, ok := r.Context().Value("pull").(*models.Pull)
1662 if !ok {
1663 log.Println("failed to get pull")
1664 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1665 return
1666 }
1667
1668 f, err := s.repoResolver.Resolve(r)
1669 if err != nil {
1670 log.Println("failed to get repo and knot", err)
1671 return
1672 }
1673
1674 if user.Did != pull.OwnerDid {
1675 log.Println("unauthorized user")
1676 w.WriteHeader(http.StatusUnauthorized)
1677 return
1678 }
1679
1680 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
1681 if !roles.IsPushAllowed() {
1682 log.Println("unauthorized user")
1683 w.WriteHeader(http.StatusUnauthorized)
1684 return
1685 }
1686
1687 scheme := "http"
1688 if !s.config.Core.Dev {
1689 scheme = "https"
1690 }
1691 host := fmt.Sprintf("%s://%s", scheme, f.Knot)
1692 xrpcc := &indigoxrpc.Client{
1693 Host: host,
1694 }
1695
1696 repo := fmt.Sprintf("%s/%s", f.Did, f.Name)
1697 xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, pull.TargetBranch, pull.PullSource.Branch)
1698 if err != nil {
1699 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1700 log.Println("failed to call XRPC repo.compare", xrpcerr)
1701 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1702 return
1703 }
1704 log.Printf("compare request failed: %s", err)
1705 s.pages.Notice(w, "resubmit-error", err.Error())
1706 return
1707 }
1708
1709 var comparison types.RepoFormatPatchResponse
1710 if err := json.Unmarshal(xrpcBytes, &comparison); err != nil {
1711 log.Println("failed to decode XRPC compare response", err)
1712 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1713 return
1714 }
1715
1716 sourceRev := comparison.Rev2
1717 patch := comparison.FormatPatchRaw
1718 combined := comparison.CombinedPatchRaw
1719
1720 s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev)
1721}
1722
1723func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) {
1724 user := s.oauth.GetUser(r)
1725
1726 pull, ok := r.Context().Value("pull").(*models.Pull)
1727 if !ok {
1728 log.Println("failed to get pull")
1729 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1730 return
1731 }
1732
1733 f, err := s.repoResolver.Resolve(r)
1734 if err != nil {
1735 log.Println("failed to get repo and knot", err)
1736 return
1737 }
1738
1739 if user.Did != pull.OwnerDid {
1740 log.Println("unauthorized user")
1741 w.WriteHeader(http.StatusUnauthorized)
1742 return
1743 }
1744
1745 forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
1746 if err != nil {
1747 log.Println("failed to get source repo", err)
1748 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1749 return
1750 }
1751
1752 // update the hidden tracking branch to latest
1753 client, err := s.oauth.ServiceClient(
1754 r,
1755 oauth.WithService(forkRepo.Knot),
1756 oauth.WithLxm(tangled.RepoHiddenRefNSID),
1757 oauth.WithDev(s.config.Core.Dev),
1758 )
1759 if err != nil {
1760 log.Printf("failed to connect to knot server: %v", err)
1761 return
1762 }
1763
1764 resp, err := tangled.RepoHiddenRef(
1765 r.Context(),
1766 client,
1767 &tangled.RepoHiddenRef_Input{
1768 ForkRef: pull.PullSource.Branch,
1769 RemoteRef: pull.TargetBranch,
1770 Repo: forkRepo.RepoAt().String(),
1771 },
1772 )
1773 if err := xrpcclient.HandleXrpcErr(err); err != nil {
1774 s.pages.Notice(w, "resubmit-error", err.Error())
1775 return
1776 }
1777 if !resp.Success {
1778 log.Println("Failed to update tracking ref.", "err", resp.Error)
1779 s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.")
1780 return
1781 }
1782
1783 hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch)
1784 // extract patch by performing compare
1785 forkScheme := "http"
1786 if !s.config.Core.Dev {
1787 forkScheme = "https"
1788 }
1789 forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot)
1790 forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name)
1791 forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, hiddenRef, pull.PullSource.Branch)
1792 if err != nil {
1793 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil {
1794 log.Println("failed to call XRPC repo.compare for fork", xrpcerr)
1795 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1796 return
1797 }
1798 log.Printf("failed to compare branches: %s", err)
1799 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1800 return
1801 }
1802
1803 var forkComparison types.RepoFormatPatchResponse
1804 if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil {
1805 log.Println("failed to decode XRPC compare response for fork", err)
1806 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1807 return
1808 }
1809
1810 // Use the fork comparison we already made
1811 comparison := forkComparison
1812
1813 sourceRev := comparison.Rev2
1814 patch := comparison.FormatPatchRaw
1815 combined := comparison.CombinedPatchRaw
1816
1817 s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev)
1818}
1819
1820func (s *Pulls) resubmitPullHelper(
1821 w http.ResponseWriter,
1822 r *http.Request,
1823 repo *models.Repo,
1824 user *oauth.User,
1825 pull *models.Pull,
1826 patch string,
1827 combined string,
1828 sourceRev string,
1829) {
1830 if pull.IsStacked() {
1831 log.Println("resubmitting stacked PR")
1832 s.resubmitStackedPullHelper(w, r, repo, user, pull, patch, pull.StackId)
1833 return
1834 }
1835
1836 if err := s.validator.ValidatePatch(&patch); err != nil {
1837 s.pages.Notice(w, "resubmit-error", err.Error())
1838 return
1839 }
1840
1841 if patch == pull.LatestPatch() {
1842 s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.")
1843 return
1844 }
1845
1846 // validate sourceRev if branch/fork based
1847 if pull.IsBranchBased() || pull.IsForkBased() {
1848 if sourceRev == pull.LatestSha() {
1849 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1850 return
1851 }
1852 }
1853
1854 tx, err := s.db.BeginTx(r.Context(), nil)
1855 if err != nil {
1856 log.Println("failed to start tx")
1857 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1858 return
1859 }
1860 defer tx.Rollback()
1861
1862 pullAt := pull.AtUri()
1863 newRoundNumber := len(pull.Submissions)
1864 newPatch := patch
1865 newSourceRev := sourceRev
1866 combinedPatch := combined
1867 err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev)
1868 if err != nil {
1869 log.Println("failed to create pull request", err)
1870 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1871 return
1872 }
1873 client, err := s.oauth.AuthorizedClient(r)
1874 if err != nil {
1875 log.Println("failed to authorize client")
1876 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1877 return
1878 }
1879
1880 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1881 if err != nil {
1882 // failed to get record
1883 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1884 return
1885 }
1886
1887 blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
1888 if err != nil {
1889 log.Println("failed to upload patch blob", err)
1890 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1891 return
1892 }
1893 record := pull.AsRecord()
1894 record.PatchBlob = blob.Blob
1895 record.CreatedAt = time.Now().Format(time.RFC3339)
1896
1897 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1898 Collection: tangled.RepoPullNSID,
1899 Repo: user.Did,
1900 Rkey: pull.Rkey,
1901 SwapRecord: ex.Cid,
1902 Record: &lexutil.LexiconTypeDecoder{
1903 Val: &record,
1904 },
1905 })
1906 if err != nil {
1907 log.Println("failed to update record", err)
1908 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1909 return
1910 }
1911
1912 if err = tx.Commit(); err != nil {
1913 log.Println("failed to commit transaction", err)
1914 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1915 return
1916 }
1917
1918 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
1919 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
1920}
1921
1922func (s *Pulls) resubmitStackedPullHelper(
1923 w http.ResponseWriter,
1924 r *http.Request,
1925 repo *models.Repo,
1926 user *oauth.User,
1927 pull *models.Pull,
1928 patch string,
1929 stackId string,
1930) {
1931 targetBranch := pull.TargetBranch
1932
1933 origStack, _ := r.Context().Value("stack").(models.Stack)
1934 newStack, err := s.newStack(r.Context(), repo, user, targetBranch, patch, pull.PullSource, stackId)
1935 if err != nil {
1936 log.Println("failed to create resubmitted stack", err)
1937 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1938 return
1939 }
1940
1941 // find the diff between the stacks, first, map them by changeId
1942 origById := make(map[string]*models.Pull)
1943 newById := make(map[string]*models.Pull)
1944 for _, p := range origStack {
1945 origById[p.ChangeId] = p
1946 }
1947 for _, p := range newStack {
1948 newById[p.ChangeId] = p
1949 }
1950
1951 // commits that got deleted: corresponding pull is closed
1952 // commits that got added: new pull is created
1953 // commits that got updated: corresponding pull is resubmitted & new round begins
1954 additions := make(map[string]*models.Pull)
1955 deletions := make(map[string]*models.Pull)
1956 updated := make(map[string]struct{})
1957
1958 // pulls in orignal stack but not in new one
1959 for _, op := range origStack {
1960 if _, ok := newById[op.ChangeId]; !ok {
1961 deletions[op.ChangeId] = op
1962 }
1963 }
1964
1965 // pulls in new stack but not in original one
1966 for _, np := range newStack {
1967 if _, ok := origById[np.ChangeId]; !ok {
1968 additions[np.ChangeId] = np
1969 }
1970 }
1971
1972 // NOTE: this loop can be written in any of above blocks,
1973 // but is written separately in the interest of simpler code
1974 for _, np := range newStack {
1975 if op, ok := origById[np.ChangeId]; ok {
1976 // pull exists in both stacks
1977 updated[op.ChangeId] = struct{}{}
1978 }
1979 }
1980
1981 tx, err := s.db.Begin()
1982 if err != nil {
1983 log.Println("failed to start transaction", err)
1984 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1985 return
1986 }
1987 defer tx.Rollback()
1988
1989 client, err := s.oauth.AuthorizedClient(r)
1990 if err != nil {
1991 log.Println("failed to authorize client")
1992 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1993 return
1994 }
1995
1996 // pds updates to make
1997 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1998
1999 // deleted pulls are marked as deleted in the DB
2000 for _, p := range deletions {
2001 // do not do delete already merged PRs
2002 if p.State == models.PullMerged {
2003 continue
2004 }
2005
2006 err := db.DeletePull(tx, p.RepoAt, p.PullId)
2007 if err != nil {
2008 log.Println("failed to delete pull", err, p.PullId)
2009 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2010 return
2011 }
2012 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2013 RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{
2014 Collection: tangled.RepoPullNSID,
2015 Rkey: p.Rkey,
2016 },
2017 })
2018 }
2019
2020 // new pulls are created
2021 for _, p := range additions {
2022 err := db.NewPull(tx, p)
2023 if err != nil {
2024 log.Println("failed to create pull", err, p.PullId)
2025 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2026 return
2027 }
2028
2029 blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
2030 if err != nil {
2031 log.Println("failed to upload patch blob", err)
2032 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
2033 return
2034 }
2035 record := p.AsRecord()
2036 record.PatchBlob = blob.Blob
2037 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2038 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
2039 Collection: tangled.RepoPullNSID,
2040 Rkey: &p.Rkey,
2041 Value: &lexutil.LexiconTypeDecoder{
2042 Val: &record,
2043 },
2044 },
2045 })
2046 }
2047
2048 // updated pulls are, well, updated; to start a new round
2049 for id := range updated {
2050 op, _ := origById[id]
2051 np, _ := newById[id]
2052
2053 // do not update already merged PRs
2054 if op.State == models.PullMerged {
2055 continue
2056 }
2057
2058 // resubmit the new pull
2059 pullAt := op.AtUri()
2060 newRoundNumber := len(op.Submissions)
2061 newPatch := np.LatestPatch()
2062 combinedPatch := np.LatestSubmission().Combined
2063 newSourceRev := np.LatestSha()
2064 err := db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev)
2065 if err != nil {
2066 log.Println("failed to update pull", err, op.PullId)
2067 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2068 return
2069 }
2070
2071 blob, err := comatproto.RepoUploadBlob(r.Context(), client, strings.NewReader(patch))
2072 if err != nil {
2073 log.Println("failed to upload patch blob", err)
2074 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
2075 return
2076 }
2077 record := np.AsRecord()
2078 record.PatchBlob = blob.Blob
2079 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
2080 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
2081 Collection: tangled.RepoPullNSID,
2082 Rkey: op.Rkey,
2083 Value: &lexutil.LexiconTypeDecoder{
2084 Val: &record,
2085 },
2086 },
2087 })
2088 }
2089
2090 // update parent-change-id relations for the entire stack
2091 for _, p := range newStack {
2092 err := db.SetPullParentChangeId(
2093 tx,
2094 p.ParentChangeId,
2095 // these should be enough filters to be unique per-stack
2096 orm.FilterEq("repo_at", p.RepoAt.String()),
2097 orm.FilterEq("owner_did", p.OwnerDid),
2098 orm.FilterEq("change_id", p.ChangeId),
2099 )
2100
2101 if err != nil {
2102 log.Println("failed to update pull", err, p.PullId)
2103 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2104 return
2105 }
2106 }
2107
2108 err = tx.Commit()
2109 if err != nil {
2110 log.Println("failed to resubmit pull", err)
2111 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
2112 return
2113 }
2114
2115 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{
2116 Repo: user.Did,
2117 Writes: writes,
2118 })
2119 if err != nil {
2120 log.Println("failed to create stacked pull request", err)
2121 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.")
2122 return
2123 }
2124
2125 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo)
2126 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2127}
2128
2129func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
2130 user := s.oauth.GetUser(r)
2131 f, err := s.repoResolver.Resolve(r)
2132 if err != nil {
2133 log.Println("failed to resolve repo:", err)
2134 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
2135 return
2136 }
2137
2138 pull, ok := r.Context().Value("pull").(*models.Pull)
2139 if !ok {
2140 log.Println("failed to get pull")
2141 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
2142 return
2143 }
2144
2145 var pullsToMerge models.Stack
2146 pullsToMerge = append(pullsToMerge, pull)
2147 if pull.IsStacked() {
2148 stack, ok := r.Context().Value("stack").(models.Stack)
2149 if !ok {
2150 log.Println("failed to get stack")
2151 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
2152 return
2153 }
2154
2155 // combine patches of substack
2156 subStack := stack.StrictlyBelow(pull)
2157 // collect the portion of the stack that is mergeable
2158 mergeable := subStack.Mergeable()
2159 // add to total patch
2160 pullsToMerge = append(pullsToMerge, mergeable...)
2161 }
2162
2163 patch := pullsToMerge.CombinedPatch()
2164
2165 ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid)
2166 if err != nil {
2167 log.Printf("resolving identity: %s", err)
2168 w.WriteHeader(http.StatusNotFound)
2169 return
2170 }
2171
2172 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
2173 if err != nil {
2174 log.Printf("failed to get primary email: %s", err)
2175 }
2176
2177 authorName := ident.Handle.String()
2178 mergeInput := &tangled.RepoMerge_Input{
2179 Did: f.Did,
2180 Name: f.Name,
2181 Branch: pull.TargetBranch,
2182 Patch: patch,
2183 CommitMessage: &pull.Title,
2184 AuthorName: &authorName,
2185 }
2186
2187 if pull.Body != "" {
2188 mergeInput.CommitBody = &pull.Body
2189 }
2190
2191 if email.Address != "" {
2192 mergeInput.AuthorEmail = &email.Address
2193 }
2194
2195 client, err := s.oauth.ServiceClient(
2196 r,
2197 oauth.WithService(f.Knot),
2198 oauth.WithLxm(tangled.RepoMergeNSID),
2199 oauth.WithDev(s.config.Core.Dev),
2200 )
2201 if err != nil {
2202 log.Printf("failed to connect to knot server: %v", err)
2203 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
2204 return
2205 }
2206
2207 err = tangled.RepoMerge(r.Context(), client, mergeInput)
2208 if err := xrpcclient.HandleXrpcErr(err); err != nil {
2209 s.pages.Notice(w, "pull-merge-error", err.Error())
2210 return
2211 }
2212
2213 tx, err := s.db.Begin()
2214 if err != nil {
2215 log.Println("failed to start transcation", err)
2216 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
2217 return
2218 }
2219 defer tx.Rollback()
2220
2221 for _, p := range pullsToMerge {
2222 err := db.MergePull(tx, f.RepoAt(), p.PullId)
2223 if err != nil {
2224 log.Printf("failed to update pull request status in database: %s", err)
2225 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
2226 return
2227 }
2228 p.State = models.PullMerged
2229 }
2230
2231 err = tx.Commit()
2232 if err != nil {
2233 // TODO: this is unsound, we should also revert the merge from the knotserver here
2234 log.Printf("failed to update pull request status in database: %s", err)
2235 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
2236 return
2237 }
2238
2239 // notify about the pull merge
2240 for _, p := range pullsToMerge {
2241 s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2242 }
2243
2244 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
2245 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2246}
2247
2248func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) {
2249 user := s.oauth.GetUser(r)
2250
2251 f, err := s.repoResolver.Resolve(r)
2252 if err != nil {
2253 log.Println("malformed middleware")
2254 return
2255 }
2256
2257 pull, ok := r.Context().Value("pull").(*models.Pull)
2258 if !ok {
2259 log.Println("failed to get pull")
2260 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
2261 return
2262 }
2263
2264 // auth filter: only owner or collaborators can close
2265 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
2266 isOwner := roles.IsOwner()
2267 isCollaborator := roles.IsCollaborator()
2268 isPullAuthor := user.Did == pull.OwnerDid
2269 isCloseAllowed := isOwner || isCollaborator || isPullAuthor
2270 if !isCloseAllowed {
2271 log.Println("failed to close pull")
2272 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
2273 return
2274 }
2275
2276 // Start a transaction
2277 tx, err := s.db.BeginTx(r.Context(), nil)
2278 if err != nil {
2279 log.Println("failed to start transaction", err)
2280 s.pages.Notice(w, "pull-close", "Failed to close pull.")
2281 return
2282 }
2283 defer tx.Rollback()
2284
2285 var pullsToClose []*models.Pull
2286 pullsToClose = append(pullsToClose, pull)
2287
2288 // if this PR is stacked, then we want to close all PRs below this one on the stack
2289 if pull.IsStacked() {
2290 stack := r.Context().Value("stack").(models.Stack)
2291 subStack := stack.StrictlyBelow(pull)
2292 pullsToClose = append(pullsToClose, subStack...)
2293 }
2294
2295 for _, p := range pullsToClose {
2296 // Close the pull in the database
2297 err = db.ClosePull(tx, f.RepoAt(), p.PullId)
2298 if err != nil {
2299 log.Println("failed to close pull", err)
2300 s.pages.Notice(w, "pull-close", "Failed to close pull.")
2301 return
2302 }
2303 p.State = models.PullClosed
2304 }
2305
2306 // Commit the transaction
2307 if err = tx.Commit(); err != nil {
2308 log.Println("failed to commit transaction", err)
2309 s.pages.Notice(w, "pull-close", "Failed to close pull.")
2310 return
2311 }
2312
2313 for _, p := range pullsToClose {
2314 s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2315 }
2316
2317 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
2318 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2319}
2320
2321func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) {
2322 user := s.oauth.GetUser(r)
2323
2324 f, err := s.repoResolver.Resolve(r)
2325 if err != nil {
2326 log.Println("failed to resolve repo", err)
2327 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2328 return
2329 }
2330
2331 pull, ok := r.Context().Value("pull").(*models.Pull)
2332 if !ok {
2333 log.Println("failed to get pull")
2334 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
2335 return
2336 }
2337
2338 // auth filter: only owner or collaborators can close
2339 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())}
2340 isOwner := roles.IsOwner()
2341 isCollaborator := roles.IsCollaborator()
2342 isPullAuthor := user.Did == pull.OwnerDid
2343 isCloseAllowed := isOwner || isCollaborator || isPullAuthor
2344 if !isCloseAllowed {
2345 log.Println("failed to close pull")
2346 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
2347 return
2348 }
2349
2350 // Start a transaction
2351 tx, err := s.db.BeginTx(r.Context(), nil)
2352 if err != nil {
2353 log.Println("failed to start transaction", err)
2354 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2355 return
2356 }
2357 defer tx.Rollback()
2358
2359 var pullsToReopen []*models.Pull
2360 pullsToReopen = append(pullsToReopen, pull)
2361
2362 // if this PR is stacked, then we want to reopen all PRs above this one on the stack
2363 if pull.IsStacked() {
2364 stack := r.Context().Value("stack").(models.Stack)
2365 subStack := stack.StrictlyAbove(pull)
2366 pullsToReopen = append(pullsToReopen, subStack...)
2367 }
2368
2369 for _, p := range pullsToReopen {
2370 // Close the pull in the database
2371 err = db.ReopenPull(tx, f.RepoAt(), p.PullId)
2372 if err != nil {
2373 log.Println("failed to close pull", err)
2374 s.pages.Notice(w, "pull-close", "Failed to close pull.")
2375 return
2376 }
2377 p.State = models.PullOpen
2378 }
2379
2380 // Commit the transaction
2381 if err = tx.Commit(); err != nil {
2382 log.Println("failed to commit transaction", err)
2383 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2384 return
2385 }
2386
2387 for _, p := range pullsToReopen {
2388 s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p)
2389 }
2390
2391 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f)
2392 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId))
2393}
2394
2395func (s *Pulls) newStack(ctx context.Context, repo *models.Repo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) {
2396 formatPatches, err := patchutil.ExtractPatches(patch)
2397 if err != nil {
2398 return nil, fmt.Errorf("Failed to extract patches: %v", err)
2399 }
2400
2401 // must have atleast 1 patch to begin with
2402 if len(formatPatches) == 0 {
2403 return nil, fmt.Errorf("No patches found in the generated format-patch.")
2404 }
2405
2406 // the stack is identified by a UUID
2407 var stack models.Stack
2408 parentChangeId := ""
2409 for _, fp := range formatPatches {
2410 // all patches must have a jj change-id
2411 changeId, err := fp.ChangeId()
2412 if err != nil {
2413 return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.")
2414 }
2415
2416 title := fp.Title
2417 body := fp.Body
2418 rkey := tid.TID()
2419
2420 mentions, references := s.mentionsResolver.Resolve(ctx, body)
2421
2422 initialSubmission := models.PullSubmission{
2423 Patch: fp.Raw,
2424 SourceRev: fp.SHA,
2425 Combined: fp.Raw,
2426 }
2427 pull := models.Pull{
2428 Title: title,
2429 Body: body,
2430 TargetBranch: targetBranch,
2431 OwnerDid: user.Did,
2432 RepoAt: repo.RepoAt(),
2433 Rkey: rkey,
2434 Mentions: mentions,
2435 References: references,
2436 Submissions: []*models.PullSubmission{
2437 &initialSubmission,
2438 },
2439 PullSource: pullSource,
2440 Created: time.Now(),
2441
2442 StackId: stackId,
2443 ChangeId: changeId,
2444 ParentChangeId: parentChangeId,
2445 }
2446
2447 stack = append(stack, &pull)
2448
2449 parentChangeId = changeId
2450 }
2451
2452 return stack, nil
2453}