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 // Handle the PR creation based on the type
674 if isBranchBased {
675 if !caps.PullRequests.BranchSubmissions {
676 s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?")
677 return
678 }
679 s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch)
680 } else if isForkBased {
681 if !caps.PullRequests.ForkSubmissions {
682 s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?")
683 return
684 }
685 s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch)
686 } else if isPatchBased {
687 if !caps.PullRequests.PatchSubmissions {
688 s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.")
689 return
690 }
691 s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch)
692 }
693 return
694 }
695}
696
697func (s *State) handleBranchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, sourceBranch string) {
698 pullSource := &db.PullSource{
699 Branch: sourceBranch,
700 }
701 recordPullSource := &tangled.RepoPull_Source{
702 Branch: sourceBranch,
703 }
704
705 // Generate a patch using /compare
706 ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
707 if err != nil {
708 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
709 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
710 return
711 }
712
713 comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
714 if err != nil {
715 log.Println("failed to compare", err)
716 s.pages.Notice(w, "pull", err.Error())
717 return
718 }
719
720 sourceRev := comparison.Rev2
721 patch := comparison.Patch
722
723 if !patchutil.IsPatchValid(patch) {
724 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
725 return
726 }
727
728 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource)
729}
730
731func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, patch string) {
732 if !patchutil.IsPatchValid(patch) {
733 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
734 return
735 }
736
737 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil)
738}
739
740func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, forkRepo string, title, body, targetBranch, sourceBranch string) {
741 fork, err := db.GetForkByDid(s.db, user.Did, forkRepo)
742 if errors.Is(err, sql.ErrNoRows) {
743 s.pages.Notice(w, "pull", "No such fork.")
744 return
745 } else if err != nil {
746 log.Println("failed to fetch fork:", err)
747 s.pages.Notice(w, "pull", "Failed to fetch fork.")
748 return
749 }
750
751 secret, err := db.GetRegistrationKey(s.db, fork.Knot)
752 if err != nil {
753 log.Println("failed to fetch registration key:", err)
754 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
755 return
756 }
757
758 sc, err := NewSignedClient(fork.Knot, secret, s.config.Dev)
759 if err != nil {
760 log.Println("failed to create signed client:", err)
761 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
762 return
763 }
764
765 us, err := NewUnsignedClient(fork.Knot, s.config.Dev)
766 if err != nil {
767 log.Println("failed to create unsigned client:", err)
768 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
769 return
770 }
771
772 resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch)
773 if err != nil {
774 log.Println("failed to create hidden ref:", err, resp.StatusCode)
775 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
776 return
777 }
778
779 switch resp.StatusCode {
780 case 404:
781 case 400:
782 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
783 return
784 }
785
786 hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch))
787 // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking
788 // the targetBranch on the target repository. This code is a bit confusing, but here's an example:
789 // hiddenRef: hidden/feature-1/main (on repo-fork)
790 // targetBranch: main (on repo-1)
791 // sourceBranch: feature-1 (on repo-fork)
792 comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch)
793 if err != nil {
794 log.Println("failed to compare across branches", err)
795 s.pages.Notice(w, "pull", err.Error())
796 return
797 }
798
799 sourceRev := comparison.Rev2
800 patch := comparison.Patch
801
802 if patchutil.IsPatchValid(patch) {
803 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
804 return
805 }
806
807 forkAtUri, err := syntax.ParseATURI(fork.AtUri)
808 if err != nil {
809 log.Println("failed to parse fork AT URI", err)
810 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
811 return
812 }
813
814 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{
815 Branch: sourceBranch,
816 RepoAt: &forkAtUri,
817 }, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri})
818}
819
820func (s *State) createPullRequest(
821 w http.ResponseWriter,
822 r *http.Request,
823 f *FullyResolvedRepo,
824 user *auth.User,
825 title, body, targetBranch string,
826 patch string,
827 sourceRev string,
828 pullSource *db.PullSource,
829 recordPullSource *tangled.RepoPull_Source,
830) {
831 tx, err := s.db.BeginTx(r.Context(), nil)
832 if err != nil {
833 log.Println("failed to start tx")
834 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
835 return
836 }
837 defer tx.Rollback()
838
839 // We've already checked earlier if it's diff-based and title is empty,
840 // so if it's still empty now, it's intentionally skipped owing to format-patch.
841 if title == "" {
842 formatPatches, err := patchutil.ExtractPatches(patch)
843 if err != nil {
844 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err))
845 return
846 }
847 if len(formatPatches) == 0 {
848 s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.")
849 return
850 }
851
852 title = formatPatches[0].Title
853 body = formatPatches[0].Body
854 }
855
856 rkey := s.TID()
857 initialSubmission := db.PullSubmission{
858 Patch: patch,
859 SourceRev: sourceRev,
860 }
861 err = db.NewPull(tx, &db.Pull{
862 Title: title,
863 Body: body,
864 TargetBranch: targetBranch,
865 OwnerDid: user.Did,
866 RepoAt: f.RepoAt,
867 Rkey: rkey,
868 Submissions: []*db.PullSubmission{
869 &initialSubmission,
870 },
871 PullSource: pullSource,
872 })
873 if err != nil {
874 log.Println("failed to create pull request", err)
875 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
876 return
877 }
878 client, _ := s.auth.AuthorizedClient(r)
879 pullId, err := db.NextPullId(s.db, f.RepoAt)
880 if err != nil {
881 log.Println("failed to get pull id", err)
882 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
883 return
884 }
885
886 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
887 Collection: tangled.RepoPullNSID,
888 Repo: user.Did,
889 Rkey: rkey,
890 Record: &lexutil.LexiconTypeDecoder{
891 Val: &tangled.RepoPull{
892 Title: title,
893 PullId: int64(pullId),
894 TargetRepo: string(f.RepoAt),
895 TargetBranch: targetBranch,
896 Patch: patch,
897 Source: recordPullSource,
898 },
899 },
900 })
901
902 err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri)
903 if err != nil {
904 log.Println("failed to get pull id", err)
905 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
906 return
907 }
908
909 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
910}
911
912func (s *State) ValidatePatch(w http.ResponseWriter, r *http.Request) {
913 _, err := fullyResolvedRepo(r)
914 if err != nil {
915 log.Println("failed to get repo and knot", err)
916 return
917 }
918
919 patch := r.FormValue("patch")
920 if patch == "" {
921 s.pages.Notice(w, "patch-error", "Patch is required.")
922 return
923 }
924
925 if patch == "" || !patchutil.IsPatchValid(patch) {
926 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.")
927 return
928 }
929
930 if patchutil.IsFormatPatch(patch) {
931 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.")
932 } else {
933 s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.")
934 }
935}
936
937func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
938 user := s.auth.GetUser(r)
939 f, err := fullyResolvedRepo(r)
940 if err != nil {
941 log.Println("failed to get repo and knot", err)
942 return
943 }
944
945 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
946 RepoInfo: f.RepoInfo(s, user),
947 })
948}
949
950func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) {
951 user := s.auth.GetUser(r)
952 f, err := fullyResolvedRepo(r)
953 if err != nil {
954 log.Println("failed to get repo and knot", err)
955 return
956 }
957
958 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
959 if err != nil {
960 log.Printf("failed to create unsigned client for %s", f.Knot)
961 s.pages.Error503(w)
962 return
963 }
964
965 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
966 if err != nil {
967 log.Println("failed to reach knotserver", err)
968 return
969 }
970
971 body, err := io.ReadAll(resp.Body)
972 if err != nil {
973 log.Printf("Error reading response body: %v", err)
974 return
975 }
976
977 var result types.RepoBranchesResponse
978 err = json.Unmarshal(body, &result)
979 if err != nil {
980 log.Println("failed to parse response:", err)
981 return
982 }
983
984 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
985 RepoInfo: f.RepoInfo(s, user),
986 Branches: result.Branches,
987 })
988}
989
990func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) {
991 user := s.auth.GetUser(r)
992 f, err := fullyResolvedRepo(r)
993 if err != nil {
994 log.Println("failed to get repo and knot", err)
995 return
996 }
997
998 forks, err := db.GetForksByDid(s.db, user.Did)
999 if err != nil {
1000 log.Println("failed to get forks", err)
1001 return
1002 }
1003
1004 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{
1005 RepoInfo: f.RepoInfo(s, user),
1006 Forks: forks,
1007 })
1008}
1009
1010func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) {
1011 user := s.auth.GetUser(r)
1012
1013 f, err := fullyResolvedRepo(r)
1014 if err != nil {
1015 log.Println("failed to get repo and knot", err)
1016 return
1017 }
1018
1019 forkVal := r.URL.Query().Get("fork")
1020
1021 // fork repo
1022 repo, err := db.GetRepo(s.db, user.Did, forkVal)
1023 if err != nil {
1024 log.Println("failed to get repo", user.Did, forkVal)
1025 return
1026 }
1027
1028 sourceBranchesClient, err := NewUnsignedClient(repo.Knot, s.config.Dev)
1029 if err != nil {
1030 log.Printf("failed to create unsigned client for %s", repo.Knot)
1031 s.pages.Error503(w)
1032 return
1033 }
1034
1035 sourceResp, err := sourceBranchesClient.Branches(user.Did, repo.Name)
1036 if err != nil {
1037 log.Println("failed to reach knotserver for source branches", err)
1038 return
1039 }
1040
1041 sourceBody, err := io.ReadAll(sourceResp.Body)
1042 if err != nil {
1043 log.Println("failed to read source response body", err)
1044 return
1045 }
1046 defer sourceResp.Body.Close()
1047
1048 var sourceResult types.RepoBranchesResponse
1049 err = json.Unmarshal(sourceBody, &sourceResult)
1050 if err != nil {
1051 log.Println("failed to parse source branches response:", err)
1052 return
1053 }
1054
1055 targetBranchesClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
1056 if err != nil {
1057 log.Printf("failed to create unsigned client for target knot %s", f.Knot)
1058 s.pages.Error503(w)
1059 return
1060 }
1061
1062 targetResp, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName)
1063 if err != nil {
1064 log.Println("failed to reach knotserver for target branches", err)
1065 return
1066 }
1067
1068 targetBody, err := io.ReadAll(targetResp.Body)
1069 if err != nil {
1070 log.Println("failed to read target response body", err)
1071 return
1072 }
1073 defer targetResp.Body.Close()
1074
1075 var targetResult types.RepoBranchesResponse
1076 err = json.Unmarshal(targetBody, &targetResult)
1077 if err != nil {
1078 log.Println("failed to parse target branches response:", err)
1079 return
1080 }
1081
1082 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
1083 RepoInfo: f.RepoInfo(s, user),
1084 SourceBranches: sourceResult.Branches,
1085 TargetBranches: targetResult.Branches,
1086 })
1087}
1088
1089func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) {
1090 user := s.auth.GetUser(r)
1091 f, err := fullyResolvedRepo(r)
1092 if err != nil {
1093 log.Println("failed to get repo and knot", err)
1094 return
1095 }
1096
1097 pull, ok := r.Context().Value("pull").(*db.Pull)
1098 if !ok {
1099 log.Println("failed to get pull")
1100 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1101 return
1102 }
1103
1104 switch r.Method {
1105 case http.MethodGet:
1106 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
1107 RepoInfo: f.RepoInfo(s, user),
1108 Pull: pull,
1109 })
1110 return
1111 case http.MethodPost:
1112 if pull.IsPatchBased() {
1113 s.resubmitPatch(w, r)
1114 return
1115 } else if pull.IsBranchBased() {
1116 s.resubmitBranch(w, r)
1117 return
1118 } else if pull.IsForkBased() {
1119 s.resubmitFork(w, r)
1120 return
1121 }
1122 }
1123}
1124
1125func (s *State) resubmitPatch(w http.ResponseWriter, r *http.Request) {
1126 user := s.auth.GetUser(r)
1127
1128 pull, ok := r.Context().Value("pull").(*db.Pull)
1129 if !ok {
1130 log.Println("failed to get pull")
1131 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1132 return
1133 }
1134
1135 f, err := fullyResolvedRepo(r)
1136 if err != nil {
1137 log.Println("failed to get repo and knot", err)
1138 return
1139 }
1140
1141 if user.Did != pull.OwnerDid {
1142 log.Println("unauthorized user")
1143 w.WriteHeader(http.StatusUnauthorized)
1144 return
1145 }
1146
1147 patch := r.FormValue("patch")
1148
1149 if err = validateResubmittedPatch(pull, patch); err != nil {
1150 s.pages.Notice(w, "resubmit-error", err.Error())
1151 return
1152 }
1153
1154 tx, err := s.db.BeginTx(r.Context(), nil)
1155 if err != nil {
1156 log.Println("failed to start tx")
1157 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1158 return
1159 }
1160 defer tx.Rollback()
1161
1162 err = db.ResubmitPull(tx, pull, patch, "")
1163 if err != nil {
1164 log.Println("failed to resubmit pull request", err)
1165 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull request. Try again later.")
1166 return
1167 }
1168 client, _ := s.auth.AuthorizedClient(r)
1169
1170 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1171 if err != nil {
1172 // failed to get record
1173 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1174 return
1175 }
1176
1177 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1178 Collection: tangled.RepoPullNSID,
1179 Repo: user.Did,
1180 Rkey: pull.Rkey,
1181 SwapRecord: ex.Cid,
1182 Record: &lexutil.LexiconTypeDecoder{
1183 Val: &tangled.RepoPull{
1184 Title: pull.Title,
1185 PullId: int64(pull.PullId),
1186 TargetRepo: string(f.RepoAt),
1187 TargetBranch: pull.TargetBranch,
1188 Patch: patch, // new patch
1189 },
1190 },
1191 })
1192 if err != nil {
1193 log.Println("failed to update record", err)
1194 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1195 return
1196 }
1197
1198 if err = tx.Commit(); err != nil {
1199 log.Println("failed to commit transaction", err)
1200 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1201 return
1202 }
1203
1204 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1205 return
1206}
1207
1208func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) {
1209 user := s.auth.GetUser(r)
1210
1211 pull, ok := r.Context().Value("pull").(*db.Pull)
1212 if !ok {
1213 log.Println("failed to get pull")
1214 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1215 return
1216 }
1217
1218 f, err := fullyResolvedRepo(r)
1219 if err != nil {
1220 log.Println("failed to get repo and knot", err)
1221 return
1222 }
1223
1224 if user.Did != pull.OwnerDid {
1225 log.Println("unauthorized user")
1226 w.WriteHeader(http.StatusUnauthorized)
1227 return
1228 }
1229
1230 if !f.RepoInfo(s, user).Roles.IsPushAllowed() {
1231 log.Println("unauthorized user")
1232 w.WriteHeader(http.StatusUnauthorized)
1233 return
1234 }
1235
1236 ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
1237 if err != nil {
1238 log.Printf("failed to create client for %s: %s", f.Knot, err)
1239 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1240 return
1241 }
1242
1243 comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch)
1244 if err != nil {
1245 log.Printf("compare request failed: %s", err)
1246 s.pages.Notice(w, "resubmit-error", err.Error())
1247 return
1248 }
1249
1250 sourceRev := comparison.Rev2
1251 patch := comparison.Patch
1252
1253 if err = validateResubmittedPatch(pull, patch); err != nil {
1254 s.pages.Notice(w, "resubmit-error", err.Error())
1255 return
1256 }
1257
1258 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1259 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1260 return
1261 }
1262
1263 tx, err := s.db.BeginTx(r.Context(), nil)
1264 if err != nil {
1265 log.Println("failed to start tx")
1266 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1267 return
1268 }
1269 defer tx.Rollback()
1270
1271 err = db.ResubmitPull(tx, pull, patch, sourceRev)
1272 if err != nil {
1273 log.Println("failed to create pull request", err)
1274 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1275 return
1276 }
1277 client, _ := s.auth.AuthorizedClient(r)
1278
1279 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1280 if err != nil {
1281 // failed to get record
1282 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1283 return
1284 }
1285
1286 recordPullSource := &tangled.RepoPull_Source{
1287 Branch: pull.PullSource.Branch,
1288 }
1289 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1290 Collection: tangled.RepoPullNSID,
1291 Repo: user.Did,
1292 Rkey: pull.Rkey,
1293 SwapRecord: ex.Cid,
1294 Record: &lexutil.LexiconTypeDecoder{
1295 Val: &tangled.RepoPull{
1296 Title: pull.Title,
1297 PullId: int64(pull.PullId),
1298 TargetRepo: string(f.RepoAt),
1299 TargetBranch: pull.TargetBranch,
1300 Patch: patch, // new patch
1301 Source: recordPullSource,
1302 },
1303 },
1304 })
1305 if err != nil {
1306 log.Println("failed to update record", err)
1307 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1308 return
1309 }
1310
1311 if err = tx.Commit(); err != nil {
1312 log.Println("failed to commit transaction", err)
1313 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1314 return
1315 }
1316
1317 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1318 return
1319}
1320
1321func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) {
1322 user := s.auth.GetUser(r)
1323
1324 pull, ok := r.Context().Value("pull").(*db.Pull)
1325 if !ok {
1326 log.Println("failed to get pull")
1327 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1328 return
1329 }
1330
1331 f, err := fullyResolvedRepo(r)
1332 if err != nil {
1333 log.Println("failed to get repo and knot", err)
1334 return
1335 }
1336
1337 if user.Did != pull.OwnerDid {
1338 log.Println("unauthorized user")
1339 w.WriteHeader(http.StatusUnauthorized)
1340 return
1341 }
1342
1343 forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
1344 if err != nil {
1345 log.Println("failed to get source repo", err)
1346 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1347 return
1348 }
1349
1350 // extract patch by performing compare
1351 ksClient, err := NewUnsignedClient(forkRepo.Knot, s.config.Dev)
1352 if err != nil {
1353 log.Printf("failed to create client for %s: %s", forkRepo.Knot, err)
1354 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1355 return
1356 }
1357
1358 secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot)
1359 if err != nil {
1360 log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err)
1361 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1362 return
1363 }
1364
1365 // update the hidden tracking branch to latest
1366 signedClient, err := NewSignedClient(forkRepo.Knot, secret, s.config.Dev)
1367 if err != nil {
1368 log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err)
1369 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1370 return
1371 }
1372
1373 resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch)
1374 if err != nil || resp.StatusCode != http.StatusNoContent {
1375 log.Printf("failed to update tracking branch: %s", err)
1376 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1377 return
1378 }
1379
1380 hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch))
1381 comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch)
1382 if err != nil {
1383 log.Printf("failed to compare branches: %s", err)
1384 s.pages.Notice(w, "resubmit-error", err.Error())
1385 return
1386 }
1387
1388 sourceRev := comparison.Rev2
1389 patch := comparison.Patch
1390
1391 if err = validateResubmittedPatch(pull, patch); err != nil {
1392 s.pages.Notice(w, "resubmit-error", err.Error())
1393 return
1394 }
1395
1396 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1397 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1398 return
1399 }
1400
1401 tx, err := s.db.BeginTx(r.Context(), nil)
1402 if err != nil {
1403 log.Println("failed to start tx")
1404 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1405 return
1406 }
1407 defer tx.Rollback()
1408
1409 err = db.ResubmitPull(tx, pull, patch, sourceRev)
1410 if err != nil {
1411 log.Println("failed to create pull request", err)
1412 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1413 return
1414 }
1415 client, _ := s.auth.AuthorizedClient(r)
1416
1417 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1418 if err != nil {
1419 // failed to get record
1420 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1421 return
1422 }
1423
1424 repoAt := pull.PullSource.RepoAt.String()
1425 recordPullSource := &tangled.RepoPull_Source{
1426 Branch: pull.PullSource.Branch,
1427 Repo: &repoAt,
1428 }
1429 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1430 Collection: tangled.RepoPullNSID,
1431 Repo: user.Did,
1432 Rkey: pull.Rkey,
1433 SwapRecord: ex.Cid,
1434 Record: &lexutil.LexiconTypeDecoder{
1435 Val: &tangled.RepoPull{
1436 Title: pull.Title,
1437 PullId: int64(pull.PullId),
1438 TargetRepo: string(f.RepoAt),
1439 TargetBranch: pull.TargetBranch,
1440 Patch: patch, // new patch
1441 Source: recordPullSource,
1442 },
1443 },
1444 })
1445 if err != nil {
1446 log.Println("failed to update record", err)
1447 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1448 return
1449 }
1450
1451 if err = tx.Commit(); err != nil {
1452 log.Println("failed to commit transaction", err)
1453 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1454 return
1455 }
1456
1457 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1458 return
1459}
1460
1461// validate a resubmission against a pull request
1462func validateResubmittedPatch(pull *db.Pull, patch string) error {
1463 if patch == "" {
1464 return fmt.Errorf("Patch is empty.")
1465 }
1466
1467 if patch == pull.LatestPatch() {
1468 return fmt.Errorf("Patch is identical to previous submission.")
1469 }
1470
1471 if !patchutil.IsPatchValid(patch) {
1472 return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
1473 }
1474
1475 return nil
1476}
1477
1478func (s *State) MergePull(w http.ResponseWriter, r *http.Request) {
1479 f, err := fullyResolvedRepo(r)
1480 if err != nil {
1481 log.Println("failed to resolve repo:", err)
1482 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1483 return
1484 }
1485
1486 pull, ok := r.Context().Value("pull").(*db.Pull)
1487 if !ok {
1488 log.Println("failed to get pull")
1489 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1490 return
1491 }
1492
1493 secret, err := db.GetRegistrationKey(s.db, f.Knot)
1494 if err != nil {
1495 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
1496 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1497 return
1498 }
1499
1500 ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid)
1501 if err != nil {
1502 log.Printf("resolving identity: %s", err)
1503 w.WriteHeader(http.StatusNotFound)
1504 return
1505 }
1506
1507 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
1508 if err != nil {
1509 log.Printf("failed to get primary email: %s", err)
1510 }
1511
1512 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
1513 if err != nil {
1514 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
1515 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1516 return
1517 }
1518
1519 // Merge the pull request
1520 resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
1521 if err != nil {
1522 log.Printf("failed to merge pull request: %s", err)
1523 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1524 return
1525 }
1526
1527 if resp.StatusCode == http.StatusOK {
1528 err := db.MergePull(s.db, f.RepoAt, pull.PullId)
1529 if err != nil {
1530 log.Printf("failed to update pull request status in database: %s", err)
1531 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1532 return
1533 }
1534 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
1535 } else {
1536 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
1537 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1538 }
1539}
1540
1541func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) {
1542 user := s.auth.GetUser(r)
1543
1544 f, err := fullyResolvedRepo(r)
1545 if err != nil {
1546 log.Println("malformed middleware")
1547 return
1548 }
1549
1550 pull, ok := r.Context().Value("pull").(*db.Pull)
1551 if !ok {
1552 log.Println("failed to get pull")
1553 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1554 return
1555 }
1556
1557 // auth filter: only owner or collaborators can close
1558 roles := RolesInRepo(s, user, f)
1559 isCollaborator := roles.IsCollaborator()
1560 isPullAuthor := user.Did == pull.OwnerDid
1561 isCloseAllowed := isCollaborator || isPullAuthor
1562 if !isCloseAllowed {
1563 log.Println("failed to close pull")
1564 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
1565 return
1566 }
1567
1568 // Start a transaction
1569 tx, err := s.db.BeginTx(r.Context(), nil)
1570 if err != nil {
1571 log.Println("failed to start transaction", err)
1572 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1573 return
1574 }
1575
1576 // Close the pull in the database
1577 err = db.ClosePull(tx, f.RepoAt, pull.PullId)
1578 if err != nil {
1579 log.Println("failed to close pull", err)
1580 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1581 return
1582 }
1583
1584 // Commit the transaction
1585 if err = tx.Commit(); err != nil {
1586 log.Println("failed to commit transaction", err)
1587 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1588 return
1589 }
1590
1591 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1592 return
1593}
1594
1595func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) {
1596 user := s.auth.GetUser(r)
1597
1598 f, err := fullyResolvedRepo(r)
1599 if err != nil {
1600 log.Println("failed to resolve repo", err)
1601 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1602 return
1603 }
1604
1605 pull, ok := r.Context().Value("pull").(*db.Pull)
1606 if !ok {
1607 log.Println("failed to get pull")
1608 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1609 return
1610 }
1611
1612 // auth filter: only owner or collaborators can close
1613 roles := RolesInRepo(s, user, f)
1614 isCollaborator := roles.IsCollaborator()
1615 isPullAuthor := user.Did == pull.OwnerDid
1616 isCloseAllowed := isCollaborator || isPullAuthor
1617 if !isCloseAllowed {
1618 log.Println("failed to close pull")
1619 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
1620 return
1621 }
1622
1623 // Start a transaction
1624 tx, err := s.db.BeginTx(r.Context(), nil)
1625 if err != nil {
1626 log.Println("failed to start transaction", err)
1627 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1628 return
1629 }
1630
1631 // Reopen the pull in the database
1632 err = db.ReopenPull(tx, f.RepoAt, pull.PullId)
1633 if err != nil {
1634 log.Println("failed to reopen pull", err)
1635 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1636 return
1637 }
1638
1639 // Commit the transaction
1640 if err = tx.Commit(); err != nil {
1641 log.Println("failed to commit transaction", err)
1642 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1643 return
1644 }
1645
1646 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1647 return
1648}