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}