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