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 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed()
454 title := r.FormValue("title")
455 body := r.FormValue("body")
456 targetBranch := r.FormValue("targetBranch")
457 sourceBranch := r.FormValue("sourceBranch")
458 patch := r.FormValue("patch")
459
460 if patch == "" {
461 if isPushAllowed && sourceBranch == "" {
462 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.")
463 return
464 }
465 s.pages.Notice(w, "pull", "Patch is empty.")
466 return
467 }
468
469 if patch != "" && sourceBranch != "" {
470 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.")
471 return
472 }
473
474 if title == "" || body == "" || targetBranch == "" {
475 s.pages.Notice(w, "pull", "Title, body and target branch are required.")
476 return
477 }
478
479 // TODO: check if knot has this capability
480 var pullSource *db.PullSource
481 if sourceBranch != "" && isPushAllowed {
482 pullSource = &db.PullSource{
483 Branch: sourceBranch,
484 }
485 // generate a patch using /compare
486 ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
487 if err != nil {
488 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
489 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
490 return
491 }
492
493 log.Println(targetBranch, sourceBranch)
494
495 resp, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
496 switch resp.StatusCode {
497 case 404:
498 case 400:
499 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
500 }
501
502 respBody, err := io.ReadAll(resp.Body)
503 if err != nil {
504 log.Println("failed to compare across branches")
505 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
506 }
507 defer resp.Body.Close()
508
509 var diffTreeResponse types.RepoDiffTreeResponse
510 err = json.Unmarshal(respBody, &diffTreeResponse)
511 if err != nil {
512 log.Println("failed to unmarshal diff tree response", err)
513 log.Println(string(respBody))
514 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
515 }
516
517 patch = diffTreeResponse.DiffTree.Patch
518 }
519
520 log.Println(patch)
521
522 // Validate patch format
523 if !isPatchValid(patch) {
524 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
525 return
526 }
527
528 tx, err := s.db.BeginTx(r.Context(), nil)
529 if err != nil {
530 log.Println("failed to start tx")
531 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
532 return
533 }
534 defer tx.Rollback()
535
536 rkey := s.TID()
537 initialSubmission := db.PullSubmission{
538 Patch: patch,
539 }
540 err = db.NewPull(tx, &db.Pull{
541 Title: title,
542 Body: body,
543 TargetBranch: targetBranch,
544 OwnerDid: user.Did,
545 RepoAt: f.RepoAt,
546 Rkey: rkey,
547 Submissions: []*db.PullSubmission{
548 &initialSubmission,
549 },
550 PullSource: pullSource,
551 })
552 if err != nil {
553 log.Println("failed to create pull request", err)
554 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
555 return
556 }
557 client, _ := s.auth.AuthorizedClient(r)
558 pullId, err := db.NextPullId(s.db, f.RepoAt)
559 if err != nil {
560 log.Println("failed to get pull id", err)
561 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
562 return
563 }
564
565 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
566 Collection: tangled.RepoPullNSID,
567 Repo: user.Did,
568 Rkey: rkey,
569 Record: &lexutil.LexiconTypeDecoder{
570 Val: &tangled.RepoPull{
571 Title: title,
572 PullId: int64(pullId),
573 TargetRepo: string(f.RepoAt),
574 TargetBranch: targetBranch,
575 Patch: patch,
576 },
577 },
578 })
579
580 err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri)
581 if err != nil {
582 log.Println("failed to get pull id", err)
583 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
584 return
585 }
586
587 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
588 return
589 }
590}
591
592func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) {
593 user := s.auth.GetUser(r)
594 f, err := fullyResolvedRepo(r)
595 if err != nil {
596 log.Println("failed to get repo and knot", err)
597 return
598 }
599
600 pull, ok := r.Context().Value("pull").(*db.Pull)
601 if !ok {
602 log.Println("failed to get pull")
603 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
604 return
605 }
606
607 switch r.Method {
608 case http.MethodGet:
609 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
610 RepoInfo: f.RepoInfo(s, user),
611 Pull: pull,
612 })
613 return
614 case http.MethodPost:
615 patch := r.FormValue("patch")
616
617 // this pull is a branch based pull
618 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed()
619 if pull.IsSameRepoBranch() && isPushAllowed {
620 sourceBranch := pull.PullSource.Branch
621 targetBranch := pull.TargetBranch
622 // extract patch by performing compare
623 ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
624 if err != nil {
625 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
626 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
627 return
628 }
629
630 log.Println(targetBranch, sourceBranch)
631
632 resp, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
633 switch resp.StatusCode {
634 case 404:
635 case 400:
636 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
637 }
638
639 respBody, err := io.ReadAll(resp.Body)
640 if err != nil {
641 log.Println("failed to compare across branches")
642 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
643 }
644 defer resp.Body.Close()
645
646 var diffTreeResponse types.RepoDiffTreeResponse
647 err = json.Unmarshal(respBody, &diffTreeResponse)
648 if err != nil {
649 log.Println("failed to unmarshal diff tree response", err)
650 log.Println(string(respBody))
651 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
652 }
653
654 patch = diffTreeResponse.DiffTree.Patch
655 }
656
657 if patch == "" {
658 s.pages.Notice(w, "resubmit-error", "Patch is empty.")
659 return
660 }
661
662 if patch == pull.LatestPatch() {
663 s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.")
664 return
665 }
666
667 // Validate patch format
668 if !isPatchValid(patch) {
669 s.pages.Notice(w, "resubmit-error", "Invalid patch format. Please provide a valid diff.")
670 return
671 }
672
673 tx, err := s.db.BeginTx(r.Context(), nil)
674 if err != nil {
675 log.Println("failed to start tx")
676 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
677 return
678 }
679 defer tx.Rollback()
680
681 err = db.ResubmitPull(tx, pull, patch)
682 if err != nil {
683 log.Println("failed to create pull request", err)
684 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
685 return
686 }
687 client, _ := s.auth.AuthorizedClient(r)
688
689 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
690 if err != nil {
691 // failed to get record
692 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
693 return
694 }
695
696 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
697 Collection: tangled.RepoPullNSID,
698 Repo: user.Did,
699 Rkey: pull.Rkey,
700 SwapRecord: ex.Cid,
701 Record: &lexutil.LexiconTypeDecoder{
702 Val: &tangled.RepoPull{
703 Title: pull.Title,
704 PullId: int64(pull.PullId),
705 TargetRepo: string(f.RepoAt),
706 TargetBranch: pull.TargetBranch,
707 Patch: patch, // new patch
708 },
709 },
710 })
711 if err != nil {
712 log.Println("failed to update record", err)
713 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
714 return
715 }
716
717 if err = tx.Commit(); err != nil {
718 log.Println("failed to commit transaction", err)
719 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
720 return
721 }
722
723 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
724 return
725 }
726}
727
728func (s *State) MergePull(w http.ResponseWriter, r *http.Request) {
729 f, err := fullyResolvedRepo(r)
730 if err != nil {
731 log.Println("failed to resolve repo:", err)
732 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
733 return
734 }
735
736 pull, ok := r.Context().Value("pull").(*db.Pull)
737 if !ok {
738 log.Println("failed to get pull")
739 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
740 return
741 }
742
743 secret, err := db.GetRegistrationKey(s.db, f.Knot)
744 if err != nil {
745 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
746 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
747 return
748 }
749
750 ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid)
751 if err != nil {
752 log.Printf("resolving identity: %s", err)
753 w.WriteHeader(http.StatusNotFound)
754 return
755 }
756
757 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
758 if err != nil {
759 log.Printf("failed to get primary email: %s", err)
760 }
761
762 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
763 if err != nil {
764 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
765 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
766 return
767 }
768
769 // Merge the pull request
770 resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
771 if err != nil {
772 log.Printf("failed to merge pull request: %s", err)
773 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
774 return
775 }
776
777 if resp.StatusCode == http.StatusOK {
778 err := db.MergePull(s.db, f.RepoAt, pull.PullId)
779 if err != nil {
780 log.Printf("failed to update pull request status in database: %s", err)
781 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
782 return
783 }
784 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
785 } else {
786 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
787 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
788 }
789}
790
791func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) {
792 user := s.auth.GetUser(r)
793
794 f, err := fullyResolvedRepo(r)
795 if err != nil {
796 log.Println("malformed middleware")
797 return
798 }
799
800 pull, ok := r.Context().Value("pull").(*db.Pull)
801 if !ok {
802 log.Println("failed to get pull")
803 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
804 return
805 }
806
807 // auth filter: only owner or collaborators can close
808 roles := RolesInRepo(s, user, f)
809 isCollaborator := roles.IsCollaborator()
810 isPullAuthor := user.Did == pull.OwnerDid
811 isCloseAllowed := isCollaborator || isPullAuthor
812 if !isCloseAllowed {
813 log.Println("failed to close pull")
814 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
815 return
816 }
817
818 // Start a transaction
819 tx, err := s.db.BeginTx(r.Context(), nil)
820 if err != nil {
821 log.Println("failed to start transaction", err)
822 s.pages.Notice(w, "pull-close", "Failed to close pull.")
823 return
824 }
825
826 // Close the pull in the database
827 err = db.ClosePull(tx, f.RepoAt, pull.PullId)
828 if err != nil {
829 log.Println("failed to close pull", err)
830 s.pages.Notice(w, "pull-close", "Failed to close pull.")
831 return
832 }
833
834 // Commit the transaction
835 if err = tx.Commit(); err != nil {
836 log.Println("failed to commit transaction", err)
837 s.pages.Notice(w, "pull-close", "Failed to close pull.")
838 return
839 }
840
841 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
842 return
843}
844
845func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) {
846 user := s.auth.GetUser(r)
847
848 f, err := fullyResolvedRepo(r)
849 if err != nil {
850 log.Println("failed to resolve repo", err)
851 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
852 return
853 }
854
855 pull, ok := r.Context().Value("pull").(*db.Pull)
856 if !ok {
857 log.Println("failed to get pull")
858 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
859 return
860 }
861
862 // auth filter: only owner or collaborators can close
863 roles := RolesInRepo(s, user, f)
864 isCollaborator := roles.IsCollaborator()
865 isPullAuthor := user.Did == pull.OwnerDid
866 isCloseAllowed := isCollaborator || isPullAuthor
867 if !isCloseAllowed {
868 log.Println("failed to close pull")
869 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
870 return
871 }
872
873 // Start a transaction
874 tx, err := s.db.BeginTx(r.Context(), nil)
875 if err != nil {
876 log.Println("failed to start transaction", err)
877 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
878 return
879 }
880
881 // Reopen the pull in the database
882 err = db.ReopenPull(tx, f.RepoAt, pull.PullId)
883 if err != nil {
884 log.Println("failed to reopen pull", err)
885 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
886 return
887 }
888
889 // Commit the transaction
890 if err = tx.Commit(); err != nil {
891 log.Println("failed to commit transaction", err)
892 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
893 return
894 }
895
896 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
897 return
898}
899
900// Very basic validation to check if it looks like a diff/patch
901// A valid patch usually starts with diff or --- lines
902func isPatchValid(patch string) bool {
903 // Basic validation to check if it looks like a diff/patch
904 // A valid patch usually starts with diff or --- lines
905 if len(patch) == 0 {
906 return false
907 }
908
909 lines := strings.Split(patch, "\n")
910 if len(lines) < 2 {
911 return false
912 }
913
914 // Check for common patch format markers
915 firstLine := strings.TrimSpace(lines[0])
916 return strings.HasPrefix(firstLine, "diff ") ||
917 strings.HasPrefix(firstLine, "--- ") ||
918 strings.HasPrefix(firstLine, "Index: ") ||
919 strings.HasPrefix(firstLine, "+++ ") ||
920 strings.HasPrefix(firstLine, "@@ ")
921}