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