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