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