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