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