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