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 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}