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