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