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