diff --git a/src/safe_outputs/create_pull_request.rs b/src/safe_outputs/create_pull_request.rs index 47430e88..71c25682 100644 --- a/src/safe_outputs/create_pull_request.rs +++ b/src/safe_outputs/create_pull_request.rs @@ -588,54 +588,21 @@ impl Drop for WorktreeGuard { } } -#[async_trait::async_trait] -impl Executor for CreatePrResult { - fn dry_run_summary(&self) -> String { - format!("create PR: '{}' in repo '{}'", self.title, self.repository) - } - - async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { - info!( - "Creating PR: '{}' in repository '{}'", - self.title, self.repository - ); - debug!( - "create-pull-request: title='{}', repo='{}', branch='{}', patch='{}'", - self.title, self.repository, self.source_branch, self.patch_file - ); - debug!("PR description length: {} chars", self.description.len()); - debug!("Source branch: {}", self.source_branch); - debug!("Patch file: {}", self.patch_file); - - let config: CreatePrConfig = ctx.get_tool_config("create-pull-request")?; - debug!("Target branch from config: {}", config.target_branch); - debug!("Draft: {}", config.draft); - debug!("Auto-complete: {}", config.auto_complete); - debug!("Squash merge: {}", config.squash_merge); - - if config.draft && config.auto_complete { - warn!( - "auto-complete cannot be set on a draft PR; set draft: false to enable auto-complete" - ); - } - - // Apply title prefix if configured - let effective_title = if let Some(ref prefix) = config.title_prefix { - format!("{}{}", prefix, self.title) - } else { - self.title.clone() - }; - - // ADO PR titles have a 400-character limit - let title_char_count = effective_title.chars().count(); - if title_char_count > 400 { - return Ok(ExecutionResult::failure(format!( - "PR title too long after applying title-prefix ({} chars, max 400)", - title_char_count - ))); - } +/// ADO connection details resolved from the [`ExecutionContext`]. +struct AdoConnection<'a> { + org_url: &'a str, + project: &'a str, + token: &'a str, +} - // Validate repository against allowed list +impl CreatePrResult { + /// Resolve `self.repository` to a canonical alias and the underlying ADO repository ID. + /// + /// Returns `Err(ExecutionResult)` when the repository is not in the allowed list. + fn resolve_repo_id( + &self, + ctx: &ExecutionContext, + ) -> anyhow::Result> { debug!( "Validating repository '{}' against allowed list", self.repository @@ -652,7 +619,7 @@ impl Executor for CreatePrResult { self.repository, ctx.allowed_repositories.keys().collect::>() ); - return Ok(ExecutionResult::failure(format!( + return Ok(Err(ExecutionResult::failure(format!( "Repository '{}' is not in the allowed list. Allowed: self, {}", self.repository, ctx.allowed_repositories @@ -660,7 +627,7 @@ impl Executor for CreatePrResult { .cloned() .collect::>() .join(", ") - ))); + )))); }; let repo_id = if repository_alias == "self" { // "self" or a name match against the pipeline's own repository @@ -685,42 +652,28 @@ impl Executor for CreatePrResult { false, "canonical alias '{repository_alias}' is absent from allowed_repositories" ); - return Ok(ExecutionResult::failure(format!( + return Ok(Err(ExecutionResult::failure(format!( "Repository alias '{}' has no configured repository", repository_alias - ))); + )))); }; debug!("Resolved repository ID: {}", repo_id); + Ok(Ok((repository_alias, repo_id))) + } - // Get ADO configuration - let org_url = ctx - .ado_org_url - .as_ref() - .context("Azure DevOps organization URL not configured")?; - let organization = ctx - .ado_organization - .as_ref() - .context("Azure DevOps organization name not configured")?; - let project = ctx - .ado_project - .as_ref() - .context("Azure DevOps project not configured")?; - let token = ctx - .access_token - .as_ref() - .context("Access token not configured")?; - debug!( - "ADO org: {}, organization: {}, project: {}", - org_url, organization, project - ); - - // Validate and read the patch file + /// Read, hash-verify, and security-validate the patch file, returning its + /// content and the `--exclude` args to apply during `git am`/`git apply`. + async fn read_and_validate_patch( + &self, + ctx: &ExecutionContext, + config: &CreatePrConfig, + ) -> anyhow::Result), ExecutionResult>> { let patch_path = ctx.working_directory.join(&self.patch_file); if !patch_path.exists() { - return Ok(ExecutionResult::failure(format!( + return Ok(Err(ExecutionResult::failure(format!( "Patch file not found: {}", self.patch_file - ))); + )))); } // Security: Enforce patch file size limit @@ -728,11 +681,11 @@ impl Executor for CreatePrResult { .await .context("Failed to get patch file metadata")?; if metadata.len() > MAX_PATCH_SIZE_BYTES { - return Ok(ExecutionResult::failure(format!( + return Ok(Err(ExecutionResult::failure(format!( "Patch file exceeds maximum size of {} bytes (got {} bytes)", MAX_PATCH_SIZE_BYTES, metadata.len() - ))); + )))); } // Read patch content for validation @@ -746,11 +699,11 @@ impl Executor for CreatePrResult { // with between Stage 1 and Stage 3. let live_hash = crate::hash::sha256_hex(patch_content.as_bytes()); if live_hash != self.patch_sha256 { - return Ok(ExecutionResult::failure(format!( + return Ok(Err(ExecutionResult::failure(format!( "Patch file SHA-256 mismatch: expected {}, got {} — \ the file may have been tampered with between stages", self.patch_sha256, live_hash - ))); + )))); } debug!("Patch file SHA-256 verified: {}", live_hash); @@ -776,10 +729,10 @@ impl Executor for CreatePrResult { debug!("Validating patch paths for security"); if let Err(e) = validate_patch_paths(&patch_content) { warn!("Patch path validation failed: {}", e); - return Ok(ExecutionResult::failure(format!( + return Ok(Err(ExecutionResult::failure(format!( "Patch validation failed: {}", e - ))); + )))); } debug!("Patch path validation passed"); @@ -806,10 +759,10 @@ impl Executor for CreatePrResult { protected.len(), protected ); - return Ok(ExecutionResult::failure(format!( + return Ok(Err(ExecutionResult::failure(format!( "Patch modifies protected files (set protected-files: allowed to override): {}", protected.join(", ") - ))); + )))); } } @@ -821,28 +774,29 @@ impl Executor for CreatePrResult { "Patch contains {} files, exceeding max of {}", file_count, config.max_files ); - return Ok(ExecutionResult::failure(format!( + return Ok(Err(ExecutionResult::failure(format!( "Patch contains {} files, exceeding maximum of {} files per PR", file_count, config.max_files - ))); + )))); } - // Resolve the target (base) branch for THIS repo. In a multi-checkout - // ("meta repo") setup the target can differ per repo (explicit - // `target-branches` override, or inferred from the repo's checkout ref - // when `infer-target-from-checkout-ref` is set); falls back to the - // literal `target-branch`. Resolution is shared with the compiler's - // prepare-pr-base deepening, so the branch we PR into is the branch that - // was fetched/deepened. - let target_branch = config.resolve_target_branch(&repository_alias, &ctx.repo_refs); - let target_branch = target_branch.as_str(); - let mut source_branch = self.source_branch.clone(); - let mut source_ref = format!("refs/heads/{}", source_branch); - let target_ref = format!("refs/heads/{}", target_branch); - debug!("Source ref: {}, Target ref: {}", source_ref, target_ref); + Ok(Ok((patch_content, exclude_args))) + } + /// Create a worktree at `target_branch`, checkout `source_branch`, apply the + /// patch, and collect the resulting file changes for the ADO push payload. + #[allow(clippy::too_many_arguments)] + async fn setup_worktree_and_collect_changes( + &self, + repository_alias: &str, + ctx: &ExecutionContext, + target_branch: &str, + source_branch: &str, + patch_path: &std::path::Path, + exclude_args: &[String], + ) -> anyhow::Result, Vec), ExecutionResult>> { let repo_git_dir = - crate::safe_outputs::resolve_repository_checkout_dir(&repository_alias, ctx)?; + crate::safe_outputs::resolve_repository_checkout_dir(repository_alias, ctx)?; debug!("Git repository directory: {}", repo_git_dir.display()); // Verify this is a git repository @@ -856,10 +810,10 @@ impl Executor for CreatePrResult { if !git_check.status.success() { warn!("Not a git repository: {}", repo_git_dir.display()); - return Ok(ExecutionResult::failure(format!( + return Ok(Err(ExecutionResult::failure(format!( "Not a git repository: {}", repo_git_dir.display() - ))); + )))); } debug!("Git repository verified"); @@ -904,10 +858,10 @@ impl Executor for CreatePrResult { "Failed to create worktree: {}", String::from_utf8_lossy(&worktree_output.stderr) ); - return Ok(ExecutionResult::failure(format!( + return Ok(Err(ExecutionResult::failure(format!( "Failed to create worktree: {}", String::from_utf8_lossy(&worktree_output.stderr) - ))); + )))); } } debug!("Worktree created successfully"); @@ -924,7 +878,7 @@ impl Executor for CreatePrResult { // branch name is not used for the remote ref. debug!("Creating source branch: {}", source_branch); let checkout_output = Command::new("git") - .args(["checkout", "-b", &source_branch]) + .args(["checkout", "-b", source_branch]) .current_dir(&worktree_path) .output() .await @@ -935,10 +889,10 @@ impl Executor for CreatePrResult { "Failed to create source branch: {}", String::from_utf8_lossy(&checkout_output.stderr) ); - return Ok(ExecutionResult::failure(format!( + return Ok(Err(ExecutionResult::failure(format!( "Failed to create source branch: {}", String::from_utf8_lossy(&checkout_output.stderr) - ))); + )))); } debug!("Source branch created"); @@ -962,9 +916,9 @@ impl Executor for CreatePrResult { // - With exclusions: use git apply --3way directly (git am does not support // --exclude flags; git apply does) let patch_committed = - match apply_patch_to_worktree(&worktree_path, &patch_path, &exclude_args).await? { + match apply_patch_to_worktree(&worktree_path, patch_path, exclude_args).await? { Ok(committed) => committed, - Err(result) => return Ok(result), + Err(result) => return Ok(Err(result)), }; // Collect changed files. The method depends on how the patch was applied: @@ -985,10 +939,10 @@ impl Executor for CreatePrResult { "Failed to get diff-tree: {}", String::from_utf8_lossy(&diff_tree_output.stderr) ); - return Ok(ExecutionResult::failure(format!( + return Ok(Err(ExecutionResult::failure(format!( "Failed to get diff-tree: {}", String::from_utf8_lossy(&diff_tree_output.stderr) - ))); + )))); } ( String::from_utf8_lossy(&diff_tree_output.stdout).to_string(), @@ -1007,10 +961,10 @@ impl Executor for CreatePrResult { "Failed to get git status: {}", String::from_utf8_lossy(&status_output.stderr) ); - return Ok(ExecutionResult::failure(format!( + return Ok(Err(ExecutionResult::failure(format!( "Failed to get git status: {}", String::from_utf8_lossy(&status_output.stderr) - ))); + )))); } ( String::from_utf8_lossy(&status_output.stdout).to_string(), @@ -1033,27 +987,19 @@ impl Executor for CreatePrResult { ); } - if changes.is_empty() { - return Ok(handle_no_changes(&config, &skipped_symlinks)); - } - - // Use ADO REST API to create branch and push changes - let client = reqwest::Client::new(); - - // Get the target branch ref to find the base commit - debug!("Getting target branch ref from ADO"); - let refs_url = format!( - "{}{}/_apis/git/repositories/{}/refs?filter=heads/{}&api-version=7.1", - org_url, project, repo_id, target_branch - ); - debug!("Refs URL: {}", refs_url); + Ok(Ok((changes, skipped_symlinks))) + } - // Resolve the base commit for the push. - // Prefer the merge-base SHA recorded at patch generation time (Stage 1) so the - // patch is applied against the exact commit it was created from. Fall back to - // querying the ADO refs API when the field is absent (backward compat with old - // NDJSON entries). - let base_commit: String = if let Some(ref recorded) = self.base_commit { + /// Resolve the base commit to push against: prefer the SHA recorded at + /// patch-generation time (Stage 1), falling back to querying the ADO refs API. + async fn resolve_base_commit( + &self, + client: &reqwest::Client, + ado: &AdoConnection<'_>, + repo_id: &str, + target_branch: &str, + ) -> anyhow::Result> { + if let Some(ref recorded) = self.base_commit { // Validate SHA format before trusting Stage 1 data if recorded.len() != 40 || !recorded.chars().all(|c| c.is_ascii_hexdigit()) { anyhow::bail!( @@ -1062,45 +1008,54 @@ impl Executor for CreatePrResult { ); } info!("Using recorded base_commit from Stage 1: {}", recorded); - recorded.clone() - } else { - debug!("No recorded base_commit — resolving from ADO refs API"); - let refs_response = client - .get(&refs_url) - .basic_auth("", Some(token)) - .send() - .await - .context("Failed to get target branch ref")?; - - if !refs_response.status().is_success() { - let status = refs_response.status(); - let body = refs_response.text().await.unwrap_or_default(); - warn!("Failed to get target branch ref: {} - {}", status, body); - return Ok(ExecutionResult::failure(format!( - "Failed to get target branch ref: {} - {}", - status, body - ))); - } - - let refs_data: serde_json::Value = refs_response.json().await?; - let resolved = refs_data["value"][0]["objectId"] - .as_str() - .context("Could not find target branch commit")?; - resolved.to_string() - }; - debug!("Base commit: {}", base_commit); + return Ok(Ok(recorded.clone())); + } - info!( - "Base commit for target branch '{}': {}", - target_branch, base_commit + debug!("No recorded base_commit — resolving from ADO refs API"); + let refs_url = format!( + "{}{}/_apis/git/repositories/{}/refs?filter=heads/{}&api-version=7.1", + ado.org_url, ado.project, repo_id, target_branch ); + debug!("Refs URL: {}", refs_url); - // Check if the source branch already exists (e.g. from a retry or previous run). - // Retry with new random suffixes up to 3 times. + let refs_response = client + .get(&refs_url) + .basic_auth("", Some(ado.token)) + .send() + .await + .context("Failed to get target branch ref")?; + + if !refs_response.status().is_success() { + let status = refs_response.status(); + let body = refs_response.text().await.unwrap_or_default(); + warn!("Failed to get target branch ref: {} - {}", status, body); + return Ok(Err(ExecutionResult::failure(format!( + "Failed to get target branch ref: {} - {}", + status, body + )))); + } + + let refs_data: serde_json::Value = refs_response.json().await?; + let resolved = refs_data["value"][0]["objectId"] + .as_str() + .context("Could not find target branch commit")?; + Ok(Ok(resolved.to_string())) + } + + /// Rename `source_branch`/`source_ref` (up to 3 attempts) if the branch already + /// exists in ADO, to avoid TOCTOU collisions from retries or previous runs. + async fn ensure_unique_source_branch( + &self, + client: &reqwest::Client, + ado: &AdoConnection<'_>, + repo_id: &str, + mut source_branch: String, + mut source_ref: String, + ) -> anyhow::Result<(String, String)> { for attempt in 0..3 { let check_ref_url = format!( "{}{}/_apis/git/repositories/{}/refs?filter=heads/{}&api-version=7.1", - org_url, project, repo_id, source_branch + ado.org_url, ado.project, repo_id, source_branch ); debug!( "Checking if source branch exists (attempt {}): {}", @@ -1110,7 +1065,7 @@ impl Executor for CreatePrResult { let check_ref_response = client .get(&check_ref_url) - .basic_auth("", Some(token)) + .basic_auth("", Some(ado.token)) .send() .await .context("Failed to check source branch existence")?; @@ -1132,6 +1087,247 @@ impl Executor for CreatePrResult { } break; } + Ok((source_branch, source_ref)) + } + + /// Build the ADO create-pull-request JSON body (title, description, work items, labels). + fn build_pr_body( + &self, + config: &CreatePrConfig, + effective_title: &str, + description_final: &str, + source_ref: &str, + target_ref: &str, + ) -> Result { + let mut pr_body = serde_json::json!({ + "sourceRefName": source_ref, + "targetRefName": target_ref, + "title": effective_title, + "description": description_final, + "isDraft": config.draft, + }); + + // Add work item links if configured + if !config.work_items.is_empty() { + debug!("Linking {} work items", config.work_items.len()); + pr_body["workItemRefs"] = serde_json::json!( + config + .work_items + .iter() + .map(|id| serde_json::json!({"id": id})) + .collect::>() + ); + } + + // Validate and add labels (merge operator labels + validated agent labels) + let all_labels = validate_and_build_labels(config, &self.agent_labels)?; + + if !all_labels.is_empty() { + debug!("Adding {} labels", all_labels.len()); + pr_body["labels"] = serde_json::json!( + all_labels + .iter() + .map(|l| serde_json::json!({"name": l})) + .collect::>() + ); + } + + Ok(pr_body) + } + + /// Build the recovery `ExecutionResult` when PR creation fails but the + /// branch was already pushed and `fallback-record-branch` is enabled. + fn build_fallback_failure_result( + &self, + status: reqwest::StatusCode, + body: &str, + source_branch: &str, + target_branch: &str, + ) -> ExecutionResult { + let fallback_description = format!( + "## Pull Request Creation Failed\n\n\ + A pull request could not be created automatically.\n\n\ + **Branch:** `{}`\n\ + **Target:** `{}`\n\ + **Repository:** `{}`\n\n\ + **Error:** {} - {}\n\n\ + ### Original PR Description\n\n\ + {}\n\n\ + ---\n\ + *To create the PR manually, merge branch `{}` into `{}`.*", + source_branch, + target_branch, + self.repository, + status, + sanitize_text(truncate_error_body(body, 500)), + sanitize_text(&self.description), + source_branch, + target_branch + ); + ExecutionResult::failure_with_data( + format!( + "Failed to create pull request: {} - {}. Branch '{}' was pushed — create the PR manually.", + status, + sanitize_text(truncate_error_body(body, 500)), + source_branch, + ), + serde_json::json!({ + "fallback": "branch-recorded", + "branch": source_branch, + "target_branch": target_branch, + "repository": self.repository, + "description": fallback_description + }), + ) + } +} + +#[async_trait::async_trait] +impl Executor for CreatePrResult { + fn dry_run_summary(&self) -> String { + format!("create PR: '{}' in repo '{}'", self.title, self.repository) + } + + async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + info!( + "Creating PR: '{}' in repository '{}'", + self.title, self.repository + ); + debug!( + "create-pull-request: title='{}', repo='{}', branch='{}', patch='{}'", + self.title, self.repository, self.source_branch, self.patch_file + ); + debug!("PR description length: {} chars", self.description.len()); + debug!("Source branch: {}", self.source_branch); + debug!("Patch file: {}", self.patch_file); + + let config: CreatePrConfig = ctx.get_tool_config("create-pull-request")?; + debug!("Target branch from config: {}", config.target_branch); + debug!("Draft: {}", config.draft); + debug!("Auto-complete: {}", config.auto_complete); + debug!("Squash merge: {}", config.squash_merge); + + if config.draft && config.auto_complete { + warn!( + "auto-complete cannot be set on a draft PR; set draft: false to enable auto-complete" + ); + } + + // Apply title prefix if configured + let effective_title = if let Some(ref prefix) = config.title_prefix { + format!("{}{}", prefix, self.title) + } else { + self.title.clone() + }; + + // ADO PR titles have a 400-character limit + let title_char_count = effective_title.chars().count(); + if title_char_count > 400 { + return Ok(ExecutionResult::failure(format!( + "PR title too long after applying title-prefix ({} chars, max 400)", + title_char_count + ))); + } + + let (repository_alias, repo_id) = match self.resolve_repo_id(ctx)? { + Ok(pair) => pair, + Err(result) => return Ok(result), + }; + + // Get ADO configuration + let org_url = ctx + .ado_org_url + .as_ref() + .context("Azure DevOps organization URL not configured")?; + let organization = ctx + .ado_organization + .as_ref() + .context("Azure DevOps organization name not configured")?; + let project = ctx + .ado_project + .as_ref() + .context("Azure DevOps project not configured")?; + let token = ctx + .access_token + .as_ref() + .context("Access token not configured")?; + debug!( + "ADO org: {}, organization: {}, project: {}", + org_url, organization, project + ); + let ado = AdoConnection { + org_url, + project, + token, + }; + + // Validate and read the patch file + let patch_path = ctx.working_directory.join(&self.patch_file); + let (_patch_content, exclude_args) = + match self.read_and_validate_patch(ctx, &config).await? { + Ok(pair) => pair, + Err(result) => return Ok(result), + }; + + // Resolve the target (base) branch for THIS repo. In a multi-checkout + // ("meta repo") setup the target can differ per repo (explicit + // `target-branches` override, or inferred from the repo's checkout ref + // when `infer-target-from-checkout-ref` is set); falls back to the + // literal `target-branch`. Resolution is shared with the compiler's + // prepare-pr-base deepening, so the branch we PR into is the branch that + // was fetched/deepened. + let target_branch = config.resolve_target_branch(&repository_alias, &ctx.repo_refs); + let target_branch = target_branch.as_str(); + let mut source_branch = self.source_branch.clone(); + let mut source_ref = format!("refs/heads/{}", source_branch); + let target_ref = format!("refs/heads/{}", target_branch); + debug!("Source ref: {}, Target ref: {}", source_ref, target_ref); + + let (changes, skipped_symlinks) = match self + .setup_worktree_and_collect_changes( + &repository_alias, + ctx, + target_branch, + &source_branch, + &patch_path, + &exclude_args, + ) + .await? + { + Ok(pair) => pair, + Err(result) => return Ok(result), + }; + + if changes.is_empty() { + return Ok(handle_no_changes(&config, &skipped_symlinks)); + } + + // Use ADO REST API to create branch and push changes + let client = reqwest::Client::new(); + + // Resolve the base commit for the push. + // Prefer the merge-base SHA recorded at patch generation time (Stage 1) so the + // patch is applied against the exact commit it was created from. Fall back to + // querying the ADO refs API when the field is absent (backward compat with old + // NDJSON entries). + let base_commit = match self + .resolve_base_commit(&client, &ado, &repo_id, target_branch) + .await? + { + Ok(commit) => commit, + Err(result) => return Ok(result), + }; + debug!("Base commit: {}", base_commit); + info!( + "Base commit for target branch '{}': {}", + target_branch, base_commit + ); + + // Check if the source branch already exists (e.g. from a retry or previous run). + // Retry with new random suffixes up to 3 times. + (source_branch, source_ref) = self + .ensure_unique_source_branch(&client, &ado, &repo_id, source_branch, source_ref) + .await?; // Push changes via ADO API (this creates the branch and commits in one call) info!("Pushing changes to ADO"); @@ -1182,42 +1378,17 @@ impl Executor for CreatePrResult { ); debug!("PR URL: {}", pr_url); - let mut pr_body = serde_json::json!({ - "sourceRefName": source_ref, - "targetRefName": target_ref, - "title": effective_title, - "description": description_final, - "isDraft": config.draft, - }); - - // Add work item links if configured - if !config.work_items.is_empty() { - debug!("Linking {} work items", config.work_items.len()); - pr_body["workItemRefs"] = serde_json::json!( - config - .work_items - .iter() - .map(|id| serde_json::json!({"id": id})) - .collect::>() - ); - } - - // Validate and add labels (merge operator labels + validated agent labels) - let all_labels = match validate_and_build_labels(&config, &self.agent_labels) { - Ok(labels) => labels, + let pr_body = match self.build_pr_body( + &config, + &effective_title, + &description_final, + &source_ref, + &target_ref, + ) { + Ok(body) => body, Err(result) => return Ok(result), }; - if !all_labels.is_empty() { - debug!("Adding {} labels", all_labels.len()); - pr_body["labels"] = serde_json::json!( - all_labels - .iter() - .map(|l| serde_json::json!({"name": l})) - .collect::>() - ); - } - let pr_response = client .post(&pr_url) .basic_auth("", Some(token)) @@ -1234,40 +1405,11 @@ impl Executor for CreatePrResult { // Record branch info for manual recovery if enabled if config.fallback_record_branch { info!("PR creation failed, recording branch info for manual recovery"); - let fallback_description = format!( - "## Pull Request Creation Failed\n\n\ - A pull request could not be created automatically.\n\n\ - **Branch:** `{}`\n\ - **Target:** `{}`\n\ - **Repository:** `{}`\n\n\ - **Error:** {} - {}\n\n\ - ### Original PR Description\n\n\ - {}\n\n\ - ---\n\ - *To create the PR manually, merge branch `{}` into `{}`.*", - source_branch, - target_branch, - self.repository, + return Ok(self.build_fallback_failure_result( status, - sanitize_text(truncate_error_body(&body, 500)), - sanitize_text(&self.description), - source_branch, - target_branch - ); - return Ok(ExecutionResult::failure_with_data( - format!( - "Failed to create pull request: {} - {}. Branch '{}' was pushed — create the PR manually.", - status, - sanitize_text(truncate_error_body(&body, 500)), - source_branch, - ), - serde_json::json!({ - "fallback": "branch-recorded", - "branch": source_branch, - "target_branch": target_branch, - "repository": self.repository, - "description": fallback_description - }), + &body, + &source_branch, + target_branch, )); }