this repo has no description
1package state
2
3import (
4 "database/sql"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "log"
10 "net/http"
11 "net/url"
12 "strconv"
13 "time"
14
15 "github.com/go-chi/chi/v5"
16 "tangled.sh/tangled.sh/core/api/tangled"
17 "tangled.sh/tangled.sh/core/appview/auth"
18 "tangled.sh/tangled.sh/core/appview/db"
19 "tangled.sh/tangled.sh/core/appview/pages"
20 "tangled.sh/tangled.sh/core/patchutil"
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 fmt.Println(latestSubmission.SourceRev, result.Branch.Hash)
258 return pages.ShouldResubmit
259 }
260
261 return pages.ShouldNotResubmit
262}
263
264func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
265 user := s.auth.GetUser(r)
266 f, err := fullyResolvedRepo(r)
267 if err != nil {
268 log.Println("failed to get repo and knot", err)
269 return
270 }
271
272 pull, ok := r.Context().Value("pull").(*db.Pull)
273 if !ok {
274 log.Println("failed to get pull")
275 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
276 return
277 }
278
279 roundId := chi.URLParam(r, "round")
280 roundIdInt, err := strconv.Atoi(roundId)
281 if err != nil || roundIdInt >= len(pull.Submissions) {
282 http.Error(w, "bad round id", http.StatusBadRequest)
283 log.Println("failed to parse round id", err)
284 return
285 }
286
287 identsToResolve := []string{pull.OwnerDid}
288 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
289 didHandleMap := make(map[string]string)
290 for _, identity := range resolvedIds {
291 if !identity.Handle.IsInvalidHandle() {
292 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
293 } else {
294 didHandleMap[identity.DID.String()] = identity.DID.String()
295 }
296 }
297
298 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
299 LoggedInUser: user,
300 DidHandleMap: didHandleMap,
301 RepoInfo: f.RepoInfo(s, user),
302 Pull: pull,
303 Round: roundIdInt,
304 Submission: pull.Submissions[roundIdInt],
305 Diff: pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch),
306 })
307
308}
309
310func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
311 pull, ok := r.Context().Value("pull").(*db.Pull)
312 if !ok {
313 log.Println("failed to get pull")
314 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
315 return
316 }
317
318 roundId := chi.URLParam(r, "round")
319 roundIdInt, err := strconv.Atoi(roundId)
320 if err != nil || roundIdInt >= len(pull.Submissions) {
321 http.Error(w, "bad round id", http.StatusBadRequest)
322 log.Println("failed to parse round id", err)
323 return
324 }
325
326 identsToResolve := []string{pull.OwnerDid}
327 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
328 didHandleMap := make(map[string]string)
329 for _, identity := range resolvedIds {
330 if !identity.Handle.IsInvalidHandle() {
331 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
332 } else {
333 didHandleMap[identity.DID.String()] = identity.DID.String()
334 }
335 }
336
337 w.Header().Set("Content-Type", "text/plain")
338 w.Write([]byte(pull.Submissions[roundIdInt].Patch))
339}
340
341func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) {
342 user := s.auth.GetUser(r)
343 params := r.URL.Query()
344
345 state := db.PullOpen
346 switch params.Get("state") {
347 case "closed":
348 state = db.PullClosed
349 case "merged":
350 state = db.PullMerged
351 }
352
353 f, err := fullyResolvedRepo(r)
354 if err != nil {
355 log.Println("failed to get repo and knot", err)
356 return
357 }
358
359 pulls, err := db.GetPulls(s.db, f.RepoAt, state)
360 if err != nil {
361 log.Println("failed to get pulls", err)
362 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
363 return
364 }
365
366 for _, p := range pulls {
367 var pullSourceRepo *db.Repo
368 if p.PullSource != nil {
369 if p.PullSource.RepoAt != nil {
370 pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String())
371 if err != nil {
372 log.Printf("failed to get repo by at uri: %v", err)
373 continue
374 } else {
375 p.PullSource.Repo = pullSourceRepo
376 }
377 }
378 }
379 }
380
381 identsToResolve := make([]string, len(pulls))
382 for i, pull := range pulls {
383 identsToResolve[i] = pull.OwnerDid
384 }
385 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
386 didHandleMap := make(map[string]string)
387 for _, identity := range resolvedIds {
388 if !identity.Handle.IsInvalidHandle() {
389 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
390 } else {
391 didHandleMap[identity.DID.String()] = identity.DID.String()
392 }
393 }
394
395 s.pages.RepoPulls(w, pages.RepoPullsParams{
396 LoggedInUser: s.auth.GetUser(r),
397 RepoInfo: f.RepoInfo(s, user),
398 Pulls: pulls,
399 DidHandleMap: didHandleMap,
400 FilteringBy: state,
401 })
402 return
403}
404
405func (s *State) PullComment(w http.ResponseWriter, r *http.Request) {
406 user := s.auth.GetUser(r)
407 f, err := fullyResolvedRepo(r)
408 if err != nil {
409 log.Println("failed to get repo and knot", err)
410 return
411 }
412
413 pull, ok := r.Context().Value("pull").(*db.Pull)
414 if !ok {
415 log.Println("failed to get pull")
416 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
417 return
418 }
419
420 roundNumberStr := chi.URLParam(r, "round")
421 roundNumber, err := strconv.Atoi(roundNumberStr)
422 if err != nil || roundNumber >= len(pull.Submissions) {
423 http.Error(w, "bad round id", http.StatusBadRequest)
424 log.Println("failed to parse round id", err)
425 return
426 }
427
428 switch r.Method {
429 case http.MethodGet:
430 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{
431 LoggedInUser: user,
432 RepoInfo: f.RepoInfo(s, user),
433 Pull: pull,
434 RoundNumber: roundNumber,
435 })
436 return
437 case http.MethodPost:
438 body := r.FormValue("body")
439 if body == "" {
440 s.pages.Notice(w, "pull", "Comment body is required")
441 return
442 }
443
444 // Start a transaction
445 tx, err := s.db.BeginTx(r.Context(), nil)
446 if err != nil {
447 log.Println("failed to start transaction", err)
448 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
449 return
450 }
451 defer tx.Rollback()
452
453 createdAt := time.Now().Format(time.RFC3339)
454 ownerDid := user.Did
455
456 pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId)
457 if err != nil {
458 log.Println("failed to get pull at", err)
459 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
460 return
461 }
462
463 atUri := f.RepoAt.String()
464 client, _ := s.auth.AuthorizedClient(r)
465 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
466 Collection: tangled.RepoPullCommentNSID,
467 Repo: user.Did,
468 Rkey: s.TID(),
469 Record: &lexutil.LexiconTypeDecoder{
470 Val: &tangled.RepoPullComment{
471 Repo: &atUri,
472 Pull: pullAt,
473 Owner: &ownerDid,
474 Body: &body,
475 CreatedAt: &createdAt,
476 },
477 },
478 })
479 if err != nil {
480 log.Println("failed to create pull comment", err)
481 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
482 return
483 }
484
485 // Create the pull comment in the database with the commentAt field
486 commentId, err := db.NewPullComment(tx, &db.PullComment{
487 OwnerDid: user.Did,
488 RepoAt: f.RepoAt.String(),
489 PullId: pull.PullId,
490 Body: body,
491 CommentAt: atResp.Uri,
492 SubmissionId: pull.Submissions[roundNumber].ID,
493 })
494 if err != nil {
495 log.Println("failed to create pull comment", err)
496 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
497 return
498 }
499
500 // Commit the transaction
501 if err = tx.Commit(); err != nil {
502 log.Println("failed to commit transaction", err)
503 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
504 return
505 }
506
507 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
508 return
509 }
510}
511
512func (s *State) NewPull(w http.ResponseWriter, r *http.Request) {
513 user := s.auth.GetUser(r)
514 f, err := fullyResolvedRepo(r)
515 if err != nil {
516 log.Println("failed to get repo and knot", err)
517 return
518 }
519
520 switch r.Method {
521 case http.MethodGet:
522 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
523 if err != nil {
524 log.Printf("failed to create unsigned client for %s", f.Knot)
525 s.pages.Error503(w)
526 return
527 }
528
529 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
530 if err != nil {
531 log.Println("failed to reach knotserver", err)
532 return
533 }
534
535 body, err := io.ReadAll(resp.Body)
536 if err != nil {
537 log.Printf("Error reading response body: %v", err)
538 return
539 }
540
541 var result types.RepoBranchesResponse
542 err = json.Unmarshal(body, &result)
543 if err != nil {
544 log.Println("failed to parse response:", err)
545 return
546 }
547
548 s.pages.RepoNewPull(w, pages.RepoNewPullParams{
549 LoggedInUser: user,
550 RepoInfo: f.RepoInfo(s, user),
551 Branches: result.Branches,
552 })
553 case http.MethodPost:
554 title := r.FormValue("title")
555 body := r.FormValue("body")
556 targetBranch := r.FormValue("targetBranch")
557 fromFork := r.FormValue("fork")
558 sourceBranch := r.FormValue("sourceBranch")
559 patch := r.FormValue("patch")
560
561 if targetBranch == "" {
562 s.pages.Notice(w, "pull", "Target branch is required.")
563 return
564 }
565
566 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
567 if err != nil {
568 log.Printf("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 comparison, 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 := comparison.Rev2
646 patch := comparison.Patch
647
648 if !patchutil.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 !patchutil.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 comparison, 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 := comparison.Rev2
725 patch := comparison.Patch
726
727 if patchutil.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(
746 w http.ResponseWriter,
747 r *http.Request,
748 f *FullyResolvedRepo,
749 user *auth.User,
750 title, body, targetBranch string,
751 patch string,
752 sourceRev string,
753 pullSource *db.PullSource,
754 recordPullSource *tangled.RepoPull_Source,
755) {
756 tx, err := s.db.BeginTx(r.Context(), nil)
757 if err != nil {
758 log.Println("failed to start tx")
759 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
760 return
761 }
762 defer tx.Rollback()
763
764 rkey := s.TID()
765 initialSubmission := db.PullSubmission{
766 Patch: patch,
767 SourceRev: sourceRev,
768 }
769 err = db.NewPull(tx, &db.Pull{
770 Title: title,
771 Body: body,
772 TargetBranch: targetBranch,
773 OwnerDid: user.Did,
774 RepoAt: f.RepoAt,
775 Rkey: rkey,
776 Submissions: []*db.PullSubmission{
777 &initialSubmission,
778 },
779 PullSource: pullSource,
780 })
781 if err != nil {
782 log.Println("failed to create pull request", err)
783 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
784 return
785 }
786 client, _ := s.auth.AuthorizedClient(r)
787 pullId, err := db.NextPullId(s.db, f.RepoAt)
788 if err != nil {
789 log.Println("failed to get pull id", err)
790 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
791 return
792 }
793
794 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
795 Collection: tangled.RepoPullNSID,
796 Repo: user.Did,
797 Rkey: rkey,
798 Record: &lexutil.LexiconTypeDecoder{
799 Val: &tangled.RepoPull{
800 Title: title,
801 PullId: int64(pullId),
802 TargetRepo: string(f.RepoAt),
803 TargetBranch: targetBranch,
804 Patch: patch,
805 Source: recordPullSource,
806 },
807 },
808 })
809
810 err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri)
811 if err != nil {
812 log.Println("failed to get pull id", err)
813 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
814 return
815 }
816
817 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
818}
819
820func (s *State) ValidatePatch(w http.ResponseWriter, r *http.Request) {
821 _, err := fullyResolvedRepo(r)
822 if err != nil {
823 log.Println("failed to get repo and knot", err)
824 return
825 }
826
827 patch := r.FormValue("patch")
828 if patch == "" {
829 s.pages.Notice(w, "patch-error", "Patch is required.")
830 return
831 }
832
833 if patch == "" || !patchutil.IsPatchValid(patch) {
834 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.")
835 return
836 }
837
838 if patchutil.IsFormatPatch(patch) {
839 s.pages.Notice(w, "patch-preview", "Format patch detected. Title and description are optional; if left out, they will be extracted from the first commit.")
840 } else {
841 s.pages.Notice(w, "patch-preview", "Regular diff detected. Please provide a title and description.")
842 }
843}
844
845func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
846 user := s.auth.GetUser(r)
847 f, err := fullyResolvedRepo(r)
848 if err != nil {
849 log.Println("failed to get repo and knot", err)
850 return
851 }
852
853 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
854 RepoInfo: f.RepoInfo(s, user),
855 })
856}
857
858func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) {
859 user := s.auth.GetUser(r)
860 f, err := fullyResolvedRepo(r)
861 if err != nil {
862 log.Println("failed to get repo and knot", err)
863 return
864 }
865
866 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
867 if err != nil {
868 log.Printf("failed to create unsigned client for %s", f.Knot)
869 s.pages.Error503(w)
870 return
871 }
872
873 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
874 if err != nil {
875 log.Println("failed to reach knotserver", err)
876 return
877 }
878
879 body, err := io.ReadAll(resp.Body)
880 if err != nil {
881 log.Printf("Error reading response body: %v", err)
882 return
883 }
884
885 var result types.RepoBranchesResponse
886 err = json.Unmarshal(body, &result)
887 if err != nil {
888 log.Println("failed to parse response:", err)
889 return
890 }
891
892 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
893 RepoInfo: f.RepoInfo(s, user),
894 Branches: result.Branches,
895 })
896}
897
898func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) {
899 user := s.auth.GetUser(r)
900 f, err := fullyResolvedRepo(r)
901 if err != nil {
902 log.Println("failed to get repo and knot", err)
903 return
904 }
905
906 forks, err := db.GetForksByDid(s.db, user.Did)
907 if err != nil {
908 log.Println("failed to get forks", err)
909 return
910 }
911
912 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{
913 RepoInfo: f.RepoInfo(s, user),
914 Forks: forks,
915 })
916}
917
918func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) {
919 user := s.auth.GetUser(r)
920
921 f, err := fullyResolvedRepo(r)
922 if err != nil {
923 log.Println("failed to get repo and knot", err)
924 return
925 }
926
927 forkVal := r.URL.Query().Get("fork")
928
929 // fork repo
930 repo, err := db.GetRepo(s.db, user.Did, forkVal)
931 if err != nil {
932 log.Println("failed to get repo", user.Did, forkVal)
933 return
934 }
935
936 sourceBranchesClient, err := NewUnsignedClient(repo.Knot, s.config.Dev)
937 if err != nil {
938 log.Printf("failed to create unsigned client for %s", repo.Knot)
939 s.pages.Error503(w)
940 return
941 }
942
943 sourceResp, err := sourceBranchesClient.Branches(user.Did, repo.Name)
944 if err != nil {
945 log.Println("failed to reach knotserver for source branches", err)
946 return
947 }
948
949 sourceBody, err := io.ReadAll(sourceResp.Body)
950 if err != nil {
951 log.Println("failed to read source response body", err)
952 return
953 }
954 defer sourceResp.Body.Close()
955
956 var sourceResult types.RepoBranchesResponse
957 err = json.Unmarshal(sourceBody, &sourceResult)
958 if err != nil {
959 log.Println("failed to parse source branches response:", err)
960 return
961 }
962
963 targetBranchesClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
964 if err != nil {
965 log.Printf("failed to create unsigned client for target knot %s", f.Knot)
966 s.pages.Error503(w)
967 return
968 }
969
970 targetResp, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName)
971 if err != nil {
972 log.Println("failed to reach knotserver for target branches", err)
973 return
974 }
975
976 targetBody, err := io.ReadAll(targetResp.Body)
977 if err != nil {
978 log.Println("failed to read target response body", err)
979 return
980 }
981 defer targetResp.Body.Close()
982
983 var targetResult types.RepoBranchesResponse
984 err = json.Unmarshal(targetBody, &targetResult)
985 if err != nil {
986 log.Println("failed to parse target branches response:", err)
987 return
988 }
989
990 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
991 RepoInfo: f.RepoInfo(s, user),
992 SourceBranches: sourceResult.Branches,
993 TargetBranches: targetResult.Branches,
994 })
995}
996
997func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) {
998 user := s.auth.GetUser(r)
999 f, err := fullyResolvedRepo(r)
1000 if err != nil {
1001 log.Println("failed to get repo and knot", err)
1002 return
1003 }
1004
1005 pull, ok := r.Context().Value("pull").(*db.Pull)
1006 if !ok {
1007 log.Println("failed to get pull")
1008 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1009 return
1010 }
1011
1012 switch r.Method {
1013 case http.MethodGet:
1014 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
1015 RepoInfo: f.RepoInfo(s, user),
1016 Pull: pull,
1017 })
1018 return
1019 case http.MethodPost:
1020 if pull.IsPatchBased() {
1021 s.resubmitPatch(w, r)
1022 return
1023 } else if pull.IsBranchBased() {
1024 s.resubmitBranch(w, r)
1025 return
1026 } else if pull.IsForkBased() {
1027 s.resubmitFork(w, r)
1028 return
1029 }
1030 }
1031}
1032
1033func (s *State) resubmitPatch(w http.ResponseWriter, r *http.Request) {
1034 user := s.auth.GetUser(r)
1035
1036 pull, ok := r.Context().Value("pull").(*db.Pull)
1037 if !ok {
1038 log.Println("failed to get pull")
1039 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1040 return
1041 }
1042
1043 f, err := fullyResolvedRepo(r)
1044 if err != nil {
1045 log.Println("failed to get repo and knot", err)
1046 return
1047 }
1048
1049 if user.Did != pull.OwnerDid {
1050 log.Println("unauthorized user")
1051 w.WriteHeader(http.StatusUnauthorized)
1052 return
1053 }
1054
1055 patch := r.FormValue("patch")
1056
1057 if err = validateResubmittedPatch(pull, patch); err != nil {
1058 s.pages.Notice(w, "resubmit-error", err.Error())
1059 }
1060
1061 tx, err := s.db.BeginTx(r.Context(), nil)
1062 if err != nil {
1063 log.Println("failed to start tx")
1064 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1065 return
1066 }
1067 defer tx.Rollback()
1068
1069 err = db.ResubmitPull(tx, pull, patch, "")
1070 if err != nil {
1071 log.Println("failed to resubmit pull request", err)
1072 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull request. Try again later.")
1073 return
1074 }
1075 client, _ := s.auth.AuthorizedClient(r)
1076
1077 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1078 if err != nil {
1079 // failed to get record
1080 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1081 return
1082 }
1083
1084 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1085 Collection: tangled.RepoPullNSID,
1086 Repo: user.Did,
1087 Rkey: pull.Rkey,
1088 SwapRecord: ex.Cid,
1089 Record: &lexutil.LexiconTypeDecoder{
1090 Val: &tangled.RepoPull{
1091 Title: pull.Title,
1092 PullId: int64(pull.PullId),
1093 TargetRepo: string(f.RepoAt),
1094 TargetBranch: pull.TargetBranch,
1095 Patch: patch, // new patch
1096 },
1097 },
1098 })
1099 if err != nil {
1100 log.Println("failed to update record", err)
1101 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1102 return
1103 }
1104
1105 if err = tx.Commit(); err != nil {
1106 log.Println("failed to commit transaction", err)
1107 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1108 return
1109 }
1110
1111 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1112 return
1113}
1114
1115func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) {
1116 user := s.auth.GetUser(r)
1117
1118 pull, ok := r.Context().Value("pull").(*db.Pull)
1119 if !ok {
1120 log.Println("failed to get pull")
1121 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1122 return
1123 }
1124
1125 f, err := fullyResolvedRepo(r)
1126 if err != nil {
1127 log.Println("failed to get repo and knot", err)
1128 return
1129 }
1130
1131 if user.Did != pull.OwnerDid {
1132 log.Println("unauthorized user")
1133 w.WriteHeader(http.StatusUnauthorized)
1134 return
1135 }
1136
1137 if !f.RepoInfo(s, user).Roles.IsPushAllowed() {
1138 log.Println("unauthorized user")
1139 w.WriteHeader(http.StatusUnauthorized)
1140 return
1141 }
1142
1143 ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
1144 if err != nil {
1145 log.Printf("failed to create client for %s: %s", f.Knot, err)
1146 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1147 return
1148 }
1149
1150 comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch)
1151 if err != nil {
1152 log.Printf("compare request failed: %s", err)
1153 s.pages.Notice(w, "resubmit-error", err.Error())
1154 return
1155 }
1156
1157 sourceRev := comparison.Rev2
1158 patch := comparison.Patch
1159
1160 if err = validateResubmittedPatch(pull, patch); err != nil {
1161 s.pages.Notice(w, "resubmit-error", err.Error())
1162 }
1163
1164 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1165 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1166 return
1167 }
1168
1169 tx, err := s.db.BeginTx(r.Context(), nil)
1170 if err != nil {
1171 log.Println("failed to start tx")
1172 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1173 return
1174 }
1175 defer tx.Rollback()
1176
1177 err = db.ResubmitPull(tx, pull, patch, sourceRev)
1178 if err != nil {
1179 log.Println("failed to create pull request", err)
1180 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1181 return
1182 }
1183 client, _ := s.auth.AuthorizedClient(r)
1184
1185 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1186 if err != nil {
1187 // failed to get record
1188 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1189 return
1190 }
1191
1192 recordPullSource := &tangled.RepoPull_Source{
1193 Branch: pull.PullSource.Branch,
1194 }
1195 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1196 Collection: tangled.RepoPullNSID,
1197 Repo: user.Did,
1198 Rkey: pull.Rkey,
1199 SwapRecord: ex.Cid,
1200 Record: &lexutil.LexiconTypeDecoder{
1201 Val: &tangled.RepoPull{
1202 Title: pull.Title,
1203 PullId: int64(pull.PullId),
1204 TargetRepo: string(f.RepoAt),
1205 TargetBranch: pull.TargetBranch,
1206 Patch: patch, // new patch
1207 Source: recordPullSource,
1208 },
1209 },
1210 })
1211 if err != nil {
1212 log.Println("failed to update record", err)
1213 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1214 return
1215 }
1216
1217 if err = tx.Commit(); err != nil {
1218 log.Println("failed to commit transaction", err)
1219 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1220 return
1221 }
1222
1223 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1224 return
1225}
1226
1227func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) {
1228 user := s.auth.GetUser(r)
1229
1230 pull, ok := r.Context().Value("pull").(*db.Pull)
1231 if !ok {
1232 log.Println("failed to get pull")
1233 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1234 return
1235 }
1236
1237 f, err := fullyResolvedRepo(r)
1238 if err != nil {
1239 log.Println("failed to get repo and knot", err)
1240 return
1241 }
1242
1243 if user.Did != pull.OwnerDid {
1244 log.Println("unauthorized user")
1245 w.WriteHeader(http.StatusUnauthorized)
1246 return
1247 }
1248
1249 forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
1250 if err != nil {
1251 log.Println("failed to get source repo", err)
1252 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1253 return
1254 }
1255
1256 // extract patch by performing compare
1257 ksClient, err := NewUnsignedClient(forkRepo.Knot, s.config.Dev)
1258 if err != nil {
1259 log.Printf("failed to create client for %s: %s", forkRepo.Knot, err)
1260 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1261 return
1262 }
1263
1264 secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot)
1265 if err != nil {
1266 log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err)
1267 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1268 return
1269 }
1270
1271 // update the hidden tracking branch to latest
1272 signedClient, err := NewSignedClient(forkRepo.Knot, secret, s.config.Dev)
1273 if err != nil {
1274 log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err)
1275 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1276 return
1277 }
1278
1279 resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch)
1280 if err != nil || resp.StatusCode != http.StatusNoContent {
1281 log.Printf("failed to update tracking branch: %s", err)
1282 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1283 return
1284 }
1285
1286 hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch))
1287 comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch)
1288 if err != nil {
1289 log.Printf("failed to compare branches: %s", err)
1290 s.pages.Notice(w, "resubmit-error", err.Error())
1291 return
1292 }
1293
1294 sourceRev := comparison.Rev2
1295 patch := comparison.Patch
1296
1297 if err = validateResubmittedPatch(pull, patch); err != nil {
1298 s.pages.Notice(w, "resubmit-error", err.Error())
1299 }
1300
1301 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1302 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1303 return
1304 }
1305
1306 tx, err := s.db.BeginTx(r.Context(), nil)
1307 if err != nil {
1308 log.Println("failed to start tx")
1309 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1310 return
1311 }
1312 defer tx.Rollback()
1313
1314 err = db.ResubmitPull(tx, pull, patch, sourceRev)
1315 if err != nil {
1316 log.Println("failed to create pull request", err)
1317 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1318 return
1319 }
1320 client, _ := s.auth.AuthorizedClient(r)
1321
1322 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1323 if err != nil {
1324 // failed to get record
1325 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1326 return
1327 }
1328
1329 repoAt := pull.PullSource.RepoAt.String()
1330 recordPullSource := &tangled.RepoPull_Source{
1331 Branch: pull.PullSource.Branch,
1332 Repo: &repoAt,
1333 }
1334 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1335 Collection: tangled.RepoPullNSID,
1336 Repo: user.Did,
1337 Rkey: pull.Rkey,
1338 SwapRecord: ex.Cid,
1339 Record: &lexutil.LexiconTypeDecoder{
1340 Val: &tangled.RepoPull{
1341 Title: pull.Title,
1342 PullId: int64(pull.PullId),
1343 TargetRepo: string(f.RepoAt),
1344 TargetBranch: pull.TargetBranch,
1345 Patch: patch, // new patch
1346 Source: recordPullSource,
1347 },
1348 },
1349 })
1350 if err != nil {
1351 log.Println("failed to update record", err)
1352 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1353 return
1354 }
1355
1356 if err = tx.Commit(); err != nil {
1357 log.Println("failed to commit transaction", err)
1358 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1359 return
1360 }
1361
1362 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1363 return
1364}
1365
1366// validate a resubmission against a pull request
1367func validateResubmittedPatch(pull *db.Pull, patch string) error {
1368 if patch == "" {
1369 return fmt.Errorf("Patch is empty.")
1370 }
1371
1372 if patch == pull.LatestPatch() {
1373 return fmt.Errorf("Patch is identical to previous submission.")
1374 }
1375
1376 if patchutil.IsPatchValid(patch) {
1377 return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
1378 }
1379
1380 return nil
1381}
1382
1383func (s *State) MergePull(w http.ResponseWriter, r *http.Request) {
1384 f, err := fullyResolvedRepo(r)
1385 if err != nil {
1386 log.Println("failed to resolve repo:", err)
1387 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1388 return
1389 }
1390
1391 pull, ok := r.Context().Value("pull").(*db.Pull)
1392 if !ok {
1393 log.Println("failed to get pull")
1394 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1395 return
1396 }
1397
1398 secret, err := db.GetRegistrationKey(s.db, f.Knot)
1399 if err != nil {
1400 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
1401 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1402 return
1403 }
1404
1405 ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid)
1406 if err != nil {
1407 log.Printf("resolving identity: %s", err)
1408 w.WriteHeader(http.StatusNotFound)
1409 return
1410 }
1411
1412 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
1413 if err != nil {
1414 log.Printf("failed to get primary email: %s", err)
1415 }
1416
1417 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
1418 if err != nil {
1419 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
1420 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1421 return
1422 }
1423
1424 // Merge the pull request
1425 resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
1426 if err != nil {
1427 log.Printf("failed to merge pull request: %s", err)
1428 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1429 return
1430 }
1431
1432 if resp.StatusCode == http.StatusOK {
1433 err := db.MergePull(s.db, f.RepoAt, pull.PullId)
1434 if err != nil {
1435 log.Printf("failed to update pull request status in database: %s", err)
1436 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1437 return
1438 }
1439 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
1440 } else {
1441 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
1442 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1443 }
1444}
1445
1446func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) {
1447 user := s.auth.GetUser(r)
1448
1449 f, err := fullyResolvedRepo(r)
1450 if err != nil {
1451 log.Println("malformed middleware")
1452 return
1453 }
1454
1455 pull, ok := r.Context().Value("pull").(*db.Pull)
1456 if !ok {
1457 log.Println("failed to get pull")
1458 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1459 return
1460 }
1461
1462 // auth filter: only owner or collaborators can close
1463 roles := RolesInRepo(s, user, f)
1464 isCollaborator := roles.IsCollaborator()
1465 isPullAuthor := user.Did == pull.OwnerDid
1466 isCloseAllowed := isCollaborator || isPullAuthor
1467 if !isCloseAllowed {
1468 log.Println("failed to close pull")
1469 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
1470 return
1471 }
1472
1473 // Start a transaction
1474 tx, err := s.db.BeginTx(r.Context(), nil)
1475 if err != nil {
1476 log.Println("failed to start transaction", err)
1477 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1478 return
1479 }
1480
1481 // Close the pull in the database
1482 err = db.ClosePull(tx, f.RepoAt, pull.PullId)
1483 if err != nil {
1484 log.Println("failed to close pull", err)
1485 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1486 return
1487 }
1488
1489 // Commit the transaction
1490 if err = tx.Commit(); err != nil {
1491 log.Println("failed to commit transaction", err)
1492 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1493 return
1494 }
1495
1496 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1497 return
1498}
1499
1500func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) {
1501 user := s.auth.GetUser(r)
1502
1503 f, err := fullyResolvedRepo(r)
1504 if err != nil {
1505 log.Println("failed to resolve repo", err)
1506 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1507 return
1508 }
1509
1510 pull, ok := r.Context().Value("pull").(*db.Pull)
1511 if !ok {
1512 log.Println("failed to get pull")
1513 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1514 return
1515 }
1516
1517 // auth filter: only owner or collaborators can close
1518 roles := RolesInRepo(s, user, f)
1519 isCollaborator := roles.IsCollaborator()
1520 isPullAuthor := user.Did == pull.OwnerDid
1521 isCloseAllowed := isCollaborator || isPullAuthor
1522 if !isCloseAllowed {
1523 log.Println("failed to close pull")
1524 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
1525 return
1526 }
1527
1528 // Start a transaction
1529 tx, err := s.db.BeginTx(r.Context(), nil)
1530 if err != nil {
1531 log.Println("failed to start transaction", err)
1532 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1533 return
1534 }
1535
1536 // Reopen the pull in the database
1537 err = db.ReopenPull(tx, f.RepoAt, pull.PullId)
1538 if err != nil {
1539 log.Println("failed to reopen pull", err)
1540 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1541 return
1542 }
1543
1544 // Commit the transaction
1545 if err = tx.Commit(); err != nil {
1546 log.Println("failed to commit transaction", err)
1547 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1548 return
1549 }
1550
1551 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1552 return
1553}