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