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