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