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