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