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