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