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