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