forked from tangled.org/core
this repo has no description
at fork-pulls 40 kB view raw
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 var resubmitResult pages.ResubmitResult 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 var resubmitResult pages.ResubmitResult 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 } 376 377 p.PullSource.Repo = pullSourceRepo 378 } 379 380 identsToResolve := make([]string, len(pulls)) 381 for i, pull := range pulls { 382 identsToResolve[i] = pull.OwnerDid 383 } 384 resolvedIds := s.resolver.ResolveIdents(r.Context(), identsToResolve) 385 didHandleMap := make(map[string]string) 386 for _, identity := range resolvedIds { 387 if !identity.Handle.IsInvalidHandle() { 388 didHandleMap[identity.DID.String()] = fmt.Sprintf("@%s", identity.Handle.String()) 389 } else { 390 didHandleMap[identity.DID.String()] = identity.DID.String() 391 } 392 } 393 394 s.pages.RepoPulls(w, pages.RepoPullsParams{ 395 LoggedInUser: s.auth.GetUser(r), 396 RepoInfo: f.RepoInfo(s, user), 397 Pulls: pulls, 398 DidHandleMap: didHandleMap, 399 FilteringBy: state, 400 }) 401 return 402} 403 404func (s *State) PullComment(w http.ResponseWriter, r *http.Request) { 405 user := s.auth.GetUser(r) 406 f, err := fullyResolvedRepo(r) 407 if err != nil { 408 log.Println("failed to get repo and knot", err) 409 return 410 } 411 412 pull, ok := r.Context().Value("pull").(*db.Pull) 413 if !ok { 414 log.Println("failed to get pull") 415 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 416 return 417 } 418 419 roundNumberStr := chi.URLParam(r, "round") 420 roundNumber, err := strconv.Atoi(roundNumberStr) 421 if err != nil || roundNumber >= len(pull.Submissions) { 422 http.Error(w, "bad round id", http.StatusBadRequest) 423 log.Println("failed to parse round id", err) 424 return 425 } 426 427 switch r.Method { 428 case http.MethodGet: 429 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{ 430 LoggedInUser: user, 431 RepoInfo: f.RepoInfo(s, user), 432 Pull: pull, 433 RoundNumber: roundNumber, 434 }) 435 return 436 case http.MethodPost: 437 body := r.FormValue("body") 438 if body == "" { 439 s.pages.Notice(w, "pull", "Comment body is required") 440 return 441 } 442 443 // Start a transaction 444 tx, err := s.db.BeginTx(r.Context(), nil) 445 if err != nil { 446 log.Println("failed to start transaction", err) 447 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 448 return 449 } 450 defer tx.Rollback() 451 452 createdAt := time.Now().Format(time.RFC3339) 453 ownerDid := user.Did 454 455 pullAt, err := db.GetPullAt(s.db, f.RepoAt, pull.PullId) 456 if err != nil { 457 log.Println("failed to get pull at", err) 458 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 459 return 460 } 461 462 atUri := f.RepoAt.String() 463 client, _ := s.auth.AuthorizedClient(r) 464 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 465 Collection: tangled.RepoPullCommentNSID, 466 Repo: user.Did, 467 Rkey: s.TID(), 468 Record: &lexutil.LexiconTypeDecoder{ 469 Val: &tangled.RepoPullComment{ 470 Repo: &atUri, 471 Pull: pullAt, 472 Owner: &ownerDid, 473 Body: &body, 474 CreatedAt: &createdAt, 475 }, 476 }, 477 }) 478 if err != nil { 479 log.Println("failed to create pull comment", err) 480 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 481 return 482 } 483 484 // Create the pull comment in the database with the commentAt field 485 commentId, err := db.NewPullComment(tx, &db.PullComment{ 486 OwnerDid: user.Did, 487 RepoAt: f.RepoAt.String(), 488 PullId: pull.PullId, 489 Body: body, 490 CommentAt: atResp.Uri, 491 SubmissionId: pull.Submissions[roundNumber].ID, 492 }) 493 if err != nil { 494 log.Println("failed to create pull comment", err) 495 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 496 return 497 } 498 499 // Commit the transaction 500 if err = tx.Commit(); err != nil { 501 log.Println("failed to commit transaction", err) 502 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 503 return 504 } 505 506 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 507 return 508 } 509} 510 511func (s *State) NewPull(w http.ResponseWriter, r *http.Request) { 512 user := s.auth.GetUser(r) 513 f, err := fullyResolvedRepo(r) 514 if err != nil { 515 log.Println("failed to get repo and knot", err) 516 return 517 } 518 519 switch r.Method { 520 case http.MethodGet: 521 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 522 if err != nil { 523 log.Printf("failed to create unsigned client for %s", f.Knot) 524 s.pages.Error503(w) 525 return 526 } 527 528 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 529 if err != nil { 530 log.Println("failed to reach knotserver", err) 531 return 532 } 533 534 body, err := io.ReadAll(resp.Body) 535 if err != nil { 536 log.Printf("Error reading response body: %v", err) 537 return 538 } 539 540 var result types.RepoBranchesResponse 541 err = json.Unmarshal(body, &result) 542 if err != nil { 543 log.Println("failed to parse response:", err) 544 return 545 } 546 547 s.pages.RepoNewPull(w, pages.RepoNewPullParams{ 548 LoggedInUser: user, 549 RepoInfo: f.RepoInfo(s, user), 550 Branches: result.Branches, 551 }) 552 case http.MethodPost: 553 title := r.FormValue("title") 554 body := r.FormValue("body") 555 targetBranch := r.FormValue("targetBranch") 556 fromFork := r.FormValue("fork") 557 sourceBranch := r.FormValue("sourceBranch") 558 patch := r.FormValue("patch") 559 560 // Validate required fields for all PR types 561 if title == "" || body == "" || targetBranch == "" { 562 s.pages.Notice(w, "pull", "Title, body and target branch are required.") 563 return 564 } 565 566 // Determine PR type based on input parameters 567 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed() 568 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 569 isForkBased := fromFork != "" && sourceBranch != "" 570 isPatchBased := patch != "" && !isBranchBased && !isForkBased 571 572 // Validate we have at least one valid PR creation method 573 if !isBranchBased && !isPatchBased && !isForkBased { 574 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.") 575 return 576 } 577 578 // Can't mix branch-based and patch-based approaches 579 if isBranchBased && patch != "" { 580 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.") 581 return 582 } 583 584 // Handle the PR creation based on the type 585 if isBranchBased { 586 s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch) 587 } else if isForkBased { 588 s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch) 589 } else if isPatchBased { 590 s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch) 591 } 592 return 593 } 594} 595 596func (s *State) handleBranchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, sourceBranch string) { 597 pullSource := &db.PullSource{ 598 Branch: sourceBranch, 599 } 600 recordPullSource := &tangled.RepoPull_Source{ 601 Branch: sourceBranch, 602 } 603 604 // Generate a patch using /compare 605 ksClient, err := NewUnsignedClient(f.Knot, s.config.Dev) 606 if err != nil { 607 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 608 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 609 return 610 } 611 612 resp, err := ksClient.Compare(f.OwnerDid(), f.RepoName, targetBranch, sourceBranch) 613 switch resp.StatusCode { 614 case 404: 615 case 400: 616 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 617 return 618 } 619 620 respBody, err := io.ReadAll(resp.Body) 621 if err != nil { 622 log.Println("failed to compare across branches") 623 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 624 return 625 } 626 defer resp.Body.Close() 627 628 var diffTreeResponse types.RepoDiffTreeResponse 629 err = json.Unmarshal(respBody, &diffTreeResponse) 630 if err != nil { 631 log.Println("failed to unmarshal diff tree response", err) 632 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 633 return 634 } 635 636 sourceRev := diffTreeResponse.DiffTree.Rev2 637 patch := diffTreeResponse.DiffTree.Patch 638 639 if !isPatchValid(patch) { 640 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 641 return 642 } 643 644 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, pullSource, recordPullSource) 645} 646 647func (s *State) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, title, body, targetBranch, patch string) { 648 if !isPatchValid(patch) { 649 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 650 return 651 } 652 653 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", nil, nil) 654} 655 656func (s *State) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *FullyResolvedRepo, user *auth.User, forkRepo string, title, body, targetBranch, sourceBranch string) { 657 fork, err := db.GetForkByDid(s.db, user.Did, forkRepo) 658 if errors.Is(err, sql.ErrNoRows) { 659 s.pages.Notice(w, "pull", "No such fork.") 660 return 661 } else if err != nil { 662 log.Println("failed to fetch fork:", err) 663 s.pages.Notice(w, "pull", "Failed to fetch fork.") 664 return 665 } 666 667 secret, err := db.GetRegistrationKey(s.db, fork.Knot) 668 if err != nil { 669 log.Println("failed to fetch registration key:", err) 670 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 671 return 672 } 673 674 sc, err := NewSignedClient(fork.Knot, secret, s.config.Dev) 675 if err != nil { 676 log.Println("failed to create signed client:", err) 677 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 678 return 679 } 680 681 us, err := NewUnsignedClient(fork.Knot, s.config.Dev) 682 if err != nil { 683 log.Println("failed to create unsigned client:", err) 684 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 685 return 686 } 687 688 resp, err := sc.NewHiddenRef(user.Did, fork.Name, sourceBranch, targetBranch) 689 if err != nil { 690 log.Println("failed to create hidden ref:", err, resp.StatusCode) 691 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 692 return 693 } 694 695 switch resp.StatusCode { 696 case 404: 697 case 400: 698 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 699 return 700 } 701 702 hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch)) 703 // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking 704 // the targetBranch on the target repository. This code is a bit confusing, but here's an example: 705 // hiddenRef: hidden/feature-1/main (on repo-fork) 706 // targetBranch: main (on repo-1) 707 // sourceBranch: feature-1 (on repo-fork) 708 diffResp, err := us.Compare(user.Did, fork.Name, hiddenRef, sourceBranch) 709 if err != nil { 710 log.Println("failed to compare across branches", err) 711 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 712 return 713 } 714 715 respBody, err := io.ReadAll(diffResp.Body) 716 if err != nil { 717 log.Println("failed to read response body", err) 718 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 719 return 720 } 721 722 defer resp.Body.Close() 723 724 var diffTreeResponse types.RepoDiffTreeResponse 725 err = json.Unmarshal(respBody, &diffTreeResponse) 726 if err != nil { 727 log.Println("failed to unmarshal diff tree response", err) 728 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 729 return 730 } 731 732 sourceRev := diffTreeResponse.DiffTree.Rev2 733 patch := diffTreeResponse.DiffTree.Patch 734 735 if !isPatchValid(patch) { 736 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 737 return 738 } 739 740 forkAtUri, err := syntax.ParseATURI(fork.AtUri) 741 if err != nil { 742 log.Println("failed to parse fork AT URI", err) 743 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 744 return 745 } 746 747 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, sourceRev, &db.PullSource{ 748 Branch: sourceBranch, 749 RepoAt: &forkAtUri, 750 }, &tangled.RepoPull_Source{Branch: sourceBranch, Repo: &fork.AtUri}) 751} 752 753func (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) { 754 tx, err := s.db.BeginTx(r.Context(), nil) 755 if err != nil { 756 log.Println("failed to start tx") 757 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 758 return 759 } 760 defer tx.Rollback() 761 762 rkey := s.TID() 763 initialSubmission := db.PullSubmission{ 764 Patch: patch, 765 SourceRev: sourceRev, 766 } 767 err = db.NewPull(tx, &db.Pull{ 768 Title: title, 769 Body: body, 770 TargetBranch: targetBranch, 771 OwnerDid: user.Did, 772 RepoAt: f.RepoAt, 773 Rkey: rkey, 774 Submissions: []*db.PullSubmission{ 775 &initialSubmission, 776 }, 777 PullSource: pullSource, 778 }) 779 if err != nil { 780 log.Println("failed to create pull request", err) 781 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 782 return 783 } 784 client, _ := s.auth.AuthorizedClient(r) 785 pullId, err := db.NextPullId(s.db, f.RepoAt) 786 if err != nil { 787 log.Println("failed to get pull id", err) 788 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 789 return 790 } 791 792 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 793 Collection: tangled.RepoPullNSID, 794 Repo: user.Did, 795 Rkey: rkey, 796 Record: &lexutil.LexiconTypeDecoder{ 797 Val: &tangled.RepoPull{ 798 Title: title, 799 PullId: int64(pullId), 800 TargetRepo: string(f.RepoAt), 801 TargetBranch: targetBranch, 802 Patch: patch, 803 Source: recordPullSource, 804 }, 805 }, 806 }) 807 808 err = db.SetPullAt(s.db, f.RepoAt, pullId, atResp.Uri) 809 if err != nil { 810 log.Println("failed to get pull id", err) 811 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 812 return 813 } 814 815 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 816} 817 818func (s *State) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { 819 user := s.auth.GetUser(r) 820 f, err := fullyResolvedRepo(r) 821 if err != nil { 822 log.Println("failed to get repo and knot", err) 823 return 824 } 825 826 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ 827 RepoInfo: f.RepoInfo(s, user), 828 }) 829} 830 831func (s *State) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) { 832 user := s.auth.GetUser(r) 833 f, err := fullyResolvedRepo(r) 834 if err != nil { 835 log.Println("failed to get repo and knot", err) 836 return 837 } 838 839 us, err := NewUnsignedClient(f.Knot, s.config.Dev) 840 if err != nil { 841 log.Printf("failed to create unsigned client for %s", f.Knot) 842 s.pages.Error503(w) 843 return 844 } 845 846 resp, err := us.Branches(f.OwnerDid(), f.RepoName) 847 if err != nil { 848 log.Println("failed to reach knotserver", err) 849 return 850 } 851 852 body, err := io.ReadAll(resp.Body) 853 if err != nil { 854 log.Printf("Error reading response body: %v", err) 855 return 856 } 857 858 var result types.RepoBranchesResponse 859 err = json.Unmarshal(body, &result) 860 if err != nil { 861 log.Println("failed to parse response:", err) 862 return 863 } 864 865 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{ 866 RepoInfo: f.RepoInfo(s, user), 867 Branches: result.Branches, 868 }) 869} 870 871func (s *State) CompareForksFragment(w http.ResponseWriter, r *http.Request) { 872 user := s.auth.GetUser(r) 873 f, err := fullyResolvedRepo(r) 874 if err != nil { 875 log.Println("failed to get repo and knot", err) 876 return 877 } 878 879 forks, err := db.GetForksByDid(s.db, user.Did) 880 if err != nil { 881 log.Println("failed to get forks", err) 882 return 883 } 884 885 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{ 886 RepoInfo: f.RepoInfo(s, user), 887 Forks: forks, 888 }) 889} 890 891func (s *State) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) { 892 user := s.auth.GetUser(r) 893 894 f, err := fullyResolvedRepo(r) 895 if err != nil { 896 log.Println("failed to get repo and knot", err) 897 return 898 } 899 900 forkVal := r.URL.Query().Get("fork") 901 902 // fork repo 903 repo, err := db.GetRepo(s.db, user.Did, forkVal) 904 if err != nil { 905 log.Println("failed to get repo", user.Did, forkVal) 906 return 907 } 908 909 sourceBranchesClient, err := NewUnsignedClient(repo.Knot, s.config.Dev) 910 if err != nil { 911 log.Printf("failed to create unsigned client for %s", repo.Knot) 912 s.pages.Error503(w) 913 return 914 } 915 916 sourceResp, err := sourceBranchesClient.Branches(user.Did, repo.Name) 917 if err != nil { 918 log.Println("failed to reach knotserver for source branches", err) 919 return 920 } 921 922 sourceBody, err := io.ReadAll(sourceResp.Body) 923 if err != nil { 924 log.Println("failed to read source response body", err) 925 return 926 } 927 defer sourceResp.Body.Close() 928 929 var sourceResult types.RepoBranchesResponse 930 err = json.Unmarshal(sourceBody, &sourceResult) 931 if err != nil { 932 log.Println("failed to parse source branches response:", err) 933 return 934 } 935 936 targetBranchesClient, err := NewUnsignedClient(f.Knot, s.config.Dev) 937 if err != nil { 938 log.Printf("failed to create unsigned client for target knot %s", f.Knot) 939 s.pages.Error503(w) 940 return 941 } 942 943 targetResp, err := targetBranchesClient.Branches(f.OwnerDid(), f.RepoName) 944 if err != nil { 945 log.Println("failed to reach knotserver for target branches", err) 946 return 947 } 948 949 targetBody, err := io.ReadAll(targetResp.Body) 950 if err != nil { 951 log.Println("failed to read target response body", err) 952 return 953 } 954 defer targetResp.Body.Close() 955 956 var targetResult types.RepoBranchesResponse 957 err = json.Unmarshal(targetBody, &targetResult) 958 if err != nil { 959 log.Println("failed to parse target branches response:", err) 960 return 961 } 962 963 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 964 RepoInfo: f.RepoInfo(s, user), 965 SourceBranches: sourceResult.Branches, 966 TargetBranches: targetResult.Branches, 967 }) 968} 969 970func (s *State) ResubmitPull(w http.ResponseWriter, r *http.Request) { 971 user := s.auth.GetUser(r) 972 f, err := fullyResolvedRepo(r) 973 if err != nil { 974 log.Println("failed to get repo and knot", err) 975 return 976 } 977 978 pull, ok := r.Context().Value("pull").(*db.Pull) 979 if !ok { 980 log.Println("failed to get pull") 981 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 982 return 983 } 984 985 switch r.Method { 986 case http.MethodGet: 987 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ 988 RepoInfo: f.RepoInfo(s, user), 989 Pull: pull, 990 }) 991 return 992 case http.MethodPost: 993 patch := r.FormValue("patch") 994 var sourceRev string 995 var recordPullSource *tangled.RepoPull_Source 996 997 var ownerDid, repoName, knotName string 998 var isSameRepo bool = pull.IsSameRepoBranch() 999 sourceBranch := pull.PullSource.Branch 1000 targetBranch := pull.TargetBranch 1001 recordPullSource = &tangled.RepoPull_Source{ 1002 Branch: sourceBranch, 1003 } 1004 1005 isPushAllowed := f.RepoInfo(s, user).Roles.IsPushAllowed() 1006 if isSameRepo && isPushAllowed { 1007 ownerDid = f.OwnerDid() 1008 repoName = f.RepoName 1009 knotName = f.Knot 1010 } else if !isSameRepo { 1011 sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 1012 if err != nil { 1013 log.Println("failed to get source repo", err) 1014 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1015 return 1016 } 1017 ownerDid = sourceRepo.Did 1018 repoName = sourceRepo.Name 1019 knotName = sourceRepo.Knot 1020 } 1021 1022 if sourceBranch != "" && knotName != "" { 1023 // extract patch by performing compare 1024 ksClient, err := NewUnsignedClient(knotName, s.config.Dev) 1025 if err != nil { 1026 log.Printf("failed to create client for %s: %s", knotName, err) 1027 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1028 return 1029 } 1030 1031 if !isSameRepo { 1032 secret, err := db.GetRegistrationKey(s.db, knotName) 1033 if err != nil { 1034 log.Printf("failed to get registration key for %s: %s", knotName, err) 1035 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1036 return 1037 } 1038 // update the hidden tracking branch to latest 1039 signedClient, err := NewSignedClient(knotName, secret, s.config.Dev) 1040 if err != nil { 1041 log.Printf("failed to create signed client for %s: %s", knotName, err) 1042 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1043 return 1044 } 1045 resp, err := signedClient.NewHiddenRef(ownerDid, repoName, sourceBranch, targetBranch) 1046 if err != nil || resp.StatusCode != http.StatusNoContent { 1047 log.Printf("failed to update tracking branch: %s", err) 1048 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1049 return 1050 } 1051 } 1052 1053 var compareResp *http.Response 1054 if !isSameRepo { 1055 hiddenRef := url.QueryEscape(fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch)) 1056 compareResp, err = ksClient.Compare(ownerDid, repoName, hiddenRef, sourceBranch) 1057 } else { 1058 compareResp, err = ksClient.Compare(ownerDid, repoName, targetBranch, sourceBranch) 1059 } 1060 if err != nil { 1061 log.Printf("failed to compare branches: %s", err) 1062 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1063 return 1064 } 1065 defer compareResp.Body.Close() 1066 1067 switch compareResp.StatusCode { 1068 case 404: 1069 case 400: 1070 s.pages.Notice(w, "pull", "Branch based pull requests are not supported on this knot.") 1071 return 1072 } 1073 1074 respBody, err := io.ReadAll(compareResp.Body) 1075 if err != nil { 1076 log.Println("failed to compare across branches") 1077 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1078 return 1079 } 1080 defer compareResp.Body.Close() 1081 1082 var diffTreeResponse types.RepoDiffTreeResponse 1083 err = json.Unmarshal(respBody, &diffTreeResponse) 1084 if err != nil { 1085 log.Println("failed to unmarshal diff tree response", err) 1086 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1087 return 1088 } 1089 1090 sourceRev = diffTreeResponse.DiffTree.Rev2 1091 patch = diffTreeResponse.DiffTree.Patch 1092 } 1093 1094 if patch == "" { 1095 s.pages.Notice(w, "resubmit-error", "Patch is empty.") 1096 return 1097 } 1098 1099 if patch == pull.LatestPatch() { 1100 s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 1101 return 1102 } 1103 1104 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { 1105 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1106 return 1107 } 1108 1109 if !isPatchValid(patch) { 1110 s.pages.Notice(w, "resubmit-error", "Invalid patch format. Please provide a valid diff.") 1111 return 1112 } 1113 1114 tx, err := s.db.BeginTx(r.Context(), nil) 1115 if err != nil { 1116 log.Println("failed to start tx") 1117 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1118 return 1119 } 1120 defer tx.Rollback() 1121 1122 err = db.ResubmitPull(tx, pull, patch, sourceRev) 1123 if err != nil { 1124 log.Println("failed to create pull request", err) 1125 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1126 return 1127 } 1128 client, _ := s.auth.AuthorizedClient(r) 1129 1130 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1131 if err != nil { 1132 // failed to get record 1133 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1134 return 1135 } 1136 1137 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1138 Collection: tangled.RepoPullNSID, 1139 Repo: user.Did, 1140 Rkey: pull.Rkey, 1141 SwapRecord: ex.Cid, 1142 Record: &lexutil.LexiconTypeDecoder{ 1143 Val: &tangled.RepoPull{ 1144 Title: pull.Title, 1145 PullId: int64(pull.PullId), 1146 TargetRepo: string(f.RepoAt), 1147 TargetBranch: pull.TargetBranch, 1148 Patch: patch, // new patch 1149 Source: recordPullSource, 1150 }, 1151 }, 1152 }) 1153 if err != nil { 1154 log.Println("failed to update record", err) 1155 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1156 return 1157 } 1158 1159 if err = tx.Commit(); err != nil { 1160 log.Println("failed to commit transaction", err) 1161 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 1162 return 1163 } 1164 1165 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1166 return 1167 } 1168} 1169 1170func (s *State) MergePull(w http.ResponseWriter, r *http.Request) { 1171 f, err := fullyResolvedRepo(r) 1172 if err != nil { 1173 log.Println("failed to resolve repo:", err) 1174 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1175 return 1176 } 1177 1178 pull, ok := r.Context().Value("pull").(*db.Pull) 1179 if !ok { 1180 log.Println("failed to get pull") 1181 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1182 return 1183 } 1184 1185 secret, err := db.GetRegistrationKey(s.db, f.Knot) 1186 if err != nil { 1187 log.Printf("no registration key found for domain %s: %s\n", f.Knot, err) 1188 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1189 return 1190 } 1191 1192 ident, err := s.resolver.ResolveIdent(r.Context(), pull.OwnerDid) 1193 if err != nil { 1194 log.Printf("resolving identity: %s", err) 1195 w.WriteHeader(http.StatusNotFound) 1196 return 1197 } 1198 1199 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid) 1200 if err != nil { 1201 log.Printf("failed to get primary email: %s", err) 1202 } 1203 1204 ksClient, err := NewSignedClient(f.Knot, secret, s.config.Dev) 1205 if err != nil { 1206 log.Printf("failed to create signed client for %s: %s", f.Knot, err) 1207 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1208 return 1209 } 1210 1211 // Merge the pull request 1212 resp, err := ksClient.Merge([]byte(pull.LatestPatch()), f.OwnerDid(), f.RepoName, pull.TargetBranch, pull.Title, pull.Body, ident.Handle.String(), email.Address) 1213 if err != nil { 1214 log.Printf("failed to merge pull request: %s", err) 1215 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1216 return 1217 } 1218 1219 if resp.StatusCode == http.StatusOK { 1220 err := db.MergePull(s.db, f.RepoAt, pull.PullId) 1221 if err != nil { 1222 log.Printf("failed to update pull request status in database: %s", err) 1223 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1224 return 1225 } 1226 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.RepoName, pull.PullId)) 1227 } else { 1228 log.Printf("knotserver returned non-OK status code for merge: %d", resp.StatusCode) 1229 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1230 } 1231} 1232 1233func (s *State) ClosePull(w http.ResponseWriter, r *http.Request) { 1234 user := s.auth.GetUser(r) 1235 1236 f, err := fullyResolvedRepo(r) 1237 if err != nil { 1238 log.Println("malformed middleware") 1239 return 1240 } 1241 1242 pull, ok := r.Context().Value("pull").(*db.Pull) 1243 if !ok { 1244 log.Println("failed to get pull") 1245 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1246 return 1247 } 1248 1249 // auth filter: only owner or collaborators can close 1250 roles := RolesInRepo(s, user, f) 1251 isCollaborator := roles.IsCollaborator() 1252 isPullAuthor := user.Did == pull.OwnerDid 1253 isCloseAllowed := isCollaborator || isPullAuthor 1254 if !isCloseAllowed { 1255 log.Println("failed to close pull") 1256 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 1257 return 1258 } 1259 1260 // Start a transaction 1261 tx, err := s.db.BeginTx(r.Context(), nil) 1262 if err != nil { 1263 log.Println("failed to start transaction", err) 1264 s.pages.Notice(w, "pull-close", "Failed to close pull.") 1265 return 1266 } 1267 1268 // Close the pull in the database 1269 err = db.ClosePull(tx, f.RepoAt, pull.PullId) 1270 if err != nil { 1271 log.Println("failed to close pull", err) 1272 s.pages.Notice(w, "pull-close", "Failed to close pull.") 1273 return 1274 } 1275 1276 // Commit the transaction 1277 if err = tx.Commit(); err != nil { 1278 log.Println("failed to commit transaction", err) 1279 s.pages.Notice(w, "pull-close", "Failed to close pull.") 1280 return 1281 } 1282 1283 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1284 return 1285} 1286 1287func (s *State) ReopenPull(w http.ResponseWriter, r *http.Request) { 1288 user := s.auth.GetUser(r) 1289 1290 f, err := fullyResolvedRepo(r) 1291 if err != nil { 1292 log.Println("failed to resolve repo", err) 1293 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1294 return 1295 } 1296 1297 pull, ok := r.Context().Value("pull").(*db.Pull) 1298 if !ok { 1299 log.Println("failed to get pull") 1300 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1301 return 1302 } 1303 1304 // auth filter: only owner or collaborators can close 1305 roles := RolesInRepo(s, user, f) 1306 isCollaborator := roles.IsCollaborator() 1307 isPullAuthor := user.Did == pull.OwnerDid 1308 isCloseAllowed := isCollaborator || isPullAuthor 1309 if !isCloseAllowed { 1310 log.Println("failed to close pull") 1311 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 1312 return 1313 } 1314 1315 // Start a transaction 1316 tx, err := s.db.BeginTx(r.Context(), nil) 1317 if err != nil { 1318 log.Println("failed to start transaction", err) 1319 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1320 return 1321 } 1322 1323 // Reopen the pull in the database 1324 err = db.ReopenPull(tx, f.RepoAt, pull.PullId) 1325 if err != nil { 1326 log.Println("failed to reopen pull", err) 1327 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1328 return 1329 } 1330 1331 // Commit the transaction 1332 if err = tx.Commit(); err != nil { 1333 log.Println("failed to commit transaction", err) 1334 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 1335 return 1336 } 1337 1338 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1339 return 1340} 1341 1342// Very basic validation to check if it looks like a diff/patch 1343// A valid patch usually starts with diff or --- lines 1344func isPatchValid(patch string) bool { 1345 // Basic validation to check if it looks like a diff/patch 1346 // A valid patch usually starts with diff or --- lines 1347 if len(patch) == 0 { 1348 return false 1349 } 1350 1351 lines := strings.Split(patch, "\n") 1352 if len(lines) < 2 { 1353 return false 1354 } 1355 1356 // Check for common patch format markers 1357 firstLine := strings.TrimSpace(lines[0]) 1358 return strings.HasPrefix(firstLine, "diff ") || 1359 strings.HasPrefix(firstLine, "--- ") || 1360 strings.HasPrefix(firstLine, "Index: ") || 1361 strings.HasPrefix(firstLine, "+++ ") || 1362 strings.HasPrefix(firstLine, "@@ ") 1363}