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