forked from tangled.org/core
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 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 f, err := fullyResolvedRepo(r) 512 if err != nil { 513 log.Println("failed to resolve repo:", err) 514 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 515 return 516 } 517 518 pull, ok := r.Context().Value("pull").(*db.Pull) 519 if !ok { 520 log.Println("failed to get pull") 521 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 522 return 523 } 524 525 secret, err := db.GetRegistrationKey(s.db, f.Knot) 526 if err != nil { 527 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err) 528 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 529 return 530 } 531 532 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 533 if err != nil { 534 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 535 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 536 return 537 } 538 539 // Merge the pull request 540 resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, "", "") 541 if err != nil { 542 log.Printf("failed to merge pull request: %s", err) 543 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 544 return 545 } 546 547 if resp.StatusCode == http.StatusOK { 548 err := db.MergePull(s.db, f.RepoAt, pull.PullId) 549 if err != nil { 550 log.Printf("failed to update pull request status in database: %s", err) 551 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 552 return 553 } 554 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId)) 555 } else { 556 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode) 557 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 558 } 559} 560 561func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) { 562 user := s.auth.GetUser(r) 563 564 f, err := fullyResolvedRepo(r) 565 if err != nil { 566 log.Println("malformed middleware") 567 return 568 } 569 570 pull, ok := r.Context().Value("pull").(*db.Pull) 571 if !ok { 572 log.Println("failed to get pull") 573 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 574 return 575 } 576 577 // auth filter: only owner or collaborators can close 578 roles := RolesInRepo(s, user, f) 579 isCollaborator := roles.IsCollaborator() 580 isPullAuthor := user.Did == pull.OwnerDid 581 isCloseAllowed := isCollaborator || isPullAuthor 582 if !isCloseAllowed { 583 log.Println("failed to close pull") 584 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 585 return 586 } 587 588 // Start a transaction 589 tx, err := s.db.BeginTx(r.Context(), nil) 590 if err != nil { 591 log.Println("failed to start transaction", err) 592 s.pages.Notice(w, "pull-close", "Failed to close pull.") 593 return 594 } 595 596 // Close the pull in the database 597 err = db.ClosePull(tx, f.RepoAt, pull.PullId) 598 if err != nil { 599 log.Println("failed to close pull", err) 600 s.pages.Notice(w, "pull-close", "Failed to close pull.") 601 return 602 } 603 604 // Commit the transaction 605 if err = tx.Commit(); err != nil { 606 log.Println("failed to commit transaction", err) 607 s.pages.Notice(w, "pull-close", "Failed to close pull.") 608 return 609 } 610 611 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 612 return 613} 614 615func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) { 616 user := s.auth.GetUser(r) 617 618 f, err := fullyResolvedRepo(r) 619 if err != nil { 620 log.Println("failed to resolve repo", err) 621 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 622 return 623 } 624 625 pull, ok := r.Context().Value("pull").(*db.Pull) 626 if !ok { 627 log.Println("failed to get pull") 628 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 629 return 630 } 631 632 // auth filter: only owner or collaborators can close 633 roles := RolesInRepo(s, user, f) 634 isCollaborator := roles.IsCollaborator() 635 isPullAuthor := user.Did == pull.OwnerDid 636 isCloseAllowed := isCollaborator || isPullAuthor 637 if !isCloseAllowed { 638 log.Println("failed to close pull") 639 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 640 return 641 } 642 643 // Start a transaction 644 tx, err := s.db.BeginTx(r.Context(), nil) 645 if err != nil { 646 log.Println("failed to start transaction", err) 647 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 648 return 649 } 650 651 // Reopen the pull in the database 652 err = db.ReopenPull(tx, f.RepoAt, pull.PullId) 653 if err != nil { 654 log.Println("failed to reopen pull", err) 655 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 656 return 657 } 658 659 // Commit the transaction 660 if err = tx.Commit(); err != nil { 661 log.Println("failed to commit transaction", err) 662 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 663 return 664 } 665 666 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 667 return 668} 669 670// Very basic validation to check if it looks like a diff/patch 671// A valid patch usually starts with diff or --- lines 672func isPatchValid(patch string) bool { 673 // Basic validation to check if it looks like a diff/patch 674 // A valid patch usually starts with diff or --- lines 675 if len(patch) == 0 { 676 return false 677 } 678 679 lines := strings.Split(patch, "\n") 680 if len(lines) < 2 { 681 return false 682 } 683 684 // Check for common patch format markers 685 firstLine := strings.TrimSpace(lines[0]) 686 return strings.HasPrefix(firstLine, "diff ") || 687 strings.HasPrefix(firstLine, "--- ") || 688 strings.HasPrefix(firstLine, "Index: ") || 689 strings.HasPrefix(firstLine, "+++ ") || 690 strings.HasPrefix(firstLine, "@@ ") 691}