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