forked from tangled.org/core
this repo has no description
1package state 2 3import ( 4 "database/sql" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "io" 9 "log" 10 "net/http" 11 "net/url" 12 "strconv" 13 "strings" 14 "time" 15 16 "github.com/go-chi/chi/v5" 17 "tangled.sh/tangled.sh/core/api/tangled" 18 "tangled.sh/tangled.sh/core/appview/auth" 19 "tangled.sh/tangled.sh/core/appview/db" 20 "tangled.sh/tangled.sh/core/appview/pages" 21 "tangled.sh/tangled.sh/core/types" 22 23 comatproto "github.com/bluesky-social/indigo/api/atproto" 24 "github.com/bluesky-social/indigo/atproto/syntax" 25 lexutil "github.com/bluesky-social/indigo/lex/util" 26) 27 28// htmx fragment 29func (s *State) PullActions(w http.ResponseWriter, r *http.Request) { 30 switch r.Method { 31 case http.MethodGet: 32 user := s.auth.GetUser(r) 33 f, err := fullyResolvedRepo(r) 34 if err != nil { 35 log.Println("failed to get repo and knot", err) 36 return 37 } 38 39 pull, ok := r.Context().Value("pull").(*db.Pull) 40 if !ok { 41 log.Println("failed to get pull") 42 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 43 return 44 } 45 46 roundNumberStr := chi.URLParam(r, "round") 47 roundNumber, err := strconv.Atoi(roundNumberStr) 48 if err != nil { 49 roundNumber = pull.LastRoundNumber() 50 } 51 if roundNumber >= len(pull.Submissions) { 52 http.Error(w, "bad round id", http.StatusBadRequest) 53 log.Println("failed to parse round id", err) 54 return 55 } 56 57 mergeCheckResponse := s.mergeCheck(f, pull) 58 resubmitResult := pages.Unknown 59 if user.Did == pull.OwnerDid { 60 resubmitResult = s.resubmitCheck(f, pull) 61 } 62 63 s.pages.PullActionsFragment(w, pages.PullActionsParams{ 64 LoggedInUser: user, 65 RepoInfo: f.RepoInfo(s, user), 66 Pull: pull, 67 RoundNumber: roundNumber, 68 MergeCheck: mergeCheckResponse, 69 ResubmitCheck: resubmitResult, 70 }) 71 return 72 } 73} 74 75func (s *State) RepoSinglePull(w http.ResponseWriter, r *http.Request) { 76 user := s.auth.GetUser(r) 77 f, err := fullyResolvedRepo(r) 78 if err != nil { 79 log.Println("failed to get repo and knot", err) 80 return 81 } 82 83 pull, ok := r.Context().Value("pull").(*db.Pull) 84 if !ok { 85 log.Println("failed to get pull") 86 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 87 return 88 } 89 90 totalIdents := 1 91 for _, submission := range pull.Submissions { 92 totalIdents += len(submission.Comments) 93 } 94 95 identsToResolve := make([]string, totalIdents) 96 97 // populate idents 98 identsToResolve[0] = pull.OwnerDid 99 idx := 1 100 for _, submission := range pull.Submissions { 101 for _, comment := range submission.Comments { 102 identsToResolve[idx] = comment.OwnerDid 103 idx += 1 104 } 105 } 106 107 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 108 didHandleMap := make(map[string]string) 109 for _, identity := range resolvedIds { 110 if !identity.Handle.IsInvalidHandle() { 111 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 112 } else { 113 didHandleMap[identity.DID.String()] = identity.DID.String() 114 } 115 } 116 117 mergeCheckResponse := s.mergeCheck(f, pull) 118 resubmitResult := pages.Unknown 119 if user.Did == pull.OwnerDid { 120 resubmitResult = s.resubmitCheck(f, pull) 121 } 122 123 var pullSourceRepo *db.Repo 124 if pull.PullSource != nil { 125 if pull.PullSource.RepoAt != nil { 126 pullSourceRepo, err = db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 127 if err != nil { 128 log.Printf("failed to get repo by at uri: %v", err) 129 return 130 } 131 } 132 } 133 134 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 135 LoggedInUser: user, 136 RepoInfo: f.RepoInfo(s, user), 137 DidHandleMap: didHandleMap, 138 Pull: pull, 139 PullSourceRepo: pullSourceRepo, 140 MergeCheck: mergeCheckResponse, 141 ResubmitCheck: resubmitResult, 142 }) 143} 144 145func (s *State) mergeCheck(f *FullyResolvedRepo, pull *db.Pull) types.MergeCheckResponse { 146 if pull.State == db.PullMerged { 147 return types.MergeCheckResponse{} 148 } 149 150 secret, err := db.GetRegistrationKey(s.db, f.Knot) 151 if err != nil { 152 log.Printf("failed to get registration key: %v", err) 153 return types.MergeCheckResponse{ 154 Error: "failed to check merge status: this knot is unregistered", 155 } 156 } 157 158 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 159 if err != nil { 160 log.Printf("failed to setup signed client for %s; ignoring: %v", f.Knot, err) 161 return types.MergeCheckResponse{ 162 Error: "failed to check merge status", 163 } 164 } 165 166 resp, err := ksClient.MergeCheck([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch) 167 if err != nil { 168 log.Println("failed to check for mergeability:", err) 169 return types.MergeCheckResponse{ 170 Error: "failed to check merge status", 171 } 172 } 173 switch resp.StatusCode { 174 case 404: 175 return types.MergeCheckResponse{ 176 Error: "failed to check merge status: this knot does not support PRs", 177 } 178 case 400: 179 return types.MergeCheckResponse{ 180 Error: "failed to check merge status: does this knot support PRs?", 181 } 182 } 183 184 respBody, err := io.ReadAll(resp.Body) 185 if err != nil { 186 log.Println("failed to read merge check response body") 187 return types.MergeCheckResponse{ 188 Error: "failed to check merge status: knot is not speaking the right language", 189 } 190 } 191 defer resp.Body.Close() 192 193 var mergeCheckResponse types.MergeCheckResponse 194 err = json.Unmarshal(respBody, &mergeCheckResponse) 195 if err != nil { 196 log.Println("failed to unmarshal merge check response", err) 197 return types.MergeCheckResponse{ 198 Error: "failed to check merge status: knot is not speaking the right language", 199 } 200 } 201 202 return mergeCheckResponse 203} 204 205func (s *State) resubmitCheck(f *FullyResolvedRepo, pull *db.Pull) pages.ResubmitResult { 206 if pull.State == db.PullMerged || pull.PullSource == nil { 207 return pages.Unknown 208 } 209 210 var knot, ownerDid, repoName string 211 212 if pull.PullSource.RepoAt != nil { 213 // fork-based pulls 214 sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 215 if err != nil { 216 log.Println("failed to get source repo", err) 217 return pages.Unknown 218 } 219 220 knot = sourceRepo.Knot 221 ownerDid = sourceRepo.Did 222 repoName = sourceRepo.Name 223 } else { 224 // pulls within the same repo 225 knot = f.Knot 226 ownerDid = f.OwnerDid() 227 repoName = f.RepoName 228 } 229 230 us, err := NewUnsignedClient(knot, s.config.Dev) 231 if err != nil { 232 log.Printf("failed to setup client for %s; ignoring: %v", knot, err) 233 return pages.Unknown 234 } 235 236 resp, err := us.Branch(ownerDid, repoName, pull.PullSource.Branch) 237 if err != nil { 238 log.Println("failed to reach knotserver", err) 239 return pages.Unknown 240 } 241 242 body, err := io.ReadAll(resp.Body) 243 if err != nil { 244 log.Printf("error reading response body: %v", err) 245 return pages.Unknown 246 } 247 defer resp.Body.Close() 248 249 var result types.RepoBranchResponse 250 if err := json.Unmarshal(body, &result); err != nil { 251 log.Println("failed to parse response:", err) 252 return pages.Unknown 253 } 254 255 latestSubmission := pull.Submissions[pull.LastRoundNumber()] 256 if latestSubmission.SourceRev != result.Branch.Hash { 257 return pages.ShouldResubmit 258 } 259 260 return pages.ShouldNotResubmit 261} 262 263func (s *State) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 264 user := s.auth.GetUser(r) 265 f, err := fullyResolvedRepo(r) 266 if err != nil { 267 log.Println("failed to get repo and knot", err) 268 return 269 } 270 271 pull, ok := r.Context().Value("pull").(*db.Pull) 272 if !ok { 273 log.Println("failed to get pull") 274 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 275 return 276 } 277 278 roundId := chi.URLParam(r, "round") 279 roundIdInt, err := strconv.Atoi(roundId) 280 if err != nil || roundIdInt >= len(pull.Submissions) { 281 http.Error(w, "bad round id", http.StatusBadRequest) 282 log.Println("failed to parse round id", err) 283 return 284 } 285 286 identsToResolve := []string{pull.OwnerDid} 287 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 288 didHandleMap := make(map[string]string) 289 for _, identity := range resolvedIds { 290 if !identity.Handle.IsInvalidHandle() { 291 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 292 } else { 293 didHandleMap[identity.DID.String()] = identity.DID.String() 294 } 295 } 296 297 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 298 LoggedInUser: user, 299 DidHandleMap: didHandleMap, 300 RepoInfo: f.RepoInfo(s, user), 301 Pull: pull, 302 Round: roundIdInt, 303 Submission: pull.Submissions[roundIdInt], 304 Diff: pull.Submissions[roundIdInt].AsNiceDiff(pull.TargetBranch), 305 }) 306 307} 308 309func (s *State) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { 310 pull, ok := r.Context().Value("pull").(*db.Pull) 311 if !ok { 312 log.Println("failed to get pull") 313 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 314 return 315 } 316 317 roundId := chi.URLParam(r, "round") 318 roundIdInt, err := strconv.Atoi(roundId) 319 if err != nil || roundIdInt >= len(pull.Submissions) { 320 http.Error(w, "bad round id", http.StatusBadRequest) 321 log.Println("failed to parse round id", err) 322 return 323 } 324 325 identsToResolve := []string{pull.OwnerDid} 326 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 327 didHandleMap := make(map[string]string) 328 for _, identity := range resolvedIds { 329 if !identity.Handle.IsInvalidHandle() { 330 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 331 } else { 332 didHandleMap[identity.DID.String()] = identity.DID.String() 333 } 334 } 335 336 w.Header().Set("Content-Type", "text/plain") 337 w.Write([]byte(pull.Submissions[roundIdInt].Patch)) 338} 339 340func (s *State) RepoPulls(w http.ResponseWriter, r *http.Request) { 341 user := s.auth.GetUser(r) 342 params := r.URL.Query() 343 344 state := db.PullOpen 345 switch params.Get("state") { 346 case "closed": 347 state = db.PullClosed 348 case "merged": 349 state = db.PullMerged 350 } 351 352 f, err := fullyResolvedRepo(r) 353 if err != nil { 354 log.Println("failed to get repo and knot", err) 355 return 356 } 357 358 pulls, err := db.GetPulls(s.db, f.RepoAt, state) 359 if err != nil { 360 log.Println("failed to get pulls", err) 361 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 362 return 363 } 364 365 for _, p := range pulls { 366 var pullSourceRepo *db.Repo 367 if p.PullSource != nil { 368 if p.PullSource.RepoAt != nil { 369 pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String()) 370 if err != nil { 371 log.Printf("failed to get repo by at uri: %v", err) 372 return 373 } 374 } 375 p.PullSource.Repo = pullSourceRepo 376 } 377 } 378 379 identsToResolve := make([]string, len(pulls)) 380 for i, pull := range pulls { 381 identsToResolve[i] = pull.OwnerDid 382 } 383 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 384 didHandleMap := make(map[string]string) 385 for _, identity := range resolvedIds { 386 if !identity.Handle.IsInvalidHandle() { 387 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 388 } else { 389 didHandleMap[identity.DID.String()] = identity.DID.String() 390 } 391 } 392 393 s.pages.RepoPulls(w, pages.RepoPullsParams{ 394 LoggedInUser: s.auth.GetUser(r), 395 RepoInfo: f.RepoInfo(s, user), 396 Pulls: pulls, 397 DidHandleMap: didHandleMap, 398 FilteringBy: state, 399 }) 400 return 401} 402 403func (s *State) PullComment(w http.ResponseWriter, r *http.Request) { 404 user := s.auth.GetUser(r) 405 f, err := fullyResolvedRepo(r) 406 if err != nil { 407 log.Println("failed to get repo and knot", err) 408 return 409 } 410 411 pull, ok := r.Context().Value("pull").(*db.Pull) 412 if !ok { 413 log.Println("failed to get pull") 414 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 415 return 416 } 417 418 roundNumberStr := chi.URLParam(r, "round") 419 roundNumber, err := strconv.Atoi(roundNumberStr) 420 if err != nil || roundNumber >= len(pull.Submissions) { 421 http.Error(w, "bad round id", http.StatusBadRequest) 422 log.Println("failed to parse round id", err) 423 return 424 } 425 426 switch r.Method { 427 case http.MethodGet: 428 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{ 429 LoggedInUser: user, 430 RepoInfo: f.RepoInfo(s, user), 431 Pull: pull, 432 RoundNumber: roundNumber, 433 }) 434 return 435 case http.MethodPost: 436 body := r.FormValue("body") 437 if body == "" { 438 s.pages.Notice(w, "pull", "Comment body is required") 439 return 440 } 441 442 // Start a transaction 443 tx, err := s.db.BeginTx(r.Context(), nil) 444 if err != nil { 445 log.Println("failed to start transaction", err) 446 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 447 return 448 } 449 defer tx.Rollback() 450 451 createdAt := time.Now().Format(time.RFC3339) 452 ownerDid := user.Did 453 454 pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId) 455 if err != nil { 456 log.Println("failed to get pull at", err) 457 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 458 return 459 } 460 461 atUri := f.RepoAt.String() 462 client, _ := s.auth.AuthorizedClient(r) 463 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 464 Collection: tangled.RepoPullCommentNSID, 465 Repo: user.Did, 466 Rkey: s.TID(), 467 Record: &lexutil.LexiconTypeDecoder{ 468 Val: &tangled.RepoPullComment{ 469 Repo: &atUri, 470 Pull: pullAt, 471 Owner: &ownerDid, 472 Body: &body, 473 CreatedAt: &createdAt, 474 }, 475 }, 476 }) 477 if err != nil { 478 log.Println("failed to create pull comment", err) 479 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 480 return 481 } 482 483 // Create the pull comment in the database with the commentAt field 484 commentId, err := db.NewPullComment(tx, &db.PullComment{ 485 OwnerDid: user.Did, 486 RepoAt: f.RepoAt.String(), 487 PullId: pull.PullId, 488 Body: body, 489 CommentAt: atResp.Uri, 490 SubmissionId: pull.Submissions[roundNumber].ID, 491 }) 492 if err != nil { 493 log.Println("failed to create pull comment", err) 494 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 495 return 496 } 497 498 // Commit the transaction 499 if err = tx.Commit(); err != nil { 500 log.Println("failed to commit transaction", err) 501 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 502 return 503 } 504 505 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 506 return 507 } 508} 509 510func (s *State) NewPull(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 get repo and knot", err) 515 return 516 } 517 518 switch r.Method { 519 case http.MethodGet: 520 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 521 if err != nil { 522 log.Printf("failed to create unsigned client for %s", f.Knot) 523 s.pages.Error503(w) 524 return 525 } 526 527 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 528 if err != nil { 529 log.Println("failed to reach knotserver", err) 530 return 531 } 532 533 body, err := io.ReadAll(resp.Body) 534 if err != nil { 535 log.Printf("Error reading response body: %v", err) 536 return 537 } 538 539 var result types.RepoBranchesResponse 540 err = json.Unmarshal(body, &result) 541 if err != nil { 542 log.Println("failed to parse response:", err) 543 return 544 } 545 546 s.pages.RepoNewPull(w, pages.RepoNewPullParams{ 547 LoggedInUser: user, 548 RepoInfo: f.RepoInfo(s, user), 549 Branches: result.Branches, 550 }) 551 case http.MethodPost: 552 title := r.FormValue("title") 553 body := r.FormValue("body") 554 targetBranch := r.FormValue("targetBranch") 555 fromFork := r.FormValue("fork") 556 sourceBranch := r.FormValue("sourceBranch") 557 patch := r.FormValue("patch") 558 559 // Validate required fields for all PR types 560 if title == "" || body == "" || targetBranch == "" { 561 s.pages.Notice(w, "pull", "Title, body and target branch are required.") 562 return 563 } 564 565 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 566 if err != nil { 567 log.Println("failed to create unsigned client to %s: %v", f.Knot, err) 568 s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 569 return 570 } 571 572 caps, err := us.Capabilities() 573 if err != nil { 574 log.Println("error fetching knot caps", f.Knot, err) 575 s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 576 return 577 } 578 579 // Determine PR type based on input parameters 580 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed() 581 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 582 isForkBased := fromFork != "" && sourceBranch != "" 583 isPatchBased := patch != "" && !isBranchBased && !isForkBased 584 585 // Validate we have at least one valid PR creation method 586 if !isBranchBased && !isPatchBased && !isForkBased { 587 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.") 588 return 589 } 590 591 // Can't mix branch-based and patch-based approaches 592 if isBranchBased && patch != "" { 593 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.") 594 return 595 } 596 597 // Handle the PR creation based on the type 598 if isBranchBased { 599 if !caps.PullRequests.BranchSubmissions { 600 s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?") 601 return 602 } 603 s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch) 604 } else if isForkBased { 605 if !caps.PullRequests.ForkSubmissions { 606 s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?") 607 return 608 } 609 s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch) 610 } else if isPatchBased { 611 if !caps.PullRequests.PatchSubmissions { 612 s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.") 613 return 614 } 615 s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch) 616 } 617 return 618 } 619} 620 621func (s *State) handleBranchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, sourceBranch string) { 622 pullSource := &db.PullSource{ 623 Branch: sourceBranch, 624 } 625 recordPullSource := &tangled.RepoPull_Source{ 626 Branch: sourceBranch, 627 } 628 629 // Generate a patch using /compare 630 ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev) 631 if err != nil { 632 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 633 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 634 return 635 } 636 637 resp, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch) 638 switch resp.StatusCode { 639 case 404: 640 case 400: 641 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 642 return 643 } 644 645 respBody, err := io.ReadAll(resp.Body) 646 if err != nil { 647 log.Println("failed to compare across branches") 648 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 649 return 650 } 651 defer resp.Body.Close() 652 653 var diffTreeResponse types.RepoDiffTreeResponse 654 err = json.Unmarshal(respBody, &diffTreeResponse) 655 if err != nil { 656 log.Println("failed to unmarshal diff tree response", err) 657 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 658 return 659 } 660 661 sourceRev := diffTreeResponse.DiffTree.Rev2 662 patch := diffTreeResponse.DiffTree.Patch 663 664 if !isPatchValid(patch) { 665 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 666 return 667 } 668 669 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource) 670} 671 672func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, patch string) { 673 if !isPatchValid(patch) { 674 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 675 return 676 } 677 678 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil) 679} 680 681func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, forkRepo string, title, body, targetBranch, sourceBranch string) { 682 fork, err := db.GetForkByDid(s.db, user.Did, forkRepo) 683 if errors.Is(err, sql.ErrNoRows) { 684 s.pages.Notice(w, "pull", "No such fork.") 685 return 686 } else if err != nil { 687 log.Println("failed to fetch fork:", err) 688 s.pages.Notice(w, "pull", "Failed to fetch fork.") 689 return 690 } 691 692 secret, err := db.GetRegistrationKey(s.db, fork.Knot) 693 if err != nil { 694 log.Println("failed to fetch registration key:", err) 695 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 696 return 697 } 698 699 sc, err := NewSignedClient(fork.Knot, secret, s.config.Dev) 700 if err != nil { 701 log.Println("failed to create signed client:", err) 702 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 703 return 704 } 705 706 us, err := NewUnsignedClient(fork.Knot, s.config.Dev) 707 if err != nil { 708 log.Println("failed to create unsigned client:", err) 709 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 710 return 711 } 712 713 resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch) 714 if err != nil { 715 log.Println("failed to create hidden ref:", err, resp.StatusCode) 716 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 717 return 718 } 719 720 switch resp.StatusCode { 721 case 404: 722 case 400: 723 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 724 return 725 } 726 727 hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch)) 728 // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking 729 // the targetBranch on the target repository. This code is a bit confusing, but here's an example: 730 // hiddenRef: hidden/feature-1/main (on repo-fork) 731 // targetBranch: main (on repo-1) 732 // sourceBranch: feature-1 (on repo-fork) 733 diffResp, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch) 734 if err != nil { 735 log.Println("failed to compare across branches", err) 736 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 737 return 738 } 739 740 respBody, err := io.ReadAll(diffResp.Body) 741 if err != nil { 742 log.Println("failed to read response body", err) 743 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 744 return 745 } 746 747 defer resp.Body.Close() 748 749 var diffTreeResponse types.RepoDiffTreeResponse 750 err = json.Unmarshal(respBody, &diffTreeResponse) 751 if err != nil { 752 log.Println("failed to unmarshal diff tree response", err) 753 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 754 return 755 } 756 757 sourceRev := diffTreeResponse.DiffTree.Rev2 758 patch := diffTreeResponse.DiffTree.Patch 759 760 if !isPatchValid(patch) { 761 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 762 return 763 } 764 765 forkAtUri, err := syntax.ParseATURI(fork.AtUri) 766 if err != nil { 767 log.Println("failed to parse fork AT URI", err) 768 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 769 return 770 } 771 772 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{ 773 Branch: sourceBranch, 774 RepoAt: &forkAtUri, 775 }, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri}) 776} 777 778func (s *State) createPullRequest(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, patch, sourceRev string, pullSource *db.PullSource, recordPullSource *tangled.RepoPull_Source) { 779 tx, err := s.db.BeginTx(r.Context(), nil) 780 if err != nil { 781 log.Println("failed to start tx") 782 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 783 return 784 } 785 defer tx.Rollback() 786 787 rkey := s.TID() 788 initialSubmission := db.PullSubmission{ 789 Patch: patch, 790 SourceRev: sourceRev, 791 } 792 err = db.NewPull(tx, &db.Pull{ 793 Title: title, 794 Body: body, 795 TargetBranch: targetBranch, 796 OwnerDid: user.Did, 797 RepoAt: f.RepoAt, 798 Rkey: rkey, 799 Submissions: []*db.PullSubmission{ 800 &initialSubmission, 801 }, 802 PullSource: pullSource, 803 }) 804 if err != nil { 805 log.Println("failed to create pull request", err) 806 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 807 return 808 } 809 client, _ := s.auth.AuthorizedClient(r) 810 pullId, err := db.NextPullId(s.db, f.RepoAt) 811 if err != nil { 812 log.Println("failed to get pull id", err) 813 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 814 return 815 } 816 817 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 818 Collection: tangled.RepoPullNSID, 819 Repo: user.Did, 820 Rkey: rkey, 821 Record: &lexutil.LexiconTypeDecoder{ 822 Val: &tangled.RepoPull{ 823 Title: title, 824 PullId: int64(pullId), 825 TargetRepo: string(f.RepoAt), 826 TargetBranch: targetBranch, 827 Patch: patch, 828 Source: recordPullSource, 829 }, 830 }, 831 }) 832 833 err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri) 834 if err != nil { 835 log.Println("failed to get pull id", err) 836 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 837 return 838 } 839 840 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 841} 842 843func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { 844 user := s.auth.GetUser(r) 845 f, err := fullyResolvedRepo(r) 846 if err != nil { 847 log.Println("failed to get repo and knot", err) 848 return 849 } 850 851 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ 852 RepoInfo: f.RepoInfo(s, user), 853 }) 854} 855 856func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) { 857 user := s.auth.GetUser(r) 858 f, err := fullyResolvedRepo(r) 859 if err != nil { 860 log.Println("failed to get repo and knot", err) 861 return 862 } 863 864 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 865 if err != nil { 866 log.Printf("failed to create unsigned client for %s", f.Knot) 867 s.pages.Error503(w) 868 return 869 } 870 871 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 872 if err != nil { 873 log.Println("failed to reach knotserver", err) 874 return 875 } 876 877 body, err := io.ReadAll(resp.Body) 878 if err != nil { 879 log.Printf("Error reading response body: %v", err) 880 return 881 } 882 883 var result types.RepoBranchesResponse 884 err = json.Unmarshal(body, &result) 885 if err != nil { 886 log.Println("failed to parse response:", err) 887 return 888 } 889 890 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{ 891 RepoInfo: f.RepoInfo(s, user), 892 Branches: result.Branches, 893 }) 894} 895 896func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) { 897 user := s.auth.GetUser(r) 898 f, err := fullyResolvedRepo(r) 899 if err != nil { 900 log.Println("failed to get repo and knot", err) 901 return 902 } 903 904 forks, err := db.GetForksByDid(s.db, user.Did) 905 if err != nil { 906 log.Println("failed to get forks", err) 907 return 908 } 909 910 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{ 911 RepoInfo: f.RepoInfo(s, user), 912 Forks: forks, 913 }) 914} 915 916func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) { 917 user := s.auth.GetUser(r) 918 919 f, err := fullyResolvedRepo(r) 920 if err != nil { 921 log.Println("failed to get repo and knot", err) 922 return 923 } 924 925 forkVal := r.URL.Query().Get("fork") 926 927 // fork repo 928 repo, err := db.GetRepo(s.db, user.Did, forkVal) 929 if err != nil { 930 log.Println("failed to get repo", user.Did, forkVal) 931 return 932 } 933 934 sourceBranchesClient, err := NewUnsignedClient(repo.Knot, s.config.Dev) 935 if err != nil { 936 log.Printf("failed to create unsigned client for %s", repo.Knot) 937 s.pages.Error503(w) 938 return 939 } 940 941 sourceResp, err := sourceBranchesClient.Branches(user.Did, repo.Name) 942 if err != nil { 943 log.Println("failed to reach knotserver for source branches", err) 944 return 945 } 946 947 sourceBody, err := io.ReadAll(sourceResp.Body) 948 if err != nil { 949 log.Println("failed to read source response body", err) 950 return 951 } 952 defer sourceResp.Body.Close() 953 954 var sourceResult types.RepoBranchesResponse 955 err = json.Unmarshal(sourceBody, &sourceResult) 956 if err != nil { 957 log.Println("failed to parse source branches response:", err) 958 return 959 } 960 961 targetBranchesClient, err := NewUnsignedClient(f.Knot, s.config.Dev) 962 if err != nil { 963 log.Printf("failed to create unsigned client for target knot %s", f.Knot) 964 s.pages.Error503(w) 965 return 966 } 967 968 targetResp, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName) 969 if err != nil { 970 log.Println("failed to reach knotserver for target branches", err) 971 return 972 } 973 974 targetBody, err := io.ReadAll(targetResp.Body) 975 if err != nil { 976 log.Println("failed to read target response body", err) 977 return 978 } 979 defer targetResp.Body.Close() 980 981 var targetResult types.RepoBranchesResponse 982 err = json.Unmarshal(targetBody, &targetResult) 983 if err != nil { 984 log.Println("failed to parse target branches response:", err) 985 return 986 } 987 988 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 989 RepoInfo: f.RepoInfo(s, user), 990 SourceBranches: sourceResult.Branches, 991 TargetBranches: targetResult.Branches, 992 }) 993} 994 995func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) { 996 user := s.auth.GetUser(r) 997 f, err := fullyResolvedRepo(r) 998 if err != nil { 999 log.Println("failed to get repo and knot", err) 1000 return 1001 } 1002 1003 pull, ok := r.Context().Value("pull").(*db.Pull) 1004 if !ok { 1005 log.Println("failed to get pull") 1006 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1007 return 1008 } 1009 1010 switch r.Method { 1011 case http.MethodGet: 1012 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ 1013 RepoInfo: f.RepoInfo(s, user), 1014 Pull: pull, 1015 }) 1016 return 1017 case http.MethodPost: 1018 patch := r.FormValue("patch") 1019 var sourceRev string 1020 var recordPullSource *tangled.RepoPull_Source 1021 1022 var ownerDid, repoName, knotName string 1023 var isSameRepo bool = pull.IsSameRepoBranch() 1024 sourceBranch := pull.PullSource.Branch 1025 targetBranch := pull.TargetBranch 1026 recordPullSource = &tangled.RepoPull_Source{ 1027 Branch: sourceBranch, 1028 } 1029 1030 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed() 1031 if isSameRepo && isPushAllowed { 1032 ownerDid = f.OwnerDid() 1033 repoName = f.RepoName 1034 knotName = f.Knot 1035 } else if !isSameRepo { 1036 sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 1037 if err != nil { 1038 log.Println("failed to get source repo", err) 1039 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1040 return 1041 } 1042 ownerDid = sourceRepo.Did 1043 repoName = sourceRepo.Name 1044 knotName = sourceRepo.Knot 1045 } 1046 1047 if sourceBranch != "" && knotName != "" { 1048 // extract patch by performing compare 1049 ksClient, err := NewUnsignedClient(knotName, s.config.Dev) 1050 if err != nil { 1051 log.Printf("failed to create client for %s: %s", knotName, err) 1052 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1053 return 1054 } 1055 1056 if !isSameRepo { 1057 secret, err := db.GetRegistrationKey(s.db, knotName) 1058 if err != nil { 1059 log.Printf("failed to get registration key for %s: %s", knotName, err) 1060 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1061 return 1062 } 1063 // update the hidden tracking branch to latest 1064 signedClient, err := NewSignedClient(knotName, secret, s.config.Dev) 1065 if err != nil { 1066 log.Printf("failed to create signed client for %s: %s", knotName, err) 1067 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1068 return 1069 } 1070 resp, err := signedClient.NewHiddenRef(ownerDid, repoName, sourceBranch, targetBranch) 1071 if err != nil || resp.StatusCode != http.StatusNoContent { 1072 log.Printf("failed to update tracking branch: %s", err) 1073 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1074 return 1075 } 1076 } 1077 1078 var compareResp *http.Response 1079 if !isSameRepo { 1080 hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch)) 1081 compareResp, err = ksClient.Compare(ownerDid, repoName, hiddenRef, sourceBranch) 1082 } else { 1083 compareResp, err = ksClient.Compare(ownerDid, repoName, targetBranch, sourceBranch) 1084 } 1085 if err != nil { 1086 log.Printf("failed to compare branches: %s", err) 1087 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1088 return 1089 } 1090 defer compareResp.Body.Close() 1091 1092 switch compareResp.StatusCode { 1093 case 404: 1094 case 400: 1095 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 1096 return 1097 } 1098 1099 respBody, err := io.ReadAll(compareResp.Body) 1100 if err != nil { 1101 log.Println("failed to compare across branches") 1102 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1103 return 1104 } 1105 defer compareResp.Body.Close() 1106 1107 var diffTreeResponse types.RepoDiffTreeResponse 1108 err = json.Unmarshal(respBody, &diffTreeResponse) 1109 if err != nil { 1110 log.Println("failed to unmarshal diff tree response", err) 1111 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1112 return 1113 } 1114 1115 sourceRev = diffTreeResponse.DiffTree.Rev2 1116 patch = diffTreeResponse.DiffTree.Patch 1117 } 1118 1119 if patch == "" { 1120 s.pages.Notice(w, "resubmit-error", "Patch is empty.") 1121 return 1122 } 1123 1124 if patch == pull.LatestPatch() { 1125 s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 1126 return 1127 } 1128 1129 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { 1130 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1131 return 1132 } 1133 1134 if !isPatchValid(patch) { 1135 s.pages.Notice(w, "resubmit-error", "Invalid patch format. Please provide a valid diff.") 1136 return 1137 } 1138 1139 tx, err := s.db.BeginTx(r.Context(), nil) 1140 if err != nil { 1141 log.Println("failed to start tx") 1142 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1143 return 1144 } 1145 defer tx.Rollback() 1146 1147 err = db.ResubmitPull(tx, pull, patch, sourceRev) 1148 if err != nil { 1149 log.Println("failed to create pull request", err) 1150 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1151 return 1152 } 1153 client, _ := s.auth.AuthorizedClient(r) 1154 1155 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1156 if err != nil { 1157 // failed to get record 1158 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1159 return 1160 } 1161 1162 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1163 Collection: tangled.RepoPullNSID, 1164 Repo: user.Did, 1165 Rkey: pull.Rkey, 1166 SwapRecord: ex.Cid, 1167 Record: &lexutil.LexiconTypeDecoder{ 1168 Val: &tangled.RepoPull{ 1169 Title: pull.Title, 1170 PullId: int64(pull.PullId), 1171 TargetRepo: string(f.RepoAt), 1172 TargetBranch: pull.TargetBranch, 1173 Patch: patch, // new patch 1174 Source: recordPullSource, 1175 }, 1176 }, 1177 }) 1178 if err != nil { 1179 log.Println("failed to update record", err) 1180 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1181 return 1182 } 1183 1184 if err = tx.Commit(); err != nil { 1185 log.Println("failed to commit transaction", err) 1186 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 1187 return 1188 } 1189 1190 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1191 return 1192 } 1193} 1194 1195func (s *State) MergePull(w http.ResponseWriter, r *http.Request) { 1196 f, err := fullyResolvedRepo(r) 1197 if err != nil { 1198 log.Println("failed to resolve repo:", err) 1199 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1200 return 1201 } 1202 1203 pull, ok := r.Context().Value("pull").(*db.Pull) 1204 if !ok { 1205 log.Println("failed to get pull") 1206 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1207 return 1208 } 1209 1210 secret, err := db.GetRegistrationKey(s.db, f.Knot) 1211 if err != nil { 1212 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err) 1213 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1214 return 1215 } 1216 1217 ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid) 1218 if err != nil { 1219 log.Printf("resolving identity: %s", err) 1220 w.WriteHeader(http.StatusNotFound) 1221 return 1222 } 1223 1224 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid) 1225 if err != nil { 1226 log.Printf("failed to get primary email: %s", err) 1227 } 1228 1229 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 1230 if err != nil { 1231 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 1232 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1233 return 1234 } 1235 1236 // Merge the pull request 1237 resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address) 1238 if err != nil { 1239 log.Printf("failed to merge pull request: %s", err) 1240 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1241 return 1242 } 1243 1244 if resp.StatusCode == http.StatusOK { 1245 err := db.MergePull(s.db, f.RepoAt, pull.PullId) 1246 if err != nil { 1247 log.Printf("failed to update pull request status in database: %s", err) 1248 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1249 return 1250 } 1251 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId)) 1252 } else { 1253 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode) 1254 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1255 } 1256} 1257 1258func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) { 1259 user := s.auth.GetUser(r) 1260 1261 f, err := fullyResolvedRepo(r) 1262 if err != nil { 1263 log.Println("malformed middleware") 1264 return 1265 } 1266 1267 pull, ok := r.Context().Value("pull").(*db.Pull) 1268 if !ok { 1269 log.Println("failed to get pull") 1270 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1271 return 1272 } 1273 1274 // auth filter: only owner or collaborators can close 1275 roles := RolesInRepo(s, user, f) 1276 isCollaborator := roles.IsCollaborator() 1277 isPullAuthor := user.Did == pull.OwnerDid 1278 isCloseAllowed := isCollaborator || isPullAuthor 1279 if !isCloseAllowed { 1280 log.Println("failed to close pull") 1281 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 1282 return 1283 } 1284 1285 // Start a transaction 1286 tx, err := s.db.BeginTx(r.Context(), nil) 1287 if err != nil { 1288 log.Println("failed to start transaction", err) 1289 s.pages.Notice(w, "pull-close", "Failed to close pull.") 1290 return 1291 } 1292 1293 // Close the pull in the database 1294 err = db.ClosePull(tx, f.RepoAt, pull.PullId) 1295 if err != nil { 1296 log.Println("failed to close pull", err) 1297 s.pages.Notice(w, "pull-close", "Failed to close pull.") 1298 return 1299 } 1300 1301 // Commit the transaction 1302 if err = tx.Commit(); err != nil { 1303 log.Println("failed to commit transaction", err) 1304 s.pages.Notice(w, "pull-close", "Failed to close pull.") 1305 return 1306 } 1307 1308 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1309 return 1310} 1311 1312func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) { 1313 user := s.auth.GetUser(r) 1314 1315 f, err := fullyResolvedRepo(r) 1316 if err != nil { 1317 log.Println("failed to resolve repo", err) 1318 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1319 return 1320 } 1321 1322 pull, ok := r.Context().Value("pull").(*db.Pull) 1323 if !ok { 1324 log.Println("failed to get pull") 1325 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1326 return 1327 } 1328 1329 // auth filter: only owner or collaborators can close 1330 roles := RolesInRepo(s, user, f) 1331 isCollaborator := roles.IsCollaborator() 1332 isPullAuthor := user.Did == pull.OwnerDid 1333 isCloseAllowed := isCollaborator || isPullAuthor 1334 if !isCloseAllowed { 1335 log.Println("failed to close pull") 1336 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 1337 return 1338 } 1339 1340 // Start a transaction 1341 tx, err := s.db.BeginTx(r.Context(), nil) 1342 if err != nil { 1343 log.Println("failed to start transaction", err) 1344 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1345 return 1346 } 1347 1348 // Reopen the pull in the database 1349 err = db.ReopenPull(tx, f.RepoAt, pull.PullId) 1350 if err != nil { 1351 log.Println("failed to reopen pull", err) 1352 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1353 return 1354 } 1355 1356 // Commit the transaction 1357 if err = tx.Commit(); err != nil { 1358 log.Println("failed to commit transaction", err) 1359 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1360 return 1361 } 1362 1363 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1364 return 1365} 1366 1367// Very basic validation to check if it looks like a diff/patch 1368// A valid patch usually starts with diff or --- lines 1369func isPatchValid(patch string) bool { 1370 // Basic validation to check if it looks like a diff/patch 1371 // A valid patch usually starts with diff or --- lines 1372 if len(patch) == 0 { 1373 return false 1374 } 1375 1376 lines := strings.Split(patch, "\n") 1377 if len(lines) < 2 { 1378 return false 1379 } 1380 1381 // Check for common patch format markers 1382 firstLine := strings.TrimSpace(lines[0]) 1383 return strings.HasPrefix(firstLine, "diff ") || 1384 strings.HasPrefix(firstLine, "--- ") || 1385 strings.HasPrefix(firstLine, "Index: ") || 1386 strings.HasPrefix(firstLine, "+++ ") || 1387 strings.HasPrefix(firstLine, "@@ ") 1388}