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