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