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