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