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