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