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