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