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