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) ResubmitPull(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 pull, ok := r.Context().Value("pull").(*db.Pull)
655 if !ok {
656 log.Println("failed to get pull")
657 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
658 return
659 }
660
661 switch r.Method {
662 case http.MethodGet:
663 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
664 RepoInfo: f.RepoInfo(s, user),
665 Pull: pull,
666 })
667 return
668 case http.MethodPost:
669 patch := r.FormValue("patch")
670 var sourceRev string
671 var recordPullSource *tangled.RepoPull_Source
672
673 // this pull is a branch based pull
674 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed()
675 if pull.IsSameRepoBranch() && isPushAllowed {
676 sourceBranch := pull.PullSource.Branch
677 targetBranch := pull.TargetBranch
678 recordPullSource = &tangled.RepoPull_Source{
679 Branch: sourceBranch,
680 }
681 // extract patch by performing compare
682 ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev)
683 if err != nil {
684 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
685 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
686 return
687 }
688
689 resp, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch)
690 switch resp.StatusCode {
691 case 404:
692 case 400:
693 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.")
694 }
695
696 respBody, err := io.ReadAll(resp.Body)
697 if err != nil {
698 log.Println("failed to compare across branches")
699 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
700 }
701 defer resp.Body.Close()
702
703 var diffTreeResponse types.RepoDiffTreeResponse
704 err = json.Unmarshal(respBody, &diffTreeResponse)
705 if err != nil {
706 log.Println("failed to unmarshal diff tree response", err)
707 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
708 }
709
710 sourceRev = diffTreeResponse.DiffTree.Rev2
711 patch = diffTreeResponse.DiffTree.Patch
712 }
713
714 if patch == "" {
715 s.pages.Notice(w, "resubmit-error", "Patch is empty.")
716 return
717 }
718
719 if patch == pull.LatestPatch() {
720 s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.")
721 return
722 }
723
724 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev {
725 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.")
726 return
727 }
728
729 // Validate patch format
730 if !isPatchValid(patch) {
731 s.pages.Notice(w, "resubmit-error", "Invalid patch format. Please provide a valid diff.")
732 return
733 }
734
735 tx, err := s.db.BeginTx(r.Context(), nil)
736 if err != nil {
737 log.Println("failed to start tx")
738 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
739 return
740 }
741 defer tx.Rollback()
742
743 err = db.ResubmitPull(tx, pull, patch, sourceRev)
744 if err != nil {
745 log.Println("failed to create pull request", err)
746 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
747 return
748 }
749 client, _ := s.auth.AuthorizedClient(r)
750
751 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
752 if err != nil {
753 // failed to get record
754 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
755 return
756 }
757
758 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
759 Collection: tangled.RepoPullNSID,
760 Repo: user.Did,
761 Rkey: pull.Rkey,
762 SwapRecord: ex.Cid,
763 Record: &lexutil.LexiconTypeDecoder{
764 Val: &tangled.RepoPull{
765 Title: pull.Title,
766 PullId: int64(pull.PullId),
767 TargetRepo: string(f.RepoAt),
768 TargetBranch: pull.TargetBranch,
769 Patch: patch, // new patch
770 Source: recordPullSource,
771 },
772 },
773 })
774 if err != nil {
775 log.Println("failed to update record", err)
776 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
777 return
778 }
779
780 if err = tx.Commit(); err != nil {
781 log.Println("failed to commit transaction", err)
782 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
783 return
784 }
785
786 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
787 return
788 }
789}
790
791func (s *State) MergePull(w http.ResponseWriter, r *http.Request) {
792 f, err := fullyResolvedRepo(r)
793 if err != nil {
794 log.Println("failed to resolve repo:", err)
795 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
796 return
797 }
798
799 pull, ok := r.Context().Value("pull").(*db.Pull)
800 if !ok {
801 log.Println("failed to get pull")
802 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
803 return
804 }
805
806 secret, err := db.GetRegistrationKey(s.db, f.Knot)
807 if err != nil {
808 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
809 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
810 return
811 }
812
813 ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid)
814 if err != nil {
815 log.Printf("resolving identity: %s", err)
816 w.WriteHeader(http.StatusNotFound)
817 return
818 }
819
820 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
821 if err != nil {
822 log.Printf("failed to get primary email: %s", err)
823 }
824
825 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
826 if err != nil {
827 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
828 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
829 return
830 }
831
832 // Merge the pull request
833 resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
834 if err != nil {
835 log.Printf("failed to merge pull request: %s", err)
836 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
837 return
838 }
839
840 if resp.StatusCode == http.StatusOK {
841 err := db.MergePull(s.db, f.RepoAt, pull.PullId)
842 if err != nil {
843 log.Printf("failed to update pull request status in database: %s", err)
844 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
845 return
846 }
847 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
848 } else {
849 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
850 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
851 }
852}
853
854func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) {
855 user := s.auth.GetUser(r)
856
857 f, err := fullyResolvedRepo(r)
858 if err != nil {
859 log.Println("malformed middleware")
860 return
861 }
862
863 pull, ok := r.Context().Value("pull").(*db.Pull)
864 if !ok {
865 log.Println("failed to get pull")
866 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
867 return
868 }
869
870 // auth filter: only owner or collaborators can close
871 roles := RolesInRepo(s, user, f)
872 isCollaborator := roles.IsCollaborator()
873 isPullAuthor := user.Did == pull.OwnerDid
874 isCloseAllowed := isCollaborator || isPullAuthor
875 if !isCloseAllowed {
876 log.Println("failed to close pull")
877 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
878 return
879 }
880
881 // Start a transaction
882 tx, err := s.db.BeginTx(r.Context(), nil)
883 if err != nil {
884 log.Println("failed to start transaction", err)
885 s.pages.Notice(w, "pull-close", "Failed to close pull.")
886 return
887 }
888
889 // Close the pull in the database
890 err = db.ClosePull(tx, f.RepoAt, pull.PullId)
891 if err != nil {
892 log.Println("failed to close pull", err)
893 s.pages.Notice(w, "pull-close", "Failed to close pull.")
894 return
895 }
896
897 // Commit the transaction
898 if err = tx.Commit(); err != nil {
899 log.Println("failed to commit transaction", err)
900 s.pages.Notice(w, "pull-close", "Failed to close pull.")
901 return
902 }
903
904 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
905 return
906}
907
908func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) {
909 user := s.auth.GetUser(r)
910
911 f, err := fullyResolvedRepo(r)
912 if err != nil {
913 log.Println("failed to resolve repo", err)
914 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
915 return
916 }
917
918 pull, ok := r.Context().Value("pull").(*db.Pull)
919 if !ok {
920 log.Println("failed to get pull")
921 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
922 return
923 }
924
925 // auth filter: only owner or collaborators can close
926 roles := RolesInRepo(s, user, f)
927 isCollaborator := roles.IsCollaborator()
928 isPullAuthor := user.Did == pull.OwnerDid
929 isCloseAllowed := isCollaborator || isPullAuthor
930 if !isCloseAllowed {
931 log.Println("failed to close pull")
932 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
933 return
934 }
935
936 // Start a transaction
937 tx, err := s.db.BeginTx(r.Context(), nil)
938 if err != nil {
939 log.Println("failed to start transaction", err)
940 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
941 return
942 }
943
944 // Reopen the pull in the database
945 err = db.ReopenPull(tx, f.RepoAt, pull.PullId)
946 if err != nil {
947 log.Println("failed to reopen pull", err)
948 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
949 return
950 }
951
952 // Commit the transaction
953 if err = tx.Commit(); err != nil {
954 log.Println("failed to commit transaction", err)
955 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
956 return
957 }
958
959 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
960 return
961}
962
963// Very basic validation to check if it looks like a diff/patch
964// A valid patch usually starts with diff or --- lines
965func isPatchValid(patch string) bool {
966 // Basic validation to check if it looks like a diff/patch
967 // A valid patch usually starts with diff or --- lines
968 if len(patch) == 0 {
969 return false
970 }
971
972 lines := strings.Split(patch, "\n")
973 if len(lines) < 2 {
974 return false
975 }
976
977 // Check for common patch format markers
978 firstLine := strings.TrimSpace(lines[0])
979 return strings.HasPrefix(firstLine, "diff ") ||
980 strings.HasPrefix(firstLine, "--- ") ||
981 strings.HasPrefix(firstLine, "Index: ") ||
982 strings.HasPrefix(firstLine, "+++ ") ||
983 strings.HasPrefix(firstLine, "@@ ")
984}