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