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