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