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