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