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