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