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