1package state
2
3import (
4 "encoding/json"
5 "fmt"
6 "io"
7 "log"
8 "net/http"
9 "strconv"
10 "strings"
11 "time"
12
13 "github.com/go-chi/chi/v5"
14 "tangled.sh/tangled.sh/core/api/tangled"
15 "tangled.sh/tangled.sh/core/appview/db"
16 "tangled.sh/tangled.sh/core/appview/pages"
17 "tangled.sh/tangled.sh/core/types"
18
19 comatproto "github.com/bluesky-social/indigo/api/atproto"
20 lexutil "github.com/bluesky-social/indigo/lex/util"
21)
22
23// htmx fragment
24func (s *State) PullActions(w http.ResponseWriter, r *http.Request) {
25 switch r.Method {
26 case http.MethodGet:
27 user := s.auth.GetUser(r)
28 f, err := fullyResolvedRepo(r)
29 if err != nil {
30 log.Println("failed to get repo and knot", err)
31 return
32 }
33
34 pull, ok := r.Context().Value("pull").(*db.Pull)
35 if !ok {
36 log.Println("failed to get pull")
37 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
38 return
39 }
40
41 roundNumberStr := chi.URLParam(r, "round")
42 roundNumber, err := strconv.Atoi(roundNumberStr)
43 if err != nil {
44 roundNumber = pull.LastRoundNumber()
45 }
46 if roundNumber >= len(pull.Submissions) {
47 http.Error(w, "bad round id", http.StatusBadRequest)
48 log.Println("failed to parse round id", err)
49 return
50 }
51
52 mergeCheckResponse := s.mergeCheck(f, pull)
53 var resubmitResult pages.ResubmitResult
54 if user.Did == pull.OwnerDid {
55 resubmitResult = s.resubmitCheck(f, pull)
56 }
57
58 s.pages.PullActionsFragment(w, pages.PullActionsParams{
59 LoggedInUser: user,
60 RepoInfo: f.RepoInfo(s, user),
61 Pull: pull,
62 RoundNumber: roundNumber,
63 MergeCheck: mergeCheckResponse,
64 ResubmitCheck: resubmitResult,
65 })
66 return
67 }
68}
69
70func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) {
71 user := s.auth.GetUser(r)
72 f, err := fullyResolvedRepo(r)
73 if err != nil {
74 log.Println("failed to get repo and knot", err)
75 return
76 }
77
78 pull, ok := r.Context().Value("pull").(*db.Pull)
79 if !ok {
80 log.Println("failed to get pull")
81 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
82 return
83 }
84
85 totalIdents := 1
86 for _, submission := range pull.Submissions {
87 totalIdents += len(submission.Comments)
88 }
89
90 identsToResolve := make([]string, totalIdents)
91
92 // populate idents
93 identsToResolve[0] = pull.OwnerDid
94 idx := 1
95 for _, submission := range pull.Submissions {
96 for _, comment := range submission.Comments {
97 identsToResolve[idx] = comment.OwnerDid
98 idx += 1
99 }
100 }
101
102 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
103 didHandleMap := make(map[string]string)
104 for _, identity := range resolvedIds {
105 if !identity.Handle.IsInvalidHandle() {
106 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
107 } else {
108 didHandleMap[identity.DID.String()] = identity.DID.String()
109 }
110 }
111
112 mergeCheckResponse := s.mergeCheck(f, pull)
113 var resubmitResult pages.ResubmitResult
114 if user.Did == pull.OwnerDid {
115 resubmitResult = s.resubmitCheck(f, pull)
116 }
117
118 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{
119 LoggedInUser: user,
120 RepoInfo: f.RepoInfo(s, user),
121 DidHandleMap: didHandleMap,
122 Pull: pull,
123 MergeCheck: mergeCheckResponse,
124 ResubmitCheck: resubmitResult,
125 })
126}
127
128func (s *State) mergeCheck(f *FullyResolvedRepo, pull *db.Pull) types.MergeCheckResponse {
129 if pull.State == db.PullMerged {
130 return types.MergeCheckResponse{}
131 }
132
133 secret, err := db.GetRegistrationKey(s.db, f.Knot)
134 if err != nil {
135 log.Printf("failed to get registration key: %v", err)
136 return types.MergeCheckResponse{
137 Error: "failed to check merge status: this knot is unregistered",
138 }
139 }
140
141 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
142 if err != nil {
143 log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err)
144 return types.MergeCheckResponse{
145 Error: "failed to check merge status",
146 }
147 }
148
149 resp, err := ksClient.MergeCheck([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch)
150 if err != nil {
151 log.Println("failed to check for mergeability:", err)
152 return types.MergeCheckResponse{
153 Error: "failed to check merge status",
154 }
155 }
156 switch resp.StatusCode {
157 case 404:
158 return types.MergeCheckResponse{
159 Error: "failed to check merge status: this knot does not support PRs",
160 }
161 case 400:
162 return types.MergeCheckResponse{
163 Error: "failed to check merge status: does this knot support PRs?",
164 }
165 }
166
167 respBody, err := io.ReadAll(resp.Body)
168 if err != nil {
169 log.Println("failed to read merge check response body")
170 return types.MergeCheckResponse{
171 Error: "failed to check merge status: knot is not speaking the right language",
172 }
173 }
174 defer resp.Body.Close()
175
176 var mergeCheckResponse types.MergeCheckResponse
177 err = json.Unmarshal(respBody, &mergeCheckResponse)
178 if err != nil {
179 log.Println("failed to unmarshal merge check response", err)
180 return types.MergeCheckResponse{
181 Error: "failed to check merge status: knot is not speaking the right language",
182 }
183 }
184
185 return mergeCheckResponse
186}
187
188func (s *State) resubmitCheck(f *FullyResolvedRepo, pull *db.Pull) pages.ResubmitResult {
189 if pull.State == db.PullMerged {
190 return pages.Unknown
191 }
192
193 if pull.PullSource == nil {
194 return pages.Unknown
195 }
196
197 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
198 if err != nil {
199 log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err)
200 return pages.Unknown
201 }
202
203 resp, err := us.Branch(f.OwnerDid(), f.RepoName, pull.PullSource.Branch)
204 if err != nil {
205 log.Println("failed to reach knotserver", err)
206 return pages.Unknown
207 }
208
209 body, err := io.ReadAll(resp.Body)
210 if err != nil {
211 log.Printf("Error reading response body: %v", err)
212 return pages.Unknown
213 }
214
215 var result types.RepoBranchResponse
216 err = json.Unmarshal(body, &result)
217 if err != nil {
218 log.Println("failed to parse response:", err)
219 return pages.Unknown
220 }
221
222 if pull.Submissions[pull.LastRoundNumber()].SourceRev != result.Branch.Hash {
223 log.Println(pull.Submissions[pull.LastRoundNumber()].SourceRev, result.Branch.Hash)
224 return pages.ShouldResubmit
225 } else {
226 return pages.ShouldNotResubmit
227 }
228}
229
230func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) {
231 user := s.auth.GetUser(r)
232 f, err := fullyResolvedRepo(r)
233 if err != nil {
234 log.Println("failed to get repo and knot", err)
235 return
236 }
237
238 pull, ok := r.Context().Value("pull").(*db.Pull)
239 if !ok {
240 log.Println("failed to get pull")
241 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
242 return
243 }
244
245 roundId := chi.URLParam(r, "round")
246 roundIdInt, err := strconv.Atoi(roundId)
247 if err != nil || roundIdInt >= len(pull.Submissions) {
248 http.Error(w, "bad round id", http.StatusBadRequest)
249 log.Println("failed to parse round id", err)
250 return
251 }
252
253 identsToResolve := []string{pull.OwnerDid}
254 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
255 didHandleMap := make(map[string]string)
256 for _, identity := range resolvedIds {
257 if !identity.Handle.IsInvalidHandle() {
258 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
259 } else {
260 didHandleMap[identity.DID.String()] = identity.DID.String()
261 }
262 }
263
264 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{
265 LoggedInUser: user,
266 DidHandleMap: didHandleMap,
267 RepoInfo: f.RepoInfo(s, user),
268 Pull: pull,
269 Round: roundIdInt,
270 Submission: pull.Submissions[roundIdInt],
271 Diff: pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch),
272 })
273
274}
275
276func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) {
277 pull, ok := r.Context().Value("pull").(*db.Pull)
278 if !ok {
279 log.Println("failed to get pull")
280 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
281 return
282 }
283
284 roundId := chi.URLParam(r, "round")
285 roundIdInt, err := strconv.Atoi(roundId)
286 if err != nil || roundIdInt >= len(pull.Submissions) {
287 http.Error(w, "bad round id", http.StatusBadRequest)
288 log.Println("failed to parse round id", err)
289 return
290 }
291
292 identsToResolve := []string{pull.OwnerDid}
293 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
294 didHandleMap := make(map[string]string)
295 for _, identity := range resolvedIds {
296 if !identity.Handle.IsInvalidHandle() {
297 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
298 } else {
299 didHandleMap[identity.DID.String()] = identity.DID.String()
300 }
301 }
302
303 w.Header().Set("Content-Type", "text/plain")
304 w.Write([]byte(pull.Submissions[roundIdInt].Patch))
305}
306
307func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) {
308 user := s.auth.GetUser(r)
309 params := r.URL.Query()
310
311 state := db.PullOpen
312 switch params.Get("state") {
313 case "closed":
314 state = db.PullClosed
315 case "merged":
316 state = db.PullMerged
317 }
318
319 f, err := fullyResolvedRepo(r)
320 if err != nil {
321 log.Println("failed to get repo and knot", err)
322 return
323 }
324
325 pulls, err := db.GetPulls(s.db, f.RepoAt, state)
326 if err != nil {
327 log.Println("failed to get pulls", err)
328 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
329 return
330 }
331
332 identsToResolve := make([]string, len(pulls))
333 for i, pull := range pulls {
334 identsToResolve[i] = pull.OwnerDid
335 }
336 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
337 didHandleMap := make(map[string]string)
338 for _, identity := range resolvedIds {
339 if !identity.Handle.IsInvalidHandle() {
340 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
341 } else {
342 didHandleMap[identity.DID.String()] = identity.DID.String()
343 }
344 }
345
346 s.pages.RepoPulls(w, pages.RepoPullsParams{
347 LoggedInUser: s.auth.GetUser(r),
348 RepoInfo: f.RepoInfo(s, user),
349 Pulls: pulls,
350 DidHandleMap: didHandleMap,
351 FilteringBy: state,
352 })
353 return
354}
355
356func (s *State) PullComment(w http.ResponseWriter, r *http.Request) {
357 user := s.auth.GetUser(r)
358 f, err := fullyResolvedRepo(r)
359 if err != nil {
360 log.Println("failed to get repo and knot", err)
361 return
362 }
363
364 pull, ok := r.Context().Value("pull").(*db.Pull)
365 if !ok {
366 log.Println("failed to get pull")
367 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
368 return
369 }
370
371 roundNumberStr := chi.URLParam(r, "round")
372 roundNumber, err := strconv.Atoi(roundNumberStr)
373 if err != nil || roundNumber >= len(pull.Submissions) {
374 http.Error(w, "bad round id", http.StatusBadRequest)
375 log.Println("failed to parse round id", err)
376 return
377 }
378
379 switch r.Method {
380 case http.MethodGet:
381 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{
382 LoggedInUser: user,
383 RepoInfo: f.RepoInfo(s, user),
384 Pull: pull,
385 RoundNumber: roundNumber,
386 })
387 return
388 case http.MethodPost:
389 body := r.FormValue("body")
390 if body == "" {
391 s.pages.Notice(w, "pull", "Comment body is required")
392 return
393 }
394
395 // Start a transaction
396 tx, err := s.db.BeginTx(r.Context(), nil)
397 if err != nil {
398 log.Println("failed to start transaction", err)
399 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
400 return
401 }
402 defer tx.Rollback()
403
404 createdAt := time.Now().Format(time.RFC3339)
405 ownerDid := user.Did
406
407 pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId)
408 if err != nil {
409 log.Println("failed to get pull at", err)
410 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
411 return
412 }
413
414 atUri := f.RepoAt.String()
415 client, _ := s.auth.AuthorizedClient(r)
416 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
417 Collection: tangled.RepoPullCommentNSID,
418 Repo: user.Did,
419 Rkey: s.TID(),
420 Record: &lexutil.LexiconTypeDecoder{
421 Val: &tangled.RepoPullComment{
422 Repo: &atUri,
423 Pull: pullAt,
424 Owner: &ownerDid,
425 Body: &body,
426 CreatedAt: &createdAt,
427 },
428 },
429 })
430 if err != nil {
431 log.Println("failed to create pull comment", err)
432 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
433 return
434 }
435
436 // Create the pull comment in the database with the commentAt field
437 commentId, err := db.NewPullComment(tx, &db.PullComment{
438 OwnerDid: user.Did,
439 RepoAt: f.RepoAt.String(),
440 PullId: pull.PullId,
441 Body: body,
442 CommentAt: atResp.Uri,
443 SubmissionId: pull.Submissions[roundNumber].ID,
444 })
445 if err != nil {
446 log.Println("failed to create pull comment", err)
447 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
448 return
449 }
450
451 // Commit the transaction
452 if err = tx.Commit(); err != nil {
453 log.Println("failed to commit transaction", err)
454 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
455 return
456 }
457
458 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
459 return
460 }
461}
462
463func (s *State) NewPull(w http.ResponseWriter, r *http.Request) {
464 user := s.auth.GetUser(r)
465 f, err := fullyResolvedRepo(r)
466 if err != nil {
467 log.Println("failed to get repo and knot", err)
468 return
469 }
470
471 switch r.Method {
472 case http.MethodGet:
473 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
474 if err != nil {
475 log.Printf("failed to create unsigned client for %s", f.Knot)
476 s.pages.Error503(w)
477 return
478 }
479
480 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
481 if err != nil {
482 log.Println("failed to reach knotserver", err)
483 return
484 }
485
486 body, err := io.ReadAll(resp.Body)
487 if err != nil {
488 log.Printf("Error reading response body: %v", err)
489 return
490 }
491
492 var result types.RepoBranchesResponse
493 err = json.Unmarshal(body, &result)
494 if err != nil {
495 log.Println("failed to parse response:", err)
496 return
497 }
498
499 s.pages.RepoNewPull(w, pages.RepoNewPullParams{
500 LoggedInUser: user,
501 RepoInfo: f.RepoInfo(s, user),
502 Branches: result.Branches,
503 })
504 case http.MethodPost:
505 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed()
506 title := r.FormValue("title")
507 body := r.FormValue("body")
508 targetBranch := r.FormValue("targetBranch")
509 sourceBranch := r.FormValue("sourceBranch")
510 patch := r.FormValue("patch")
511
512 isBranchBased := isPushAllowed && (sourceBranch != "")
513 isPatchBased := patch != ""
514
515 if !isBranchBased && !isPatchBased {
516 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.")
517 return
518 }
519
520 if isBranchBased && isPatchBased {
521 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.")
522 return
523 }
524
525 if title == "" || body == "" || targetBranch == "" {
526 s.pages.Notice(w, "pull", "Title, body and target branch are required.")
527 return
528 }
529
530 // TODO: check if knot has this capability
531 var sourceRev string
532 var pullSource *db.PullSource
533 var recordPullSource *tangled.RepoPull_Source
534 if isBranchBased {
535 pullSource = &db.PullSource{
536 Branch: sourceBranch,
537 }
538 recordPullSource = &tangled.RepoPull_Source{
539 Branch: sourceBranch,
540 }
541 // generate a patch using /compare
542 ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
543 if err != nil {
544 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
545 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
546 return
547 }
548
549 resp, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
550 switch resp.StatusCode {
551 case 404:
552 case 400:
553 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
554 }
555
556 respBody, err := io.ReadAll(resp.Body)
557 if err != nil {
558 log.Println("failed to compare across branches")
559 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
560 }
561 defer resp.Body.Close()
562
563 var diffTreeResponse types.RepoDiffTreeResponse
564 err = json.Unmarshal(respBody, &diffTreeResponse)
565 if err != nil {
566 log.Println("failed to unmarshal diff tree response", err)
567 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
568 }
569
570 sourceRev = diffTreeResponse.DiffTree.Rev2
571 patch = diffTreeResponse.DiffTree.Patch
572 }
573
574 // Validate patch format
575 if !isPatchValid(patch) {
576 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
577 return
578 }
579
580 tx, err := s.db.BeginTx(r.Context(), nil)
581 if err != nil {
582 log.Println("failed to start tx")
583 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
584 return
585 }
586 defer tx.Rollback()
587
588 rkey := s.TID()
589 initialSubmission := db.PullSubmission{
590 Patch: patch,
591 SourceRev: sourceRev,
592 }
593 err = db.NewPull(tx, &db.Pull{
594 Title: title,
595 Body: body,
596 TargetBranch: targetBranch,
597 OwnerDid: user.Did,
598 RepoAt: f.RepoAt,
599 Rkey: rkey,
600 Submissions: []*db.PullSubmission{
601 &initialSubmission,
602 },
603 PullSource: pullSource,
604 })
605 if err != nil {
606 log.Println("failed to create pull request", err)
607 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
608 return
609 }
610 client, _ := s.auth.AuthorizedClient(r)
611 pullId, err := db.NextPullId(s.db, f.RepoAt)
612 if err != nil {
613 log.Println("failed to get pull id", err)
614 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
615 return
616 }
617
618 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
619 Collection: tangled.RepoPullNSID,
620 Repo: user.Did,
621 Rkey: rkey,
622 Record: &lexutil.LexiconTypeDecoder{
623 Val: &tangled.RepoPull{
624 Title: title,
625 PullId: int64(pullId),
626 TargetRepo: string(f.RepoAt),
627 TargetBranch: targetBranch,
628 Patch: patch,
629 Source: recordPullSource,
630 },
631 },
632 })
633
634 err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri)
635 if err != nil {
636 log.Println("failed to get pull id", err)
637 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
638 return
639 }
640
641 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
642 return
643 }
644}
645
646func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) {
647 user := s.auth.GetUser(r)
648 f, err := fullyResolvedRepo(r)
649 if err != nil {
650 log.Println("failed to get repo and knot", err)
651 return
652 }
653
654 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{
655 RepoInfo: f.RepoInfo(s, user),
656 })
657}
658
659func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) {
660 user := s.auth.GetUser(r)
661 f, err := fullyResolvedRepo(r)
662 if err != nil {
663 log.Println("failed to get repo and knot", err)
664 return
665 }
666
667 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
668 if err != nil {
669 log.Printf("failed to create unsigned client for %s", f.Knot)
670 s.pages.Error503(w)
671 return
672 }
673
674 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
675 if err != nil {
676 log.Println("failed to reach knotserver", err)
677 return
678 }
679
680 body, err := io.ReadAll(resp.Body)
681 if err != nil {
682 log.Printf("Error reading response body: %v", err)
683 return
684 }
685
686 var result types.RepoBranchesResponse
687 err = json.Unmarshal(body, &result)
688 if err != nil {
689 log.Println("failed to parse response:", err)
690 return
691 }
692
693 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{
694 RepoInfo: f.RepoInfo(s, user),
695 Branches: result.Branches,
696 })
697}
698
699func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) {
700 user := s.auth.GetUser(r)
701 f, err := fullyResolvedRepo(r)
702 if err != nil {
703 log.Println("failed to get repo and knot", err)
704 return
705 }
706
707 pull, ok := r.Context().Value("pull").(*db.Pull)
708 if !ok {
709 log.Println("failed to get pull")
710 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
711 return
712 }
713
714 switch r.Method {
715 case http.MethodGet:
716 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
717 RepoInfo: f.RepoInfo(s, user),
718 Pull: pull,
719 })
720 return
721 case http.MethodPost:
722 patch := r.FormValue("patch")
723 var sourceRev string
724 var recordPullSource *tangled.RepoPull_Source
725
726 // this pull is a branch based pull
727 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed()
728 if pull.IsSameRepoBranch() && isPushAllowed {
729 sourceBranch := pull.PullSource.Branch
730 targetBranch := pull.TargetBranch
731 recordPullSource = &tangled.RepoPull_Source{
732 Branch: sourceBranch,
733 }
734 // extract patch by performing compare
735 ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
736 if err != nil {
737 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
738 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
739 return
740 }
741
742 resp, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
743 switch resp.StatusCode {
744 case 404:
745 case 400:
746 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
747 }
748
749 respBody, err := io.ReadAll(resp.Body)
750 if err != nil {
751 log.Println("failed to compare across branches")
752 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
753 }
754 defer resp.Body.Close()
755
756 var diffTreeResponse types.RepoDiffTreeResponse
757 err = json.Unmarshal(respBody, &diffTreeResponse)
758 if err != nil {
759 log.Println("failed to unmarshal diff tree response", err)
760 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
761 }
762
763 sourceRev = diffTreeResponse.DiffTree.Rev2
764 patch = diffTreeResponse.DiffTree.Patch
765 }
766
767 if patch == "" {
768 s.pages.Notice(w, "resubmit-error", "Patch is empty.")
769 return
770 }
771
772 if patch == pull.LatestPatch() {
773 s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.")
774 return
775 }
776
777 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
778 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
779 return
780 }
781
782 // Validate patch format
783 if !isPatchValid(patch) {
784 s.pages.Notice(w, "resubmit-error", "Invalid patch format. Please provide a valid diff.")
785 return
786 }
787
788 tx, err := s.db.BeginTx(r.Context(), nil)
789 if err != nil {
790 log.Println("failed to start tx")
791 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
792 return
793 }
794 defer tx.Rollback()
795
796 err = db.ResubmitPull(tx, pull, patch, sourceRev)
797 if err != nil {
798 log.Println("failed to create pull request", err)
799 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
800 return
801 }
802 client, _ := s.auth.AuthorizedClient(r)
803
804 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
805 if err != nil {
806 // failed to get record
807 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
808 return
809 }
810
811 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
812 Collection: tangled.RepoPullNSID,
813 Repo: user.Did,
814 Rkey: pull.Rkey,
815 SwapRecord: ex.Cid,
816 Record: &lexutil.LexiconTypeDecoder{
817 Val: &tangled.RepoPull{
818 Title: pull.Title,
819 PullId: int64(pull.PullId),
820 TargetRepo: string(f.RepoAt),
821 TargetBranch: pull.TargetBranch,
822 Patch: patch, // new patch
823 Source: recordPullSource,
824 },
825 },
826 })
827 if err != nil {
828 log.Println("failed to update record", err)
829 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
830 return
831 }
832
833 if err = tx.Commit(); err != nil {
834 log.Println("failed to commit transaction", err)
835 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
836 return
837 }
838
839 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
840 return
841 }
842}
843
844func (s *State) MergePull(w http.ResponseWriter, r *http.Request) {
845 f, err := fullyResolvedRepo(r)
846 if err != nil {
847 log.Println("failed to resolve repo:", err)
848 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
849 return
850 }
851
852 pull, ok := r.Context().Value("pull").(*db.Pull)
853 if !ok {
854 log.Println("failed to get pull")
855 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
856 return
857 }
858
859 secret, err := db.GetRegistrationKey(s.db, f.Knot)
860 if err != nil {
861 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
862 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
863 return
864 }
865
866 ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid)
867 if err != nil {
868 log.Printf("resolving identity: %s", err)
869 w.WriteHeader(http.StatusNotFound)
870 return
871 }
872
873 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
874 if err != nil {
875 log.Printf("failed to get primary email: %s", err)
876 }
877
878 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
879 if err != nil {
880 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
881 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
882 return
883 }
884
885 // Merge the pull request
886 resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
887 if err != nil {
888 log.Printf("failed to merge pull request: %s", err)
889 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
890 return
891 }
892
893 if resp.StatusCode == http.StatusOK {
894 err := db.MergePull(s.db, f.RepoAt, pull.PullId)
895 if err != nil {
896 log.Printf("failed to update pull request status in database: %s", err)
897 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
898 return
899 }
900 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
901 } else {
902 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
903 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
904 }
905}
906
907func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) {
908 user := s.auth.GetUser(r)
909
910 f, err := fullyResolvedRepo(r)
911 if err != nil {
912 log.Println("malformed middleware")
913 return
914 }
915
916 pull, ok := r.Context().Value("pull").(*db.Pull)
917 if !ok {
918 log.Println("failed to get pull")
919 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
920 return
921 }
922
923 // auth filter: only owner or collaborators can close
924 roles := RolesInRepo(s, user, f)
925 isCollaborator := roles.IsCollaborator()
926 isPullAuthor := user.Did == pull.OwnerDid
927 isCloseAllowed := isCollaborator || isPullAuthor
928 if !isCloseAllowed {
929 log.Println("failed to close pull")
930 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
931 return
932 }
933
934 // Start a transaction
935 tx, err := s.db.BeginTx(r.Context(), nil)
936 if err != nil {
937 log.Println("failed to start transaction", err)
938 s.pages.Notice(w, "pull-close", "Failed to close pull.")
939 return
940 }
941
942 // Close the pull in the database
943 err = db.ClosePull(tx, f.RepoAt, pull.PullId)
944 if err != nil {
945 log.Println("failed to close pull", err)
946 s.pages.Notice(w, "pull-close", "Failed to close pull.")
947 return
948 }
949
950 // Commit the transaction
951 if err = tx.Commit(); err != nil {
952 log.Println("failed to commit transaction", err)
953 s.pages.Notice(w, "pull-close", "Failed to close pull.")
954 return
955 }
956
957 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
958 return
959}
960
961func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) {
962 user := s.auth.GetUser(r)
963
964 f, err := fullyResolvedRepo(r)
965 if err != nil {
966 log.Println("failed to resolve repo", err)
967 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
968 return
969 }
970
971 pull, ok := r.Context().Value("pull").(*db.Pull)
972 if !ok {
973 log.Println("failed to get pull")
974 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
975 return
976 }
977
978 // auth filter: only owner or collaborators can close
979 roles := RolesInRepo(s, user, f)
980 isCollaborator := roles.IsCollaborator()
981 isPullAuthor := user.Did == pull.OwnerDid
982 isCloseAllowed := isCollaborator || isPullAuthor
983 if !isCloseAllowed {
984 log.Println("failed to close pull")
985 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
986 return
987 }
988
989 // Start a transaction
990 tx, err := s.db.BeginTx(r.Context(), nil)
991 if err != nil {
992 log.Println("failed to start transaction", err)
993 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
994 return
995 }
996
997 // Reopen the pull in the database
998 err = db.ReopenPull(tx, f.RepoAt, pull.PullId)
999 if err != nil {
1000 log.Println("failed to reopen pull", err)
1001 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1002 return
1003 }
1004
1005 // Commit the transaction
1006 if err = tx.Commit(); err != nil {
1007 log.Println("failed to commit transaction", err)
1008 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
1009 return
1010 }
1011
1012 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
1013 return
1014}
1015
1016// Very basic validation to check if it looks like a diff/patch
1017// A valid patch usually starts with diff or --- lines
1018func isPatchValid(patch string) bool {
1019 // Basic validation to check if it looks like a diff/patch
1020 // A valid patch usually starts with diff or --- lines
1021 if len(patch) == 0 {
1022 return false
1023 }
1024
1025 lines := strings.Split(patch, "\n")
1026 if len(lines) < 2 {
1027 return false
1028 }
1029
1030 // Check for common patch format markers
1031 firstLine := strings.TrimSpace(lines[0])
1032 return strings.HasPrefix(firstLine, "diff ") ||
1033 strings.HasPrefix(firstLine, "--- ") ||
1034 strings.HasPrefix(firstLine, "Index: ") ||
1035 strings.HasPrefix(firstLine, "+++ ") ||
1036 strings.HasPrefix(firstLine, "@@ ")
1037}