forked from
tangled.org/core
fork
Configure Feed
Select the types of activity you want to include in your feed.
Monorepo for Tangled
fork
Configure Feed
Select the types of activity you want to include in your feed.
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}