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