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