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