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