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