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