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