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