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 log.Println(atResp.Uri)
379 if err != nil {
380 log.Println("failed to create pull comment", err)
381 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
382 return
383 }
384
385 // Create the pull comment in the database with the commentAt field
386 commentId, err := db.NewPullComment(tx, &db.PullComment{
387 OwnerDid: user.Did,
388 RepoAt: f.RepoAt.String(),
389 PullId: pull.PullId,
390 Body: body,
391 CommentAt: atResp.Uri,
392 SubmissionId: pull.Submissions[roundNumber].ID,
393 })
394 if err != nil {
395 log.Println("failed to create pull comment", err)
396 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
397 return
398 }
399
400 // Commit the transaction
401 if err = tx.Commit(); err != nil {
402 log.Println("failed to commit transaction", err)
403 s.pages.Notice(w, "pull-comment", "Failed to create comment.")
404 return
405 }
406
407 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId))
408 return
409 }
410}
411
412func (s *State) NewPull(w http.ResponseWriter, r *http.Request) {
413 user := s.auth.GetUser(r)
414 f, err := fullyResolvedRepo(r)
415 if err != nil {
416 log.Println("failed to get repo and knot", err)
417 return
418 }
419
420 switch r.Method {
421 case http.MethodGet:
422 us, err := NewUnsignedClient(f.Knot, s.config.Dev)
423 if err != nil {
424 log.Printf("failed to create unsigned client for %s", f.Knot)
425 s.pages.Error503(w)
426 return
427 }
428
429 resp, err := us.Branches(f.OwnerDid(), f.RepoName)
430 if err != nil {
431 log.Println("failed to reach knotserver", err)
432 return
433 }
434
435 body, err := io.ReadAll(resp.Body)
436 if err != nil {
437 log.Printf("Error reading response body: %v", err)
438 return
439 }
440
441 var result types.RepoBranchesResponse
442 err = json.Unmarshal(body, &result)
443 if err != nil {
444 log.Println("failed to parse response:", err)
445 return
446 }
447
448 s.pages.RepoNewPull(w, pages.RepoNewPullParams{
449 LoggedInUser: user,
450 RepoInfo: f.RepoInfo(s, user),
451 Branches: result.Branches,
452 })
453 case http.MethodPost:
454 title := r.FormValue("title")
455 body := r.FormValue("body")
456 targetBranch := r.FormValue("targetBranch")
457 patch := r.FormValue("patch")
458
459 if title == "" || body == "" || patch == "" || targetBranch == "" {
460 s.pages.Notice(w, "pull", "Title, body and patch diff are required.")
461 return
462 }
463
464 // Validate patch format
465 if !isPatchValid(patch) {
466 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.")
467 return
468 }
469
470 tx, err := s.db.BeginTx(r.Context(), nil)
471 if err != nil {
472 log.Println("failed to start tx")
473 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
474 return
475 }
476 defer tx.Rollback()
477
478 rkey := s.TID()
479 initialSubmission := db.PullSubmission{
480 Patch: patch,
481 }
482 err = db.NewPull(tx, &db.Pull{
483 Title: title,
484 Body: body,
485 TargetBranch: targetBranch,
486 OwnerDid: user.Did,
487 RepoAt: f.RepoAt,
488 Rkey: rkey,
489 Submissions: []*db.PullSubmission{
490 &initialSubmission,
491 },
492 })
493 if err != nil {
494 log.Println("failed to create pull request", err)
495 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
496 return
497 }
498 client, _ := s.auth.AuthorizedClient(r)
499 pullId, err := db.NextPullId(s.db, f.RepoAt)
500 if err != nil {
501 log.Println("failed to get pull id", err)
502 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
503 return
504 }
505
506 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
507 Collection: tangled.RepoPullNSID,
508 Repo: user.Did,
509 Rkey: rkey,
510 Record: &lexutil.LexiconTypeDecoder{
511 Val: &tangled.RepoPull{
512 Title: title,
513 PullId: int64(pullId),
514 TargetRepo: string(f.RepoAt),
515 TargetBranch: targetBranch,
516 Patch: patch,
517 },
518 },
519 })
520
521 err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri)
522 if err != nil {
523 log.Println("failed to get pull id", err)
524 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.")
525 return
526 }
527
528 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId))
529 return
530 }
531}
532
533func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) {
534 user := s.auth.GetUser(r)
535 f, err := fullyResolvedRepo(r)
536 if err != nil {
537 log.Println("failed to get repo and knot", err)
538 return
539 }
540
541 pull, ok := r.Context().Value("pull").(*db.Pull)
542 if !ok {
543 log.Println("failed to get pull")
544 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
545 return
546 }
547
548 switch r.Method {
549 case http.MethodGet:
550 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{
551 RepoInfo: f.RepoInfo(s, user),
552 Pull: pull,
553 })
554 return
555 case http.MethodPost:
556 patch := r.FormValue("patch")
557
558 if patch == "" {
559 s.pages.Notice(w, "resubmit-error", "Patch is empty.")
560 return
561 }
562
563 if patch == pull.LatestPatch() {
564 s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.")
565 return
566 }
567
568 // Validate patch format
569 if !isPatchValid(patch) {
570 s.pages.Notice(w, "resubmit-error", "Invalid patch format. Please provide a valid diff.")
571 return
572 }
573
574 tx, err := s.db.BeginTx(r.Context(), nil)
575 if err != nil {
576 log.Println("failed to start tx")
577 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
578 return
579 }
580 defer tx.Rollback()
581
582 err = db.ResubmitPull(tx, pull, patch)
583 if err != nil {
584 log.Println("failed to create pull request", err)
585 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.")
586 return
587 }
588 client, _ := s.auth.AuthorizedClient(r)
589
590 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey)
591 if err != nil {
592 // failed to get record
593 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.")
594 return
595 }
596
597 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{
598 Collection: tangled.RepoPullNSID,
599 Repo: user.Did,
600 Rkey: pull.Rkey,
601 SwapRecord: ex.Cid,
602 Record: &lexutil.LexiconTypeDecoder{
603 Val: &tangled.RepoPull{
604 Title: pull.Title,
605 PullId: int64(pull.PullId),
606 TargetRepo: string(f.RepoAt),
607 TargetBranch: pull.TargetBranch,
608 Patch: patch, // new patch
609 },
610 },
611 })
612 if err != nil {
613 log.Println("failed to update record", err)
614 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.")
615 return
616 }
617
618 if err = tx.Commit(); err != nil {
619 log.Println("failed to commit transaction", err)
620 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.")
621 return
622 }
623
624 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
625 return
626 }
627}
628
629func (s *State) MergePull(w http.ResponseWriter, r *http.Request) {
630 f, err := fullyResolvedRepo(r)
631 if err != nil {
632 log.Println("failed to resolve repo:", err)
633 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
634 return
635 }
636
637 pull, ok := r.Context().Value("pull").(*db.Pull)
638 if !ok {
639 log.Println("failed to get pull")
640 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
641 return
642 }
643
644 secret, err := db.GetRegistrationKey(s.db, f.Knot)
645 if err != nil {
646 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err)
647 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
648 return
649 }
650
651 ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid)
652 if err != nil {
653 log.Printf("resolving identity: %s", err)
654 w.WriteHeader(http.StatusNotFound)
655 return
656 }
657
658 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid)
659 if err != nil {
660 log.Printf("failed to get primary email: %s", err)
661 }
662
663 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev)
664 if err != nil {
665 log.Printf("failed to create signed client for %s: %s", f.Knot, err)
666 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
667 return
668 }
669
670 // Merge the pull request
671 resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address)
672 if err != nil {
673 log.Printf("failed to merge pull request: %s", err)
674 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
675 return
676 }
677
678 if resp.StatusCode == http.StatusOK {
679 err := db.MergePull(s.db, f.RepoAt, pull.PullId)
680 if err != nil {
681 log.Printf("failed to update pull request status in database: %s", err)
682 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
683 return
684 }
685 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId))
686 } else {
687 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode)
688 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.")
689 }
690}
691
692func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) {
693 user := s.auth.GetUser(r)
694
695 f, err := fullyResolvedRepo(r)
696 if err != nil {
697 log.Println("malformed middleware")
698 return
699 }
700
701 pull, ok := r.Context().Value("pull").(*db.Pull)
702 if !ok {
703 log.Println("failed to get pull")
704 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
705 return
706 }
707
708 // auth filter: only owner or collaborators can close
709 roles := RolesInRepo(s, user, f)
710 isCollaborator := roles.IsCollaborator()
711 isPullAuthor := user.Did == pull.OwnerDid
712 isCloseAllowed := isCollaborator || isPullAuthor
713 if !isCloseAllowed {
714 log.Println("failed to close pull")
715 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
716 return
717 }
718
719 // Start a transaction
720 tx, err := s.db.BeginTx(r.Context(), nil)
721 if err != nil {
722 log.Println("failed to start transaction", err)
723 s.pages.Notice(w, "pull-close", "Failed to close pull.")
724 return
725 }
726
727 // Close the pull in the database
728 err = db.ClosePull(tx, f.RepoAt, pull.PullId)
729 if err != nil {
730 log.Println("failed to close pull", err)
731 s.pages.Notice(w, "pull-close", "Failed to close pull.")
732 return
733 }
734
735 // Commit the transaction
736 if err = tx.Commit(); err != nil {
737 log.Println("failed to commit transaction", err)
738 s.pages.Notice(w, "pull-close", "Failed to close pull.")
739 return
740 }
741
742 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
743 return
744}
745
746func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) {
747 user := s.auth.GetUser(r)
748
749 f, err := fullyResolvedRepo(r)
750 if err != nil {
751 log.Println("failed to resolve repo", err)
752 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
753 return
754 }
755
756 pull, ok := r.Context().Value("pull").(*db.Pull)
757 if !ok {
758 log.Println("failed to get pull")
759 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.")
760 return
761 }
762
763 // auth filter: only owner or collaborators can close
764 roles := RolesInRepo(s, user, f)
765 isCollaborator := roles.IsCollaborator()
766 isPullAuthor := user.Did == pull.OwnerDid
767 isCloseAllowed := isCollaborator || isPullAuthor
768 if !isCloseAllowed {
769 log.Println("failed to close pull")
770 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.")
771 return
772 }
773
774 // Start a transaction
775 tx, err := s.db.BeginTx(r.Context(), nil)
776 if err != nil {
777 log.Println("failed to start transaction", err)
778 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
779 return
780 }
781
782 // Reopen the pull in the database
783 err = db.ReopenPull(tx, f.RepoAt, pull.PullId)
784 if err != nil {
785 log.Println("failed to reopen pull", err)
786 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
787 return
788 }
789
790 // Commit the transaction
791 if err = tx.Commit(); err != nil {
792 log.Println("failed to commit transaction", err)
793 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.")
794 return
795 }
796
797 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId))
798 return
799}
800
801// Very basic validation to check if it looks like a diff/patch
802// A valid patch usually starts with diff or --- lines
803func isPatchValid(patch string) bool {
804 // Basic validation to check if it looks like a diff/patch
805 // A valid patch usually starts with diff or --- lines
806 if len(patch) == 0 {
807 return false
808 }
809
810 lines := strings.Split(patch, "\n")
811 if len(lines) < 2 {
812 return false
813 }
814
815 // Check for common patch format markers
816 firstLine := strings.TrimSpace(lines[0])
817 return strings.HasPrefix(firstLine, "diff ") ||
818 strings.HasPrefix(firstLine, "--- ") ||
819 strings.HasPrefix(firstLine, "Index: ") ||
820 strings.HasPrefix(firstLine, "+++ ") ||
821 strings.HasPrefix(firstLine, "@@ ")
822}