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