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