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