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 "sort"
12 "strconv"
13 "time"
14
15 "tangled.sh/tangled.sh/core/api/tangled"
16 "tangled.sh/tangled.sh/core/appview"
17 "tangled.sh/tangled.sh/core/appview/db"
18 "tangled.sh/tangled.sh/core/appview/knotclient"
19 "tangled.sh/tangled.sh/core/appview/oauth"
20 "tangled.sh/tangled.sh/core/appview/pages"
21 "tangled.sh/tangled.sh/core/patchutil"
22 "tangled.sh/tangled.sh/core/types"
23
24 comatproto "github.com/bluesky-social/indigo/api/atproto"
25 "github.com/bluesky-social/indigo/atproto/syntax"
26 lexutil "github.com/bluesky-social/indigo/lex/util"
27 "github.com/go-chi/chi/v5"
28)
29
30// htmx fragment
31func (s *State) PullActions(w http.ResponseWriter, r *http.Request) {
32 switch r.Method {
33 case http.MethodGet:
34 user := s.oauth.GetUser(r)
35 f, err := s.fullyResolvedRepo(r)
36 if err != nil {
37 log.Println("failed to get repo and knot", err)
38 return
39 }
40
41 pull, ok := r.Context().Value("pull").(*db.Pull)
42 if !ok {
43 log.Println("failed to get pull")
44 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
45 return
46 }
47
48 roundNumberStr := chi.URLParam(r, "round")
49 roundNumber, err := strconv.Atoi(roundNumberStr)
50 if err != nil {
51 roundNumber = pull.LastRoundNumber()
52 }
53 if roundNumber >= len(pull.Submissions) {
54 http.Error(w, "bad round id", http.StatusBadRequest)
55 log.Println("failed to parse round id", err)
56 return
57 }
58
59 mergeCheckResponse := s.mergeCheck(f, pull)
60 resubmitResult := pages.Unknown
61 if user.Did == pull.OwnerDid {
62 resubmitResult = s.resubmitCheck(f, pull)
63 }
64
65 s.pages.PullActionsFragment(w, pages.PullActionsParams{
66 LoggedInUser: user,
67 RepoInfo: f.RepoInfo(s, user),
68 Pull: pull,
69 RoundNumber: roundNumber,
70 MergeCheck: mergeCheckResponse,
71 ResubmitCheck: resubmitResult,
72 })
73 return
74 }
75}
76
77func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) {
78 user := s.oauth.GetUser(r)
79 f, err := s.fullyResolvedRepo(r)
80 if err != nil {
81 log.Println("failed to get repo and knot", err)
82 return
83 }
84
85 pull, ok := r.Context().Value("pull").(*db.Pull)
86 if !ok {
87 log.Println("failed to get pull")
88 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
89 return
90 }
91
92 totalIdents := 1
93 for _, submission := range pull.Submissions {
94 totalIdents += len(submission.Comments)
95 }
96
97 identsToResolve := make([]string, totalIdents)
98
99 // populate idents
100 identsToResolve[0] = pull.OwnerDid
101 idx := 1
102 for _, submission := range pull.Submissions {
103 for _, comment := range submission.Comments {
104 identsToResolve[idx] = comment.OwnerDid
105 idx += 1
106 }
107 }
108
109 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
110 didHandleMap := make(map[string]string)
111 for _, identity := range resolvedIds {
112 if !identity.Handle.IsInvalidHandle() {
113 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
114 } else {
115 didHandleMap[identity.DID.String()] = identity.DID.String()
116 }
117 }
118
119 mergeCheckResponse := s.mergeCheck(f, pull)
120 resubmitResult := pages.Unknown
121 if user != nil && user.Did == pull.OwnerDid {
122 resubmitResult = s.resubmitCheck(f, pull)
123 }
124
125 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
126 LoggedInUser: user,
127 RepoInfo: f.RepoInfo(s, user),
128 DidHandleMap: didHandleMap,
129 Pull: pull,
130 MergeCheck: mergeCheckResponse,
131 ResubmitCheck: resubmitResult,
132 })
133}
134
135func (s *State) mergeCheck(f *FullyResolvedRepo, pull *db.Pull) types.MergeCheckResponse {
136 if pull.State == db.PullMerged {
137 return types.MergeCheckResponse{}
138 }
139
140 secret, err := db.GetRegistrationKey(s.db, f.Knot)
141 if err != nil {
142 log.Printf("failed to get registration key: %v", err)
143 return types.MergeCheckResponse{
144 Error: "failed to check merge status: this knot is unregistered",
145 }
146 }
147
148 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
149 if err != nil {
150 log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err)
151 return types.MergeCheckResponse{
152 Error: "failed to check merge status",
153 }
154 }
155
156 resp, err := ksClient.MergeCheck([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch)
157 if err != nil {
158 log.Println("failed to check for mergeability:", err)
159 return types.MergeCheckResponse{
160 Error: "failed to check merge status",
161 }
162 }
163 switch resp.StatusCode {
164 case 404:
165 return types.MergeCheckResponse{
166 Error: "failed to check merge status: this knot does not support PRs",
167 }
168 case 400:
169 return types.MergeCheckResponse{
170 Error: "failed to check merge status: does this knot support PRs?",
171 }
172 }
173
174 respBody, err := io.ReadAll(resp.Body)
175 if err != nil {
176 log.Println("failed to read merge check response body")
177 return types.MergeCheckResponse{
178 Error: "failed to check merge status: knot is not speaking the right language",
179 }
180 }
181 defer resp.Body.Close()
182
183 var mergeCheckResponse types.MergeCheckResponse
184 err = json.Unmarshal(respBody, &mergeCheckResponse)
185 if err != nil {
186 log.Println("failed to unmarshal merge check response", err)
187 return types.MergeCheckResponse{
188 Error: "failed to check merge status: knot is not speaking the right language",
189 }
190 }
191
192 return mergeCheckResponse
193}
194
195func (s *State) resubmitCheck(f *FullyResolvedRepo, pull *db.Pull) pages.ResubmitResult {
196 if pull.State == db.PullMerged || pull.PullSource == nil {
197 return pages.Unknown
198 }
199
200 var knot, ownerDid, repoName string
201
202 if pull.PullSource.RepoAt != nil {
203 // fork-based pulls
204 sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
205 if err != nil {
206 log.Println("failed to get source repo", err)
207 return pages.Unknown
208 }
209
210 knot = sourceRepo.Knot
211 ownerDid = sourceRepo.Did
212 repoName = sourceRepo.Name
213 } else {
214 // pulls within the same repo
215 knot = f.Knot
216 ownerDid = f.OwnerDid()
217 repoName = f.RepoName
218 }
219
220 us, err := knotclient.NewUnsignedClient(knot, s.config.Core.Dev)
221 if err != nil {
222 log.Printf("failed to setup client for %s; ignoring: %v", knot, err)
223 return pages.Unknown
224 }
225
226 resp, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch)
227 if err != nil {
228 log.Println("failed to reach knotserver", err)
229 return pages.Unknown
230 }
231
232 body, err := io.ReadAll(resp.Body)
233 if err != nil {
234 log.Printf("error reading response body: %v", err)
235 return pages.Unknown
236 }
237 defer resp.Body.Close()
238
239 var result types.RepoBranchResponse
240 if err := json.Unmarshal(body, &result); err != nil {
241 log.Println("failed to parse response:", err)
242 return pages.Unknown
243 }
244
245 latestSubmission := pull.Submissions[pull.LastRoundNumber()]
246 if latestSubmission.SourceRev != result.Branch.Hash {
247 fmt.Println(latestSubmission.SourceRev, result.Branch.Hash)
248 return pages.ShouldResubmit
249 }
250
251 return pages.ShouldNotResubmit
252}
253
254func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
255 user := s.oauth.GetUser(r)
256 f, err := s.fullyResolvedRepo(r)
257 if err != nil {
258 log.Println("failed to get repo and knot", err)
259 return
260 }
261
262 pull, ok := r.Context().Value("pull").(*db.Pull)
263 if !ok {
264 log.Println("failed to get pull")
265 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
266 return
267 }
268
269 roundId := chi.URLParam(r, "round")
270 roundIdInt, err := strconv.Atoi(roundId)
271 if err != nil || roundIdInt >= len(pull.Submissions) {
272 http.Error(w, "bad round id", http.StatusBadRequest)
273 log.Println("failed to parse round id", err)
274 return
275 }
276
277 identsToResolve := []string{pull.OwnerDid}
278 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
279 didHandleMap := make(map[string]string)
280 for _, identity := range resolvedIds {
281 if !identity.Handle.IsInvalidHandle() {
282 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
283 } else {
284 didHandleMap[identity.DID.String()] = identity.DID.String()
285 }
286 }
287
288 diff := pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch)
289
290 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
291 LoggedInUser: user,
292 DidHandleMap: didHandleMap,
293 RepoInfo: f.RepoInfo(s, user),
294 Pull: pull,
295 Round: roundIdInt,
296 Submission: pull.Submissions[roundIdInt],
297 Diff: &diff,
298 })
299
300}
301
302func (s *State) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) {
303 user := s.oauth.GetUser(r)
304
305 f, err := s.fullyResolvedRepo(r)
306 if err != nil {
307 log.Println("failed to get repo and knot", err)
308 return
309 }
310
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 get pull.")
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 if roundIdInt == 0 {
327 http.Error(w, "bad round id", http.StatusBadRequest)
328 log.Println("cannot interdiff initial submission")
329 return
330 }
331
332 identsToResolve := []string{pull.OwnerDid}
333 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
334 didHandleMap := make(map[string]string)
335 for _, identity := range resolvedIds {
336 if !identity.Handle.IsInvalidHandle() {
337 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
338 } else {
339 didHandleMap[identity.DID.String()] = identity.DID.String()
340 }
341 }
342
343 currentPatch, err := pull.Submissions[roundIdInt].AsDiff(pull.TargetBranch)
344 if err != nil {
345 log.Println("failed to interdiff; current patch malformed")
346 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.")
347 return
348 }
349
350 previousPatch, err := pull.Submissions[roundIdInt-1].AsDiff(pull.TargetBranch)
351 if err != nil {
352 log.Println("failed to interdiff; previous patch malformed")
353 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.")
354 return
355 }
356
357 interdiff := patchutil.Interdiff(previousPatch, currentPatch)
358
359 s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{
360 LoggedInUser: s.oauth.GetUser(r),
361 RepoInfo: f.RepoInfo(s, user),
362 Pull: pull,
363 Round: roundIdInt,
364 DidHandleMap: didHandleMap,
365 Interdiff: interdiff,
366 })
367 return
368}
369
370func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
371 pull, ok := r.Context().Value("pull").(*db.Pull)
372 if !ok {
373 log.Println("failed to get pull")
374 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
375 return
376 }
377
378 roundId := chi.URLParam(r, "round")
379 roundIdInt, err := strconv.Atoi(roundId)
380 if err != nil || roundIdInt >= len(pull.Submissions) {
381 http.Error(w, "bad round id", http.StatusBadRequest)
382 log.Println("failed to parse round id", err)
383 return
384 }
385
386 identsToResolve := []string{pull.OwnerDid}
387 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
388 didHandleMap := make(map[string]string)
389 for _, identity := range resolvedIds {
390 if !identity.Handle.IsInvalidHandle() {
391 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
392 } else {
393 didHandleMap[identity.DID.String()] = identity.DID.String()
394 }
395 }
396
397 w.Header().Set("Content-Type", "text/plain")
398 w.Write([]byte(pull.Submissions[roundIdInt].Patch))
399}
400
401func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) {
402 user := s.oauth.GetUser(r)
403 params := r.URL.Query()
404
405 state := db.PullOpen
406 switch params.Get("state") {
407 case "closed":
408 state = db.PullClosed
409 case "merged":
410 state = db.PullMerged
411 }
412
413 f, err := s.fullyResolvedRepo(r)
414 if err != nil {
415 log.Println("failed to get repo and knot", err)
416 return
417 }
418
419 pulls, err := db.GetPulls(s.db, f.RepoAt, state)
420 if err != nil {
421 log.Println("failed to get pulls", err)
422 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
423 return
424 }
425
426 for _, p := range pulls {
427 var pullSourceRepo *db.Repo
428 if p.PullSource != nil {
429 if p.PullSource.RepoAt != nil {
430 pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String())
431 if err != nil {
432 log.Printf("failed to get repo by at uri: %v", err)
433 continue
434 } else {
435 p.PullSource.Repo = pullSourceRepo
436 }
437 }
438 }
439 }
440
441 identsToResolve := make([]string, len(pulls))
442 for i, pull := range pulls {
443 identsToResolve[i] = pull.OwnerDid
444 }
445 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
446 didHandleMap := make(map[string]string)
447 for _, identity := range resolvedIds {
448 if !identity.Handle.IsInvalidHandle() {
449 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
450 } else {
451 didHandleMap[identity.DID.String()] = identity.DID.String()
452 }
453 }
454
455 s.pages.RepoPulls(w, pages.RepoPullsParams{
456 LoggedInUser: s.oauth.GetUser(r),
457 RepoInfo: f.RepoInfo(s, user),
458 Pulls: pulls,
459 DidHandleMap: didHandleMap,
460 FilteringBy: state,
461 })
462 return
463}
464
465func (s *State) PullComment(w http.ResponseWriter, r *http.Request) {
466 user := s.oauth.GetUser(r)
467 f, err := s.fullyResolvedRepo(r)
468 if err != nil {
469 log.Println("failed to get repo and knot", err)
470 return
471 }
472
473 pull, ok := r.Context().Value("pull").(*db.Pull)
474 if !ok {
475 log.Println("failed to get pull")
476 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
477 return
478 }
479
480 roundNumberStr := chi.URLParam(r, "round")
481 roundNumber, err := strconv.Atoi(roundNumberStr)
482 if err != nil || roundNumber >= len(pull.Submissions) {
483 http.Error(w, "bad round id", http.StatusBadRequest)
484 log.Println("failed to parse round id", err)
485 return
486 }
487
488 switch r.Method {
489 case http.MethodGet:
490 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{
491 LoggedInUser: user,
492 RepoInfo: f.RepoInfo(s, user),
493 Pull: pull,
494 RoundNumber: roundNumber,
495 })
496 return
497 case http.MethodPost:
498 body := r.FormValue("body")
499 if body == "" {
500 s.pages.Notice(w, "pull", "Comment body is required")
501 return
502 }
503
504 // Start a transaction
505 tx, err := s.db.BeginTx(r.Context(), nil)
506 if err != nil {
507 log.Println("failed to start transaction", err)
508 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
509 return
510 }
511 defer tx.Rollback()
512
513 createdAt := time.Now().Format(time.RFC3339)
514 ownerDid := user.Did
515
516 pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId)
517 if err != nil {
518 log.Println("failed to get pull at", err)
519 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
520 return
521 }
522
523 atUri := f.RepoAt.String()
524 client, err := s.oauth.AuthorizedClient(r)
525 if err != nil {
526 log.Println("failed to get authorized client", err)
527 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
528 return
529 }
530 atResp, err := client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
531 Collection: tangled.RepoPullCommentNSID,
532 Repo: user.Did,
533 Rkey: appview.TID(),
534 Record: &lexutil.LexiconTypeDecoder{
535 Val: &tangled.RepoPullComment{
536 Repo: &atUri,
537 Pull: string(pullAt),
538 Owner: &ownerDid,
539 Body: body,
540 CreatedAt: createdAt,
541 },
542 },
543 })
544 if err != nil {
545 log.Println("failed to create pull comment", err)
546 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
547 return
548 }
549
550 // Create the pull comment in the database with the commentAt field
551 commentId, err := db.NewPullComment(tx, &db.PullComment{
552 OwnerDid: user.Did,
553 RepoAt: f.RepoAt.String(),
554 PullId: pull.PullId,
555 Body: body,
556 CommentAt: atResp.Uri,
557 SubmissionId: pull.Submissions[roundNumber].ID,
558 })
559 if err != nil {
560 log.Println("failed to create pull comment", err)
561 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
562 return
563 }
564
565 // Commit the transaction
566 if err = tx.Commit(); err != nil {
567 log.Println("failed to commit transaction", err)
568 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
569 return
570 }
571
572 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
573 return
574 }
575}
576
577func (s *State) NewPull(w http.ResponseWriter, r *http.Request) {
578 user := s.oauth.GetUser(r)
579 f, err := s.fullyResolvedRepo(r)
580 if err != nil {
581 log.Println("failed to get repo and knot", err)
582 return
583 }
584
585 switch r.Method {
586 case http.MethodGet:
587 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
588 if err != nil {
589 log.Printf("failed to create unsigned client for %s", f.Knot)
590 s.pages.Error503(w)
591 return
592 }
593
594 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
595 if err != nil {
596 log.Println("failed to reach knotserver", err)
597 return
598 }
599
600 body, err := io.ReadAll(resp.Body)
601 if err != nil {
602 log.Printf("Error reading response body: %v", err)
603 return
604 }
605
606 var result types.RepoBranchesResponse
607 err = json.Unmarshal(body, &result)
608 if err != nil {
609 log.Println("failed to parse response:", err)
610 return
611 }
612
613 s.pages.RepoNewPull(w, pages.RepoNewPullParams{
614 LoggedInUser: user,
615 RepoInfo: f.RepoInfo(s, user),
616 Branches: result.Branches,
617 })
618 case http.MethodPost:
619 title := r.FormValue("title")
620 body := r.FormValue("body")
621 targetBranch := r.FormValue("targetBranch")
622 fromFork := r.FormValue("fork")
623 sourceBranch := r.FormValue("sourceBranch")
624 patch := r.FormValue("patch")
625
626 if targetBranch == "" {
627 s.pages.Notice(w, "pull", "Target branch is required.")
628 return
629 }
630
631 // Determine PR type based on input parameters
632 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed()
633 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == ""
634 isForkBased := fromFork != "" && sourceBranch != ""
635 isPatchBased := patch != "" && !isBranchBased && !isForkBased
636 isStacked := r.FormValue("isStacked") == "on"
637
638 if isPatchBased && !patchutil.IsFormatPatch(patch) {
639 if title == "" {
640 s.pages.Notice(w, "pull", "Title is required for git-diff patches.")
641 return
642 }
643 }
644
645 // Validate we have at least one valid PR creation method
646 if !isBranchBased && !isPatchBased && !isForkBased {
647 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.")
648 return
649 }
650
651 // Can't mix branch-based and patch-based approaches
652 if isBranchBased && patch != "" {
653 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.")
654 return
655 }
656
657 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
658 if err != nil {
659 log.Printf("failed to create unsigned client to %s: %v", f.Knot, err)
660 s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
661 return
662 }
663
664 caps, err := us.Capabilities()
665 if err != nil {
666 log.Println("error fetching knot caps", f.Knot, err)
667 s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
668 return
669 }
670
671 if !caps.PullRequests.FormatPatch {
672 s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.")
673 return
674 }
675
676 // Handle the PR creation based on the type
677 if isBranchBased {
678 if !caps.PullRequests.BranchSubmissions {
679 s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?")
680 return
681 }
682 s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch, isStacked)
683 } else if isForkBased {
684 if !caps.PullRequests.ForkSubmissions {
685 s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?")
686 return
687 }
688 s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch, isStacked)
689 } else if isPatchBased {
690 if !caps.PullRequests.PatchSubmissions {
691 s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.")
692 return
693 }
694 s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch, isStacked)
695 }
696 return
697 }
698}
699
700func (s *State) handleBranchBasedPull(
701 w http.ResponseWriter,
702 r *http.Request,
703 f *FullyResolvedRepo,
704 user *oauth.User,
705 title,
706 body,
707 targetBranch,
708 sourceBranch string,
709 isStacked bool,
710) {
711 pullSource := &db.PullSource{
712 Branch: sourceBranch,
713 }
714 recordPullSource := &tangled.RepoPull_Source{
715 Branch: sourceBranch,
716 }
717
718 // Generate a patch using /compare
719 ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
720 if err != nil {
721 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
722 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
723 return
724 }
725
726 comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
727 if err != nil {
728 log.Println("failed to compare", err)
729 s.pages.Notice(w, "pull", err.Error())
730 return
731 }
732
733 sourceRev := comparison.Rev2
734 patch := comparison.Patch
735
736 if !patchutil.IsPatchValid(patch) {
737 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
738 return
739 }
740
741 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource, isStacked)
742}
743
744func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) {
745 if !patchutil.IsPatchValid(patch) {
746 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
747 return
748 }
749
750 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil, isStacked)
751}
752
753func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) {
754 fork, err := db.GetForkByDid(s.db, user.Did, forkRepo)
755 if errors.Is(err, sql.ErrNoRows) {
756 s.pages.Notice(w, "pull", "No such fork.")
757 return
758 } else if err != nil {
759 log.Println("failed to fetch fork:", err)
760 s.pages.Notice(w, "pull", "Failed to fetch fork.")
761 return
762 }
763
764 secret, err := db.GetRegistrationKey(s.db, fork.Knot)
765 if err != nil {
766 log.Println("failed to fetch registration key:", err)
767 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
768 return
769 }
770
771 sc, err := knotclient.NewSignedClient(fork.Knot, secret, s.config.Core.Dev)
772 if err != nil {
773 log.Println("failed to create signed client:", err)
774 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
775 return
776 }
777
778 us, err := knotclient.NewUnsignedClient(fork.Knot, s.config.Core.Dev)
779 if err != nil {
780 log.Println("failed to create unsigned client:", err)
781 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
782 return
783 }
784
785 resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch)
786 if err != nil {
787 log.Println("failed to create hidden ref:", err, resp.StatusCode)
788 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
789 return
790 }
791
792 switch resp.StatusCode {
793 case 404:
794 case 400:
795 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
796 return
797 }
798
799 hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch)
800 // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking
801 // the targetBranch on the target repository. This code is a bit confusing, but here's an example:
802 // hiddenRef: hidden/feature-1/main (on repo-fork)
803 // targetBranch: main (on repo-1)
804 // sourceBranch: feature-1 (on repo-fork)
805 comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch)
806 if err != nil {
807 log.Println("failed to compare across branches", err)
808 s.pages.Notice(w, "pull", err.Error())
809 return
810 }
811
812 sourceRev := comparison.Rev2
813 patch := comparison.Patch
814
815 if !patchutil.IsPatchValid(patch) {
816 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
817 return
818 }
819
820 forkAtUri, err := syntax.ParseATURI(fork.AtUri)
821 if err != nil {
822 log.Println("failed to parse fork AT URI", err)
823 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
824 return
825 }
826
827 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{
828 Branch: sourceBranch,
829 RepoAt: &forkAtUri,
830 }, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri}, isStacked)
831}
832
833func (s *State) createPullRequest(
834 w http.ResponseWriter,
835 r *http.Request,
836 f *FullyResolvedRepo,
837 user *oauth.User,
838 title, body, targetBranch string,
839 patch string,
840 sourceRev string,
841 pullSource *db.PullSource,
842 recordPullSource *tangled.RepoPull_Source,
843 isStacked bool,
844) {
845 if isStacked {
846 }
847
848 tx, err := s.db.BeginTx(r.Context(), nil)
849 if err != nil {
850 log.Println("failed to start tx")
851 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
852 return
853 }
854 defer tx.Rollback()
855
856 // We've already checked earlier if it's diff-based and title is empty,
857 // so if it's still empty now, it's intentionally skipped owing to format-patch.
858 if title == "" {
859 formatPatches, err := patchutil.ExtractPatches(patch)
860 if err != nil {
861 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
862 return
863 }
864 if len(formatPatches) == 0 {
865 s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.")
866 return
867 }
868
869 title = formatPatches[0].Title
870 body = formatPatches[0].Body
871 }
872
873 rkey := appview.TID()
874 initialSubmission := db.PullSubmission{
875 Patch: patch,
876 SourceRev: sourceRev,
877 }
878 err = db.NewPull(tx, &db.Pull{
879 Title: title,
880 Body: body,
881 TargetBranch: targetBranch,
882 OwnerDid: user.Did,
883 RepoAt: f.RepoAt,
884 Rkey: rkey,
885 Submissions: []*db.PullSubmission{
886 &initialSubmission,
887 },
888 PullSource: pullSource,
889 })
890 if err != nil {
891 log.Println("failed to create pull request", err)
892 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
893 return
894 }
895 client, err := s.oauth.AuthorizedClient(r)
896 if err != nil {
897 log.Println("failed to get authorized client", err)
898 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
899 return
900 }
901 pullId, err := db.NextPullId(tx, f.RepoAt)
902 if err != nil {
903 log.Println("failed to get pull id", err)
904 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
905 return
906 }
907
908 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
909 Collection: tangled.RepoPullNSID,
910 Repo: user.Did,
911 Rkey: rkey,
912 Record: &lexutil.LexiconTypeDecoder{
913 Val: &tangled.RepoPull{
914 Title: title,
915 PullId: int64(pullId),
916 TargetRepo: string(f.RepoAt),
917 TargetBranch: targetBranch,
918 Patch: patch,
919 Source: recordPullSource,
920 },
921 },
922 })
923 if err != nil {
924 log.Println("failed to create pull request", err)
925 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
926 return
927 }
928
929 if err = tx.Commit(); err != nil {
930 log.Println("failed to create pull request", err)
931 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
932 return
933 }
934
935 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
936}
937
938func (s *State) ValidatePatch(w http.ResponseWriter, r *http.Request) {
939 _, err := s.fullyResolvedRepo(r)
940 if err != nil {
941 log.Println("failed to get repo and knot", err)
942 return
943 }
944
945 patch := r.FormValue("patch")
946 if patch == "" {
947 s.pages.Notice(w, "patch-error", "Patch is required.")
948 return
949 }
950
951 if patch == "" || !patchutil.IsPatchValid(patch) {
952 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.")
953 return
954 }
955
956 if patchutil.IsFormatPatch(patch) {
957 s.pages.Notice(w, "patch-preview", "git-format-patch detected. Title and description are optional; if left out, they will be extracted from the first commit.")
958 } else {
959 s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.")
960 }
961}
962
963func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
964 user := s.oauth.GetUser(r)
965 f, err := s.fullyResolvedRepo(r)
966 if err != nil {
967 log.Println("failed to get repo and knot", err)
968 return
969 }
970
971 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
972 RepoInfo: f.RepoInfo(s, user),
973 })
974}
975
976func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) {
977 user := s.oauth.GetUser(r)
978 f, err := s.fullyResolvedRepo(r)
979 if err != nil {
980 log.Println("failed to get repo and knot", err)
981 return
982 }
983
984 us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
985 if err != nil {
986 log.Printf("failed to create unsigned client for %s", f.Knot)
987 s.pages.Error503(w)
988 return
989 }
990
991 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
992 if err != nil {
993 log.Println("failed to reach knotserver", err)
994 return
995 }
996
997 body, err := io.ReadAll(resp.Body)
998 if err != nil {
999 log.Printf("Error reading response body: %v", err)
1000 return
1001 }
1002
1003 var result types.RepoBranchesResponse
1004 err = json.Unmarshal(body, &result)
1005 if err != nil {
1006 log.Println("failed to parse response:", err)
1007 return
1008 }
1009
1010 branches := result.Branches
1011 sort.Slice(branches, func(i int, j int) bool {
1012 return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When)
1013 })
1014
1015 withoutDefault := []types.Branch{}
1016 for _, b := range branches {
1017 if b.IsDefault {
1018 continue
1019 }
1020 withoutDefault = append(withoutDefault, b)
1021 }
1022
1023 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
1024 RepoInfo: f.RepoInfo(s, user),
1025 Branches: withoutDefault,
1026 })
1027}
1028
1029func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) {
1030 user := s.oauth.GetUser(r)
1031 f, err := s.fullyResolvedRepo(r)
1032 if err != nil {
1033 log.Println("failed to get repo and knot", err)
1034 return
1035 }
1036
1037 forks, err := db.GetForksByDid(s.db, user.Did)
1038 if err != nil {
1039 log.Println("failed to get forks", err)
1040 return
1041 }
1042
1043 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{
1044 RepoInfo: f.RepoInfo(s, user),
1045 Forks: forks,
1046 })
1047}
1048
1049func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) {
1050 user := s.oauth.GetUser(r)
1051
1052 f, err := s.fullyResolvedRepo(r)
1053 if err != nil {
1054 log.Println("failed to get repo and knot", err)
1055 return
1056 }
1057
1058 forkVal := r.URL.Query().Get("fork")
1059
1060 // fork repo
1061 repo, err := db.GetRepo(s.db, user.Did, forkVal)
1062 if err != nil {
1063 log.Println("failed to get repo", user.Did, forkVal)
1064 return
1065 }
1066
1067 sourceBranchesClient, err := knotclient.NewUnsignedClient(repo.Knot, s.config.Core.Dev)
1068 if err != nil {
1069 log.Printf("failed to create unsigned client for %s", repo.Knot)
1070 s.pages.Error503(w)
1071 return
1072 }
1073
1074 sourceResp, err := sourceBranchesClient.Branches(user.Did, repo.Name)
1075 if err != nil {
1076 log.Println("failed to reach knotserver for source branches", err)
1077 return
1078 }
1079
1080 sourceBody, err := io.ReadAll(sourceResp.Body)
1081 if err != nil {
1082 log.Println("failed to read source response body", err)
1083 return
1084 }
1085 defer sourceResp.Body.Close()
1086
1087 var sourceResult types.RepoBranchesResponse
1088 err = json.Unmarshal(sourceBody, &sourceResult)
1089 if err != nil {
1090 log.Println("failed to parse source branches response:", err)
1091 return
1092 }
1093
1094 targetBranchesClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1095 if err != nil {
1096 log.Printf("failed to create unsigned client for target knot %s", f.Knot)
1097 s.pages.Error503(w)
1098 return
1099 }
1100
1101 targetResp, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName)
1102 if err != nil {
1103 log.Println("failed to reach knotserver for target branches", err)
1104 return
1105 }
1106
1107 targetBody, err := io.ReadAll(targetResp.Body)
1108 if err != nil {
1109 log.Println("failed to read target response body", err)
1110 return
1111 }
1112 defer targetResp.Body.Close()
1113
1114 var targetResult types.RepoBranchesResponse
1115 err = json.Unmarshal(targetBody, &targetResult)
1116 if err != nil {
1117 log.Println("failed to parse target branches response:", err)
1118 return
1119 }
1120
1121 sourceBranches := sourceResult.Branches
1122 sort.Slice(sourceBranches, func(i int, j int) bool {
1123 return sourceBranches[i].Commit.Committer.When.After(sourceBranches[j].Commit.Committer.When)
1124 })
1125
1126 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
1127 RepoInfo: f.RepoInfo(s, user),
1128 SourceBranches: sourceResult.Branches,
1129 TargetBranches: targetResult.Branches,
1130 })
1131}
1132
1133func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) {
1134 user := s.oauth.GetUser(r)
1135 f, err := s.fullyResolvedRepo(r)
1136 if err != nil {
1137 log.Println("failed to get repo and knot", err)
1138 return
1139 }
1140
1141 pull, ok := r.Context().Value("pull").(*db.Pull)
1142 if !ok {
1143 log.Println("failed to get pull")
1144 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1145 return
1146 }
1147
1148 switch r.Method {
1149 case http.MethodGet:
1150 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
1151 RepoInfo: f.RepoInfo(s, user),
1152 Pull: pull,
1153 })
1154 return
1155 case http.MethodPost:
1156 if pull.IsPatchBased() {
1157 s.resubmitPatch(w, r)
1158 return
1159 } else if pull.IsBranchBased() {
1160 s.resubmitBranch(w, r)
1161 return
1162 } else if pull.IsForkBased() {
1163 s.resubmitFork(w, r)
1164 return
1165 }
1166 }
1167}
1168
1169func (s *State) resubmitPatch(w http.ResponseWriter, r *http.Request) {
1170 user := s.oauth.GetUser(r)
1171
1172 pull, ok := r.Context().Value("pull").(*db.Pull)
1173 if !ok {
1174 log.Println("failed to get pull")
1175 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1176 return
1177 }
1178
1179 f, err := s.fullyResolvedRepo(r)
1180 if err != nil {
1181 log.Println("failed to get repo and knot", err)
1182 return
1183 }
1184
1185 if user.Did != pull.OwnerDid {
1186 log.Println("unauthorized user")
1187 w.WriteHeader(http.StatusUnauthorized)
1188 return
1189 }
1190
1191 patch := r.FormValue("patch")
1192
1193 if err = validateResubmittedPatch(pull, patch); err != nil {
1194 s.pages.Notice(w, "resubmit-error", err.Error())
1195 return
1196 }
1197
1198 tx, err := s.db.BeginTx(r.Context(), nil)
1199 if err != nil {
1200 log.Println("failed to start tx")
1201 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1202 return
1203 }
1204 defer tx.Rollback()
1205
1206 err = db.ResubmitPull(tx, pull, patch, "")
1207 if err != nil {
1208 log.Println("failed to resubmit pull request", err)
1209 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull request. Try again later.")
1210 return
1211 }
1212 client, err := s.oauth.AuthorizedClient(r)
1213 if err != nil {
1214 log.Println("failed to get authorized client", err)
1215 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1216 return
1217 }
1218
1219 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1220 if err != nil {
1221 // failed to get record
1222 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1223 return
1224 }
1225
1226 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1227 Collection: tangled.RepoPullNSID,
1228 Repo: user.Did,
1229 Rkey: pull.Rkey,
1230 SwapRecord: ex.Cid,
1231 Record: &lexutil.LexiconTypeDecoder{
1232 Val: &tangled.RepoPull{
1233 Title: pull.Title,
1234 PullId: int64(pull.PullId),
1235 TargetRepo: string(f.RepoAt),
1236 TargetBranch: pull.TargetBranch,
1237 Patch: patch, // new patch
1238 },
1239 },
1240 })
1241 if err != nil {
1242 log.Println("failed to update record", err)
1243 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1244 return
1245 }
1246
1247 if err = tx.Commit(); err != nil {
1248 log.Println("failed to commit transaction", err)
1249 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1250 return
1251 }
1252
1253 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1254 return
1255}
1256
1257func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) {
1258 user := s.oauth.GetUser(r)
1259
1260 pull, ok := r.Context().Value("pull").(*db.Pull)
1261 if !ok {
1262 log.Println("failed to get pull")
1263 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1264 return
1265 }
1266
1267 f, err := s.fullyResolvedRepo(r)
1268 if err != nil {
1269 log.Println("failed to get repo and knot", err)
1270 return
1271 }
1272
1273 if user.Did != pull.OwnerDid {
1274 log.Println("unauthorized user")
1275 w.WriteHeader(http.StatusUnauthorized)
1276 return
1277 }
1278
1279 if !f.RepoInfo(s, user).Roles.IsPushAllowed() {
1280 log.Println("unauthorized user")
1281 w.WriteHeader(http.StatusUnauthorized)
1282 return
1283 }
1284
1285 ksClient, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev)
1286 if err != nil {
1287 log.Printf("failed to create client for %s: %s", f.Knot, err)
1288 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1289 return
1290 }
1291
1292 comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch)
1293 if err != nil {
1294 log.Printf("compare request failed: %s", err)
1295 s.pages.Notice(w, "resubmit-error", err.Error())
1296 return
1297 }
1298
1299 sourceRev := comparison.Rev2
1300 patch := comparison.Patch
1301
1302 if err = validateResubmittedPatch(pull, patch); err != nil {
1303 s.pages.Notice(w, "resubmit-error", err.Error())
1304 return
1305 }
1306
1307 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1308 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1309 return
1310 }
1311
1312 tx, err := s.db.BeginTx(r.Context(), nil)
1313 if err != nil {
1314 log.Println("failed to start tx")
1315 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1316 return
1317 }
1318 defer tx.Rollback()
1319
1320 err = db.ResubmitPull(tx, pull, patch, sourceRev)
1321 if err != nil {
1322 log.Println("failed to create pull request", err)
1323 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1324 return
1325 }
1326 client, err := s.oauth.AuthorizedClient(r)
1327 if err != nil {
1328 log.Println("failed to authorize client")
1329 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1330 return
1331 }
1332
1333 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1334 if err != nil {
1335 // failed to get record
1336 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1337 return
1338 }
1339
1340 recordPullSource := &tangled.RepoPull_Source{
1341 Branch: pull.PullSource.Branch,
1342 }
1343 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1344 Collection: tangled.RepoPullNSID,
1345 Repo: user.Did,
1346 Rkey: pull.Rkey,
1347 SwapRecord: ex.Cid,
1348 Record: &lexutil.LexiconTypeDecoder{
1349 Val: &tangled.RepoPull{
1350 Title: pull.Title,
1351 PullId: int64(pull.PullId),
1352 TargetRepo: string(f.RepoAt),
1353 TargetBranch: pull.TargetBranch,
1354 Patch: patch, // new patch
1355 Source: recordPullSource,
1356 },
1357 },
1358 })
1359 if err != nil {
1360 log.Println("failed to update record", err)
1361 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1362 return
1363 }
1364
1365 if err = tx.Commit(); err != nil {
1366 log.Println("failed to commit transaction", err)
1367 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1368 return
1369 }
1370
1371 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1372 return
1373}
1374
1375func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) {
1376 user := s.oauth.GetUser(r)
1377
1378 pull, ok := r.Context().Value("pull").(*db.Pull)
1379 if !ok {
1380 log.Println("failed to get pull")
1381 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1382 return
1383 }
1384
1385 f, err := s.fullyResolvedRepo(r)
1386 if err != nil {
1387 log.Println("failed to get repo and knot", err)
1388 return
1389 }
1390
1391 if user.Did != pull.OwnerDid {
1392 log.Println("unauthorized user")
1393 w.WriteHeader(http.StatusUnauthorized)
1394 return
1395 }
1396
1397 forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
1398 if err != nil {
1399 log.Println("failed to get source repo", err)
1400 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1401 return
1402 }
1403
1404 // extract patch by performing compare
1405 ksClient, err := knotclient.NewUnsignedClient(forkRepo.Knot, s.config.Core.Dev)
1406 if err != nil {
1407 log.Printf("failed to create client for %s: %s", forkRepo.Knot, err)
1408 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1409 return
1410 }
1411
1412 secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot)
1413 if err != nil {
1414 log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err)
1415 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1416 return
1417 }
1418
1419 // update the hidden tracking branch to latest
1420 signedClient, err := knotclient.NewSignedClient(forkRepo.Knot, secret, s.config.Core.Dev)
1421 if err != nil {
1422 log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err)
1423 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1424 return
1425 }
1426
1427 resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch)
1428 if err != nil || resp.StatusCode != http.StatusNoContent {
1429 log.Printf("failed to update tracking branch: %s", err)
1430 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1431 return
1432 }
1433
1434 hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch)
1435 comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch)
1436 if err != nil {
1437 log.Printf("failed to compare branches: %s", err)
1438 s.pages.Notice(w, "resubmit-error", err.Error())
1439 return
1440 }
1441
1442 sourceRev := comparison.Rev2
1443 patch := comparison.Patch
1444
1445 if err = validateResubmittedPatch(pull, patch); err != nil {
1446 s.pages.Notice(w, "resubmit-error", err.Error())
1447 return
1448 }
1449
1450 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1451 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1452 return
1453 }
1454
1455 tx, err := s.db.BeginTx(r.Context(), nil)
1456 if err != nil {
1457 log.Println("failed to start tx")
1458 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1459 return
1460 }
1461 defer tx.Rollback()
1462
1463 err = db.ResubmitPull(tx, pull, patch, sourceRev)
1464 if err != nil {
1465 log.Println("failed to create pull request", err)
1466 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1467 return
1468 }
1469 client, err := s.oauth.AuthorizedClient(r)
1470 if err != nil {
1471 log.Println("failed to get client")
1472 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1473 return
1474 }
1475
1476 ex, err := client.RepoGetRecord(r.Context(), "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1477 if err != nil {
1478 // failed to get record
1479 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1480 return
1481 }
1482
1483 repoAt := pull.PullSource.RepoAt.String()
1484 recordPullSource := &tangled.RepoPull_Source{
1485 Branch: pull.PullSource.Branch,
1486 Repo: &repoAt,
1487 }
1488 _, err = client.RepoPutRecord(r.Context(), &comatproto.RepoPutRecord_Input{
1489 Collection: tangled.RepoPullNSID,
1490 Repo: user.Did,
1491 Rkey: pull.Rkey,
1492 SwapRecord: ex.Cid,
1493 Record: &lexutil.LexiconTypeDecoder{
1494 Val: &tangled.RepoPull{
1495 Title: pull.Title,
1496 PullId: int64(pull.PullId),
1497 TargetRepo: string(f.RepoAt),
1498 TargetBranch: pull.TargetBranch,
1499 Patch: patch, // new patch
1500 Source: recordPullSource,
1501 },
1502 },
1503 })
1504 if err != nil {
1505 log.Println("failed to update record", err)
1506 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1507 return
1508 }
1509
1510 if err = tx.Commit(); err != nil {
1511 log.Println("failed to commit transaction", err)
1512 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1513 return
1514 }
1515
1516 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1517 return
1518}
1519
1520// validate a resubmission against a pull request
1521func validateResubmittedPatch(pull *db.Pull, patch string) error {
1522 if patch == "" {
1523 return fmt.Errorf("Patch is empty.")
1524 }
1525
1526 if patch == pull.LatestPatch() {
1527 return fmt.Errorf("Patch is identical to previous submission.")
1528 }
1529
1530 if !patchutil.IsPatchValid(patch) {
1531 return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
1532 }
1533
1534 return nil
1535}
1536
1537func (s *State) MergePull(w http.ResponseWriter, r *http.Request) {
1538 f, err := s.fullyResolvedRepo(r)
1539 if err != nil {
1540 log.Println("failed to resolve repo:", err)
1541 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1542 return
1543 }
1544
1545 pull, ok := r.Context().Value("pull").(*db.Pull)
1546 if !ok {
1547 log.Println("failed to get pull")
1548 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1549 return
1550 }
1551
1552 secret, err := db.GetRegistrationKey(s.db, f.Knot)
1553 if err != nil {
1554 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
1555 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1556 return
1557 }
1558
1559 ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid)
1560 if err != nil {
1561 log.Printf("resolving identity: %s", err)
1562 w.WriteHeader(http.StatusNotFound)
1563 return
1564 }
1565
1566 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
1567 if err != nil {
1568 log.Printf("failed to get primary email: %s", err)
1569 }
1570
1571 ksClient, err := knotclient.NewSignedClient(f.Knot, secret, s.config.Core.Dev)
1572 if err != nil {
1573 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
1574 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1575 return
1576 }
1577
1578 // Merge the pull request
1579 resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
1580 if err != nil {
1581 log.Printf("failed to merge pull request: %s", err)
1582 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1583 return
1584 }
1585
1586 if resp.StatusCode == http.StatusOK {
1587 err := db.MergePull(s.db, f.RepoAt, pull.PullId)
1588 if err != nil {
1589 log.Printf("failed to update pull request status in database: %s", err)
1590 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1591 return
1592 }
1593 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
1594 } else {
1595 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
1596 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1597 }
1598}
1599
1600func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) {
1601 user := s.oauth.GetUser(r)
1602
1603 f, err := s.fullyResolvedRepo(r)
1604 if err != nil {
1605 log.Println("malformed middleware")
1606 return
1607 }
1608
1609 pull, ok := r.Context().Value("pull").(*db.Pull)
1610 if !ok {
1611 log.Println("failed to get pull")
1612 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1613 return
1614 }
1615
1616 // auth filter: only owner or collaborators can close
1617 roles := RolesInRepo(s, user, f)
1618 isCollaborator := roles.IsCollaborator()
1619 isPullAuthor := user.Did == pull.OwnerDid
1620 isCloseAllowed := isCollaborator || isPullAuthor
1621 if !isCloseAllowed {
1622 log.Println("failed to close pull")
1623 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
1624 return
1625 }
1626
1627 // Start a transaction
1628 tx, err := s.db.BeginTx(r.Context(), nil)
1629 if err != nil {
1630 log.Println("failed to start transaction", err)
1631 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1632 return
1633 }
1634
1635 // Close the pull in the database
1636 err = db.ClosePull(tx, f.RepoAt, pull.PullId)
1637 if err != nil {
1638 log.Println("failed to close pull", err)
1639 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1640 return
1641 }
1642
1643 // Commit the transaction
1644 if err = tx.Commit(); err != nil {
1645 log.Println("failed to commit transaction", err)
1646 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1647 return
1648 }
1649
1650 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1651 return
1652}
1653
1654func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) {
1655 user := s.oauth.GetUser(r)
1656
1657 f, err := s.fullyResolvedRepo(r)
1658 if err != nil {
1659 log.Println("failed to resolve repo", err)
1660 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1661 return
1662 }
1663
1664 pull, ok := r.Context().Value("pull").(*db.Pull)
1665 if !ok {
1666 log.Println("failed to get pull")
1667 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1668 return
1669 }
1670
1671 // auth filter: only owner or collaborators can close
1672 roles := RolesInRepo(s, user, f)
1673 isCollaborator := roles.IsCollaborator()
1674 isPullAuthor := user.Did == pull.OwnerDid
1675 isCloseAllowed := isCollaborator || isPullAuthor
1676 if !isCloseAllowed {
1677 log.Println("failed to close pull")
1678 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
1679 return
1680 }
1681
1682 // Start a transaction
1683 tx, err := s.db.BeginTx(r.Context(), nil)
1684 if err != nil {
1685 log.Println("failed to start transaction", err)
1686 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1687 return
1688 }
1689
1690 // Reopen the pull in the database
1691 err = db.ReopenPull(tx, f.RepoAt, pull.PullId)
1692 if err != nil {
1693 log.Println("failed to reopen pull", err)
1694 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1695 return
1696 }
1697
1698 // Commit the transaction
1699 if err = tx.Commit(); err != nil {
1700 log.Println("failed to commit transaction", err)
1701 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1702 return
1703 }
1704
1705 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1706 return
1707}