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 "github.com/sotangled/tangled/api/tangled"
15 "github.com/sotangled/tangled/appview/db"
16 "github.com/sotangled/tangled/appview/pages"
17 "github.com/sotangled/tangled/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: %w", 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) RepoPulls(w http.ResponseWriter, r *http.Request) {
225 user := s.auth.GetUser(r)
226 params := r.URL.Query()
227
228 state := db.PullOpen
229 switch params.Get("state") {
230 case "closed":
231 state = db.PullClosed
232 case "merged":
233 state = db.PullMerged
234 }
235
236 f, err := fullyResolvedRepo(r)
237 if err != nil {
238 log.Println("failed to get repo and knot", err)
239 return
240 }
241
242 pulls, err := db.GetPulls(s.db, f.RepoAt, state)
243 if err != nil {
244 log.Println("failed to get pulls", err)
245 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.")
246 return
247 }
248
249 identsToResolve := make([]string, len(pulls))
250 for i, pull := range pulls {
251 identsToResolve[i] = pull.OwnerDid
252 }
253 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve)
254 didHandleMap := make(map[string]string)
255 for _, identity := range resolvedIds {
256 if !identity.Handle.IsInvalidHandle() {
257 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String())
258 } else {
259 didHandleMap[identity.DID.String()] = identity.DID.String()
260 }
261 }
262
263 s.pages.RepoPulls(w, pages.RepoPullsParams{
264 LoggedInUser: s.auth.GetUser(r),
265 RepoInfo: f.RepoInfo(s, user),
266 Pulls: pulls,
267 DidHandleMap: didHandleMap,
268 FilteringBy: state,
269 })
270 return
271}
272
273func (s *State) PullComment(w http.ResponseWriter, r *http.Request) {
274 user := s.auth.GetUser(r)
275 f, err := fullyResolvedRepo(r)
276 if err != nil {
277 log.Println("failed to get repo and knot", err)
278 return
279 }
280
281 pull, ok := r.Context().Value("pull").(*db.Pull)
282 if !ok {
283 log.Println("failed to get pull")
284 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
285 return
286 }
287
288 roundNumberStr := chi.URLParam(r, "round")
289 roundNumber, err := strconv.Atoi(roundNumberStr)
290 if err != nil || roundNumber >= len(pull.Submissions) {
291 http.Error(w, "bad round id", http.StatusBadRequest)
292 log.Println("failed to parse round id", err)
293 return
294 }
295
296 switch r.Method {
297 case http.MethodGet:
298 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{
299 LoggedInUser: user,
300 RepoInfo: f.RepoInfo(s, user),
301 Pull: pull,
302 RoundNumber: roundNumber,
303 })
304 return
305 case http.MethodPost:
306 body := r.FormValue("body")
307 if body == "" {
308 s.pages.Notice(w, "pull", "Comment body is required")
309 return
310 }
311
312 // Start a transaction
313 tx, err := s.db.BeginTx(r.Context(), nil)
314 if err != nil {
315 log.Println("failed to start transaction", err)
316 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
317 return
318 }
319 defer tx.Rollback()
320
321 createdAt := time.Now().Format(time.RFC3339)
322 ownerDid := user.Did
323
324 pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId)
325 if err != nil {
326 log.Println("failed to get pull at", err)
327 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
328 return
329 }
330
331 atUri := f.RepoAt.String()
332 client, _ := s.auth.AuthorizedClient(r)
333 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
334 Collection: tangled.RepoPullCommentNSID,
335 Repo: user.Did,
336 Rkey: s.TID(),
337 Record: &lexutil.LexiconTypeDecoder{
338 Val: &tangled.RepoPullComment{
339 Repo: &atUri,
340 Pull: pullAt,
341 Owner: &ownerDid,
342 Body: &body,
343 CreatedAt: &createdAt,
344 },
345 },
346 })
347 log.Println(atResp.Uri)
348 if err != nil {
349 log.Println("failed to create pull comment", err)
350 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
351 return
352 }
353
354 // Create the pull comment in the database with the commentAt field
355 commentId, err := db.NewPullComment(tx, &db.PullComment{
356 OwnerDid: user.Did,
357 RepoAt: f.RepoAt.String(),
358 PullId: pull.PullId,
359 Body: body,
360 CommentAt: atResp.Uri,
361 SubmissionId: pull.Submissions[roundNumber].ID,
362 })
363 if err != nil {
364 log.Println("failed to create pull comment", err)
365 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
366 return
367 }
368
369 // Commit the transaction
370 if err = tx.Commit(); err != nil {
371 log.Println("failed to commit transaction", err)
372 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
373 return
374 }
375
376 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
377 return
378 }
379}
380
381func (s *State) NewPull(w http.ResponseWriter, r *http.Request) {
382 user := s.auth.GetUser(r)
383 f, err := fullyResolvedRepo(r)
384 if err != nil {
385 log.Println("failed to get repo and knot", err)
386 return
387 }
388
389 switch r.Method {
390 case http.MethodGet:
391 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
392 if err != nil {
393 log.Printf("failed to create unsigned client for %s", f.Knot)
394 s.pages.Error503(w)
395 return
396 }
397
398 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
399 if err != nil {
400 log.Println("failed to reach knotserver", err)
401 return
402 }
403
404 body, err := io.ReadAll(resp.Body)
405 if err != nil {
406 log.Printf("Error reading response body: %v", err)
407 return
408 }
409
410 var result types.RepoBranchesResponse
411 err = json.Unmarshal(body, &result)
412 if err != nil {
413 log.Println("failed to parse response:", err)
414 return
415 }
416
417 s.pages.RepoNewPull(w, pages.RepoNewPullParams{
418 LoggedInUser: user,
419 RepoInfo: f.RepoInfo(s, user),
420 Branches: result.Branches,
421 })
422 case http.MethodPost:
423 title := r.FormValue("title")
424 body := r.FormValue("body")
425 targetBranch := r.FormValue("targetBranch")
426 patch := r.FormValue("patch")
427
428 if title == "" || body == "" || patch == "" || targetBranch == "" {
429 s.pages.Notice(w, "pull", "Title, body and patch diff are required.")
430 return
431 }
432
433 // Validate patch format
434 if !isPatchValid(patch) {
435 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
436 return
437 }
438
439 tx, err := s.db.BeginTx(r.Context(), nil)
440 if err != nil {
441 log.Println("failed to start tx")
442 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
443 return
444 }
445 defer tx.Rollback()
446
447 rkey := s.TID()
448 initialSubmission := db.PullSubmission{
449 Patch: patch,
450 }
451 err = db.NewPull(tx, &db.Pull{
452 Title: title,
453 Body: body,
454 TargetBranch: targetBranch,
455 OwnerDid: user.Did,
456 RepoAt: f.RepoAt,
457 Rkey: rkey,
458 Submissions: []*db.PullSubmission{
459 &initialSubmission,
460 },
461 })
462 if err != nil {
463 log.Println("failed to create pull request", err)
464 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
465 return
466 }
467 client, _ := s.auth.AuthorizedClient(r)
468 pullId, err := db.NextPullId(s.db, f.RepoAt)
469 if err != nil {
470 log.Println("failed to get pull id", err)
471 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
472 return
473 }
474
475 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
476 Collection: tangled.RepoPullNSID,
477 Repo: user.Did,
478 Rkey: rkey,
479 Record: &lexutil.LexiconTypeDecoder{
480 Val: &tangled.RepoPull{
481 Title: title,
482 PullId: int64(pullId),
483 TargetRepo: string(f.RepoAt),
484 TargetBranch: targetBranch,
485 Patch: patch,
486 },
487 },
488 })
489
490 err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri)
491 if err != nil {
492 log.Println("failed to get pull id", err)
493 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
494 return
495 }
496
497 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
498 return
499 }
500}
501
502func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) {
503 user := s.auth.GetUser(r)
504 f, err := fullyResolvedRepo(r)
505 if err != nil {
506 log.Println("failed to get repo and knot", err)
507 return
508 }
509
510 pull, ok := r.Context().Value("pull").(*db.Pull)
511 if !ok {
512 log.Println("failed to get pull")
513 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
514 return
515 }
516
517 switch r.Method {
518 case http.MethodGet:
519 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
520 RepoInfo: f.RepoInfo(s, user),
521 Pull: pull,
522 })
523 return
524 case http.MethodPost:
525 patch := r.FormValue("patch")
526
527 if patch == "" {
528 s.pages.Notice(w, "resubmit-error", "Patch is empty.")
529 return
530 }
531
532 if patch == pull.LatestPatch() {
533 s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.")
534 return
535 }
536
537 // Validate patch format
538 if !isPatchValid(patch) {
539 s.pages.Notice(w, "resubmit-error", "Invalid patch format. Please provide a valid diff.")
540 return
541 }
542
543 tx, err := s.db.BeginTx(r.Context(), nil)
544 if err != nil {
545 log.Println("failed to start tx")
546 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
547 return
548 }
549 defer tx.Rollback()
550
551 err = db.ResubmitPull(tx, pull, patch)
552 if err != nil {
553 log.Println("failed to create pull request", err)
554 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
555 return
556 }
557 client, _ := s.auth.AuthorizedClient(r)
558
559 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
560 if err != nil {
561 // failed to get record
562 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
563 return
564 }
565
566 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
567 Collection: tangled.RepoPullNSID,
568 Repo: user.Did,
569 Rkey: pull.Rkey,
570 SwapRecord: ex.Cid,
571 Record: &lexutil.LexiconTypeDecoder{
572 Val: &tangled.RepoPull{
573 Title: pull.Title,
574 PullId: int64(pull.PullId),
575 TargetRepo: string(f.RepoAt),
576 TargetBranch: pull.TargetBranch,
577 Patch: patch, // new patch
578 },
579 },
580 })
581 if err != nil {
582 log.Println("failed to update record", err)
583 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
584 return
585 }
586
587 if err = tx.Commit(); err != nil {
588 log.Println("failed to commit transaction", err)
589 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
590 return
591 }
592
593 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
594 return
595 }
596}
597
598func (s *State) MergePull(w http.ResponseWriter, r *http.Request) {
599 f, err := fullyResolvedRepo(r)
600 if err != nil {
601 log.Println("failed to resolve repo:", err)
602 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
603 return
604 }
605
606 pull, ok := r.Context().Value("pull").(*db.Pull)
607 if !ok {
608 log.Println("failed to get pull")
609 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
610 return
611 }
612
613 secret, err := db.GetRegistrationKey(s.db, f.Knot)
614 if err != nil {
615 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
616 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
617 return
618 }
619
620 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
621 if err != nil {
622 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
623 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
624 return
625 }
626
627 // Merge the pull request
628 resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, "", "")
629 if err != nil {
630 log.Printf("failed to merge pull request: %s", err)
631 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
632 return
633 }
634
635 if resp.StatusCode == http.StatusOK {
636 err := db.MergePull(s.db, f.RepoAt, pull.PullId)
637 if err != nil {
638 log.Printf("failed to update pull request status in database: %s", err)
639 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
640 return
641 }
642 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
643 } else {
644 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
645 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
646 }
647}
648
649func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) {
650 user := s.auth.GetUser(r)
651
652 f, err := fullyResolvedRepo(r)
653 if err != nil {
654 log.Println("malformed middleware")
655 return
656 }
657
658 pull, ok := r.Context().Value("pull").(*db.Pull)
659 if !ok {
660 log.Println("failed to get pull")
661 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
662 return
663 }
664
665 // auth filter: only owner or collaborators can close
666 roles := RolesInRepo(s, user, f)
667 isCollaborator := roles.IsCollaborator()
668 isPullAuthor := user.Did == pull.OwnerDid
669 isCloseAllowed := isCollaborator || isPullAuthor
670 if !isCloseAllowed {
671 log.Println("failed to close pull")
672 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
673 return
674 }
675
676 // Start a transaction
677 tx, err := s.db.BeginTx(r.Context(), nil)
678 if err != nil {
679 log.Println("failed to start transaction", err)
680 s.pages.Notice(w, "pull-close", "Failed to close pull.")
681 return
682 }
683
684 // Close the pull in the database
685 err = db.ClosePull(tx, f.RepoAt, pull.PullId)
686 if err != nil {
687 log.Println("failed to close pull", err)
688 s.pages.Notice(w, "pull-close", "Failed to close pull.")
689 return
690 }
691
692 // Commit the transaction
693 if err = tx.Commit(); err != nil {
694 log.Println("failed to commit transaction", err)
695 s.pages.Notice(w, "pull-close", "Failed to close pull.")
696 return
697 }
698
699 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
700 return
701}
702
703func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) {
704 user := s.auth.GetUser(r)
705
706 f, err := fullyResolvedRepo(r)
707 if err != nil {
708 log.Println("failed to resolve repo", err)
709 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
710 return
711 }
712
713 pull, ok := r.Context().Value("pull").(*db.Pull)
714 if !ok {
715 log.Println("failed to get pull")
716 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
717 return
718 }
719
720 // auth filter: only owner or collaborators can close
721 roles := RolesInRepo(s, user, f)
722 isCollaborator := roles.IsCollaborator()
723 isPullAuthor := user.Did == pull.OwnerDid
724 isCloseAllowed := isCollaborator || isPullAuthor
725 if !isCloseAllowed {
726 log.Println("failed to close pull")
727 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
728 return
729 }
730
731 // Start a transaction
732 tx, err := s.db.BeginTx(r.Context(), nil)
733 if err != nil {
734 log.Println("failed to start transaction", err)
735 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
736 return
737 }
738
739 // Reopen the pull in the database
740 err = db.ReopenPull(tx, f.RepoAt, pull.PullId)
741 if err != nil {
742 log.Println("failed to reopen pull", err)
743 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
744 return
745 }
746
747 // Commit the transaction
748 if err = tx.Commit(); err != nil {
749 log.Println("failed to commit transaction", err)
750 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
751 return
752 }
753
754 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
755 return
756}
757
758// Very basic validation to check if it looks like a diff/patch
759// A valid patch usually starts with diff or --- lines
760func isPatchValid(patch string) bool {
761 // Basic validation to check if it looks like a diff/patch
762 // A valid patch usually starts with diff or --- lines
763 if len(patch) == 0 {
764 return false
765 }
766
767 lines := strings.Split(patch, "\n")
768 if len(lines) < 2 {
769 return false
770 }
771
772 // Check for common patch format markers
773 firstLine := strings.TrimSpace(lines[0])
774 return strings.HasPrefix(firstLine, "diff ") ||
775 strings.HasPrefix(firstLine, "--- ") ||
776 strings.HasPrefix(firstLine, "Index: ") ||
777 strings.HasPrefix(firstLine, "+++ ") ||
778 strings.HasPrefix(firstLine, "@@ ")
779}