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