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