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