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