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