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 "github.com/go-chi/chi/v5"
16 "tangled.sh/tangled.sh/core/api/tangled"
17 "tangled.sh/tangled.sh/core/appview/auth"
18 "tangled.sh/tangled.sh/core/appview/db"
19 "tangled.sh/tangled.sh/core/appview/pages"
20 "tangled.sh/tangled.sh/core/patchutil"
21 "tangled.sh/tangled.sh/core/types"
22
23 comatproto "github.com/bluesky-social/indigo/api/atproto"
24 "github.com/bluesky-social/indigo/atproto/syntax"
25 lexutil "github.com/bluesky-social/indigo/lex/util"
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) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
311 pull, ok := r.Context().Value("pull").(*db.Pull)
312 if !ok {
313 log.Println("failed to get pull")
314 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
315 return
316 }
317
318 roundId := chi.URLParam(r, "round")
319 roundIdInt, err := strconv.Atoi(roundId)
320 if err != nil || roundIdInt >= len(pull.Submissions) {
321 http.Error(w, "bad round id", http.StatusBadRequest)
322 log.Println("failed to parse round id", err)
323 return
324 }
325
326 identsToResolve := []string{pull.OwnerDid}
327 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
328 didHandleMap := make(map[string]string)
329 for _, identity := range resolvedIds {
330 if !identity.Handle.IsInvalidHandle() {
331 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
332 } else {
333 didHandleMap[identity.DID.String()] = identity.DID.String()
334 }
335 }
336
337 w.Header().Set("Content-Type", "text/plain")
338 w.Write([]byte(pull.Submissions[roundIdInt].Patch))
339}
340
341func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) {
342 user := s.auth.GetUser(r)
343 params := r.URL.Query()
344
345 state := db.PullOpen
346 switch params.Get("state") {
347 case "closed":
348 state = db.PullClosed
349 case "merged":
350 state = db.PullMerged
351 }
352
353 f, err := fullyResolvedRepo(r)
354 if err != nil {
355 log.Println("failed to get repo and knot", err)
356 return
357 }
358
359 pulls, err := db.GetPulls(s.db, f.RepoAt, state)
360 if err != nil {
361 log.Println("failed to get pulls", err)
362 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
363 return
364 }
365
366 for _, p := range pulls {
367 var pullSourceRepo *db.Repo
368 if p.PullSource != nil {
369 if p.PullSource.RepoAt != nil {
370 pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String())
371 if err != nil {
372 log.Printf("failed to get repo by at uri: %v", err)
373 continue
374 } else {
375 p.PullSource.Repo = pullSourceRepo
376 }
377 }
378 }
379 }
380
381 identsToResolve := make([]string, len(pulls))
382 for i, pull := range pulls {
383 identsToResolve[i] = pull.OwnerDid
384 }
385 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
386 didHandleMap := make(map[string]string)
387 for _, identity := range resolvedIds {
388 if !identity.Handle.IsInvalidHandle() {
389 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
390 } else {
391 didHandleMap[identity.DID.String()] = identity.DID.String()
392 }
393 }
394
395 s.pages.RepoPulls(w, pages.RepoPullsParams{
396 LoggedInUser: s.auth.GetUser(r),
397 RepoInfo: f.RepoInfo(s, user),
398 Pulls: pulls,
399 DidHandleMap: didHandleMap,
400 FilteringBy: state,
401 })
402 return
403}
404
405func (s *State) PullComment(w http.ResponseWriter, r *http.Request) {
406 user := s.auth.GetUser(r)
407 f, err := fullyResolvedRepo(r)
408 if err != nil {
409 log.Println("failed to get repo and knot", err)
410 return
411 }
412
413 pull, ok := r.Context().Value("pull").(*db.Pull)
414 if !ok {
415 log.Println("failed to get pull")
416 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
417 return
418 }
419
420 roundNumberStr := chi.URLParam(r, "round")
421 roundNumber, err := strconv.Atoi(roundNumberStr)
422 if err != nil || roundNumber >= len(pull.Submissions) {
423 http.Error(w, "bad round id", http.StatusBadRequest)
424 log.Println("failed to parse round id", err)
425 return
426 }
427
428 switch r.Method {
429 case http.MethodGet:
430 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{
431 LoggedInUser: user,
432 RepoInfo: f.RepoInfo(s, user),
433 Pull: pull,
434 RoundNumber: roundNumber,
435 })
436 return
437 case http.MethodPost:
438 body := r.FormValue("body")
439 if body == "" {
440 s.pages.Notice(w, "pull", "Comment body is required")
441 return
442 }
443
444 // Start a transaction
445 tx, err := s.db.BeginTx(r.Context(), nil)
446 if err != nil {
447 log.Println("failed to start transaction", err)
448 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
449 return
450 }
451 defer tx.Rollback()
452
453 createdAt := time.Now().Format(time.RFC3339)
454 ownerDid := user.Did
455
456 pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId)
457 if err != nil {
458 log.Println("failed to get pull at", err)
459 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
460 return
461 }
462
463 atUri := f.RepoAt.String()
464 client, _ := s.auth.AuthorizedClient(r)
465 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
466 Collection: tangled.RepoPullCommentNSID,
467 Repo: user.Did,
468 Rkey: s.TID(),
469 Record: &lexutil.LexiconTypeDecoder{
470 Val: &tangled.RepoPullComment{
471 Repo: &atUri,
472 Pull: pullAt,
473 Owner: &ownerDid,
474 Body: &body,
475 CreatedAt: &createdAt,
476 },
477 },
478 })
479 if err != nil {
480 log.Println("failed to create pull comment", err)
481 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
482 return
483 }
484
485 // Create the pull comment in the database with the commentAt field
486 commentId, err := db.NewPullComment(tx, &db.PullComment{
487 OwnerDid: user.Did,
488 RepoAt: f.RepoAt.String(),
489 PullId: pull.PullId,
490 Body: body,
491 CommentAt: atResp.Uri,
492 SubmissionId: pull.Submissions[roundNumber].ID,
493 })
494 if err != nil {
495 log.Println("failed to create pull comment", err)
496 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
497 return
498 }
499
500 // Commit the transaction
501 if err = tx.Commit(); err != nil {
502 log.Println("failed to commit transaction", err)
503 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
504 return
505 }
506
507 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
508 return
509 }
510}
511
512func (s *State) NewPull(w http.ResponseWriter, r *http.Request) {
513 user := s.auth.GetUser(r)
514 f, err := fullyResolvedRepo(r)
515 if err != nil {
516 log.Println("failed to get repo and knot", err)
517 return
518 }
519
520 switch r.Method {
521 case http.MethodGet:
522 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
523 if err != nil {
524 log.Printf("failed to create unsigned client for %s", f.Knot)
525 s.pages.Error503(w)
526 return
527 }
528
529 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
530 if err != nil {
531 log.Println("failed to reach knotserver", err)
532 return
533 }
534
535 body, err := io.ReadAll(resp.Body)
536 if err != nil {
537 log.Printf("Error reading response body: %v", err)
538 return
539 }
540
541 var result types.RepoBranchesResponse
542 err = json.Unmarshal(body, &result)
543 if err != nil {
544 log.Println("failed to parse response:", err)
545 return
546 }
547
548 s.pages.RepoNewPull(w, pages.RepoNewPullParams{
549 LoggedInUser: user,
550 RepoInfo: f.RepoInfo(s, user),
551 Branches: result.Branches,
552 })
553 case http.MethodPost:
554 title := r.FormValue("title")
555 body := r.FormValue("body")
556 targetBranch := r.FormValue("targetBranch")
557 fromFork := r.FormValue("fork")
558 sourceBranch := r.FormValue("sourceBranch")
559 patch := r.FormValue("patch")
560
561 // Validate required fields for all PR types
562 if title == "" || body == "" || targetBranch == "" {
563 s.pages.Notice(w, "pull", "Title, body and target branch are required.")
564 return
565 }
566
567 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
568 if err != nil {
569 log.Println("failed to create unsigned client to %s: %v", f.Knot, err)
570 s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
571 return
572 }
573
574 caps, err := us.Capabilities()
575 if err != nil {
576 log.Println("error fetching knot caps", f.Knot, err)
577 s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.")
578 return
579 }
580
581 // Determine PR type based on input parameters
582 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed()
583 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == ""
584 isForkBased := fromFork != "" && sourceBranch != ""
585 isPatchBased := patch != "" && !isBranchBased && !isForkBased
586
587 // Validate we have at least one valid PR creation method
588 if !isBranchBased && !isPatchBased && !isForkBased {
589 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.")
590 return
591 }
592
593 // Can't mix branch-based and patch-based approaches
594 if isBranchBased && patch != "" {
595 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.")
596 return
597 }
598
599 // Handle the PR creation based on the type
600 if isBranchBased {
601 if !caps.PullRequests.BranchSubmissions {
602 s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?")
603 return
604 }
605 s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch)
606 } else if isForkBased {
607 if !caps.PullRequests.ForkSubmissions {
608 s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?")
609 return
610 }
611 s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch)
612 } else if isPatchBased {
613 if !caps.PullRequests.PatchSubmissions {
614 s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.")
615 return
616 }
617 s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch)
618 }
619 return
620 }
621}
622
623func (s *State) handleBranchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, sourceBranch string) {
624 pullSource := &db.PullSource{
625 Branch: sourceBranch,
626 }
627 recordPullSource := &tangled.RepoPull_Source{
628 Branch: sourceBranch,
629 }
630
631 // Generate a patch using /compare
632 ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
633 if err != nil {
634 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
635 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
636 return
637 }
638
639 comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
640 if err != nil {
641 log.Println("failed to compare", err)
642 s.pages.Notice(w, "pull", err.Error())
643 return
644 }
645
646 sourceRev := comparison.Rev2
647 patch := comparison.Patch
648
649 if !patchutil.IsPatchValid(patch) {
650 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
651 return
652 }
653
654 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource)
655}
656
657func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, patch string) {
658 if !patchutil.IsPatchValid(patch) {
659 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
660 return
661 }
662
663 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil)
664}
665
666func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, forkRepo string, title, body, targetBranch, sourceBranch string) {
667 fork, err := db.GetForkByDid(s.db, user.Did, forkRepo)
668 if errors.Is(err, sql.ErrNoRows) {
669 s.pages.Notice(w, "pull", "No such fork.")
670 return
671 } else if err != nil {
672 log.Println("failed to fetch fork:", err)
673 s.pages.Notice(w, "pull", "Failed to fetch fork.")
674 return
675 }
676
677 secret, err := db.GetRegistrationKey(s.db, fork.Knot)
678 if err != nil {
679 log.Println("failed to fetch registration key:", err)
680 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
681 return
682 }
683
684 sc, err := NewSignedClient(fork.Knot, secret, s.config.Dev)
685 if err != nil {
686 log.Println("failed to create signed client:", err)
687 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
688 return
689 }
690
691 us, err := NewUnsignedClient(fork.Knot, s.config.Dev)
692 if err != nil {
693 log.Println("failed to create unsigned client:", err)
694 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
695 return
696 }
697
698 resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch)
699 if err != nil {
700 log.Println("failed to create hidden ref:", err, resp.StatusCode)
701 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
702 return
703 }
704
705 switch resp.StatusCode {
706 case 404:
707 case 400:
708 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
709 return
710 }
711
712 hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch))
713 // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking
714 // the targetBranch on the target repository. This code is a bit confusing, but here's an example:
715 // hiddenRef: hidden/feature-1/main (on repo-fork)
716 // targetBranch: main (on repo-1)
717 // sourceBranch: feature-1 (on repo-fork)
718 comparison, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch)
719 if err != nil {
720 log.Println("failed to compare across branches", 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 forkAtUri, err := syntax.ParseATURI(fork.AtUri)
734 if err != nil {
735 log.Println("failed to parse fork AT URI", err)
736 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
737 return
738 }
739
740 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{
741 Branch: sourceBranch,
742 RepoAt: &forkAtUri,
743 }, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri})
744}
745
746func (s *State) createPullRequest(
747 w http.ResponseWriter,
748 r *http.Request,
749 f *FullyResolvedRepo,
750 user *auth.User,
751 title, body, targetBranch string,
752 patch string,
753 sourceRev string,
754 pullSource *db.PullSource,
755 recordPullSource *tangled.RepoPull_Source,
756) {
757 tx, err := s.db.BeginTx(r.Context(), nil)
758 if err != nil {
759 log.Println("failed to start tx")
760 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
761 return
762 }
763 defer tx.Rollback()
764
765 rkey := s.TID()
766 initialSubmission := db.PullSubmission{
767 Patch: patch,
768 SourceRev: sourceRev,
769 }
770 err = db.NewPull(tx, &db.Pull{
771 Title: title,
772 Body: body,
773 TargetBranch: targetBranch,
774 OwnerDid: user.Did,
775 RepoAt: f.RepoAt,
776 Rkey: rkey,
777 Submissions: []*db.PullSubmission{
778 &initialSubmission,
779 },
780 PullSource: pullSource,
781 })
782 if err != nil {
783 log.Println("failed to create pull request", err)
784 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
785 return
786 }
787 client, _ := s.auth.AuthorizedClient(r)
788 pullId, err := db.NextPullId(s.db, f.RepoAt)
789 if err != nil {
790 log.Println("failed to get pull id", err)
791 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
792 return
793 }
794
795 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
796 Collection: tangled.RepoPullNSID,
797 Repo: user.Did,
798 Rkey: rkey,
799 Record: &lexutil.LexiconTypeDecoder{
800 Val: &tangled.RepoPull{
801 Title: title,
802 PullId: int64(pullId),
803 TargetRepo: string(f.RepoAt),
804 TargetBranch: targetBranch,
805 Patch: patch,
806 Source: recordPullSource,
807 },
808 },
809 })
810
811 err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri)
812 if err != nil {
813 log.Println("failed to get pull id", err)
814 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
815 return
816 }
817
818 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
819}
820
821func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
822 user := s.auth.GetUser(r)
823 f, err := fullyResolvedRepo(r)
824 if err != nil {
825 log.Println("failed to get repo and knot", err)
826 return
827 }
828
829 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
830 RepoInfo: f.RepoInfo(s, user),
831 })
832}
833
834func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) {
835 user := s.auth.GetUser(r)
836 f, err := fullyResolvedRepo(r)
837 if err != nil {
838 log.Println("failed to get repo and knot", err)
839 return
840 }
841
842 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
843 if err != nil {
844 log.Printf("failed to create unsigned client for %s", f.Knot)
845 s.pages.Error503(w)
846 return
847 }
848
849 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
850 if err != nil {
851 log.Println("failed to reach knotserver", err)
852 return
853 }
854
855 body, err := io.ReadAll(resp.Body)
856 if err != nil {
857 log.Printf("Error reading response body: %v", err)
858 return
859 }
860
861 var result types.RepoBranchesResponse
862 err = json.Unmarshal(body, &result)
863 if err != nil {
864 log.Println("failed to parse response:", err)
865 return
866 }
867
868 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
869 RepoInfo: f.RepoInfo(s, user),
870 Branches: result.Branches,
871 })
872}
873
874func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) {
875 user := s.auth.GetUser(r)
876 f, err := fullyResolvedRepo(r)
877 if err != nil {
878 log.Println("failed to get repo and knot", err)
879 return
880 }
881
882 forks, err := db.GetForksByDid(s.db, user.Did)
883 if err != nil {
884 log.Println("failed to get forks", err)
885 return
886 }
887
888 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{
889 RepoInfo: f.RepoInfo(s, user),
890 Forks: forks,
891 })
892}
893
894func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) {
895 user := s.auth.GetUser(r)
896
897 f, err := fullyResolvedRepo(r)
898 if err != nil {
899 log.Println("failed to get repo and knot", err)
900 return
901 }
902
903 forkVal := r.URL.Query().Get("fork")
904
905 // fork repo
906 repo, err := db.GetRepo(s.db, user.Did, forkVal)
907 if err != nil {
908 log.Println("failed to get repo", user.Did, forkVal)
909 return
910 }
911
912 sourceBranchesClient, err := NewUnsignedClient(repo.Knot, s.config.Dev)
913 if err != nil {
914 log.Printf("failed to create unsigned client for %s", repo.Knot)
915 s.pages.Error503(w)
916 return
917 }
918
919 sourceResp, err := sourceBranchesClient.Branches(user.Did, repo.Name)
920 if err != nil {
921 log.Println("failed to reach knotserver for source branches", err)
922 return
923 }
924
925 sourceBody, err := io.ReadAll(sourceResp.Body)
926 if err != nil {
927 log.Println("failed to read source response body", err)
928 return
929 }
930 defer sourceResp.Body.Close()
931
932 var sourceResult types.RepoBranchesResponse
933 err = json.Unmarshal(sourceBody, &sourceResult)
934 if err != nil {
935 log.Println("failed to parse source branches response:", err)
936 return
937 }
938
939 targetBranchesClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
940 if err != nil {
941 log.Printf("failed to create unsigned client for target knot %s", f.Knot)
942 s.pages.Error503(w)
943 return
944 }
945
946 targetResp, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName)
947 if err != nil {
948 log.Println("failed to reach knotserver for target branches", err)
949 return
950 }
951
952 targetBody, err := io.ReadAll(targetResp.Body)
953 if err != nil {
954 log.Println("failed to read target response body", err)
955 return
956 }
957 defer targetResp.Body.Close()
958
959 var targetResult types.RepoBranchesResponse
960 err = json.Unmarshal(targetBody, &targetResult)
961 if err != nil {
962 log.Println("failed to parse target branches response:", err)
963 return
964 }
965
966 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{
967 RepoInfo: f.RepoInfo(s, user),
968 SourceBranches: sourceResult.Branches,
969 TargetBranches: targetResult.Branches,
970 })
971}
972
973func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) {
974 user := s.auth.GetUser(r)
975 f, err := fullyResolvedRepo(r)
976 if err != nil {
977 log.Println("failed to get repo and knot", err)
978 return
979 }
980
981 pull, ok := r.Context().Value("pull").(*db.Pull)
982 if !ok {
983 log.Println("failed to get pull")
984 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
985 return
986 }
987
988 switch r.Method {
989 case http.MethodGet:
990 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
991 RepoInfo: f.RepoInfo(s, user),
992 Pull: pull,
993 })
994 return
995 case http.MethodPost:
996 if pull.IsPatchBased() {
997 s.resubmitPatch(w, r)
998 return
999 } else if pull.IsBranchBased() {
1000 s.resubmitBranch(w, r)
1001 return
1002 } else if pull.IsForkBased() {
1003 s.resubmitFork(w, r)
1004 return
1005 }
1006 }
1007}
1008
1009func (s *State) resubmitPatch(w http.ResponseWriter, r *http.Request) {
1010 user := s.auth.GetUser(r)
1011
1012 pull, ok := r.Context().Value("pull").(*db.Pull)
1013 if !ok {
1014 log.Println("failed to get pull")
1015 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1016 return
1017 }
1018
1019 f, err := fullyResolvedRepo(r)
1020 if err != nil {
1021 log.Println("failed to get repo and knot", err)
1022 return
1023 }
1024
1025 if user.Did != pull.OwnerDid {
1026 log.Println("unauthorized user")
1027 w.WriteHeader(http.StatusUnauthorized)
1028 return
1029 }
1030
1031 patch := r.FormValue("patch")
1032
1033 if err = validateResubmittedPatch(pull, patch); err != nil {
1034 s.pages.Notice(w, "resubmit-error", err.Error())
1035 }
1036
1037 tx, err := s.db.BeginTx(r.Context(), nil)
1038 if err != nil {
1039 log.Println("failed to start tx")
1040 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1041 return
1042 }
1043 defer tx.Rollback()
1044
1045 err = db.ResubmitPull(tx, pull, patch, "")
1046 if err != nil {
1047 log.Println("failed to resubmit pull request", err)
1048 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull request. Try again later.")
1049 return
1050 }
1051 client, _ := s.auth.AuthorizedClient(r)
1052
1053 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1054 if err != nil {
1055 // failed to get record
1056 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1057 return
1058 }
1059
1060 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1061 Collection: tangled.RepoPullNSID,
1062 Repo: user.Did,
1063 Rkey: pull.Rkey,
1064 SwapRecord: ex.Cid,
1065 Record: &lexutil.LexiconTypeDecoder{
1066 Val: &tangled.RepoPull{
1067 Title: pull.Title,
1068 PullId: int64(pull.PullId),
1069 TargetRepo: string(f.RepoAt),
1070 TargetBranch: pull.TargetBranch,
1071 Patch: patch, // new patch
1072 },
1073 },
1074 })
1075 if err != nil {
1076 log.Println("failed to update record", err)
1077 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1078 return
1079 }
1080
1081 if err = tx.Commit(); err != nil {
1082 log.Println("failed to commit transaction", err)
1083 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1084 return
1085 }
1086
1087 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1088 return
1089}
1090
1091func (s *State) resubmitBranch(w http.ResponseWriter, r *http.Request) {
1092 user := s.auth.GetUser(r)
1093
1094 pull, ok := r.Context().Value("pull").(*db.Pull)
1095 if !ok {
1096 log.Println("failed to get pull")
1097 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1098 return
1099 }
1100
1101 f, err := fullyResolvedRepo(r)
1102 if err != nil {
1103 log.Println("failed to get repo and knot", err)
1104 return
1105 }
1106
1107 if user.Did != pull.OwnerDid {
1108 log.Println("unauthorized user")
1109 w.WriteHeader(http.StatusUnauthorized)
1110 return
1111 }
1112
1113 if !f.RepoInfo(s, user).Roles.IsPushAllowed() {
1114 log.Println("unauthorized user")
1115 w.WriteHeader(http.StatusUnauthorized)
1116 return
1117 }
1118
1119 ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
1120 if err != nil {
1121 log.Printf("failed to create client for %s: %s", f.Knot, err)
1122 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1123 return
1124 }
1125
1126 comparison, err := ksClient.Compare(f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.PullSource.Branch)
1127 if err != nil {
1128 log.Printf("compare request failed: %s", err)
1129 s.pages.Notice(w, "resubmit-error", err.Error())
1130 return
1131 }
1132
1133 sourceRev := comparison.Rev2
1134 patch := comparison.Patch
1135
1136 if err = validateResubmittedPatch(pull, patch); err != nil {
1137 s.pages.Notice(w, "resubmit-error", err.Error())
1138 }
1139
1140 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1141 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1142 return
1143 }
1144
1145 tx, err := s.db.BeginTx(r.Context(), nil)
1146 if err != nil {
1147 log.Println("failed to start tx")
1148 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1149 return
1150 }
1151 defer tx.Rollback()
1152
1153 err = db.ResubmitPull(tx, pull, patch, sourceRev)
1154 if err != nil {
1155 log.Println("failed to create pull request", err)
1156 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1157 return
1158 }
1159 client, _ := s.auth.AuthorizedClient(r)
1160
1161 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1162 if err != nil {
1163 // failed to get record
1164 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1165 return
1166 }
1167
1168 recordPullSource := &tangled.RepoPull_Source{
1169 Branch: pull.PullSource.Branch,
1170 }
1171 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1172 Collection: tangled.RepoPullNSID,
1173 Repo: user.Did,
1174 Rkey: pull.Rkey,
1175 SwapRecord: ex.Cid,
1176 Record: &lexutil.LexiconTypeDecoder{
1177 Val: &tangled.RepoPull{
1178 Title: pull.Title,
1179 PullId: int64(pull.PullId),
1180 TargetRepo: string(f.RepoAt),
1181 TargetBranch: pull.TargetBranch,
1182 Patch: patch, // new patch
1183 Source: recordPullSource,
1184 },
1185 },
1186 })
1187 if err != nil {
1188 log.Println("failed to update record", err)
1189 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1190 return
1191 }
1192
1193 if err = tx.Commit(); err != nil {
1194 log.Println("failed to commit transaction", err)
1195 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1196 return
1197 }
1198
1199 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1200 return
1201}
1202
1203func (s *State) resubmitFork(w http.ResponseWriter, r *http.Request) {
1204 user := s.auth.GetUser(r)
1205
1206 pull, ok := r.Context().Value("pull").(*db.Pull)
1207 if !ok {
1208 log.Println("failed to get pull")
1209 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.")
1210 return
1211 }
1212
1213 f, err := fullyResolvedRepo(r)
1214 if err != nil {
1215 log.Println("failed to get repo and knot", err)
1216 return
1217 }
1218
1219 if user.Did != pull.OwnerDid {
1220 log.Println("unauthorized user")
1221 w.WriteHeader(http.StatusUnauthorized)
1222 return
1223 }
1224
1225 forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String())
1226 if err != nil {
1227 log.Println("failed to get source repo", err)
1228 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1229 return
1230 }
1231
1232 // extract patch by performing compare
1233 ksClient, err := NewUnsignedClient(forkRepo.Knot, s.config.Dev)
1234 if err != nil {
1235 log.Printf("failed to create client for %s: %s", forkRepo.Knot, err)
1236 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1237 return
1238 }
1239
1240 secret, err := db.GetRegistrationKey(s.db, forkRepo.Knot)
1241 if err != nil {
1242 log.Printf("failed to get registration key for %s: %s", forkRepo.Knot, err)
1243 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1244 return
1245 }
1246
1247 // update the hidden tracking branch to latest
1248 signedClient, err := NewSignedClient(forkRepo.Knot, secret, s.config.Dev)
1249 if err != nil {
1250 log.Printf("failed to create signed client for %s: %s", forkRepo.Knot, err)
1251 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1252 return
1253 }
1254
1255 resp, err := signedClient.NewHiddenRef(forkRepo.Did, forkRepo.Name, pull.PullSource.Branch, pull.TargetBranch)
1256 if err != nil || resp.StatusCode != http.StatusNoContent {
1257 log.Printf("failed to update tracking branch: %s", err)
1258 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1259 return
1260 }
1261
1262 hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch))
1263 comparison, err := ksClient.Compare(forkRepo.Did, forkRepo.Name, hiddenRef, pull.PullSource.Branch)
1264 if err != nil {
1265 log.Printf("failed to compare branches: %s", err)
1266 s.pages.Notice(w, "resubmit-error", err.Error())
1267 return
1268 }
1269
1270 sourceRev := comparison.Rev2
1271 patch := comparison.Patch
1272
1273 if err = validateResubmittedPatch(pull, patch); err != nil {
1274 s.pages.Notice(w, "resubmit-error", err.Error())
1275 }
1276
1277 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
1278 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
1279 return
1280 }
1281
1282 tx, err := s.db.BeginTx(r.Context(), nil)
1283 if err != nil {
1284 log.Println("failed to start tx")
1285 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1286 return
1287 }
1288 defer tx.Rollback()
1289
1290 err = db.ResubmitPull(tx, pull, patch, sourceRev)
1291 if err != nil {
1292 log.Println("failed to create pull request", err)
1293 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
1294 return
1295 }
1296 client, _ := s.auth.AuthorizedClient(r)
1297
1298 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
1299 if err != nil {
1300 // failed to get record
1301 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
1302 return
1303 }
1304
1305 repoAt := pull.PullSource.RepoAt.String()
1306 recordPullSource := &tangled.RepoPull_Source{
1307 Branch: pull.PullSource.Branch,
1308 Repo: &repoAt,
1309 }
1310 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
1311 Collection: tangled.RepoPullNSID,
1312 Repo: user.Did,
1313 Rkey: pull.Rkey,
1314 SwapRecord: ex.Cid,
1315 Record: &lexutil.LexiconTypeDecoder{
1316 Val: &tangled.RepoPull{
1317 Title: pull.Title,
1318 PullId: int64(pull.PullId),
1319 TargetRepo: string(f.RepoAt),
1320 TargetBranch: pull.TargetBranch,
1321 Patch: patch, // new patch
1322 Source: recordPullSource,
1323 },
1324 },
1325 })
1326 if err != nil {
1327 log.Println("failed to update record", err)
1328 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
1329 return
1330 }
1331
1332 if err = tx.Commit(); err != nil {
1333 log.Println("failed to commit transaction", err)
1334 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
1335 return
1336 }
1337
1338 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1339 return
1340}
1341
1342// validate a resubmission against a pull request
1343func validateResubmittedPatch(pull *db.Pull, patch string) error {
1344 if patch == "" {
1345 return fmt.Errorf("Patch is empty.")
1346 }
1347
1348 if patch == pull.LatestPatch() {
1349 return fmt.Errorf("Patch is identical to previous submission.")
1350 }
1351
1352 if patchutil.IsPatchValid(patch) {
1353 return fmt.Errorf("Invalid patch format. Please provide a valid diff.")
1354 }
1355
1356 return nil
1357}
1358
1359func (s *State) MergePull(w http.ResponseWriter, r *http.Request) {
1360 f, err := fullyResolvedRepo(r)
1361 if err != nil {
1362 log.Println("failed to resolve repo:", err)
1363 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1364 return
1365 }
1366
1367 pull, ok := r.Context().Value("pull").(*db.Pull)
1368 if !ok {
1369 log.Println("failed to get pull")
1370 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1371 return
1372 }
1373
1374 secret, err := db.GetRegistrationKey(s.db, f.Knot)
1375 if err != nil {
1376 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
1377 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1378 return
1379 }
1380
1381 ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid)
1382 if err != nil {
1383 log.Printf("resolving identity: %s", err)
1384 w.WriteHeader(http.StatusNotFound)
1385 return
1386 }
1387
1388 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
1389 if err != nil {
1390 log.Printf("failed to get primary email: %s", err)
1391 }
1392
1393 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
1394 if err != nil {
1395 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
1396 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1397 return
1398 }
1399
1400 // Merge the pull request
1401 resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
1402 if err != nil {
1403 log.Printf("failed to merge pull request: %s", err)
1404 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1405 return
1406 }
1407
1408 if resp.StatusCode == http.StatusOK {
1409 err := db.MergePull(s.db, f.RepoAt, pull.PullId)
1410 if err != nil {
1411 log.Printf("failed to update pull request status in database: %s", err)
1412 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1413 return
1414 }
1415 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
1416 } else {
1417 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
1418 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
1419 }
1420}
1421
1422func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) {
1423 user := s.auth.GetUser(r)
1424
1425 f, err := fullyResolvedRepo(r)
1426 if err != nil {
1427 log.Println("malformed middleware")
1428 return
1429 }
1430
1431 pull, ok := r.Context().Value("pull").(*db.Pull)
1432 if !ok {
1433 log.Println("failed to get pull")
1434 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
1435 return
1436 }
1437
1438 // auth filter: only owner or collaborators can close
1439 roles := RolesInRepo(s, user, f)
1440 isCollaborator := roles.IsCollaborator()
1441 isPullAuthor := user.Did == pull.OwnerDid
1442 isCloseAllowed := isCollaborator || isPullAuthor
1443 if !isCloseAllowed {
1444 log.Println("failed to close pull")
1445 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
1446 return
1447 }
1448
1449 // Start a transaction
1450 tx, err := s.db.BeginTx(r.Context(), nil)
1451 if err != nil {
1452 log.Println("failed to start transaction", err)
1453 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1454 return
1455 }
1456
1457 // Close the pull in the database
1458 err = db.ClosePull(tx, f.RepoAt, pull.PullId)
1459 if err != nil {
1460 log.Println("failed to close pull", err)
1461 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1462 return
1463 }
1464
1465 // Commit the transaction
1466 if err = tx.Commit(); err != nil {
1467 log.Println("failed to commit transaction", err)
1468 s.pages.Notice(w, "pull-close", "Failed to close pull.")
1469 return
1470 }
1471
1472 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1473 return
1474}
1475
1476func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) {
1477 user := s.auth.GetUser(r)
1478
1479 f, err := fullyResolvedRepo(r)
1480 if err != nil {
1481 log.Println("failed to resolve repo", err)
1482 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
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 // auth filter: only owner or collaborators can close
1494 roles := RolesInRepo(s, user, f)
1495 isCollaborator := roles.IsCollaborator()
1496 isPullAuthor := user.Did == pull.OwnerDid
1497 isCloseAllowed := isCollaborator || isPullAuthor
1498 if !isCloseAllowed {
1499 log.Println("failed to close pull")
1500 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
1501 return
1502 }
1503
1504 // Start a transaction
1505 tx, err := s.db.BeginTx(r.Context(), nil)
1506 if err != nil {
1507 log.Println("failed to start transaction", err)
1508 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1509 return
1510 }
1511
1512 // Reopen the pull in the database
1513 err = db.ReopenPull(tx, f.RepoAt, pull.PullId)
1514 if err != nil {
1515 log.Println("failed to reopen pull", err)
1516 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1517 return
1518 }
1519
1520 // Commit the transaction
1521 if err = tx.Commit(); err != nil {
1522 log.Println("failed to commit transaction", err)
1523 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1524 return
1525 }
1526
1527 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1528 return
1529}