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 var resubmitResult pages.ResubmitResult
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 var resubmitResult pages.ResubmitResult
119 if 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.Repo != nil {
201 // fork-based pulls
202 sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.Repo.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 return pages.ShouldResubmit
246 }
247
248 return pages.ShouldNotResubmit
249}
250
251func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
252 user := s.auth.GetUser(r)
253 f, err := fullyResolvedRepo(r)
254 if err != nil {
255 log.Println("failed to get repo and knot", err)
256 return
257 }
258
259 pull, ok := r.Context().Value("pull").(*db.Pull)
260 if !ok {
261 log.Println("failed to get pull")
262 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
263 return
264 }
265
266 roundId := chi.URLParam(r, "round")
267 roundIdInt, err := strconv.Atoi(roundId)
268 if err != nil || roundIdInt >= len(pull.Submissions) {
269 http.Error(w, "bad round id", http.StatusBadRequest)
270 log.Println("failed to parse round id", err)
271 return
272 }
273
274 identsToResolve := []string{pull.OwnerDid}
275 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
276 didHandleMap := make(map[string]string)
277 for _, identity := range resolvedIds {
278 if !identity.Handle.IsInvalidHandle() {
279 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
280 } else {
281 didHandleMap[identity.DID.String()] = identity.DID.String()
282 }
283 }
284
285 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
286 LoggedInUser: user,
287 DidHandleMap: didHandleMap,
288 RepoInfo: f.RepoInfo(s, user),
289 Pull: pull,
290 Round: roundIdInt,
291 Submission: pull.Submissions[roundIdInt],
292 Diff: pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch),
293 })
294
295}
296
297func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
298 pull, ok := r.Context().Value("pull").(*db.Pull)
299 if !ok {
300 log.Println("failed to get pull")
301 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
302 return
303 }
304
305 roundId := chi.URLParam(r, "round")
306 roundIdInt, err := strconv.Atoi(roundId)
307 if err != nil || roundIdInt >= len(pull.Submissions) {
308 http.Error(w, "bad round id", http.StatusBadRequest)
309 log.Println("failed to parse round id", err)
310 return
311 }
312
313 identsToResolve := []string{pull.OwnerDid}
314 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
315 didHandleMap := make(map[string]string)
316 for _, identity := range resolvedIds {
317 if !identity.Handle.IsInvalidHandle() {
318 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
319 } else {
320 didHandleMap[identity.DID.String()] = identity.DID.String()
321 }
322 }
323
324 w.Header().Set("Content-Type", "text/plain")
325 w.Write([]byte(pull.Submissions[roundIdInt].Patch))
326}
327
328func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) {
329 user := s.auth.GetUser(r)
330 params := r.URL.Query()
331
332 state := db.PullOpen
333 switch params.Get("state") {
334 case "closed":
335 state = db.PullClosed
336 case "merged":
337 state = db.PullMerged
338 }
339
340 f, err := fullyResolvedRepo(r)
341 if err != nil {
342 log.Println("failed to get repo and knot", err)
343 return
344 }
345
346 pulls, err := db.GetPulls(s.db, f.RepoAt, state)
347 if err != nil {
348 log.Println("failed to get pulls", err)
349 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
350 return
351 }
352
353 identsToResolve := make([]string, len(pulls))
354 for i, pull := range pulls {
355 identsToResolve[i] = pull.OwnerDid
356 }
357 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
358 didHandleMap := make(map[string]string)
359 for _, identity := range resolvedIds {
360 if !identity.Handle.IsInvalidHandle() {
361 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
362 } else {
363 didHandleMap[identity.DID.String()] = identity.DID.String()
364 }
365 }
366
367 s.pages.RepoPulls(w, pages.RepoPullsParams{
368 LoggedInUser: s.auth.GetUser(r),
369 RepoInfo: f.RepoInfo(s, user),
370 Pulls: pulls,
371 DidHandleMap: didHandleMap,
372 FilteringBy: state,
373 })
374 return
375}
376
377func (s *State) PullComment(w http.ResponseWriter, r *http.Request) {
378 user := s.auth.GetUser(r)
379 f, err := fullyResolvedRepo(r)
380 if err != nil {
381 log.Println("failed to get repo and knot", err)
382 return
383 }
384
385 pull, ok := r.Context().Value("pull").(*db.Pull)
386 if !ok {
387 log.Println("failed to get pull")
388 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
389 return
390 }
391
392 roundNumberStr := chi.URLParam(r, "round")
393 roundNumber, err := strconv.Atoi(roundNumberStr)
394 if err != nil || roundNumber >= len(pull.Submissions) {
395 http.Error(w, "bad round id", http.StatusBadRequest)
396 log.Println("failed to parse round id", err)
397 return
398 }
399
400 switch r.Method {
401 case http.MethodGet:
402 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{
403 LoggedInUser: user,
404 RepoInfo: f.RepoInfo(s, user),
405 Pull: pull,
406 RoundNumber: roundNumber,
407 })
408 return
409 case http.MethodPost:
410 body := r.FormValue("body")
411 if body == "" {
412 s.pages.Notice(w, "pull", "Comment body is required")
413 return
414 }
415
416 // Start a transaction
417 tx, err := s.db.BeginTx(r.Context(), nil)
418 if err != nil {
419 log.Println("failed to start transaction", err)
420 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
421 return
422 }
423 defer tx.Rollback()
424
425 createdAt := time.Now().Format(time.RFC3339)
426 ownerDid := user.Did
427
428 pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId)
429 if err != nil {
430 log.Println("failed to get pull at", err)
431 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
432 return
433 }
434
435 atUri := f.RepoAt.String()
436 client, _ := s.auth.AuthorizedClient(r)
437 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
438 Collection: tangled.RepoPullCommentNSID,
439 Repo: user.Did,
440 Rkey: s.TID(),
441 Record: &lexutil.LexiconTypeDecoder{
442 Val: &tangled.RepoPullComment{
443 Repo: &atUri,
444 Pull: pullAt,
445 Owner: &ownerDid,
446 Body: &body,
447 CreatedAt: &createdAt,
448 },
449 },
450 })
451 if err != nil {
452 log.Println("failed to create pull comment", err)
453 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
454 return
455 }
456
457 // Create the pull comment in the database with the commentAt field
458 commentId, err := db.NewPullComment(tx, &db.PullComment{
459 OwnerDid: user.Did,
460 RepoAt: f.RepoAt.String(),
461 PullId: pull.PullId,
462 Body: body,
463 CommentAt: atResp.Uri,
464 SubmissionId: pull.Submissions[roundNumber].ID,
465 })
466 if err != nil {
467 log.Println("failed to create pull comment", err)
468 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
469 return
470 }
471
472 // Commit the transaction
473 if err = tx.Commit(); err != nil {
474 log.Println("failed to commit transaction", err)
475 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
476 return
477 }
478
479 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
480 return
481 }
482}
483
484func (s *State) NewPull(w http.ResponseWriter, r *http.Request) {
485 user := s.auth.GetUser(r)
486 f, err := fullyResolvedRepo(r)
487 if err != nil {
488 log.Println("failed to get repo and knot", err)
489 return
490 }
491
492 forks, err := db.GetForksByDid(s.db, user.Did)
493 if err != nil {
494 log.Println("failed to get forks", err)
495 return
496 }
497
498 switch r.Method {
499 case http.MethodGet:
500 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
501 if err != nil {
502 log.Printf("failed to create unsigned client for %s", f.Knot)
503 s.pages.Error503(w)
504 return
505 }
506
507 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
508 if err != nil {
509 log.Println("failed to reach knotserver", err)
510 return
511 }
512
513 body, err := io.ReadAll(resp.Body)
514 if err != nil {
515 log.Printf("Error reading response body: %v", err)
516 return
517 }
518
519 var result types.RepoBranchesResponse
520 err = json.Unmarshal(body, &result)
521 if err != nil {
522 log.Println("failed to parse response:", err)
523 return
524 }
525
526 s.pages.RepoNewPull(w, pages.RepoNewPullParams{
527 LoggedInUser: user,
528 RepoInfo: f.RepoInfo(s, user),
529 Forks: forks,
530 Branches: result.Branches,
531 })
532 case http.MethodPost:
533 title := r.FormValue("title")
534 body := r.FormValue("body")
535 targetBranch := r.FormValue("targetBranch")
536 fromFork := r.FormValue("fromFork")
537 sourceBranch := r.FormValue("sourceBranch")
538 patch := r.FormValue("patch")
539
540 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed()
541 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == ""
542 isPatchBased := patch != ""
543 isForkBased := fromFork != "" && sourceBranch != ""
544
545 if !isBranchBased && !isPatchBased && !isForkBased {
546 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.")
547 return
548 }
549
550 if isBranchBased && isPatchBased {
551 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.")
552 return
553 }
554
555 if title == "" || body == "" || targetBranch == "" {
556 s.pages.Notice(w, "pull", "Title, body and target branch are required.")
557 return
558 }
559
560 if isBranchBased {
561 s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch)
562 } else if isPatchBased {
563 s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch)
564 } else if isForkBased {
565 s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch)
566 }
567 return
568 }
569}
570
571func (s *State) handleBranchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, sourceBranch string) {
572 pullSource := &db.PullSource{
573 Branch: sourceBranch,
574 }
575 recordPullSource := &tangled.RepoPull_Source{
576 Branch: sourceBranch,
577 }
578
579 // Generate a patch using /compare
580 ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
581 if err != nil {
582 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
583 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
584 return
585 }
586
587 resp, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
588 switch resp.StatusCode {
589 case 404:
590 case 400:
591 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
592 return
593 }
594
595 respBody, err := io.ReadAll(resp.Body)
596 if err != nil {
597 log.Println("failed to compare across branches")
598 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
599 return
600 }
601 defer resp.Body.Close()
602
603 var diffTreeResponse types.RepoDiffTreeResponse
604 err = json.Unmarshal(respBody, &diffTreeResponse)
605 if err != nil {
606 log.Println("failed to unmarshal diff tree response", err)
607 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
608 return
609 }
610
611 sourceRev := diffTreeResponse.DiffTree.Rev2
612 patch := diffTreeResponse.DiffTree.Patch
613
614 if !isPatchValid(patch) {
615 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
616 return
617 }
618
619 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource)
620}
621
622func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, patch string) {
623 if !isPatchValid(patch) {
624 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
625 return
626 }
627
628 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil)
629}
630
631func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, forkRepo string, title, body, targetBranch, sourceBranch string) {
632 fork, err := db.GetForkByDid(s.db, user.Did, forkRepo)
633 if errors.Is(err, sql.ErrNoRows) {
634 s.pages.Notice(w, "pull", "No such fork.")
635 return
636 } else if err != nil {
637 log.Println("failed to fetch fork:", err)
638 s.pages.Notice(w, "pull", "Failed to fetch fork.")
639 return
640 }
641
642 secret, err := db.GetRegistrationKey(s.db, fork.Knot)
643 if err != nil {
644 log.Println("failed to fetch registration key:", err)
645 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
646 return
647 }
648
649 sc, err := NewSignedClient(fork.Knot, secret, s.config.Dev)
650 if err != nil {
651 log.Println("failed to create signed client:", err)
652 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
653 return
654 }
655
656 us, err := NewUnsignedClient(fork.Knot, s.config.Dev)
657 if err != nil {
658 log.Println("failed to create unsigned client:", err)
659 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
660 return
661 }
662
663 resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch)
664 if err != nil {
665 log.Println("failed to create hidden ref:", err, resp.StatusCode)
666 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
667 return
668 }
669
670 switch resp.StatusCode {
671 case 404:
672 case 400:
673 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
674 return
675 }
676
677 hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch))
678 // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking
679 // the targetBranch on the target repository. This code is a bit confusing, but here's an example:
680 // hiddenRef: hidden/feature-1/main (on repo-fork)
681 // targetBranch: main (on repo-1)
682 // sourceBranch: feature-1 (on repo-fork)
683 diffResp, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch)
684 if err != nil {
685 log.Println("failed to compare across branches", err)
686 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
687 return
688 }
689
690 respBody, err := io.ReadAll(diffResp.Body)
691 if err != nil {
692 log.Println("failed to read response body", err)
693 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
694 return
695 }
696
697 defer resp.Body.Close()
698
699 var diffTreeResponse types.RepoDiffTreeResponse
700 err = json.Unmarshal(respBody, &diffTreeResponse)
701 if err != nil {
702 log.Println("failed to unmarshal diff tree response", err)
703 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
704 return
705 }
706
707 sourceRev := diffTreeResponse.DiffTree.Rev2
708 patch := diffTreeResponse.DiffTree.Patch
709
710 if !isPatchValid(patch) {
711 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
712 return
713 }
714
715 forkAtUri, err := syntax.ParseATURI(fork.AtUri)
716 if err != nil {
717 log.Println("failed to parse fork AT URI", err)
718 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
719 return
720 }
721
722 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{
723 Branch: sourceBranch,
724 Repo: &forkAtUri,
725 }, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri})
726}
727
728func (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) {
729 tx, err := s.db.BeginTx(r.Context(), nil)
730 if err != nil {
731 log.Println("failed to start tx")
732 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
733 return
734 }
735 defer tx.Rollback()
736
737 rkey := s.TID()
738 initialSubmission := db.PullSubmission{
739 Patch: patch,
740 SourceRev: sourceRev,
741 }
742 err = db.NewPull(tx, &db.Pull{
743 Title: title,
744 Body: body,
745 TargetBranch: targetBranch,
746 OwnerDid: user.Did,
747 RepoAt: f.RepoAt,
748 Rkey: rkey,
749 Submissions: []*db.PullSubmission{
750 &initialSubmission,
751 },
752 PullSource: pullSource,
753 })
754 if err != nil {
755 log.Println("failed to create pull request", err)
756 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
757 return
758 }
759 client, _ := s.auth.AuthorizedClient(r)
760 pullId, err := db.NextPullId(s.db, f.RepoAt)
761 if err != nil {
762 log.Println("failed to get pull id", err)
763 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
764 return
765 }
766
767 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
768 Collection: tangled.RepoPullNSID,
769 Repo: user.Did,
770 Rkey: rkey,
771 Record: &lexutil.LexiconTypeDecoder{
772 Val: &tangled.RepoPull{
773 Title: title,
774 PullId: int64(pullId),
775 TargetRepo: string(f.RepoAt),
776 TargetBranch: targetBranch,
777 Patch: patch,
778 Source: recordPullSource,
779 },
780 },
781 })
782
783 err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri)
784 if err != nil {
785 log.Println("failed to get pull id", err)
786 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
787 return
788 }
789
790 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
791}
792
793func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
794 user := s.auth.GetUser(r)
795 f, err := fullyResolvedRepo(r)
796 if err != nil {
797 log.Println("failed to get repo and knot", err)
798 return
799 }
800
801 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
802 RepoInfo: f.RepoInfo(s, user),
803 })
804}
805
806func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) {
807 user := s.auth.GetUser(r)
808 f, err := fullyResolvedRepo(r)
809 if err != nil {
810 log.Println("failed to get repo and knot", err)
811 return
812 }
813
814 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
815 if err != nil {
816 log.Printf("failed to create unsigned client for %s", f.Knot)
817 s.pages.Error503(w)
818 return
819 }
820
821 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
822 if err != nil {
823 log.Println("failed to reach knotserver", err)
824 return
825 }
826
827 body, err := io.ReadAll(resp.Body)
828 if err != nil {
829 log.Printf("Error reading response body: %v", err)
830 return
831 }
832
833 var result types.RepoBranchesResponse
834 err = json.Unmarshal(body, &result)
835 if err != nil {
836 log.Println("failed to parse response:", err)
837 return
838 }
839
840 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
841 RepoInfo: f.RepoInfo(s, user),
842 Branches: result.Branches,
843 })
844}
845
846func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) {
847 user := s.auth.GetUser(r)
848 f, err := fullyResolvedRepo(r)
849 if err != nil {
850 log.Println("failed to get repo and knot", err)
851 return
852 }
853
854 pull, ok := r.Context().Value("pull").(*db.Pull)
855 if !ok {
856 log.Println("failed to get pull")
857 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
858 return
859 }
860
861 switch r.Method {
862 case http.MethodGet:
863 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
864 RepoInfo: f.RepoInfo(s, user),
865 Pull: pull,
866 })
867 return
868 case http.MethodPost:
869 patch := r.FormValue("patch")
870 var sourceRev string
871 var recordPullSource *tangled.RepoPull_Source
872
873 var ownerDid, repoName, knotName string
874 var isSameRepo bool = pull.IsSameRepoBranch()
875 sourceBranch := pull.PullSource.Branch
876 targetBranch := pull.TargetBranch
877 recordPullSource = &tangled.RepoPull_Source{
878 Branch: sourceBranch,
879 }
880
881 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed()
882 if isSameRepo && isPushAllowed {
883 ownerDid = f.OwnerDid()
884 repoName = f.RepoName
885 knotName = f.Knot
886 } else if !isSameRepo {
887 sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.Repo.String())
888 if err != nil {
889 log.Println("failed to get source repo", err)
890 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
891 return
892 }
893 ownerDid = sourceRepo.Did
894 repoName = sourceRepo.Name
895 knotName = sourceRepo.Knot
896 }
897
898 if sourceBranch != "" && knotName != "" {
899 // extract patch by performing compare
900 ksClient, err := NewUnsignedClient(knotName, s.config.Dev)
901 if err != nil {
902 log.Printf("failed to create client for %s: %s", knotName, err)
903 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
904 return
905 }
906
907 if !isSameRepo {
908 secret, err := db.GetRegistrationKey(s.db, knotName)
909 if err != nil {
910 log.Printf("failed to get registration key for %s: %s", knotName, err)
911 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
912 return
913 }
914 // update the hidden tracking branch to latest
915 signedClient, err := NewSignedClient(knotName, secret, s.config.Dev)
916 if err != nil {
917 log.Printf("failed to create signed client for %s: %s", knotName, err)
918 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
919 return
920 }
921 resp, err := signedClient.NewHiddenRef(ownerDid, repoName, sourceBranch, targetBranch)
922 if err != nil || resp.StatusCode != http.StatusNoContent {
923 log.Printf("failed to update tracking branch: %s", err)
924 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
925 return
926 }
927 }
928
929 var compareResp *http.Response
930 if !isSameRepo {
931 hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch))
932 compareResp, err = ksClient.Compare(ownerDid, repoName, hiddenRef, sourceBranch)
933 } else {
934 compareResp, err = ksClient.Compare(ownerDid, repoName, targetBranch, sourceBranch)
935 }
936 if err != nil {
937 log.Printf("failed to compare branches: %s", err)
938 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
939 return
940 }
941 defer compareResp.Body.Close()
942
943 switch compareResp.StatusCode {
944 case 404:
945 case 400:
946 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
947 return
948 }
949
950 respBody, err := io.ReadAll(compareResp.Body)
951 if err != nil {
952 log.Println("failed to compare across branches")
953 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
954 return
955 }
956 defer compareResp.Body.Close()
957
958 var diffTreeResponse types.RepoDiffTreeResponse
959 err = json.Unmarshal(respBody, &diffTreeResponse)
960 if err != nil {
961 log.Println("failed to unmarshal diff tree response", err)
962 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
963 return
964 }
965
966 sourceRev = diffTreeResponse.DiffTree.Rev2
967 patch = diffTreeResponse.DiffTree.Patch
968 }
969
970 if patch == "" {
971 s.pages.Notice(w, "resubmit-error", "Patch is empty.")
972 return
973 }
974
975 if patch == pull.LatestPatch() {
976 s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.")
977 return
978 }
979
980 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
981 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
982 return
983 }
984
985 if !isPatchValid(patch) {
986 s.pages.Notice(w, "resubmit-error", "Invalid patch format. Please provide a valid diff.")
987 return
988 }
989
990 tx, err := s.db.BeginTx(r.Context(), nil)
991 if err != nil {
992 log.Println("failed to start tx")
993 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
994 return
995 }
996 defer tx.Rollback()
997
998 err = db.ResubmitPull(tx, pull, patch, sourceRev)
999 if err != nil {
1000 log.Println("failed to create pull request", err)
1001 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1002 return
1003 }
1004 client, _ := s.auth.AuthorizedClient(r)
1005
1006 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1007 if err != nil {
1008 // failed to get record
1009 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1010 return
1011 }
1012
1013 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1014 Collection: tangled.RepoPullNSID,
1015 Repo: user.Did,
1016 Rkey: pull.Rkey,
1017 SwapRecord: ex.Cid,
1018 Record: &lexutil.LexiconTypeDecoder{
1019 Val: &tangled.RepoPull{
1020 Title: pull.Title,
1021 PullId: int64(pull.PullId),
1022 TargetRepo: string(f.RepoAt),
1023 TargetBranch: pull.TargetBranch,
1024 Patch: patch, // new patch
1025 Source: recordPullSource,
1026 },
1027 },
1028 })
1029 if err != nil {
1030 log.Println("failed to update record", err)
1031 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1032 return
1033 }
1034
1035 if err = tx.Commit(); err != nil {
1036 log.Println("failed to commit transaction", err)
1037 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1038 return
1039 }
1040
1041 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1042 return
1043 }
1044}
1045
1046func (s *State) MergePull(w http.ResponseWriter, r *http.Request) {
1047 f, err := fullyResolvedRepo(r)
1048 if err != nil {
1049 log.Println("failed to resolve repo:", err)
1050 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1051 return
1052 }
1053
1054 pull, ok := r.Context().Value("pull").(*db.Pull)
1055 if !ok {
1056 log.Println("failed to get pull")
1057 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1058 return
1059 }
1060
1061 secret, err := db.GetRegistrationKey(s.db, f.Knot)
1062 if err != nil {
1063 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
1064 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1065 return
1066 }
1067
1068 ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid)
1069 if err != nil {
1070 log.Printf("resolving identity: %s", err)
1071 w.WriteHeader(http.StatusNotFound)
1072 return
1073 }
1074
1075 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
1076 if err != nil {
1077 log.Printf("failed to get primary email: %s", err)
1078 }
1079
1080 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
1081 if err != nil {
1082 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
1083 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1084 return
1085 }
1086
1087 // Merge the pull request
1088 resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
1089 if err != nil {
1090 log.Printf("failed to merge pull request: %s", err)
1091 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1092 return
1093 }
1094
1095 if resp.StatusCode == http.StatusOK {
1096 err := db.MergePull(s.db, f.RepoAt, pull.PullId)
1097 if err != nil {
1098 log.Printf("failed to update pull request status in database: %s", err)
1099 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1100 return
1101 }
1102 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
1103 } else {
1104 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
1105 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1106 }
1107}
1108
1109func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) {
1110 user := s.auth.GetUser(r)
1111
1112 f, err := fullyResolvedRepo(r)
1113 if err != nil {
1114 log.Println("malformed middleware")
1115 return
1116 }
1117
1118 pull, ok := r.Context().Value("pull").(*db.Pull)
1119 if !ok {
1120 log.Println("failed to get pull")
1121 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1122 return
1123 }
1124
1125 // auth filter: only owner or collaborators can close
1126 roles := RolesInRepo(s, user, f)
1127 isCollaborator := roles.IsCollaborator()
1128 isPullAuthor := user.Did == pull.OwnerDid
1129 isCloseAllowed := isCollaborator || isPullAuthor
1130 if !isCloseAllowed {
1131 log.Println("failed to close pull")
1132 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
1133 return
1134 }
1135
1136 // Start a transaction
1137 tx, err := s.db.BeginTx(r.Context(), nil)
1138 if err != nil {
1139 log.Println("failed to start transaction", err)
1140 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1141 return
1142 }
1143
1144 // Close the pull in the database
1145 err = db.ClosePull(tx, f.RepoAt, pull.PullId)
1146 if err != nil {
1147 log.Println("failed to close pull", err)
1148 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1149 return
1150 }
1151
1152 // Commit the transaction
1153 if err = tx.Commit(); err != nil {
1154 log.Println("failed to commit transaction", err)
1155 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1156 return
1157 }
1158
1159 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1160 return
1161}
1162
1163func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) {
1164 user := s.auth.GetUser(r)
1165
1166 f, err := fullyResolvedRepo(r)
1167 if err != nil {
1168 log.Println("failed to resolve repo", err)
1169 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1170 return
1171 }
1172
1173 pull, ok := r.Context().Value("pull").(*db.Pull)
1174 if !ok {
1175 log.Println("failed to get pull")
1176 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1177 return
1178 }
1179
1180 // auth filter: only owner or collaborators can close
1181 roles := RolesInRepo(s, user, f)
1182 isCollaborator := roles.IsCollaborator()
1183 isPullAuthor := user.Did == pull.OwnerDid
1184 isCloseAllowed := isCollaborator || isPullAuthor
1185 if !isCloseAllowed {
1186 log.Println("failed to close pull")
1187 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
1188 return
1189 }
1190
1191 // Start a transaction
1192 tx, err := s.db.BeginTx(r.Context(), nil)
1193 if err != nil {
1194 log.Println("failed to start transaction", err)
1195 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1196 return
1197 }
1198
1199 // Reopen the pull in the database
1200 err = db.ReopenPull(tx, f.RepoAt, pull.PullId)
1201 if err != nil {
1202 log.Println("failed to reopen pull", err)
1203 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1204 return
1205 }
1206
1207 // Commit the transaction
1208 if err = tx.Commit(); err != nil {
1209 log.Println("failed to commit transaction", err)
1210 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1211 return
1212 }
1213
1214 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1215 return
1216}
1217
1218// Very basic validation to check if it looks like a diff/patch
1219// A valid patch usually starts with diff or --- lines
1220func isPatchValid(patch string) bool {
1221 // Basic validation to check if it looks like a diff/patch
1222 // A valid patch usually starts with diff or --- lines
1223 if len(patch) == 0 {
1224 return false
1225 }
1226
1227 lines := strings.Split(patch, "\n")
1228 if len(lines) < 2 {
1229 return false
1230 }
1231
1232 // Check for common patch format markers
1233 firstLine := strings.TrimSpace(lines[0])
1234 return strings.HasPrefix(firstLine, "diff ") ||
1235 strings.HasPrefix(firstLine, "--- ") ||
1236 strings.HasPrefix(firstLine, "Index: ") ||
1237 strings.HasPrefix(firstLine, "+++ ") ||
1238 strings.HasPrefix(firstLine, "@@ ")
1239}