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