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