1package state
2
3import (
4 "database/sql"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "log"
10 "net/http"
11 "sort"
12 "strconv"
13 "strings"
14 "time"
15
16 "tangled.sh/tangled.sh/core/api/tangled"
17 "tangled.sh/tangled.sh/core/appview"
18 "tangled.sh/tangled.sh/core/appview/db"
19 "tangled.sh/tangled.sh/core/appview/oauth"
20 "tangled.sh/tangled.sh/core/appview/pages"
21 "tangled.sh/tangled.sh/core/knotclient"
22 "tangled.sh/tangled.sh/core/patchutil"
23 "tangled.sh/tangled.sh/core/types"
24
25 "github.com/bluekeyes/go-gitdiff/gitdiff"
26 comatproto "github.com/bluesky-social/indigo/api/atproto"
27 "github.com/bluesky-social/indigo/atproto/syntax"
28 lexutil "github.com/bluesky-social/indigo/lex/util"
29 "github.com/go-chi/chi/v5"
30 "github.com/google/uuid"
31 "github.com/posthog/posthog-go"
32)
33
34// htmx fragment
35func (s *State) PullActions(w http.ResponseWriter, r *http.Request) {
36 switch r.Method {
37 case http.MethodGet:
38 user := s.oauth.GetUser(r)
39 f, err := s.fullyResolvedRepo(r)
40 if err != nil {
41 log.Println("failed to get repo and knot", err)
42 return
43 }
44
45 pull, ok := r.Context().Value("pull").(*db.Pull)
46 if !ok {
47 log.Println("failed to get pull")
48 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
49 return
50 }
51
52 // can be nil if this pull is not stacked
53 stack, _ := r.Context().Value("stack").(db.Stack)
54
55 roundNumberStr := chi.URLParam(r, "round")
56 roundNumber, err := strconv.Atoi(roundNumberStr)
57 if err != nil {
58 roundNumber = pull.LastRoundNumber()
59 }
60 if roundNumber >= len(pull.Submissions) {
61 http.Error(w, "bad round id", http.StatusBadRequest)
62 log.Println("failed to parse round id", err)
63 return
64 }
65
66 mergeCheckResponse := s.mergeCheck(f, pull, stack)
67 resubmitResult := pages.Unknown
68 if user.Did == pull.OwnerDid {
69 resubmitResult = s.resubmitCheck(f, pull, stack)
70 }
71
72 s.pages.PullActionsFragment(w, pages.PullActionsParams{
73 LoggedInUser: user,
74 RepoInfo: f.RepoInfo(s, user),
75 Pull: pull,
76 RoundNumber: roundNumber,
77 MergeCheck: mergeCheckResponse,
78 ResubmitCheck: resubmitResult,
79 Stack: stack,
80 })
81 return
82 }
83}
84
85func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) {
86 user := s.oauth.GetUser(r)
87 f, err := s.fullyResolvedRepo(r)
88 if err != nil {
89 log.Println("failed to get repo and knot", err)
90 return
91 }
92
93 pull, ok := r.Context().Value("pull").(*db.Pull)
94 if !ok {
95 log.Println("failed to get pull")
96 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
97 return
98 }
99
100 // can be nil if this pull is not stacked
101 stack, _ := r.Context().Value("stack").(db.Stack)
102 abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*db.Pull)
103
104 totalIdents := 1
105 for _, submission := range pull.Submissions {
106 totalIdents += len(submission.Comments)
107 }
108
109 identsToResolve := make([]string, totalIdents)
110
111 // populate idents
112 identsToResolve[0] = pull.OwnerDid
113 idx := 1
114 for _, submission := range pull.Submissions {
115 for _, comment := range submission.Comments {
116 identsToResolve[idx] = comment.OwnerDid
117 idx += 1
118 }
119 }
120
121 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
122 didHandleMap := make(map[string]string)
123 for _, identity := range resolvedIds {
124 if !identity.Handle.IsInvalidHandle() {
125 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
126 } else {
127 didHandleMap[identity.DID.String()] = identity.DID.String()
128 }
129 }
130
131 mergeCheckResponse := s.mergeCheck(f, pull, stack)
132 resubmitResult := pages.Unknown
133 if user != nil && user.Did == pull.OwnerDid {
134 resubmitResult = s.resubmitCheck(f, pull, stack)
135 }
136
137 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
138 LoggedInUser: user,
139 RepoInfo: f.RepoInfo(s, user),
140 DidHandleMap: didHandleMap,
141 Pull: pull,
142 Stack: stack,
143 AbandonedPulls: abandonedPulls,
144 MergeCheck: mergeCheckResponse,
145 ResubmitCheck: resubmitResult,
146 })
147}
148
149func (s *State) mergeCheck(f *FullyResolvedRepo, pull *db.Pull, stack db.Stack) types.MergeCheckResponse {
150 if pull.State == db.PullMerged {
151 return types.MergeCheckResponse{}
152 }
153
154 secret, err := db.GetRegistrationKey(s.db, f.Knot)
155 if err != nil {
156 log.Printf("failed to get registration key: %v", err)
157 return types.MergeCheckResponse{
158 Error: "failed to check merge status: this knot is unregistered",
159 }
160 }
161
162 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
163 if err != nil {
164 log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err)
165 return types.MergeCheckResponse{
166 Error: "failed to check merge status",
167 }
168 }
169
170 patch := pull.LatestPatch()
171 if pull.IsStacked() {
172 // combine patches of substack
173 subStack := stack.Below(pull)
174 // collect the portion of the stack that is mergeable
175 mergeable := subStack.Mergeable()
176 // combine each patch
177 patch = mergeable.CombinedPatch()
178 }
179
180 resp, err := ksClient.MergeCheck([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch)
181 if err != nil {
182 log.Println("failed to check for mergeability:", err)
183 return types.MergeCheckResponse{
184 Error: "failed to check merge status",
185 }
186 }
187 switch resp.StatusCode {
188 case 404:
189 return types.MergeCheckResponse{
190 Error: "failed to check merge status: this knot does not support PRs",
191 }
192 case 400:
193 return types.MergeCheckResponse{
194 Error: "failed to check merge status: does this knot support PRs?",
195 }
196 }
197
198 respBody, err := io.ReadAll(resp.Body)
199 if err != nil {
200 log.Println("failed to read merge check response body")
201 return types.MergeCheckResponse{
202 Error: "failed to check merge status: knot is not speaking the right language",
203 }
204 }
205 defer resp.Body.Close()
206
207 var mergeCheckResponse types.MergeCheckResponse
208 err = json.Unmarshal(respBody, &mergeCheckResponse)
209 if err != nil {
210 log.Println("failed to unmarshal merge check response", err)
211 return types.MergeCheckResponse{
212 Error: "failed to check merge status: knot is not speaking the right language",
213 }
214 }
215
216 return mergeCheckResponse
217}
218
219func (s *State) resubmitCheck(f *FullyResolvedRepo, pull *db.Pull, stack db.Stack) pages.ResubmitResult {
220 if pull.State == db.PullMerged || pull.State == db.PullDeleted || pull.PullSource == nil {
221 return pages.Unknown
222 }
223
224 var knot, ownerDid, repoName string
225
226 if pull.PullSource.RepoAt != nil {
227 // fork-based pulls
228 sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
229 if err != nil {
230 log.Println("failed to get source repo", err)
231 return pages.Unknown
232 }
233
234 knot = sourceRepo.Knot
235 ownerDid = sourceRepo.Did
236 repoName = sourceRepo.Name
237 } else {
238 // pulls within the same repo
239 knot = f.Knot
240 ownerDid = f.OwnerDid()
241 repoName = f.RepoName
242 }
243
244 us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev)
245 if err != nil {
246 log.Printf("failed to setup client for %s; ignoring: %v", knot, err)
247 return pages.Unknown
248 }
249
250 result, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch)
251 if err != nil {
252 log.Println("failed to reach knotserver", err)
253 return pages.Unknown
254 }
255
256 latestSourceRev := pull.Submissions[pull.LastRoundNumber()].SourceRev
257
258 if pull.IsStacked() && stack != nil {
259 top := stack[0]
260 latestSourceRev = top.Submissions[top.LastRoundNumber()].SourceRev
261 }
262
263 if latestSourceRev != result.Branch.Hash {
264 log.Println(latestSourceRev, result.Branch.Hash)
265 return pages.ShouldResubmit
266 }
267
268 return pages.ShouldNotResubmit
269}
270
271func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
272 user := s.oauth.GetUser(r)
273 f, err := s.fullyResolvedRepo(r)
274 if err != nil {
275 log.Println("failed to get repo and knot", err)
276 return
277 }
278
279 pull, ok := r.Context().Value("pull").(*db.Pull)
280 if !ok {
281 log.Println("failed to get pull")
282 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
283 return
284 }
285
286 stack, _ := r.Context().Value("stack").(db.Stack)
287
288 roundId := chi.URLParam(r, "round")
289 roundIdInt, err := strconv.Atoi(roundId)
290 if err != nil || roundIdInt >= len(pull.Submissions) {
291 http.Error(w, "bad round id", http.StatusBadRequest)
292 log.Println("failed to parse round id", err)
293 return
294 }
295
296 identsToResolve := []string{pull.OwnerDid}
297 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
298 didHandleMap := make(map[string]string)
299 for _, identity := range resolvedIds {
300 if !identity.Handle.IsInvalidHandle() {
301 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
302 } else {
303 didHandleMap[identity.DID.String()] = identity.DID.String()
304 }
305 }
306
307 diff := pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch)
308
309 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
310 LoggedInUser: user,
311 DidHandleMap: didHandleMap,
312 RepoInfo: f.RepoInfo(s, user),
313 Pull: pull,
314 Stack: stack,
315 Round: roundIdInt,
316 Submission: pull.Submissions[roundIdInt],
317 Diff: &diff,
318 })
319
320}
321
322func (s *State) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) {
323 user := s.oauth.GetUser(r)
324
325 f, err := s.fullyResolvedRepo(r)
326 if err != nil {
327 log.Println("failed to get repo and knot", err)
328 return
329 }
330
331 pull, ok := r.Context().Value("pull").(*db.Pull)
332 if !ok {
333 log.Println("failed to get pull")
334 s.pages.Notice(w, "pull-error", "Failed to get pull.")
335 return
336 }
337
338 roundId := chi.URLParam(r, "round")
339 roundIdInt, err := strconv.Atoi(roundId)
340 if err != nil || roundIdInt >= len(pull.Submissions) {
341 http.Error(w, "bad round id", http.StatusBadRequest)
342 log.Println("failed to parse round id", err)
343 return
344 }
345
346 if roundIdInt == 0 {
347 http.Error(w, "bad round id", http.StatusBadRequest)
348 log.Println("cannot interdiff initial submission")
349 return
350 }
351
352 identsToResolve := []string{pull.OwnerDid}
353 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
354 didHandleMap := make(map[string]string)
355 for _, identity := range resolvedIds {
356 if !identity.Handle.IsInvalidHandle() {
357 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
358 } else {
359 didHandleMap[identity.DID.String()] = identity.DID.String()
360 }
361 }
362
363 currentPatch, err := pull.Submissions[roundIdInt].AsDiff(pull.TargetBranch)
364 if err != nil {
365 log.Println("failed to interdiff; current patch malformed")
366 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.")
367 return
368 }
369
370 previousPatch, err := pull.Submissions[roundIdInt-1].AsDiff(pull.TargetBranch)
371 if err != nil {
372 log.Println("failed to interdiff; previous patch malformed")
373 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.")
374 return
375 }
376
377 interdiff := patchutil.Interdiff(previousPatch, currentPatch)
378
379 s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{
380 LoggedInUser: s.oauth.GetUser(r),
381 RepoInfo: f.RepoInfo(s, user),
382 Pull: pull,
383 Round: roundIdInt,
384 DidHandleMap: didHandleMap,
385 Interdiff: interdiff,
386 })
387 return
388}
389
390func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
391 pull, ok := r.Context().Value("pull").(*db.Pull)
392 if !ok {
393 log.Println("failed to get pull")
394 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
395 return
396 }
397
398 roundId := chi.URLParam(r, "round")
399 roundIdInt, err := strconv.Atoi(roundId)
400 if err != nil || roundIdInt >= len(pull.Submissions) {
401 http.Error(w, "bad round id", http.StatusBadRequest)
402 log.Println("failed to parse round id", err)
403 return
404 }
405
406 identsToResolve := []string{pull.OwnerDid}
407 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
408 didHandleMap := make(map[string]string)
409 for _, identity := range resolvedIds {
410 if !identity.Handle.IsInvalidHandle() {
411 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
412 } else {
413 didHandleMap[identity.DID.String()] = identity.DID.String()
414 }
415 }
416
417 w.Header().Set("Content-Type", "text/plain")
418 w.Write([]byte(pull.Submissions[roundIdInt].Patch))
419}
420
421func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) {
422 user := s.oauth.GetUser(r)
423 params := r.URL.Query()
424
425 state := db.PullOpen
426 switch params.Get("state") {
427 case "closed":
428 state = db.PullClosed
429 case "merged":
430 state = db.PullMerged
431 }
432
433 f, err := s.fullyResolvedRepo(r)
434 if err != nil {
435 log.Println("failed to get repo and knot", err)
436 return
437 }
438
439 pulls, err := db.GetPulls(
440 s.db,
441 db.FilterEq("repo_at", f.RepoAt),
442 db.FilterEq("state", state),
443 )
444 if err != nil {
445 log.Println("failed to get pulls", err)
446 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
447 return
448 }
449
450 for _, p := range pulls {
451 var pullSourceRepo *db.Repo
452 if p.PullSource != nil {
453 if p.PullSource.RepoAt != nil {
454 pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String())
455 if err != nil {
456 log.Printf("failed to get repo by at uri: %v", err)
457 continue
458 } else {
459 p.PullSource.Repo = pullSourceRepo
460 }
461 }
462 }
463 }
464
465 identsToResolve := make([]string, len(pulls))
466 for i, pull := range pulls {
467 identsToResolve[i] = pull.OwnerDid
468 }
469 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
470 didHandleMap := make(map[string]string)
471 for _, identity := range resolvedIds {
472 if !identity.Handle.IsInvalidHandle() {
473 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
474 } else {
475 didHandleMap[identity.DID.String()] = identity.DID.String()
476 }
477 }
478
479 s.pages.RepoPulls(w, pages.RepoPullsParams{
480 LoggedInUser: s.oauth.GetUser(r),
481 RepoInfo: f.RepoInfo(s, user),
482 Pulls: pulls,
483 DidHandleMap: didHandleMap,
484 FilteringBy: state,
485 })
486 return
487}
488
489func (s *State) PullComment(w http.ResponseWriter, r *http.Request) {
490 user := s.oauth.GetUser(r)
491 f, err := s.fullyResolvedRepo(r)
492 if err != nil {
493 log.Println("failed to get repo and knot", err)
494 return
495 }
496
497 pull, ok := r.Context().Value("pull").(*db.Pull)
498 if !ok {
499 log.Println("failed to get pull")
500 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
501 return
502 }
503
504 roundNumberStr := chi.URLParam(r, "round")
505 roundNumber, err := strconv.Atoi(roundNumberStr)
506 if err != nil || roundNumber >= len(pull.Submissions) {
507 http.Error(w, "bad round id", http.StatusBadRequest)
508 log.Println("failed to parse round id", err)
509 return
510 }
511
512 switch r.Method {
513 case http.MethodGet:
514 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{
515 LoggedInUser: user,
516 RepoInfo: f.RepoInfo(s, user),
517 Pull: pull,
518 RoundNumber: roundNumber,
519 })
520 return
521 case http.MethodPost:
522 body := r.FormValue("body")
523 if body == "" {
524 s.pages.Notice(w, "pull", "Comment body is required")
525 return
526 }
527
528 // Start a transaction
529 tx, err := s.db.BeginTx(r.Context(), nil)
530 if err != nil {
531 log.Println("failed to start transaction", err)
532 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
533 return
534 }
535 defer tx.Rollback()
536
537 createdAt := time.Now().Format(time.RFC3339)
538 ownerDid := user.Did
539
540 pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId)
541 if err != nil {
542 log.Println("failed to get pull at", err)
543 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
544 return
545 }
546
547 atUri := f.RepoAt.String()
548 client, err := s.oauth.AuthorizedClient(r)
549 if err != nil {
550 log.Println("failed to get authorized client", err)
551 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
552 return
553 }
554 atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
555 Collection: tangled.RepoPullCommentNSID,
556 Repo: user.Did,
557 Rkey: appview.TID(),
558 Record: &lexutil.LexiconTypeDecoder{
559 Val: &tangled.RepoPullComment{
560 Repo: &atUri,
561 Pull: string(pullAt),
562 Owner: &ownerDid,
563 Body: body,
564 CreatedAt: createdAt,
565 },
566 },
567 })
568 if err != nil {
569 log.Println("failed to create pull comment", err)
570 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
571 return
572 }
573
574 // Create the pull comment in the database with the commentAt field
575 commentId, err := db.NewPullComment(tx, &db.PullComment{
576 OwnerDid: user.Did,
577 RepoAt: f.RepoAt.String(),
578 PullId: pull.PullId,
579 Body: body,
580 CommentAt: atResp.Uri,
581 SubmissionId: pull.Submissions[roundNumber].ID,
582 })
583 if err != nil {
584 log.Println("failed to create pull comment", err)
585 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
586 return
587 }
588
589 // Commit the transaction
590 if err = tx.Commit(); err != nil {
591 log.Println("failed to commit transaction", err)
592 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
593 return
594 }
595
596 if !s.config.Core.Dev {
597 err = s.posthog.Enqueue(posthog.Capture{
598 DistinctId: user.Did,
599 Event: "new_pull_comment",
600 Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pull.PullId},
601 })
602 if err != nil {
603 log.Println("failed to enqueue posthog event:", err)
604 }
605 }
606
607 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
608 return
609 }
610}
611
612func (s *State) NewPull(w http.ResponseWriter, r *http.Request) {
613 user := s.oauth.GetUser(r)
614 f, err := s.fullyResolvedRepo(r)
615 if err != nil {
616 log.Println("failed to get repo and knot", err)
617 return
618 }
619
620 switch r.Method {
621 case http.MethodGet:
622 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
623 if err != nil {
624 log.Printf("failed to create unsigned client for %s", f.Knot)
625 s.pages.Error503(w)
626 return
627 }
628
629 result, err := us.Branches(f.OwnerDid(), f.RepoName)
630 if err != nil {
631 log.Println("failed to reach knotserver", err)
632 return
633 }
634
635 s.pages.RepoNewPull(w, pages.RepoNewPullParams{
636 LoggedInUser: user,
637 RepoInfo: f.RepoInfo(s, user),
638 Branches: result.Branches,
639 })
640
641 case http.MethodPost:
642 title := r.FormValue("title")
643 body := r.FormValue("body")
644 targetBranch := r.FormValue("targetBranch")
645 fromFork := r.FormValue("fork")
646 sourceBranch := r.FormValue("sourceBranch")
647 patch := r.FormValue("patch")
648
649 if targetBranch == "" {
650 s.pages.Notice(w, "pull", "Target branch is required.")
651 return
652 }
653
654 // Determine PR type based on input parameters
655 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed()
656 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == ""
657 isForkBased := fromFork != "" && sourceBranch != ""
658 isPatchBased := patch != "" && !isBranchBased && !isForkBased
659 isStacked := r.FormValue("isStacked") == "on"
660
661 if isPatchBased && !patchutil.IsFormatPatch(patch) {
662 if title == "" {
663 s.pages.Notice(w, "pull", "Title is required for git-diff patches.")
664 return
665 }
666 }
667
668 // Validate we have at least one valid PR creation method
669 if !isBranchBased && !isPatchBased && !isForkBased {
670 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.")
671 return
672 }
673
674 // Can't mix branch-based and patch-based approaches
675 if isBranchBased && patch != "" {
676 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.")
677 return
678 }
679
680 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
681 if err != nil {
682 log.Printf("failed to create unsigned client to %s: %v", f.Knot, err)
683 s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
684 return
685 }
686
687 caps, err := us.Capabilities()
688 if err != nil {
689 log.Println("error fetching knot caps", f.Knot, err)
690 s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
691 return
692 }
693
694 if !caps.PullRequests.FormatPatch {
695 s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.")
696 return
697 }
698
699 // Handle the PR creation based on the type
700 if isBranchBased {
701 if !caps.PullRequests.BranchSubmissions {
702 s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?")
703 return
704 }
705 s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch, isStacked)
706 } else if isForkBased {
707 if !caps.PullRequests.ForkSubmissions {
708 s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?")
709 return
710 }
711 s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch, isStacked)
712 } else if isPatchBased {
713 if !caps.PullRequests.PatchSubmissions {
714 s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.")
715 return
716 }
717 s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch, isStacked)
718 }
719 return
720 }
721}
722
723func (s *State) handleBranchBasedPull(
724 w http.ResponseWriter,
725 r *http.Request,
726 f *FullyResolvedRepo,
727 user *oauth.User,
728 title,
729 body,
730 targetBranch,
731 sourceBranch string,
732 isStacked bool,
733) {
734 pullSource := &db.PullSource{
735 Branch: sourceBranch,
736 }
737 recordPullSource := &tangled.RepoPull_Source{
738 Branch: sourceBranch,
739 }
740
741 // Generate a patch using /compare
742 ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
743 if err != nil {
744 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
745 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
746 return
747 }
748
749 comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
750 if err != nil {
751 log.Println("failed to compare", err)
752 s.pages.Notice(w, "pull", err.Error())
753 return
754 }
755
756 sourceRev := comparison.Rev2
757 patch := comparison.Patch
758
759 if !patchutil.IsPatchValid(patch) {
760 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
761 return
762 }
763
764 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked)
765}
766
767func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) {
768 if !patchutil.IsPatchValid(patch) {
769 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
770 return
771 }
772
773 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil, isStacked)
774}
775
776func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) {
777 fork, err := db.GetForkByDid(s.db, user.Did, forkRepo)
778 if errors.Is(err, sql.ErrNoRows) {
779 s.pages.Notice(w, "pull", "No such fork.")
780 return
781 } else if err != nil {
782 log.Println("failed to fetch fork:", err)
783 s.pages.Notice(w, "pull", "Failed to fetch fork.")
784 return
785 }
786
787 secret, err := db.GetRegistrationKey(s.db, fork.Knot)
788 if err != nil {
789 log.Println("failed to fetch registration key:", err)
790 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
791 return
792 }
793
794 sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev)
795 if err != nil {
796 log.Println("failed to create signed client:", err)
797 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
798 return
799 }
800
801 us, err := knotclient.NewUnsignedClient(fork.Knot, s.config.Core.Dev)
802 if err != nil {
803 log.Println("failed to create unsigned client:", err)
804 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
805 return
806 }
807
808 resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch)
809 if err != nil {
810 log.Println("failed to create hidden ref:", err, resp.StatusCode)
811 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
812 return
813 }
814
815 switch resp.StatusCode {
816 case 404:
817 case 400:
818 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
819 return
820 }
821
822 hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch)
823 // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking
824 // the targetBranch on the target repository. This code is a bit confusing, but here's an example:
825 // hiddenRef: hidden/feature-1/main (on repo-fork)
826 // targetBranch: main (on repo-1)
827 // sourceBranch: feature-1 (on repo-fork)
828 comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch)
829 if err != nil {
830 log.Println("failed to compare across branches", err)
831 s.pages.Notice(w, "pull", err.Error())
832 return
833 }
834
835 sourceRev := comparison.Rev2
836 patch := comparison.Patch
837
838 if !patchutil.IsPatchValid(patch) {
839 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
840 return
841 }
842
843 forkAtUri, err := syntax.ParseATURI(fork.AtUri)
844 if err != nil {
845 log.Println("failed to parse fork AT URI", err)
846 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
847 return
848 }
849
850 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{
851 Branch: sourceBranch,
852 RepoAt: &forkAtUri,
853 }, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri}, isStacked)
854}
855
856func (s *State) createPullRequest(
857 w http.ResponseWriter,
858 r *http.Request,
859 f *FullyResolvedRepo,
860 user *oauth.User,
861 title, body, targetBranch string,
862 patch string,
863 sourceRev string,
864 pullSource *db.PullSource,
865 recordPullSource *tangled.RepoPull_Source,
866 isStacked bool,
867) {
868 if isStacked {
869 // creates a series of PRs, each linking to the previous, identified by jj's change-id
870 s.createStackedPulLRequest(
871 w,
872 r,
873 f,
874 user,
875 targetBranch,
876 patch,
877 sourceRev,
878 pullSource,
879 )
880 return
881 }
882
883 client, err := s.oauth.AuthorizedClient(r)
884 if err != nil {
885 log.Println("failed to get authorized client", err)
886 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
887 return
888 }
889
890 tx, err := s.db.BeginTx(r.Context(), nil)
891 if err != nil {
892 log.Println("failed to start tx")
893 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
894 return
895 }
896 defer tx.Rollback()
897
898 // We've already checked earlier if it's diff-based and title is empty,
899 // so if it's still empty now, it's intentionally skipped owing to format-patch.
900 if title == "" {
901 formatPatches, err := patchutil.ExtractPatches(patch)
902 if err != nil {
903 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
904 return
905 }
906 if len(formatPatches) == 0 {
907 s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.")
908 return
909 }
910
911 title = formatPatches[0].Title
912 body = formatPatches[0].Body
913 }
914
915 rkey := appview.TID()
916 initialSubmission := db.PullSubmission{
917 Patch: patch,
918 SourceRev: sourceRev,
919 }
920 err = db.NewPull(tx, &db.Pull{
921 Title: title,
922 Body: body,
923 TargetBranch: targetBranch,
924 OwnerDid: user.Did,
925 RepoAt: f.RepoAt,
926 Rkey: rkey,
927 Submissions: []*db.PullSubmission{
928 &initialSubmission,
929 },
930 PullSource: pullSource,
931 })
932 if err != nil {
933 log.Println("failed to create pull request", err)
934 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
935 return
936 }
937 pullId, err := db.NextPullId(tx, f.RepoAt)
938 if err != nil {
939 log.Println("failed to get pull id", err)
940 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
941 return
942 }
943
944 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
945 Collection: tangled.RepoPullNSID,
946 Repo: user.Did,
947 Rkey: rkey,
948 Record: &lexutil.LexiconTypeDecoder{
949 Val: &tangled.RepoPull{
950 Title: title,
951 PullId: int64(pullId),
952 TargetRepo: string(f.RepoAt),
953 TargetBranch: targetBranch,
954 Patch: patch,
955 Source: recordPullSource,
956 },
957 },
958 })
959 if err != nil {
960 log.Println("failed to create pull request", err)
961 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
962 return
963 }
964
965 if err = tx.Commit(); err != nil {
966 log.Println("failed to create pull request", err)
967 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
968 return
969 }
970
971 if !s.config.Core.Dev {
972 err = s.posthog.Enqueue(posthog.Capture{
973 DistinctId: user.Did,
974 Event: "new_pull",
975 Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pullId},
976 })
977 if err != nil {
978 log.Println("failed to enqueue posthog event:", err)
979 }
980 }
981
982 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
983}
984
985func (s *State) createStackedPulLRequest(
986 w http.ResponseWriter,
987 r *http.Request,
988 f *FullyResolvedRepo,
989 user *oauth.User,
990 targetBranch string,
991 patch string,
992 sourceRev string,
993 pullSource *db.PullSource,
994) {
995 // run some necessary checks for stacked-prs first
996
997 // must be branch or fork based
998 if sourceRev == "" {
999 log.Println("stacked PR from patch-based pull")
1000 s.pages.Notice(w, "pull", "Stacking is only supported on branch and fork based pull-requests.")
1001 return
1002 }
1003
1004 formatPatches, err := patchutil.ExtractPatches(patch)
1005 if err != nil {
1006 log.Println("failed to extract patches", err)
1007 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
1008 return
1009 }
1010
1011 // must have atleast 1 patch to begin with
1012 if len(formatPatches) == 0 {
1013 log.Println("empty patches")
1014 s.pages.Notice(w, "pull", "No patches found in the generated format-patch.")
1015 return
1016 }
1017
1018 // build a stack out of this patch
1019 stackId := uuid.New()
1020 stack, err := newStack(f, user, targetBranch, patch, pullSource, stackId.String())
1021 if err != nil {
1022 log.Println("failed to create stack", err)
1023 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err))
1024 return
1025 }
1026
1027 client, err := s.oauth.AuthorizedClient(r)
1028 if err != nil {
1029 log.Println("failed to get authorized client", err)
1030 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1031 return
1032 }
1033
1034 // apply all record creations at once
1035 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1036 for _, p := range stack {
1037 record := p.AsRecord()
1038 write := comatproto.RepoApplyWrites_Input_Writes_Elem{
1039 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
1040 Collection: tangled.RepoPullNSID,
1041 Rkey: &p.Rkey,
1042 Value: &lexutil.LexiconTypeDecoder{
1043 Val: &record,
1044 },
1045 },
1046 }
1047 writes = append(writes, &write)
1048 }
1049 _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{
1050 Repo: user.Did,
1051 Writes: writes,
1052 })
1053 if err != nil {
1054 log.Println("failed to create stacked pull request", err)
1055 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.")
1056 return
1057 }
1058
1059 // create all pulls at once
1060 tx, err := s.db.BeginTx(r.Context(), nil)
1061 if err != nil {
1062 log.Println("failed to start tx")
1063 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1064 return
1065 }
1066 defer tx.Rollback()
1067
1068 for _, p := range stack {
1069 err = db.NewPull(tx, p)
1070 if err != nil {
1071 log.Println("failed to create pull request", err)
1072 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1073 return
1074 }
1075 }
1076
1077 if err = tx.Commit(); err != nil {
1078 log.Println("failed to create pull request", err)
1079 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1080 return
1081 }
1082
1083 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", f.OwnerSlashRepo()))
1084}
1085
1086func (s *State) ValidatePatch(w http.ResponseWriter, r *http.Request) {
1087 _, err := s.fullyResolvedRepo(r)
1088 if err != nil {
1089 log.Println("failed to get repo and knot", err)
1090 return
1091 }
1092
1093 patch := r.FormValue("patch")
1094 if patch == "" {
1095 s.pages.Notice(w, "patch-error", "Patch is required.")
1096 return
1097 }
1098
1099 if patch == "" || !patchutil.IsPatchValid(patch) {
1100 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.")
1101 return
1102 }
1103
1104 if patchutil.IsFormatPatch(patch) {
1105 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.")
1106 } else {
1107 s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.")
1108 }
1109}
1110
1111func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
1112 user := s.oauth.GetUser(r)
1113 f, err := s.fullyResolvedRepo(r)
1114 if err != nil {
1115 log.Println("failed to get repo and knot", err)
1116 return
1117 }
1118
1119 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
1120 RepoInfo: f.RepoInfo(s, user),
1121 })
1122}
1123
1124func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) {
1125 user := s.oauth.GetUser(r)
1126 f, err := s.fullyResolvedRepo(r)
1127 if err != nil {
1128 log.Println("failed to get repo and knot", err)
1129 return
1130 }
1131
1132 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1133 if err != nil {
1134 log.Printf("failed to create unsigned client for %s", f.Knot)
1135 s.pages.Error503(w)
1136 return
1137 }
1138
1139 result, err := us.Branches(f.OwnerDid(), f.RepoName)
1140 if err != nil {
1141 log.Println("failed to reach knotserver", err)
1142 return
1143 }
1144
1145 branches := result.Branches
1146 sort.Slice(branches, func(i int, j int) bool {
1147 return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When)
1148 })
1149
1150 withoutDefault := []types.Branch{}
1151 for _, b := range branches {
1152 if b.IsDefault {
1153 continue
1154 }
1155 withoutDefault = append(withoutDefault, b)
1156 }
1157
1158 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
1159 RepoInfo: f.RepoInfo(s, user),
1160 Branches: withoutDefault,
1161 })
1162}
1163
1164func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) {
1165 user := s.oauth.GetUser(r)
1166 f, err := s.fullyResolvedRepo(r)
1167 if err != nil {
1168 log.Println("failed to get repo and knot", err)
1169 return
1170 }
1171
1172 forks, err := db.GetForksByDid(s.db, user.Did)
1173 if err != nil {
1174 log.Println("failed to get forks", err)
1175 return
1176 }
1177
1178 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{
1179 RepoInfo: f.RepoInfo(s, user),
1180 Forks: forks,
1181 })
1182}
1183
1184func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) {
1185 user := s.oauth.GetUser(r)
1186
1187 f, err := s.fullyResolvedRepo(r)
1188 if err != nil {
1189 log.Println("failed to get repo and knot", err)
1190 return
1191 }
1192
1193 forkVal := r.URL.Query().Get("fork")
1194
1195 // fork repo
1196 repo, err := db.GetRepo(s.db, user.Did, forkVal)
1197 if err != nil {
1198 log.Println("failed to get repo", user.Did, forkVal)
1199 return
1200 }
1201
1202 sourceBranchesClient, err := knotclient.NewUnsignedClient(repo.Knot, s.config.Core.Dev)
1203 if err != nil {
1204 log.Printf("failed to create unsigned client for %s", repo.Knot)
1205 s.pages.Error503(w)
1206 return
1207 }
1208
1209 sourceResult, err := sourceBranchesClient.Branches(user.Did, repo.Name)
1210 if err != nil {
1211 log.Println("failed to reach knotserver for source branches", err)
1212 return
1213 }
1214
1215 targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1216 if err != nil {
1217 log.Printf("failed to create unsigned client for target knot %s", f.Knot)
1218 s.pages.Error503(w)
1219 return
1220 }
1221
1222 targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName)
1223 if err != nil {
1224 log.Println("failed to reach knotserver for target branches", err)
1225 return
1226 }
1227
1228 sourceBranches := sourceResult.Branches
1229 sort.Slice(sourceBranches, func(i int, j int) bool {
1230 return sourceBranches[i].Commit.Committer.When.After(sourceBranches[j].Commit.Committer.When)
1231 })
1232
1233 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
1234 RepoInfo: f.RepoInfo(s, user),
1235 SourceBranches: sourceResult.Branches,
1236 TargetBranches: targetResult.Branches,
1237 })
1238}
1239
1240func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) {
1241 user := s.oauth.GetUser(r)
1242 f, err := s.fullyResolvedRepo(r)
1243 if err != nil {
1244 log.Println("failed to get repo and knot", err)
1245 return
1246 }
1247
1248 pull, ok := r.Context().Value("pull").(*db.Pull)
1249 if !ok {
1250 log.Println("failed to get pull")
1251 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1252 return
1253 }
1254
1255 switch r.Method {
1256 case http.MethodGet:
1257 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
1258 RepoInfo: f.RepoInfo(s, user),
1259 Pull: pull,
1260 })
1261 return
1262 case http.MethodPost:
1263 if pull.IsPatchBased() {
1264 s.resubmitPatch(w, r)
1265 return
1266 } else if pull.IsBranchBased() {
1267 s.resubmitBranch(w, r)
1268 return
1269 } else if pull.IsForkBased() {
1270 s.resubmitFork(w, r)
1271 return
1272 }
1273 }
1274}
1275
1276func (s *State) resubmitPatch(w http.ResponseWriter, r *http.Request) {
1277 user := s.oauth.GetUser(r)
1278
1279 pull, ok := r.Context().Value("pull").(*db.Pull)
1280 if !ok {
1281 log.Println("failed to get pull")
1282 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1283 return
1284 }
1285
1286 f, err := s.fullyResolvedRepo(r)
1287 if err != nil {
1288 log.Println("failed to get repo and knot", err)
1289 return
1290 }
1291
1292 if user.Did != pull.OwnerDid {
1293 log.Println("unauthorized user")
1294 w.WriteHeader(http.StatusUnauthorized)
1295 return
1296 }
1297
1298 patch := r.FormValue("patch")
1299
1300 s.resubmitPullHelper(w, r, f, user, pull, patch, "")
1301}
1302
1303func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) {
1304 user := s.oauth.GetUser(r)
1305
1306 pull, ok := r.Context().Value("pull").(*db.Pull)
1307 if !ok {
1308 log.Println("failed to get pull")
1309 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1310 return
1311 }
1312
1313 f, err := s.fullyResolvedRepo(r)
1314 if err != nil {
1315 log.Println("failed to get repo and knot", err)
1316 return
1317 }
1318
1319 if user.Did != pull.OwnerDid {
1320 log.Println("unauthorized user")
1321 w.WriteHeader(http.StatusUnauthorized)
1322 return
1323 }
1324
1325 if !f.RepoInfo(s, user).Roles.IsPushAllowed() {
1326 log.Println("unauthorized user")
1327 w.WriteHeader(http.StatusUnauthorized)
1328 return
1329 }
1330
1331 ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1332 if err != nil {
1333 log.Printf("failed to create client for %s: %s", f.Knot, err)
1334 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1335 return
1336 }
1337
1338 comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch)
1339 if err != nil {
1340 log.Printf("compare request failed: %s", err)
1341 s.pages.Notice(w, "resubmit-error", err.Error())
1342 return
1343 }
1344
1345 sourceRev := comparison.Rev2
1346 patch := comparison.Patch
1347
1348 s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev)
1349}
1350
1351func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) {
1352 user := s.oauth.GetUser(r)
1353
1354 pull, ok := r.Context().Value("pull").(*db.Pull)
1355 if !ok {
1356 log.Println("failed to get pull")
1357 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1358 return
1359 }
1360
1361 f, err := s.fullyResolvedRepo(r)
1362 if err != nil {
1363 log.Println("failed to get repo and knot", err)
1364 return
1365 }
1366
1367 if user.Did != pull.OwnerDid {
1368 log.Println("unauthorized user")
1369 w.WriteHeader(http.StatusUnauthorized)
1370 return
1371 }
1372
1373 forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
1374 if err != nil {
1375 log.Println("failed to get source repo", err)
1376 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1377 return
1378 }
1379
1380 // extract patch by performing compare
1381 ksClient, err := knotclient.NewUnsignedClient(forkRepo.Knot, s.config.Core.Dev)
1382 if err != nil {
1383 log.Printf("failed to create client for %s: %s", forkRepo.Knot, err)
1384 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1385 return
1386 }
1387
1388 secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot)
1389 if err != nil {
1390 log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err)
1391 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1392 return
1393 }
1394
1395 // update the hidden tracking branch to latest
1396 signedClient, err := knotclient.NewSignedClient(forkRepo.Knot, secret, s.config.Core.Dev)
1397 if err != nil {
1398 log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err)
1399 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1400 return
1401 }
1402
1403 resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch)
1404 if err != nil || resp.StatusCode != http.StatusNoContent {
1405 log.Printf("failed to update tracking branch: %s", err)
1406 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1407 return
1408 }
1409
1410 hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch)
1411 comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch)
1412 if err != nil {
1413 log.Printf("failed to compare branches: %s", err)
1414 s.pages.Notice(w, "resubmit-error", err.Error())
1415 return
1416 }
1417
1418 sourceRev := comparison.Rev2
1419 patch := comparison.Patch
1420
1421 s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev)
1422}
1423
1424// validate a resubmission against a pull request
1425func validateResubmittedPatch(pull *db.Pull, patch string) error {
1426 if patch == "" {
1427 return fmt.Errorf("Patch is empty.")
1428 }
1429
1430 if patch == pull.LatestPatch() {
1431 return fmt.Errorf("Patch is identical to previous submission.")
1432 }
1433
1434 if !patchutil.IsPatchValid(patch) {
1435 return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
1436 }
1437
1438 return nil
1439}
1440
1441func (s *State) resubmitPullHelper(
1442 w http.ResponseWriter,
1443 r *http.Request,
1444 f *FullyResolvedRepo,
1445 user *oauth.User,
1446 pull *db.Pull,
1447 patch string,
1448 sourceRev string,
1449) {
1450 if pull.IsStacked() {
1451 log.Println("resubmitting stacked PR")
1452 s.resubmitStackedPullHelper(w, r, f, user, pull, patch, pull.StackId)
1453 return
1454 }
1455
1456 if err := validateResubmittedPatch(pull, patch); err != nil {
1457 s.pages.Notice(w, "resubmit-error", err.Error())
1458 return
1459 }
1460
1461 // validate sourceRev if branch/fork based
1462 if pull.IsBranchBased() || pull.IsForkBased() {
1463 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1464 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1465 return
1466 }
1467 }
1468
1469 tx, err := s.db.BeginTx(r.Context(), nil)
1470 if err != nil {
1471 log.Println("failed to start tx")
1472 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1473 return
1474 }
1475 defer tx.Rollback()
1476
1477 err = db.ResubmitPull(tx, pull, patch, sourceRev)
1478 if err != nil {
1479 log.Println("failed to create pull request", err)
1480 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1481 return
1482 }
1483 client, err := s.oauth.AuthorizedClient(r)
1484 if err != nil {
1485 log.Println("failed to authorize client")
1486 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1487 return
1488 }
1489
1490 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1491 if err != nil {
1492 // failed to get record
1493 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1494 return
1495 }
1496
1497 var recordPullSource *tangled.RepoPull_Source
1498 if pull.IsBranchBased() {
1499 recordPullSource = &tangled.RepoPull_Source{
1500 Branch: pull.PullSource.Branch,
1501 }
1502 }
1503 if pull.IsForkBased() {
1504 repoAt := pull.PullSource.RepoAt.String()
1505 recordPullSource = &tangled.RepoPull_Source{
1506 Branch: pull.PullSource.Branch,
1507 Repo: &repoAt,
1508 }
1509 }
1510
1511 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1512 Collection: tangled.RepoPullNSID,
1513 Repo: user.Did,
1514 Rkey: pull.Rkey,
1515 SwapRecord: ex.Cid,
1516 Record: &lexutil.LexiconTypeDecoder{
1517 Val: &tangled.RepoPull{
1518 Title: pull.Title,
1519 PullId: int64(pull.PullId),
1520 TargetRepo: string(f.RepoAt),
1521 TargetBranch: pull.TargetBranch,
1522 Patch: patch, // new patch
1523 Source: recordPullSource,
1524 },
1525 },
1526 })
1527 if err != nil {
1528 log.Println("failed to update record", err)
1529 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1530 return
1531 }
1532
1533 if err = tx.Commit(); err != nil {
1534 log.Println("failed to commit transaction", err)
1535 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1536 return
1537 }
1538
1539 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1540 return
1541}
1542
1543func (s *State) resubmitStackedPullHelper(
1544 w http.ResponseWriter,
1545 r *http.Request,
1546 f *FullyResolvedRepo,
1547 user *oauth.User,
1548 pull *db.Pull,
1549 patch string,
1550 stackId string,
1551) {
1552 targetBranch := pull.TargetBranch
1553
1554 origStack, _ := r.Context().Value("stack").(db.Stack)
1555 newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId)
1556 if err != nil {
1557 log.Println("failed to create resubmitted stack", err)
1558 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1559 return
1560 }
1561
1562 // find the diff between the stacks, first, map them by changeId
1563 origById := make(map[string]*db.Pull)
1564 newById := make(map[string]*db.Pull)
1565 for _, p := range origStack {
1566 origById[p.ChangeId] = p
1567 }
1568 for _, p := range newStack {
1569 newById[p.ChangeId] = p
1570 }
1571
1572 // commits that got deleted: corresponding pull is closed
1573 // commits that got added: new pull is created
1574 // commits that got updated: corresponding pull is resubmitted & new round begins
1575 //
1576 // for commits that were unchanged: no changes, parent-change-id is updated as necessary
1577 additions := make(map[string]*db.Pull)
1578 deletions := make(map[string]*db.Pull)
1579 unchanged := make(map[string]struct{})
1580 updated := make(map[string]struct{})
1581
1582 // pulls in orignal stack but not in new one
1583 for _, op := range origStack {
1584 if _, ok := newById[op.ChangeId]; !ok {
1585 deletions[op.ChangeId] = op
1586 }
1587 }
1588
1589 // pulls in new stack but not in original one
1590 for _, np := range newStack {
1591 if _, ok := origById[np.ChangeId]; !ok {
1592 additions[np.ChangeId] = np
1593 }
1594 }
1595
1596 // NOTE: this loop can be written in any of above blocks,
1597 // but is written separately in the interest of simpler code
1598 for _, np := range newStack {
1599 if op, ok := origById[np.ChangeId]; ok {
1600 // pull exists in both stacks
1601 // TODO: can we avoid reparse?
1602 origFiles, origHeaderStr, _ := gitdiff.Parse(strings.NewReader(op.LatestPatch()))
1603 newFiles, newHeaderStr, _ := gitdiff.Parse(strings.NewReader(np.LatestPatch()))
1604
1605 origHeader, _ := gitdiff.ParsePatchHeader(origHeaderStr)
1606 newHeader, _ := gitdiff.ParsePatchHeader(newHeaderStr)
1607
1608 patchutil.SortPatch(newFiles)
1609 patchutil.SortPatch(origFiles)
1610
1611 // text content of patch may be identical, but a jj rebase might have forwarded it
1612 if patchutil.Equal(newFiles, origFiles) && origHeader.SHA == newHeader.SHA {
1613 unchanged[op.ChangeId] = struct{}{}
1614 } else {
1615 updated[op.ChangeId] = struct{}{}
1616 }
1617 }
1618 }
1619
1620 tx, err := s.db.Begin()
1621 if err != nil {
1622 log.Println("failed to start transaction", err)
1623 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1624 return
1625 }
1626 defer tx.Rollback()
1627
1628 // pds updates to make
1629 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1630
1631 // deleted pulls are marked as deleted in the DB
1632 for _, p := range deletions {
1633 err := db.DeletePull(tx, p.RepoAt, p.PullId)
1634 if err != nil {
1635 log.Println("failed to delete pull", err, p.PullId)
1636 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1637 return
1638 }
1639 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1640 RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{
1641 Collection: tangled.RepoPullNSID,
1642 Rkey: p.Rkey,
1643 },
1644 })
1645 }
1646
1647 // new pulls are created
1648 for _, p := range additions {
1649 err := db.NewPull(tx, p)
1650 if err != nil {
1651 log.Println("failed to create pull", err, p.PullId)
1652 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1653 return
1654 }
1655
1656 record := p.AsRecord()
1657 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1658 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
1659 Collection: tangled.RepoPullNSID,
1660 Rkey: &p.Rkey,
1661 Value: &lexutil.LexiconTypeDecoder{
1662 Val: &record,
1663 },
1664 },
1665 })
1666 }
1667
1668 // updated pulls are, well, updated; to start a new round
1669 for id := range updated {
1670 op, _ := origById[id]
1671 np, _ := newById[id]
1672
1673 submission := np.Submissions[np.LastRoundNumber()]
1674
1675 // resubmit the old pull
1676 err := db.ResubmitPull(tx, op, submission.Patch, submission.SourceRev)
1677
1678 if err != nil {
1679 log.Println("failed to update pull", err, op.PullId)
1680 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1681 return
1682 }
1683
1684 record := op.AsRecord()
1685 record.Patch = submission.Patch
1686
1687 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1688 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
1689 Collection: tangled.RepoPullNSID,
1690 Rkey: op.Rkey,
1691 Value: &lexutil.LexiconTypeDecoder{
1692 Val: &record,
1693 },
1694 },
1695 })
1696 }
1697
1698 // update parent-change-id relations for the entire stack
1699 for _, p := range newStack {
1700 err := db.SetPullParentChangeId(
1701 tx,
1702 p.ParentChangeId,
1703 // these should be enough filters to be unique per-stack
1704 db.FilterEq("repo_at", p.RepoAt.String()),
1705 db.FilterEq("owner_did", p.OwnerDid),
1706 db.FilterEq("change_id", p.ChangeId),
1707 )
1708
1709 if err != nil {
1710 log.Println("failed to update pull", err, p.PullId)
1711 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1712 return
1713 }
1714 }
1715
1716 err = tx.Commit()
1717 if err != nil {
1718 log.Println("failed to resubmit pull", err)
1719 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1720 return
1721 }
1722
1723 client, err := s.oauth.AuthorizedClient(r)
1724 if err != nil {
1725 log.Println("failed to authorize client")
1726 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1727 return
1728 }
1729
1730 _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{
1731 Repo: user.Did,
1732 Writes: writes,
1733 })
1734 if err != nil {
1735 log.Println("failed to create stacked pull request", err)
1736 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.")
1737 return
1738 }
1739
1740 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1741 return
1742}
1743
1744func (s *State) MergePull(w http.ResponseWriter, r *http.Request) {
1745 f, err := s.fullyResolvedRepo(r)
1746 if err != nil {
1747 log.Println("failed to resolve repo:", err)
1748 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1749 return
1750 }
1751
1752 pull, ok := r.Context().Value("pull").(*db.Pull)
1753 if !ok {
1754 log.Println("failed to get pull")
1755 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
1756 return
1757 }
1758
1759 var pullsToMerge db.Stack
1760 pullsToMerge = append(pullsToMerge, pull)
1761 if pull.IsStacked() {
1762 stack, ok := r.Context().Value("stack").(db.Stack)
1763 if !ok {
1764 log.Println("failed to get stack")
1765 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
1766 return
1767 }
1768
1769 // combine patches of substack
1770 subStack := stack.StrictlyBelow(pull)
1771 // collect the portion of the stack that is mergeable
1772 mergeable := subStack.Mergeable()
1773 // add to total patch
1774 pullsToMerge = append(pullsToMerge, mergeable...)
1775 }
1776
1777 patch := pullsToMerge.CombinedPatch()
1778
1779 secret, err := db.GetRegistrationKey(s.db, f.Knot)
1780 if err != nil {
1781 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
1782 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1783 return
1784 }
1785
1786 ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid)
1787 if err != nil {
1788 log.Printf("resolving identity: %s", err)
1789 w.WriteHeader(http.StatusNotFound)
1790 return
1791 }
1792
1793 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
1794 if err != nil {
1795 log.Printf("failed to get primary email: %s", err)
1796 }
1797
1798 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
1799 if err != nil {
1800 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
1801 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1802 return
1803 }
1804
1805 // Merge the pull request
1806 resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
1807 if err != nil {
1808 log.Printf("failed to merge pull request: %s", err)
1809 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1810 return
1811 }
1812
1813 if resp.StatusCode != http.StatusOK {
1814 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
1815 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1816 return
1817 }
1818
1819 tx, err := s.db.Begin()
1820 if err != nil {
1821 log.Println("failed to start transcation", err)
1822 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1823 return
1824 }
1825 defer tx.Rollback()
1826
1827 for _, p := range pullsToMerge {
1828 err := db.MergePull(tx, f.RepoAt, p.PullId)
1829 if err != nil {
1830 log.Printf("failed to update pull request status in database: %s", err)
1831 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1832 return
1833 }
1834 }
1835
1836 err = tx.Commit()
1837 if err != nil {
1838 // TODO: this is unsound, we should also revert the merge from the knotserver here
1839 log.Printf("failed to update pull request status in database: %s", err)
1840 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1841 return
1842 }
1843
1844 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
1845}
1846
1847func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) {
1848 user := s.oauth.GetUser(r)
1849
1850 f, err := s.fullyResolvedRepo(r)
1851 if err != nil {
1852 log.Println("malformed middleware")
1853 return
1854 }
1855
1856 pull, ok := r.Context().Value("pull").(*db.Pull)
1857 if !ok {
1858 log.Println("failed to get pull")
1859 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1860 return
1861 }
1862
1863 // auth filter: only owner or collaborators can close
1864 roles := RolesInRepo(s, user, f)
1865 isCollaborator := roles.IsCollaborator()
1866 isPullAuthor := user.Did == pull.OwnerDid
1867 isCloseAllowed := isCollaborator || isPullAuthor
1868 if !isCloseAllowed {
1869 log.Println("failed to close pull")
1870 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
1871 return
1872 }
1873
1874 // Start a transaction
1875 tx, err := s.db.BeginTx(r.Context(), nil)
1876 if err != nil {
1877 log.Println("failed to start transaction", err)
1878 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1879 return
1880 }
1881 defer tx.Rollback()
1882
1883 var pullsToClose []*db.Pull
1884 pullsToClose = append(pullsToClose, pull)
1885
1886 // if this PR is stacked, then we want to close all PRs below this one on the stack
1887 if pull.IsStacked() {
1888 stack := r.Context().Value("stack").(db.Stack)
1889 subStack := stack.StrictlyBelow(pull)
1890 pullsToClose = append(pullsToClose, subStack...)
1891 }
1892
1893 for _, p := range pullsToClose {
1894 // Close the pull in the database
1895 err = db.ClosePull(tx, f.RepoAt, p.PullId)
1896 if err != nil {
1897 log.Println("failed to close pull", err)
1898 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1899 return
1900 }
1901 }
1902
1903 // Commit the transaction
1904 if err = tx.Commit(); err != nil {
1905 log.Println("failed to commit transaction", err)
1906 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1907 return
1908 }
1909
1910 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1911 return
1912}
1913
1914func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) {
1915 user := s.oauth.GetUser(r)
1916
1917 f, err := s.fullyResolvedRepo(r)
1918 if err != nil {
1919 log.Println("failed to resolve repo", err)
1920 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1921 return
1922 }
1923
1924 pull, ok := r.Context().Value("pull").(*db.Pull)
1925 if !ok {
1926 log.Println("failed to get pull")
1927 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1928 return
1929 }
1930
1931 // auth filter: only owner or collaborators can close
1932 roles := RolesInRepo(s, user, f)
1933 isCollaborator := roles.IsCollaborator()
1934 isPullAuthor := user.Did == pull.OwnerDid
1935 isCloseAllowed := isCollaborator || isPullAuthor
1936 if !isCloseAllowed {
1937 log.Println("failed to close pull")
1938 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
1939 return
1940 }
1941
1942 // Start a transaction
1943 tx, err := s.db.BeginTx(r.Context(), nil)
1944 if err != nil {
1945 log.Println("failed to start transaction", err)
1946 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1947 return
1948 }
1949 defer tx.Rollback()
1950
1951 var pullsToReopen []*db.Pull
1952 pullsToReopen = append(pullsToReopen, pull)
1953
1954 // if this PR is stacked, then we want to reopen all PRs above this one on the stack
1955 if pull.IsStacked() {
1956 stack := r.Context().Value("stack").(db.Stack)
1957 subStack := stack.StrictlyAbove(pull)
1958 pullsToReopen = append(pullsToReopen, subStack...)
1959 }
1960
1961 for _, p := range pullsToReopen {
1962 // Close the pull in the database
1963 err = db.ReopenPull(tx, f.RepoAt, p.PullId)
1964 if err != nil {
1965 log.Println("failed to close pull", err)
1966 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1967 return
1968 }
1969 }
1970
1971 // Commit the transaction
1972 if err = tx.Commit(); err != nil {
1973 log.Println("failed to commit transaction", err)
1974 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1975 return
1976 }
1977
1978 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1979 return
1980}
1981
1982func newStack(f *FullyResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) {
1983 formatPatches, err := patchutil.ExtractPatches(patch)
1984 if err != nil {
1985 return nil, fmt.Errorf("Failed to extract patches: %v", err)
1986 }
1987
1988 // must have atleast 1 patch to begin with
1989 if len(formatPatches) == 0 {
1990 return nil, fmt.Errorf("No patches found in the generated format-patch.")
1991 }
1992
1993 // the stack is identified by a UUID
1994 var stack db.Stack
1995 parentChangeId := ""
1996 for _, fp := range formatPatches {
1997 // all patches must have a jj change-id
1998 changeId, err := fp.ChangeId()
1999 if err != nil {
2000 return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.")
2001 }
2002
2003 title := fp.Title
2004 body := fp.Body
2005 rkey := appview.TID()
2006
2007 initialSubmission := db.PullSubmission{
2008 Patch: fp.Raw,
2009 SourceRev: fp.SHA,
2010 }
2011 pull := db.Pull{
2012 Title: title,
2013 Body: body,
2014 TargetBranch: targetBranch,
2015 OwnerDid: user.Did,
2016 RepoAt: f.RepoAt,
2017 Rkey: rkey,
2018 Submissions: []*db.PullSubmission{
2019 &initialSubmission,
2020 },
2021 PullSource: pullSource,
2022 Created: time.Now(),
2023
2024 StackId: stackId,
2025 ChangeId: changeId,
2026 ParentChangeId: parentChangeId,
2027 }
2028
2029 stack = append(stack, &pull)
2030
2031 parentChangeId = changeId
2032 }
2033
2034 return stack, nil
2035}