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