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 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; charset=utf-8")
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 // Generate a patch using /compare
802 ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
803 if err != nil {
804 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
805 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
806 return
807 }
808
809 comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
810 if err != nil {
811 log.Println("failed to compare", err)
812 s.pages.Notice(w, "pull", err.Error())
813 return
814 }
815
816 sourceRev := comparison.Rev2
817 patch := comparison.Patch
818
819 if !patchutil.IsPatchValid(patch) {
820 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
821 return
822 }
823
824 pullSource := &db.PullSource{
825 Branch: sourceBranch,
826 }
827 recordPullSource := &tangled.RepoPull_Source{
828 Branch: sourceBranch,
829 Sha: comparison.Rev2,
830 }
831
832 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked)
833}
834
835func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) {
836 if !patchutil.IsPatchValid(patch) {
837 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
838 return
839 }
840
841 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil, isStacked)
842}
843
844func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) {
845 fork, err := db.GetForkByDid(s.db, user.Did, forkRepo)
846 if errors.Is(err, sql.ErrNoRows) {
847 s.pages.Notice(w, "pull", "No such fork.")
848 return
849 } else if err != nil {
850 log.Println("failed to fetch fork:", err)
851 s.pages.Notice(w, "pull", "Failed to fetch fork.")
852 return
853 }
854
855 secret, err := db.GetRegistrationKey(s.db, fork.Knot)
856 if err != nil {
857 log.Println("failed to fetch registration key:", err)
858 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
859 return
860 }
861
862 sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev)
863 if err != nil {
864 log.Println("failed to create signed client:", err)
865 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
866 return
867 }
868
869 us, err := knotclient.NewUnsignedClient(fork.Knot, s.config.Core.Dev)
870 if err != nil {
871 log.Println("failed to create unsigned client:", err)
872 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
873 return
874 }
875
876 resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch)
877 if err != nil {
878 log.Println("failed to create hidden ref:", err, resp.StatusCode)
879 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
880 return
881 }
882
883 switch resp.StatusCode {
884 case 404:
885 case 400:
886 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
887 return
888 }
889
890 hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch)
891 // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking
892 // the targetBranch on the target repository. This code is a bit confusing, but here's an example:
893 // hiddenRef: hidden/feature-1/main (on repo-fork)
894 // targetBranch: main (on repo-1)
895 // sourceBranch: feature-1 (on repo-fork)
896 comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch)
897 if err != nil {
898 log.Println("failed to compare across branches", err)
899 s.pages.Notice(w, "pull", err.Error())
900 return
901 }
902
903 sourceRev := comparison.Rev2
904 patch := comparison.Patch
905
906 if !patchutil.IsPatchValid(patch) {
907 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
908 return
909 }
910
911 forkAtUri, err := syntax.ParseATURI(fork.AtUri)
912 if err != nil {
913 log.Println("failed to parse fork AT URI", err)
914 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
915 return
916 }
917
918 pullSource := &db.PullSource{
919 Branch: sourceBranch,
920 RepoAt: &forkAtUri,
921 }
922 recordPullSource := &tangled.RepoPull_Source{
923 Branch: sourceBranch,
924 Repo: &fork.AtUri,
925 Sha: sourceRev,
926 }
927
928 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked)
929}
930
931func (s *Pulls) createPullRequest(
932 w http.ResponseWriter,
933 r *http.Request,
934 f *reporesolver.ResolvedRepo,
935 user *oauth.User,
936 title, body, targetBranch string,
937 patch string,
938 sourceRev string,
939 pullSource *db.PullSource,
940 recordPullSource *tangled.RepoPull_Source,
941 isStacked bool,
942) {
943 if isStacked {
944 // creates a series of PRs, each linking to the previous, identified by jj's change-id
945 s.createStackedPullRequest(
946 w,
947 r,
948 f,
949 user,
950 targetBranch,
951 patch,
952 sourceRev,
953 pullSource,
954 )
955 return
956 }
957
958 client, err := s.oauth.AuthorizedClient(r)
959 if err != nil {
960 log.Println("failed to get authorized client", err)
961 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
962 return
963 }
964
965 tx, err := s.db.BeginTx(r.Context(), nil)
966 if err != nil {
967 log.Println("failed to start tx")
968 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
969 return
970 }
971 defer tx.Rollback()
972
973 // We've already checked earlier if it's diff-based and title is empty,
974 // so if it's still empty now, it's intentionally skipped owing to format-patch.
975 if title == "" {
976 formatPatches, err := patchutil.ExtractPatches(patch)
977 if err != nil {
978 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
979 return
980 }
981 if len(formatPatches) == 0 {
982 s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.")
983 return
984 }
985
986 title = formatPatches[0].Title
987 body = formatPatches[0].Body
988 }
989
990 rkey := appview.TID()
991 initialSubmission := db.PullSubmission{
992 Patch: patch,
993 SourceRev: sourceRev,
994 }
995 err = db.NewPull(tx, &db.Pull{
996 Title: title,
997 Body: body,
998 TargetBranch: targetBranch,
999 OwnerDid: user.Did,
1000 RepoAt: f.RepoAt,
1001 Rkey: rkey,
1002 Submissions: []*db.PullSubmission{
1003 &initialSubmission,
1004 },
1005 PullSource: pullSource,
1006 })
1007 if err != nil {
1008 log.Println("failed to create pull request", err)
1009 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1010 return
1011 }
1012 pullId, err := db.NextPullId(tx, f.RepoAt)
1013 if err != nil {
1014 log.Println("failed to get pull id", err)
1015 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1016 return
1017 }
1018
1019 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1020 Collection: tangled.RepoPullNSID,
1021 Repo: user.Did,
1022 Rkey: rkey,
1023 Record: &lexutil.LexiconTypeDecoder{
1024 Val: &tangled.RepoPull{
1025 Title: title,
1026 PullId: int64(pullId),
1027 TargetRepo: string(f.RepoAt),
1028 TargetBranch: targetBranch,
1029 Patch: patch,
1030 Source: recordPullSource,
1031 },
1032 },
1033 })
1034 if err != nil {
1035 log.Println("failed to create pull request", err)
1036 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1037 return
1038 }
1039
1040 if err = tx.Commit(); err != nil {
1041 log.Println("failed to create pull request", err)
1042 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1043 return
1044 }
1045
1046 if !s.config.Core.Dev {
1047 err = s.posthog.Enqueue(posthog.Capture{
1048 DistinctId: user.Did,
1049 Event: "new_pull",
1050 Properties: posthog.Properties{"repo_at": f.RepoAt.String(), "pull_id": pullId},
1051 })
1052 if err != nil {
1053 log.Println("failed to enqueue posthog event:", err)
1054 }
1055 }
1056
1057 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
1058}
1059
1060func (s *Pulls) createStackedPullRequest(
1061 w http.ResponseWriter,
1062 r *http.Request,
1063 f *reporesolver.ResolvedRepo,
1064 user *oauth.User,
1065 targetBranch string,
1066 patch string,
1067 sourceRev string,
1068 pullSource *db.PullSource,
1069) {
1070 // run some necessary checks for stacked-prs first
1071
1072 // must be branch or fork based
1073 if sourceRev == "" {
1074 log.Println("stacked PR from patch-based pull")
1075 s.pages.Notice(w, "pull", "Stacking is only supported on branch and fork based pull-requests.")
1076 return
1077 }
1078
1079 formatPatches, err := patchutil.ExtractPatches(patch)
1080 if err != nil {
1081 log.Println("failed to extract patches", err)
1082 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
1083 return
1084 }
1085
1086 // must have atleast 1 patch to begin with
1087 if len(formatPatches) == 0 {
1088 log.Println("empty patches")
1089 s.pages.Notice(w, "pull", "No patches found in the generated format-patch.")
1090 return
1091 }
1092
1093 // build a stack out of this patch
1094 stackId := uuid.New()
1095 stack, err := newStack(f, user, targetBranch, patch, pullSource, stackId.String())
1096 if err != nil {
1097 log.Println("failed to create stack", err)
1098 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err))
1099 return
1100 }
1101
1102 client, err := s.oauth.AuthorizedClient(r)
1103 if err != nil {
1104 log.Println("failed to get authorized client", err)
1105 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1106 return
1107 }
1108
1109 // apply all record creations at once
1110 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1111 for _, p := range stack {
1112 record := p.AsRecord()
1113 write := comatproto.RepoApplyWrites_Input_Writes_Elem{
1114 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
1115 Collection: tangled.RepoPullNSID,
1116 Rkey: &p.Rkey,
1117 Value: &lexutil.LexiconTypeDecoder{
1118 Val: &record,
1119 },
1120 },
1121 }
1122 writes = append(writes, &write)
1123 }
1124 _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{
1125 Repo: user.Did,
1126 Writes: writes,
1127 })
1128 if err != nil {
1129 log.Println("failed to create stacked pull request", err)
1130 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.")
1131 return
1132 }
1133
1134 // create all pulls at once
1135 tx, err := s.db.BeginTx(r.Context(), nil)
1136 if err != nil {
1137 log.Println("failed to start tx")
1138 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1139 return
1140 }
1141 defer tx.Rollback()
1142
1143 for _, p := range stack {
1144 err = db.NewPull(tx, p)
1145 if err != nil {
1146 log.Println("failed to create pull request", err)
1147 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1148 return
1149 }
1150 }
1151
1152 if err = tx.Commit(); err != nil {
1153 log.Println("failed to create pull request", err)
1154 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
1155 return
1156 }
1157
1158 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", f.OwnerSlashRepo()))
1159}
1160
1161func (s *Pulls) ValidatePatch(w http.ResponseWriter, r *http.Request) {
1162 _, err := s.repoResolver.Resolve(r)
1163 if err != nil {
1164 log.Println("failed to get repo and knot", err)
1165 return
1166 }
1167
1168 patch := r.FormValue("patch")
1169 if patch == "" {
1170 s.pages.Notice(w, "patch-error", "Patch is required.")
1171 return
1172 }
1173
1174 if patch == "" || !patchutil.IsPatchValid(patch) {
1175 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.")
1176 return
1177 }
1178
1179 if patchutil.IsFormatPatch(patch) {
1180 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.")
1181 } else {
1182 s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.")
1183 }
1184}
1185
1186func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
1187 user := s.oauth.GetUser(r)
1188 f, err := s.repoResolver.Resolve(r)
1189 if err != nil {
1190 log.Println("failed to get repo and knot", err)
1191 return
1192 }
1193
1194 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
1195 RepoInfo: f.RepoInfo(user),
1196 })
1197}
1198
1199func (s *Pulls) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) {
1200 user := s.oauth.GetUser(r)
1201 f, err := s.repoResolver.Resolve(r)
1202 if err != nil {
1203 log.Println("failed to get repo and knot", err)
1204 return
1205 }
1206
1207 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1208 if err != nil {
1209 log.Printf("failed to create unsigned client for %s", f.Knot)
1210 s.pages.Error503(w)
1211 return
1212 }
1213
1214 result, err := us.Branches(f.OwnerDid(), f.RepoName)
1215 if err != nil {
1216 log.Println("failed to reach knotserver", err)
1217 return
1218 }
1219
1220 branches := result.Branches
1221 sort.Slice(branches, func(i int, j int) bool {
1222 return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When)
1223 })
1224
1225 withoutDefault := []types.Branch{}
1226 for _, b := range branches {
1227 if b.IsDefault {
1228 continue
1229 }
1230 withoutDefault = append(withoutDefault, b)
1231 }
1232
1233 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
1234 RepoInfo: f.RepoInfo(user),
1235 Branches: withoutDefault,
1236 })
1237}
1238
1239func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) {
1240 user := s.oauth.GetUser(r)
1241 f, err := s.repoResolver.Resolve(r)
1242 if err != nil {
1243 log.Println("failed to get repo and knot", err)
1244 return
1245 }
1246
1247 forks, err := db.GetForksByDid(s.db, user.Did)
1248 if err != nil {
1249 log.Println("failed to get forks", err)
1250 return
1251 }
1252
1253 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{
1254 RepoInfo: f.RepoInfo(user),
1255 Forks: forks,
1256 Selected: r.URL.Query().Get("fork"),
1257 })
1258}
1259
1260func (s *Pulls) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) {
1261 user := s.oauth.GetUser(r)
1262
1263 f, err := s.repoResolver.Resolve(r)
1264 if err != nil {
1265 log.Println("failed to get repo and knot", err)
1266 return
1267 }
1268
1269 forkVal := r.URL.Query().Get("fork")
1270
1271 // fork repo
1272 repo, err := db.GetRepo(s.db, user.Did, forkVal)
1273 if err != nil {
1274 log.Println("failed to get repo", user.Did, forkVal)
1275 return
1276 }
1277
1278 sourceBranchesClient, err := knotclient.NewUnsignedClient(repo.Knot, s.config.Core.Dev)
1279 if err != nil {
1280 log.Printf("failed to create unsigned client for %s", repo.Knot)
1281 s.pages.Error503(w)
1282 return
1283 }
1284
1285 sourceResult, err := sourceBranchesClient.Branches(user.Did, repo.Name)
1286 if err != nil {
1287 log.Println("failed to reach knotserver for source branches", err)
1288 return
1289 }
1290
1291 targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1292 if err != nil {
1293 log.Printf("failed to create unsigned client for target knot %s", f.Knot)
1294 s.pages.Error503(w)
1295 return
1296 }
1297
1298 targetResult, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName)
1299 if err != nil {
1300 log.Println("failed to reach knotserver for target branches", err)
1301 return
1302 }
1303
1304 sourceBranches := sourceResult.Branches
1305 sort.Slice(sourceBranches, func(i int, j int) bool {
1306 return sourceBranches[i].Commit.Committer.When.After(sourceBranches[j].Commit.Committer.When)
1307 })
1308
1309 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
1310 RepoInfo: f.RepoInfo(user),
1311 SourceBranches: sourceBranches,
1312 TargetBranches: targetResult.Branches,
1313 })
1314}
1315
1316func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) {
1317 user := s.oauth.GetUser(r)
1318 f, err := s.repoResolver.Resolve(r)
1319 if err != nil {
1320 log.Println("failed to get repo and knot", err)
1321 return
1322 }
1323
1324 pull, ok := r.Context().Value("pull").(*db.Pull)
1325 if !ok {
1326 log.Println("failed to get pull")
1327 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1328 return
1329 }
1330
1331 switch r.Method {
1332 case http.MethodGet:
1333 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
1334 RepoInfo: f.RepoInfo(user),
1335 Pull: pull,
1336 })
1337 return
1338 case http.MethodPost:
1339 if pull.IsPatchBased() {
1340 s.resubmitPatch(w, r)
1341 return
1342 } else if pull.IsBranchBased() {
1343 s.resubmitBranch(w, r)
1344 return
1345 } else if pull.IsForkBased() {
1346 s.resubmitFork(w, r)
1347 return
1348 }
1349 }
1350}
1351
1352func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) {
1353 user := s.oauth.GetUser(r)
1354
1355 pull, ok := r.Context().Value("pull").(*db.Pull)
1356 if !ok {
1357 log.Println("failed to get pull")
1358 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1359 return
1360 }
1361
1362 f, err := s.repoResolver.Resolve(r)
1363 if err != nil {
1364 log.Println("failed to get repo and knot", err)
1365 return
1366 }
1367
1368 if user.Did != pull.OwnerDid {
1369 log.Println("unauthorized user")
1370 w.WriteHeader(http.StatusUnauthorized)
1371 return
1372 }
1373
1374 patch := r.FormValue("patch")
1375
1376 s.resubmitPullHelper(w, r, f, user, pull, patch, "")
1377}
1378
1379func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) {
1380 user := s.oauth.GetUser(r)
1381
1382 pull, ok := r.Context().Value("pull").(*db.Pull)
1383 if !ok {
1384 log.Println("failed to get pull")
1385 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1386 return
1387 }
1388
1389 f, err := s.repoResolver.Resolve(r)
1390 if err != nil {
1391 log.Println("failed to get repo and knot", err)
1392 return
1393 }
1394
1395 if user.Did != pull.OwnerDid {
1396 log.Println("unauthorized user")
1397 w.WriteHeader(http.StatusUnauthorized)
1398 return
1399 }
1400
1401 if !f.RepoInfo(user).Roles.IsPushAllowed() {
1402 log.Println("unauthorized user")
1403 w.WriteHeader(http.StatusUnauthorized)
1404 return
1405 }
1406
1407 ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1408 if err != nil {
1409 log.Printf("failed to create client for %s: %s", f.Knot, err)
1410 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1411 return
1412 }
1413
1414 comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch)
1415 if err != nil {
1416 log.Printf("compare request failed: %s", err)
1417 s.pages.Notice(w, "resubmit-error", err.Error())
1418 return
1419 }
1420
1421 sourceRev := comparison.Rev2
1422 patch := comparison.Patch
1423
1424 s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev)
1425}
1426
1427func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) {
1428 user := s.oauth.GetUser(r)
1429
1430 pull, ok := r.Context().Value("pull").(*db.Pull)
1431 if !ok {
1432 log.Println("failed to get pull")
1433 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1434 return
1435 }
1436
1437 f, err := s.repoResolver.Resolve(r)
1438 if err != nil {
1439 log.Println("failed to get repo and knot", err)
1440 return
1441 }
1442
1443 if user.Did != pull.OwnerDid {
1444 log.Println("unauthorized user")
1445 w.WriteHeader(http.StatusUnauthorized)
1446 return
1447 }
1448
1449 forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
1450 if err != nil {
1451 log.Println("failed to get source repo", err)
1452 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1453 return
1454 }
1455
1456 // extract patch by performing compare
1457 ksClient, err := knotclient.NewUnsignedClient(forkRepo.Knot, s.config.Core.Dev)
1458 if err != nil {
1459 log.Printf("failed to create client for %s: %s", forkRepo.Knot, err)
1460 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1461 return
1462 }
1463
1464 secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot)
1465 if err != nil {
1466 log.Printf("failed to get registration key 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 // update the hidden tracking branch to latest
1472 signedClient, err := knotclient.NewSignedClient(forkRepo.Knot, secret, s.config.Core.Dev)
1473 if err != nil {
1474 log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err)
1475 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1476 return
1477 }
1478
1479 resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch)
1480 if err != nil || resp.StatusCode != http.StatusNoContent {
1481 log.Printf("failed to update tracking branch: %s", err)
1482 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1483 return
1484 }
1485
1486 hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch)
1487 comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch)
1488 if err != nil {
1489 log.Printf("failed to compare branches: %s", err)
1490 s.pages.Notice(w, "resubmit-error", err.Error())
1491 return
1492 }
1493
1494 sourceRev := comparison.Rev2
1495 patch := comparison.Patch
1496
1497 s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev)
1498}
1499
1500// validate a resubmission against a pull request
1501func validateResubmittedPatch(pull *db.Pull, patch string) error {
1502 if patch == "" {
1503 return fmt.Errorf("Patch is empty.")
1504 }
1505
1506 if patch == pull.LatestPatch() {
1507 return fmt.Errorf("Patch is identical to previous submission.")
1508 }
1509
1510 if !patchutil.IsPatchValid(patch) {
1511 return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
1512 }
1513
1514 return nil
1515}
1516
1517func (s *Pulls) resubmitPullHelper(
1518 w http.ResponseWriter,
1519 r *http.Request,
1520 f *reporesolver.ResolvedRepo,
1521 user *oauth.User,
1522 pull *db.Pull,
1523 patch string,
1524 sourceRev string,
1525) {
1526 if pull.IsStacked() {
1527 log.Println("resubmitting stacked PR")
1528 s.resubmitStackedPullHelper(w, r, f, user, pull, patch, pull.StackId)
1529 return
1530 }
1531
1532 if err := validateResubmittedPatch(pull, patch); err != nil {
1533 s.pages.Notice(w, "resubmit-error", err.Error())
1534 return
1535 }
1536
1537 // validate sourceRev if branch/fork based
1538 if pull.IsBranchBased() || pull.IsForkBased() {
1539 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1540 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1541 return
1542 }
1543 }
1544
1545 tx, err := s.db.BeginTx(r.Context(), nil)
1546 if err != nil {
1547 log.Println("failed to start tx")
1548 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1549 return
1550 }
1551 defer tx.Rollback()
1552
1553 err = db.ResubmitPull(tx, pull, patch, sourceRev)
1554 if err != nil {
1555 log.Println("failed to create pull request", err)
1556 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1557 return
1558 }
1559 client, err := s.oauth.AuthorizedClient(r)
1560 if err != nil {
1561 log.Println("failed to authorize client")
1562 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1563 return
1564 }
1565
1566 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1567 if err != nil {
1568 // failed to get record
1569 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1570 return
1571 }
1572
1573 var recordPullSource *tangled.RepoPull_Source
1574 if pull.IsBranchBased() {
1575 recordPullSource = &tangled.RepoPull_Source{
1576 Branch: pull.PullSource.Branch,
1577 Sha: sourceRev,
1578 }
1579 }
1580 if pull.IsForkBased() {
1581 repoAt := pull.PullSource.RepoAt.String()
1582 recordPullSource = &tangled.RepoPull_Source{
1583 Branch: pull.PullSource.Branch,
1584 Repo: &repoAt,
1585 Sha: sourceRev,
1586 }
1587 }
1588
1589 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1590 Collection: tangled.RepoPullNSID,
1591 Repo: user.Did,
1592 Rkey: pull.Rkey,
1593 SwapRecord: ex.Cid,
1594 Record: &lexutil.LexiconTypeDecoder{
1595 Val: &tangled.RepoPull{
1596 Title: pull.Title,
1597 PullId: int64(pull.PullId),
1598 TargetRepo: string(f.RepoAt),
1599 TargetBranch: pull.TargetBranch,
1600 Patch: patch, // new patch
1601 Source: recordPullSource,
1602 },
1603 },
1604 })
1605 if err != nil {
1606 log.Println("failed to update record", err)
1607 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1608 return
1609 }
1610
1611 if err = tx.Commit(); err != nil {
1612 log.Println("failed to commit transaction", err)
1613 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1614 return
1615 }
1616
1617 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1618 return
1619}
1620
1621func (s *Pulls) resubmitStackedPullHelper(
1622 w http.ResponseWriter,
1623 r *http.Request,
1624 f *reporesolver.ResolvedRepo,
1625 user *oauth.User,
1626 pull *db.Pull,
1627 patch string,
1628 stackId string,
1629) {
1630 targetBranch := pull.TargetBranch
1631
1632 origStack, _ := r.Context().Value("stack").(db.Stack)
1633 newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId)
1634 if err != nil {
1635 log.Println("failed to create resubmitted stack", err)
1636 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1637 return
1638 }
1639
1640 // find the diff between the stacks, first, map them by changeId
1641 origById := make(map[string]*db.Pull)
1642 newById := make(map[string]*db.Pull)
1643 for _, p := range origStack {
1644 origById[p.ChangeId] = p
1645 }
1646 for _, p := range newStack {
1647 newById[p.ChangeId] = p
1648 }
1649
1650 // commits that got deleted: corresponding pull is closed
1651 // commits that got added: new pull is created
1652 // commits that got updated: corresponding pull is resubmitted & new round begins
1653 //
1654 // for commits that were unchanged: no changes, parent-change-id is updated as necessary
1655 additions := make(map[string]*db.Pull)
1656 deletions := make(map[string]*db.Pull)
1657 unchanged := make(map[string]struct{})
1658 updated := make(map[string]struct{})
1659
1660 // pulls in orignal stack but not in new one
1661 for _, op := range origStack {
1662 if _, ok := newById[op.ChangeId]; !ok {
1663 deletions[op.ChangeId] = op
1664 }
1665 }
1666
1667 // pulls in new stack but not in original one
1668 for _, np := range newStack {
1669 if _, ok := origById[np.ChangeId]; !ok {
1670 additions[np.ChangeId] = np
1671 }
1672 }
1673
1674 // NOTE: this loop can be written in any of above blocks,
1675 // but is written separately in the interest of simpler code
1676 for _, np := range newStack {
1677 if op, ok := origById[np.ChangeId]; ok {
1678 // pull exists in both stacks
1679 // TODO: can we avoid reparse?
1680 origFiles, origHeaderStr, _ := gitdiff.Parse(strings.NewReader(op.LatestPatch()))
1681 newFiles, newHeaderStr, _ := gitdiff.Parse(strings.NewReader(np.LatestPatch()))
1682
1683 origHeader, _ := gitdiff.ParsePatchHeader(origHeaderStr)
1684 newHeader, _ := gitdiff.ParsePatchHeader(newHeaderStr)
1685
1686 patchutil.SortPatch(newFiles)
1687 patchutil.SortPatch(origFiles)
1688
1689 // text content of patch may be identical, but a jj rebase might have forwarded it
1690 //
1691 // we still need to update the hash in submission.Patch and submission.SourceRev
1692 if patchutil.Equal(newFiles, origFiles) &&
1693 origHeader.Title == newHeader.Title &&
1694 origHeader.Body == newHeader.Body {
1695 unchanged[op.ChangeId] = struct{}{}
1696 } else {
1697 updated[op.ChangeId] = struct{}{}
1698 }
1699 }
1700 }
1701
1702 tx, err := s.db.Begin()
1703 if err != nil {
1704 log.Println("failed to start transaction", err)
1705 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1706 return
1707 }
1708 defer tx.Rollback()
1709
1710 // pds updates to make
1711 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem
1712
1713 // deleted pulls are marked as deleted in the DB
1714 for _, p := range deletions {
1715 err := db.DeletePull(tx, p.RepoAt, p.PullId)
1716 if err != nil {
1717 log.Println("failed to delete pull", err, p.PullId)
1718 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1719 return
1720 }
1721 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1722 RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{
1723 Collection: tangled.RepoPullNSID,
1724 Rkey: p.Rkey,
1725 },
1726 })
1727 }
1728
1729 // new pulls are created
1730 for _, p := range additions {
1731 err := db.NewPull(tx, p)
1732 if err != nil {
1733 log.Println("failed to create pull", err, p.PullId)
1734 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1735 return
1736 }
1737
1738 record := p.AsRecord()
1739 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1740 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{
1741 Collection: tangled.RepoPullNSID,
1742 Rkey: &p.Rkey,
1743 Value: &lexutil.LexiconTypeDecoder{
1744 Val: &record,
1745 },
1746 },
1747 })
1748 }
1749
1750 // updated pulls are, well, updated; to start a new round
1751 for id := range updated {
1752 op, _ := origById[id]
1753 np, _ := newById[id]
1754
1755 submission := np.Submissions[np.LastRoundNumber()]
1756
1757 // resubmit the old pull
1758 err := db.ResubmitPull(tx, op, submission.Patch, submission.SourceRev)
1759
1760 if err != nil {
1761 log.Println("failed to update pull", err, op.PullId)
1762 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1763 return
1764 }
1765
1766 record := op.AsRecord()
1767 record.Patch = submission.Patch
1768
1769 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1770 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
1771 Collection: tangled.RepoPullNSID,
1772 Rkey: op.Rkey,
1773 Value: &lexutil.LexiconTypeDecoder{
1774 Val: &record,
1775 },
1776 },
1777 })
1778 }
1779
1780 // unchanged pulls are edited without starting a new round
1781 //
1782 // update source-revs & patches without advancing rounds
1783 for changeId := range unchanged {
1784 op, _ := origById[changeId]
1785 np, _ := newById[changeId]
1786
1787 origSubmission := op.Submissions[op.LastRoundNumber()]
1788 newSubmission := np.Submissions[np.LastRoundNumber()]
1789
1790 log.Println("moving unchanged change id : ", changeId)
1791
1792 err := db.UpdatePull(
1793 tx,
1794 newSubmission.Patch,
1795 newSubmission.SourceRev,
1796 db.FilterEq("id", origSubmission.ID),
1797 )
1798
1799 if err != nil {
1800 log.Println("failed to update pull", err, op.PullId)
1801 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1802 return
1803 }
1804
1805 record := op.AsRecord()
1806 record.Patch = newSubmission.Patch
1807
1808 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{
1809 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{
1810 Collection: tangled.RepoPullNSID,
1811 Rkey: op.Rkey,
1812 Value: &lexutil.LexiconTypeDecoder{
1813 Val: &record,
1814 },
1815 },
1816 })
1817 }
1818
1819 // update parent-change-id relations for the entire stack
1820 for _, p := range newStack {
1821 err := db.SetPullParentChangeId(
1822 tx,
1823 p.ParentChangeId,
1824 // these should be enough filters to be unique per-stack
1825 db.FilterEq("repo_at", p.RepoAt.String()),
1826 db.FilterEq("owner_did", p.OwnerDid),
1827 db.FilterEq("change_id", p.ChangeId),
1828 )
1829
1830 if err != nil {
1831 log.Println("failed to update pull", err, p.PullId)
1832 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1833 return
1834 }
1835 }
1836
1837 err = tx.Commit()
1838 if err != nil {
1839 log.Println("failed to resubmit pull", err)
1840 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.")
1841 return
1842 }
1843
1844 client, err := s.oauth.AuthorizedClient(r)
1845 if err != nil {
1846 log.Println("failed to authorize client")
1847 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1848 return
1849 }
1850
1851 _, err = client.RepoApplyWrites(r.Context(), &comatproto.RepoApplyWrites_Input{
1852 Repo: user.Did,
1853 Writes: writes,
1854 })
1855 if err != nil {
1856 log.Println("failed to create stacked pull request", err)
1857 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.")
1858 return
1859 }
1860
1861 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1862 return
1863}
1864
1865func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) {
1866 f, err := s.repoResolver.Resolve(r)
1867 if err != nil {
1868 log.Println("failed to resolve repo:", err)
1869 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1870 return
1871 }
1872
1873 pull, ok := r.Context().Value("pull").(*db.Pull)
1874 if !ok {
1875 log.Println("failed to get pull")
1876 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
1877 return
1878 }
1879
1880 var pullsToMerge db.Stack
1881 pullsToMerge = append(pullsToMerge, pull)
1882 if pull.IsStacked() {
1883 stack, ok := r.Context().Value("stack").(db.Stack)
1884 if !ok {
1885 log.Println("failed to get stack")
1886 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.")
1887 return
1888 }
1889
1890 // combine patches of substack
1891 subStack := stack.StrictlyBelow(pull)
1892 // collect the portion of the stack that is mergeable
1893 mergeable := subStack.Mergeable()
1894 // add to total patch
1895 pullsToMerge = append(pullsToMerge, mergeable...)
1896 }
1897
1898 patch := pullsToMerge.CombinedPatch()
1899
1900 secret, err := db.GetRegistrationKey(s.db, f.Knot)
1901 if err != nil {
1902 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
1903 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1904 return
1905 }
1906
1907 ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid)
1908 if err != nil {
1909 log.Printf("resolving identity: %s", err)
1910 w.WriteHeader(http.StatusNotFound)
1911 return
1912 }
1913
1914 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
1915 if err != nil {
1916 log.Printf("failed to get primary email: %s", err)
1917 }
1918
1919 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
1920 if err != nil {
1921 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
1922 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1923 return
1924 }
1925
1926 // Merge the pull request
1927 resp, err := ksClient.Merge([]byte(patch), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
1928 if err != nil {
1929 log.Printf("failed to merge pull request: %s", err)
1930 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1931 return
1932 }
1933
1934 if resp.StatusCode != http.StatusOK {
1935 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
1936 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1937 return
1938 }
1939
1940 tx, err := s.db.Begin()
1941 if err != nil {
1942 log.Println("failed to start transcation", err)
1943 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1944 return
1945 }
1946 defer tx.Rollback()
1947
1948 for _, p := range pullsToMerge {
1949 err := db.MergePull(tx, f.RepoAt, p.PullId)
1950 if err != nil {
1951 log.Printf("failed to update pull request status in database: %s", err)
1952 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1953 return
1954 }
1955 }
1956
1957 err = tx.Commit()
1958 if err != nil {
1959 // TODO: this is unsound, we should also revert the merge from the knotserver here
1960 log.Printf("failed to update pull request status in database: %s", err)
1961 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1962 return
1963 }
1964
1965 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
1966}
1967
1968func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) {
1969 user := s.oauth.GetUser(r)
1970
1971 f, err := s.repoResolver.Resolve(r)
1972 if err != nil {
1973 log.Println("malformed middleware")
1974 return
1975 }
1976
1977 pull, ok := r.Context().Value("pull").(*db.Pull)
1978 if !ok {
1979 log.Println("failed to get pull")
1980 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1981 return
1982 }
1983
1984 // auth filter: only owner or collaborators can close
1985 roles := f.RolesInRepo(user)
1986 isCollaborator := roles.IsCollaborator()
1987 isPullAuthor := user.Did == pull.OwnerDid
1988 isCloseAllowed := isCollaborator || isPullAuthor
1989 if !isCloseAllowed {
1990 log.Println("failed to close pull")
1991 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
1992 return
1993 }
1994
1995 // Start a transaction
1996 tx, err := s.db.BeginTx(r.Context(), nil)
1997 if err != nil {
1998 log.Println("failed to start transaction", err)
1999 s.pages.Notice(w, "pull-close", "Failed to close pull.")
2000 return
2001 }
2002 defer tx.Rollback()
2003
2004 var pullsToClose []*db.Pull
2005 pullsToClose = append(pullsToClose, pull)
2006
2007 // if this PR is stacked, then we want to close all PRs below this one on the stack
2008 if pull.IsStacked() {
2009 stack := r.Context().Value("stack").(db.Stack)
2010 subStack := stack.StrictlyBelow(pull)
2011 pullsToClose = append(pullsToClose, subStack...)
2012 }
2013
2014 for _, p := range pullsToClose {
2015 // Close the pull in the database
2016 err = db.ClosePull(tx, f.RepoAt, p.PullId)
2017 if err != nil {
2018 log.Println("failed to close pull", err)
2019 s.pages.Notice(w, "pull-close", "Failed to close pull.")
2020 return
2021 }
2022 }
2023
2024 // Commit the transaction
2025 if err = tx.Commit(); err != nil {
2026 log.Println("failed to commit transaction", err)
2027 s.pages.Notice(w, "pull-close", "Failed to close pull.")
2028 return
2029 }
2030
2031 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2032 return
2033}
2034
2035func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) {
2036 user := s.oauth.GetUser(r)
2037
2038 f, err := s.repoResolver.Resolve(r)
2039 if err != nil {
2040 log.Println("failed to resolve repo", err)
2041 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2042 return
2043 }
2044
2045 pull, ok := r.Context().Value("pull").(*db.Pull)
2046 if !ok {
2047 log.Println("failed to get pull")
2048 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
2049 return
2050 }
2051
2052 // auth filter: only owner or collaborators can close
2053 roles := f.RolesInRepo(user)
2054 isCollaborator := roles.IsCollaborator()
2055 isPullAuthor := user.Did == pull.OwnerDid
2056 isCloseAllowed := isCollaborator || isPullAuthor
2057 if !isCloseAllowed {
2058 log.Println("failed to close pull")
2059 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
2060 return
2061 }
2062
2063 // Start a transaction
2064 tx, err := s.db.BeginTx(r.Context(), nil)
2065 if err != nil {
2066 log.Println("failed to start transaction", err)
2067 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2068 return
2069 }
2070 defer tx.Rollback()
2071
2072 var pullsToReopen []*db.Pull
2073 pullsToReopen = append(pullsToReopen, pull)
2074
2075 // if this PR is stacked, then we want to reopen all PRs above this one on the stack
2076 if pull.IsStacked() {
2077 stack := r.Context().Value("stack").(db.Stack)
2078 subStack := stack.StrictlyAbove(pull)
2079 pullsToReopen = append(pullsToReopen, subStack...)
2080 }
2081
2082 for _, p := range pullsToReopen {
2083 // Close the pull in the database
2084 err = db.ReopenPull(tx, f.RepoAt, p.PullId)
2085 if err != nil {
2086 log.Println("failed to close pull", err)
2087 s.pages.Notice(w, "pull-close", "Failed to close pull.")
2088 return
2089 }
2090 }
2091
2092 // Commit the transaction
2093 if err = tx.Commit(); err != nil {
2094 log.Println("failed to commit transaction", err)
2095 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
2096 return
2097 }
2098
2099 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
2100 return
2101}
2102
2103func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *db.PullSource, stackId string) (db.Stack, error) {
2104 formatPatches, err := patchutil.ExtractPatches(patch)
2105 if err != nil {
2106 return nil, fmt.Errorf("Failed to extract patches: %v", err)
2107 }
2108
2109 // must have atleast 1 patch to begin with
2110 if len(formatPatches) == 0 {
2111 return nil, fmt.Errorf("No patches found in the generated format-patch.")
2112 }
2113
2114 // the stack is identified by a UUID
2115 var stack db.Stack
2116 parentChangeId := ""
2117 for _, fp := range formatPatches {
2118 // all patches must have a jj change-id
2119 changeId, err := fp.ChangeId()
2120 if err != nil {
2121 return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.")
2122 }
2123
2124 title := fp.Title
2125 body := fp.Body
2126 rkey := appview.TID()
2127
2128 initialSubmission := db.PullSubmission{
2129 Patch: fp.Raw,
2130 SourceRev: fp.SHA,
2131 }
2132 pull := db.Pull{
2133 Title: title,
2134 Body: body,
2135 TargetBranch: targetBranch,
2136 OwnerDid: user.Did,
2137 RepoAt: f.RepoAt,
2138 Rkey: rkey,
2139 Submissions: []*db.PullSubmission{
2140 &initialSubmission,
2141 },
2142 PullSource: pullSource,
2143 Created: time.Now(),
2144
2145 StackId: stackId,
2146 ChangeId: changeId,
2147 ParentChangeId: parentChangeId,
2148 }
2149
2150 stack = append(stack, &pull)
2151
2152 parentChangeId = changeId
2153 }
2154
2155 return stack, nil
2156}