From bc0ea0ab41694609885dddb88f7a8e13170907c8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:06:14 +0000 Subject: [PATCH 01/43] Add update-pull-request safe output Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- src/compile/common.rs | 5 + src/compile/types.rs | 22 + src/execute.rs | 6 +- src/inspect/catalog.rs | 1 + src/mcp.rs | 21 +- src/safe_outputs/mod.rs | 4 + src/safe_outputs/update_pull_request.rs | 687 ++++++++++++++++++++++++ 7 files changed, 740 insertions(+), 6 deletions(-) create mode 100644 src/safe_outputs/update_pull_request.rs diff --git a/src/compile/common.rs b/src/compile/common.rs index b24239e47..53dda72e0 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -2427,6 +2427,11 @@ pub fn validate_github_issue_outputs_config(front_matter: &FrontMatter) -> Resul crate::safe_outputs::validate_update_github_issue_config(&config)?; } } + "update-pull-request" => { + if let Some(config) = front_matter.update_pull_request_config()? { + crate::safe_outputs::validate_update_pull_request_config(&config)?; + } + } "set-github-issue-field" => { if let Some(config) = front_matter.set_github_issue_field_config()? { crate::safe_outputs::validate_set_github_issue_field_config(&config)?; diff --git a/src/compile/types.rs b/src/compile/types.rs index 08eb3afe4..7057c6664 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -709,6 +709,7 @@ pub const SAFE_OUTPUTS_GITHUB_ACTOR_LOGIN_ENV: &str = "ADO_AW_GITHUB_ACTOR_LOGIN /// intentionally does not depend on the safe-output module registry. pub const GITHUB_ISSUE_SAFE_OUTPUT_TOOLS: &[&str] = &[ "create-github-issue", + "update-pull-request", "set-github-issue-type", "comment-on-github-issue", "hide-github-issue-comment", @@ -2297,6 +2298,12 @@ impl FrontMatter { self.typed_safe_output_config("update-github-issue") } + pub fn update_pull_request_config( + &self, + ) -> anyhow::Result> { + self.typed_safe_output_config("update-pull-request") + } + pub fn set_github_issue_field_config( &self, ) -> anyhow::Result> { @@ -2344,6 +2351,18 @@ impl FrontMatter { ..Default::default() })) } + "update-pull-request" => { + Ok(self + .update_pull_request_config()? + .map(|config| GithubIssueCompilerConfig { + target_repo: config.target_repo, + allowed_repos: config.allowed_repos, + required_labels: config.required_labels, + required_title_prefix: config.required_title_prefix, + pull_requests: Some(true), + ..Default::default() + })) + } "set-github-issue-type" => { Ok(self .set_github_issue_type_config()? @@ -2494,6 +2513,9 @@ impl FrontMatter { continue; }; match tool.as_str() { + "update-pull-request" => { + pull_requests = true; + } "comment-on-github-issue" | "add-github-issue-labels" | "update-github-issue" => { let tool_issues = config.issues.unwrap_or(true); let tool_pull_requests = config.pull_requests.unwrap_or(false); diff --git a/src/execute.rs b/src/execute.rs index 7740caa53..b3fb6afd5 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -26,8 +26,8 @@ use crate::safe_outputs::{ ReplyToPrCommentResult, ReportIncompleteResult, ResolvePrThreadResult, SetGithubIssueFieldResult, SetGithubIssueTypeResult, SubmitPrReviewResult, ToolResult, UnassignGithubIssueFromUserResult, UpdateGithubIssueResult, UpdatePrResult, - UpdateWikiPageResult, UpdateWorkItemResult, UploadBuildAttachmentResult, - UploadPipelineArtifactResult, UploadWorkitemAttachmentResult, + UpdatePullRequestResult, UpdateWikiPageResult, UpdateWorkItemResult, + UploadBuildAttachmentResult, UploadPipelineArtifactResult, UploadWorkitemAttachmentResult, }; use crate::sanitize::neutralize_pipeline_commands; @@ -259,6 +259,7 @@ pub async fn execute_safe_outputs( AddGithubIssueLabelsResult, RemoveGithubIssueLabelsResult, CloseGithubIssueResult, + UpdatePullRequestResult, UpdateGithubIssueResult, SetGithubIssueFieldResult, AssignGithubIssueMilestoneResult, @@ -777,6 +778,7 @@ async fn dispatch_github_tools( "add-github-issue-labels" => AddGithubIssueLabelsResult, "remove-github-issue-labels" => RemoveGithubIssueLabelsResult, "close-github-issue" => CloseGithubIssueResult, + "update-pull-request" => UpdatePullRequestResult, "update-github-issue" => UpdateGithubIssueResult, "set-github-issue-field" => SetGithubIssueFieldResult, "assign-github-issue-milestone" => AssignGithubIssueMilestoneResult, diff --git a/src/inspect/catalog.rs b/src/inspect/catalog.rs index 2c2796d79..d336c52a0 100644 --- a/src/inspect/catalog.rs +++ b/src/inspect/catalog.rs @@ -310,6 +310,7 @@ fn safe_output_description(name: &str) -> &'static str { "set-github-issue-field" => "Sets a repository-defined field on a GitHub issue", "submit-pr-review" => "Parameters for submitting a pull request review", "update-pr" => "Parameters for updating a pull request", + "update-pull-request" => "Updates a GitHub pull request title, body, or branch", "unassign-github-issue-from-user" => { "Removes operator-permitted GitHub users from an issue" } diff --git a/src/mcp.rs b/src/mcp.rs index 17af63e48..c1b80f9bb 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -30,10 +30,11 @@ use crate::safe_outputs::{ SetGithubIssueFieldResult, SetGithubIssueTypeParams, SetGithubIssueTypeResult, SubmitPrReviewParams, SubmitPrReviewResult, ToolResult, UnassignGithubIssueFromUserParams, UnassignGithubIssueFromUserResult, UpdateGithubIssueParams, UpdateGithubIssueResult, - UpdatePrParams, UpdatePrResult, UpdateWikiPageParams, UpdateWikiPageResult, - UpdateWorkItemParams, UpdateWorkItemResult, UploadBuildAttachmentParams, - UploadBuildAttachmentResult, UploadPipelineArtifactParams, UploadPipelineArtifactResult, - UploadWorkitemAttachmentParams, UploadWorkitemAttachmentResult, Validate, anyhow_to_mcp_error, + UpdatePrParams, UpdatePrResult, UpdatePullRequestParams, UpdatePullRequestResult, + UpdateWikiPageParams, UpdateWikiPageResult, UpdateWorkItemParams, UpdateWorkItemResult, + UploadBuildAttachmentParams, UploadBuildAttachmentResult, UploadPipelineArtifactParams, + UploadPipelineArtifactResult, UploadWorkitemAttachmentParams, UploadWorkitemAttachmentResult, + Validate, anyhow_to_mcp_error, }; use crate::sanitize::{SanitizeContent, sanitize as sanitize_text, sanitize_markdown}; use crate::secure::WorkItemTemporaryId; @@ -957,6 +958,18 @@ issue_number may be a positive number or a temporary_id from create-github-issue self.queue_sanitized_output(result).await } + #[tool( + name = "update-pull-request", + description = "Update a configured GitHub pull request title or body, or update its branch from the base branch." + )] + async fn update_pull_request( + &self, + params: Parameters, + ) -> Result { + let result: UpdatePullRequestResult = params.0.try_into()?; + self.queue_sanitized_output(result).await + } + #[tool( name = "set-github-issue-field", description = "Set an operator-permitted repository-defined field on a configured GitHub issue." diff --git a/src/safe_outputs/mod.rs b/src/safe_outputs/mod.rs index bd86a5085..38767834f 100644 --- a/src/safe_outputs/mod.rs +++ b/src/safe_outputs/mod.rs @@ -48,6 +48,7 @@ pub const DEBUG_ONLY_TOOLS: &[&str] = &[]; pub const CONFIGURED_ONLY_TOOLS: &[&str] = tool_names![ AssignWorkItemResult, CreateGithubIssueResult, + UpdatePullRequestResult, SetGithubIssueTypeResult, CommentOnGithubIssueResult, HideGithubIssueCommentResult, @@ -92,6 +93,7 @@ pub const ALL_KNOWN_SAFE_OUTPUTS: &[&str] = all_safe_output_names![ ReplyToPrCommentResult, ResolvePrThreadResult, CreateGithubIssueResult, + UpdatePullRequestResult, SetGithubIssueTypeResult, CommentOnGithubIssueResult, HideGithubIssueCommentResult, @@ -796,6 +798,7 @@ mod set_github_issue_type; mod submit_pr_review; mod unassign_github_issue_from_user; mod update_github_issue; +mod update_pull_request; mod update_pr; mod update_wiki_page; mod update_work_item; @@ -840,6 +843,7 @@ pub use set_github_issue_type::*; pub use submit_pr_review::*; pub use unassign_github_issue_from_user::*; pub use update_github_issue::*; +pub use update_pull_request::*; pub use update_pr::*; pub use update_wiki_page::*; pub use update_work_item::*; diff --git a/src/safe_outputs/update_pull_request.rs b/src/safe_outputs/update_pull_request.rs new file mode 100644 index 000000000..3b928f7df --- /dev/null +++ b/src/safe_outputs/update_pull_request.rs @@ -0,0 +1,687 @@ +//! `update-pull-request` GitHub safe output. + +use anyhow::ensure; +use log::{debug, info, warn}; +use reqwest::Method; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use url::Url; + +use ado_aw_derive::SanitizeConfig; + +use crate::safe_outputs::{ + ExecutionContext, ExecutionResult, Executor, GithubBodyOperation, GithubClient, + GithubMutationFilters, GithubRepositoryPolicy, GithubTargetCapabilities, GithubTargetKind, + GithubTargetMetadata, Validate, build_github_trace_footer, resolve_github_repository, + validate_github_mutation_filter_config, validate_github_mutation_filters, + validate_github_repository, validate_github_target_capability, +}; +use crate::sanitize::{SanitizeContent, sanitize as sanitize_text, sanitize_config}; +use crate::tool_result; + +const MAX_TITLE_LEN: usize = 256; +const MAX_BODY_LEN: usize = 65_536; + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum GithubPullRequestNumber { + Number(u64), + String(String), +} + +impl GithubPullRequestNumber { + fn parse(&self, field: &str) -> anyhow::Result { + let number = match self { + Self::Number(number) => *number, + Self::String(value) => value + .trim() + .strip_prefix('#') + .unwrap_or_else(|| value.trim()) + .parse::() + .map_err(|_| anyhow::anyhow!("{field} must be a positive pull request number"))?, + }; + ensure!(number > 0, "{field} must be positive"); + Ok(number) + } +} + +#[derive(Deserialize, JsonSchema)] +pub struct UpdatePullRequestParams { + /// New pull request title. + #[serde(default)] + pub title: Option, + /// Pull request body content in Markdown. + #[serde(default)] + pub body: Option, + /// Body update operation. Defaults to the configured operation, then `replace`. + #[serde(default)] + pub operation: Option, + /// When true, update the PR branch from the base branch before other updates. + #[serde(default, rename = "update_branch", alias = "updateBranch")] + pub update_branch: Option, + /// Pull request number. Required when front matter uses `target: "*"`. + #[serde(default, rename = "pull_request_number", alias = "pullRequestNumber")] + pub pull_request_number: Option, + /// Alias for pull_request_number. + #[serde(default, rename = "pr_number", alias = "prNumber")] + pub pr_number: Option, + /// Alias for pull_request_number. + #[serde(default)] + pub pr: Option, + /// Optional target repository. + #[serde(default)] + pub repository: Option, +} + +impl UpdatePullRequestParams { + fn requested_number(&self) -> anyhow::Result> { + let mut found = None; + for (field, value) in [ + ("pull_request_number", self.pull_request_number.as_ref()), + ("pr_number", self.pr_number.as_ref()), + ("pr", self.pr.as_ref()), + ] { + if let Some(value) = value { + let number = value.parse(field)?; + if let Some(existing) = found { + ensure!( + existing == number, + "pull request number aliases must all refer to the same PR" + ); + } + found = Some(number); + } + } + Ok(found) + } +} + +impl Validate for UpdatePullRequestParams { + fn validate(&self) -> anyhow::Result<()> { + ensure!( + self.title.is_some() || self.body.is_some() || self.update_branch == Some(true), + "at least one of title, body, or update_branch: true is required" + ); + if let Some(title) = self.title.as_deref() { + ensure!(!title.trim().is_empty(), "title must not be empty"); + ensure!( + title.len() <= MAX_TITLE_LEN, + "title must be {MAX_TITLE_LEN} characters or fewer" + ); + } + if let Some(body) = self.body.as_deref() { + ensure!( + body.len() <= MAX_BODY_LEN, + "body must be {MAX_BODY_LEN} characters or fewer" + ); + } else { + ensure!( + self.operation.is_none(), + "operation may only be provided when body is provided" + ); + } + if let Some(repository) = self.repository.as_deref() { + validate_github_repository(repository)?; + } + let _ = self.requested_number()?; + Ok(()) + } +} + +tool_result! { + name = "update-pull-request", + write = true, + params = UpdatePullRequestParams, + default_max = 1, + /// Result of updating a GitHub pull request. + pub struct UpdatePullRequestResult { + #[serde(default)] + title: Option, + #[serde(default)] + body: Option, + #[serde(default)] + operation: Option, + #[serde(default, rename = "update_branch")] + update_branch: Option, + #[serde(default, rename = "pull_request_number")] + pull_request_number: Option, + #[serde(default, rename = "pr_number")] + pr_number: Option, + #[serde(default)] + pr: Option, + #[serde(default)] + repository: Option, + } +} + +impl SanitizeContent for UpdatePullRequestResult { + fn sanitize_content_fields(&mut self) { + self.title = self.title.as_deref().map(sanitize_text); + self.body = self.body.as_deref().map(sanitize_text); + self.repository = self.repository.as_deref().map(sanitize_config); + } +} + +fn default_true() -> bool { + true +} + +fn default_operation() -> GithubBodyOperation { + GithubBodyOperation::Replace +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +pub enum UpdatePullRequestTarget { + Number(u64), + Named(String), +} + +impl Default for UpdatePullRequestTarget { + fn default() -> Self { + Self::Named("triggering".to_string()) + } +} + +#[derive(Debug, Clone, SanitizeConfig, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpdatePullRequestConfig { + /// Whether title updates are enabled. Defaults to true. + #[serde(default = "default_true")] + #[sanitize_config(skip)] + pub title: bool, + /// Whether body updates are enabled. Defaults to true. + #[serde(default = "default_true")] + #[sanitize_config(skip)] + pub body: bool, + /// Default branch update policy. Defaults to false. + #[serde(default, rename = "update-branch")] + #[sanitize_config(skip)] + pub update_branch: bool, + /// gh-aw-compatible stacked-PR fallback knob. Parsed for config parity. + #[serde(default = "default_true", rename = "sync-stack")] + #[sanitize_config(skip)] + pub sync_stack: bool, + /// Include the standard ado-aw trace footer in body updates. + #[serde(default = "default_true")] + #[sanitize_config(skip)] + pub footer: bool, + /// Body update operation. Defaults to replace. + #[serde(default = "default_operation")] + #[sanitize_config(skip)] + pub operation: GithubBodyOperation, + /// `"triggering"` (default), `"*"`, or a fixed PR number. + #[serde(default)] + pub target: UpdatePullRequestTarget, + #[serde(default, rename = "target-repo")] + pub target_repo: Option, + #[serde(default, rename = "allowed-repos")] + pub allowed_repos: Vec, + #[serde(default, rename = "required-labels")] + pub required_labels: Vec, + #[serde(default, rename = "required-title-prefix")] + pub required_title_prefix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[sanitize_config(skip)] + pub max: Option, +} + +impl Default for UpdatePullRequestConfig { + fn default() -> Self { + Self { + title: true, + body: true, + update_branch: false, + sync_stack: true, + footer: true, + operation: GithubBodyOperation::Replace, + target: UpdatePullRequestTarget::default(), + target_repo: None, + allowed_repos: Vec::new(), + required_labels: Vec::new(), + required_title_prefix: None, + max: None, + } + } +} + +pub(crate) fn validate_update_pull_request_config( + config: &UpdatePullRequestConfig, +) -> anyhow::Result<()> { + match &config.target { + UpdatePullRequestTarget::Number(number) => { + ensure!(*number > 0, "target PR number must be positive"); + } + UpdatePullRequestTarget::Named(target) => { + ensure!( + matches!(target.as_str(), "triggering" | "*"), + "target must be \"triggering\", \"*\", or a positive pull request number" + ); + } + } + validate_github_mutation_filter_config(GithubMutationFilters { + required_labels: &config.required_labels, + required_title_prefix: config.required_title_prefix.as_deref(), + })?; + Ok(()) +} + +#[derive(Debug, Deserialize)] +struct RawPullRequestTarget { + number: u64, + title: String, + #[serde(default)] + body: Option, + state: String, + #[serde(default)] + labels: Vec, + html_url: Option, +} + +#[derive(Debug, Deserialize)] +struct RawLabel { + name: String, +} + +impl RawPullRequestTarget { + fn metadata(&self) -> GithubTargetMetadata { + GithubTargetMetadata { + number: self.number, + node_id: None, + title: self.title.clone(), + state: self.state.clone(), + labels: self.labels.iter().map(|label| label.name.clone()).collect(), + kind: GithubTargetKind::PullRequest, + html_url: self.html_url.clone(), + } + } +} + +fn repository_route(client: &GithubClient, repository: &str, tail: &[&str]) -> anyhow::Result { + validate_github_repository(repository)?; + let (owner, name) = repository + .split_once('/') + .expect("validated GitHub repository contains slash"); + let mut url = client.rest_api_url().clone(); + { + let mut path = url + .path_segments_mut() + .map_err(|_| anyhow::anyhow!("GitHub API URL cannot be a base URL"))?; + path.pop_if_empty(); + path.push("repos"); + path.push(owner); + path.push(name); + for segment in tail { + path.push(segment); + } + } + Ok(url) +} + +fn body_with_footer(body: &str, include_footer: bool, ctx: &ExecutionContext) -> String { + if include_footer { + format!("{body}\n\n{}", build_github_trace_footer(ctx)) + } else { + body.to_string() + } +} + +fn island_markers(ctx: &ExecutionContext) -> Result<(String, String), ExecutionResult> { + let Some(definition_id) = ctx.definition_id else { + return Err(ExecutionResult::failure( + "SYSTEM_DEFINITIONID is required for replace-island", + )); + }; + Ok(( + format!(""), + format!(""), + )) +} + +fn replace_island( + current: &str, + replacement: &str, + ctx: &ExecutionContext, +) -> Result { + let (start_marker, end_marker) = island_markers(ctx)?; + let starts: Vec = current + .match_indices(&start_marker) + .map(|(index, _)| index) + .collect(); + let ends: Vec = current + .match_indices(&end_marker) + .map(|(index, _)| index) + .collect(); + if starts.len() != 1 || ends.len() != 1 { + let island = format!("{start_marker}\n{replacement}\n{end_marker}"); + return Ok(if current.is_empty() { + island + } else { + format!("{current}\n\n---\n\n{island}") + }); + } + let start = starts[0]; + let end = ends[0]; + if end <= start { + return Err(ExecutionResult::failure( + "replace-island markers are out of order", + )); + } + let end_after_marker = end + end_marker.len(); + Ok(format!( + "{}{}\n{}\n{}{}", + ¤t[..start], + start_marker, + replacement, + end_marker, + ¤t[end_after_marker..] + )) +} + +fn build_updated_body( + current: &str, + new_content: &str, + operation: GithubBodyOperation, + include_footer: bool, + ctx: &ExecutionContext, +) -> Result { + let section = body_with_footer(new_content, include_footer, ctx); + let updated = match operation { + GithubBodyOperation::Append => { + if current.is_empty() { + section + } else { + format!("{current}\n\n---\n\n{section}") + } + } + GithubBodyOperation::Prepend => { + if current.is_empty() { + section + } else { + format!("{section}\n\n---\n\n{current}") + } + } + GithubBodyOperation::Replace => section, + GithubBodyOperation::ReplaceIsland => replace_island(current, §ion, ctx)?, + }; + if updated.len() > MAX_BODY_LEN { + return Err(ExecutionResult::failure(format!( + "updated body exceeds GitHub's {MAX_BODY_LEN}-character limit" + ))); + } + Ok(updated) +} + +impl UpdatePullRequestResult { + fn requested_number(&self) -> anyhow::Result> { + let params = UpdatePullRequestParams { + title: self.title.clone(), + body: self.body.clone(), + operation: self.operation, + update_branch: self.update_branch, + pull_request_number: self.pull_request_number.clone(), + pr_number: self.pr_number.clone(), + pr: self.pr.clone(), + repository: self.repository.clone(), + }; + params.requested_number() + } + + fn requested_fields(&self, config: &UpdatePullRequestConfig) -> Vec<&'static str> { + let mut fields = Vec::new(); + if self.title.is_some() { + fields.push("title"); + } + if self.body.is_some() { + fields.push("body"); + } + if self.update_branch.unwrap_or(config.update_branch) { + fields.push("update_branch"); + } + fields + } + + fn resolve_number( + &self, + config: &UpdatePullRequestConfig, + ) -> Result { + let requested = self + .requested_number() + .map_err(|error| ExecutionResult::failure(error.to_string()))?; + match &config.target { + UpdatePullRequestTarget::Number(number) => { + if let Some(requested) = requested + && requested != *number + { + return Err(ExecutionResult::failure(format!( + "requested pull_request_number #{requested} does not match configured target #{number}" + ))); + } + Ok(*number) + } + UpdatePullRequestTarget::Named(target) if target == "*" => { + requested.ok_or_else(|| { + ExecutionResult::failure( + "pull_request_number is required when safe-outputs.update-pull-request.target is \"*\"", + ) + }) + } + UpdatePullRequestTarget::Named(target) if target == "triggering" => { + requested.ok_or_else(|| { + ExecutionResult::failure( + "SYSTEM_PULLREQUEST_PULLREQUESTID is required for target \"triggering\"", + ) + }) + } + UpdatePullRequestTarget::Named(target) => Err(ExecutionResult::failure(format!( + "unsupported update-pull-request target '{}'", + crate::sanitize::neutralize_pipeline_commands(target) + ))), + } + } + + async fn fetch_target( + &self, + client: &GithubClient, + repository: &str, + number: u64, + ) -> anyhow::Result> { + let response = client + .send( + Method::GET, + repository_route(client, repository, &["pulls", &number.to_string()])?, + None, + ) + .await?; + let response = match response.require_success("Failed to fetch GitHub pull request") { + Ok(response) => response, + Err(error) => return Ok(Err(ExecutionResult::failure(error.to_string()))), + }; + match response.json("Failed to parse GitHub pull request") { + Ok(target) => Ok(Ok(target)), + Err(error) => Ok(Err(ExecutionResult::failure(error.to_string()))), + } + } + + async fn update_branch( + &self, + client: &GithubClient, + repository: &str, + number: u64, + ) -> anyhow::Result> { + let response = client + .send( + Method::PUT, + repository_route( + client, + repository, + &["pulls", &number.to_string(), "update-branch"], + )?, + None, + ) + .await?; + if response.is_success() { + return Ok(Ok(())); + } + let error = response + .require_success("Failed to update GitHub pull request branch") + .expect_err("non-success response must produce an API error"); + let message = error.message.to_ascii_lowercase(); + if message.contains("there are no new commits on the base branch") + || message.contains("merge conflict between base and head") + || message.contains("head ref does not exist") + { + warn!("Non-fatal update-pull-request branch update failure: {error}"); + return Ok(Ok(())); + } + Ok(Err(ExecutionResult::failure(error.to_string()))) + } +} + +fn ctx_pull_request_id_from(ctx: &ExecutionContext) -> Result { + let raw = ctx.pull_request_id.as_deref().ok_or_else(|| { + ExecutionResult::failure("SYSTEM_PULLREQUEST_PULLREQUESTID is required for target \"triggering\"") + })?; + raw.parse::() + .ok() + .filter(|number| *number > 0) + .ok_or_else(|| { + ExecutionResult::failure(format!( + "SYSTEM_PULLREQUEST_PULLREQUESTID '{}' is not a positive pull request number", + crate::sanitize::neutralize_pipeline_commands(raw) + )) + }) +} + +#[async_trait::async_trait] +impl Executor for UpdatePullRequestResult { + fn dry_run_summary(&self) -> String { + "update GitHub pull request".to_string() + } + + async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + if !ctx.tool_configs.contains_key("update-pull-request") { + return Ok(ExecutionResult::failure( + "update-pull-request is not configured for this workflow", + )); + } + let Some(token) = ctx.github_token.as_ref() else { + return Ok(ExecutionResult::failure( + "ADO_AW_GITHUB_TOKEN is not set; configure safe-outputs.github-token \ + or safe-outputs.github-app", + )); + }; + let config: UpdatePullRequestConfig = ctx.get_tool_config("update-pull-request")?; + validate_update_pull_request_config(&config)?; + if self.title.is_some() && !config.title { + return Ok(ExecutionResult::failure( + "update-pull-request field 'title' is not enabled by configuration", + )); + } + if self.body.is_some() && !config.body { + return Ok(ExecutionResult::failure( + "update-pull-request field 'body' is not enabled by configuration", + )); + } + let number = match &config.target { + UpdatePullRequestTarget::Named(target) if target == "triggering" => { + match self.requested_number()? { + Some(number) => number, + None => match ctx_pull_request_id_from(ctx) { + Ok(number) => number, + Err(result) => return Ok(result), + }, + } + } + _ => match self.resolve_number(&config) { + Ok(number) => number, + Err(result) => return Ok(result), + }, + }; + let repository = match resolve_github_repository( + self.repository.as_deref(), + GithubRepositoryPolicy::new(config.target_repo.as_deref(), &config.allowed_repos), + ctx, + ) { + Ok(repository) => repository, + Err(result) => return Ok(result), + }; + let client = GithubClient::new(&ctx.github_api_url, token)?; + let current = match self.fetch_target(&client, &repository, number).await? { + Ok(target) => target, + Err(result) => return Ok(result), + }; + let metadata = current.metadata(); + if let Err(result) = validate_github_target_capability( + &metadata, + GithubTargetCapabilities { + issues: false, + pull_requests: true, + }, + ) { + return Ok(result); + } + let filters = GithubMutationFilters { + required_labels: &config.required_labels, + required_title_prefix: config.required_title_prefix.as_deref(), + }; + if let Err(result) = validate_github_mutation_filters(&metadata, filters) { + return Ok(result); + } + let update_branch = self.update_branch.unwrap_or(config.update_branch); + if update_branch + && let Err(result) = self.update_branch(&client, &repository, number).await? + { + return Ok(result); + } + + let mut payload = Map::new(); + if let Some(title) = self.title.as_ref() { + payload.insert("title".to_string(), Value::String(title.clone())); + } + if let Some(body) = self.body.as_deref() { + let updated = match build_updated_body( + current.body.as_deref().unwrap_or_default(), + body, + self.operation.unwrap_or(config.operation), + config.footer, + ctx, + ) { + Ok(body) => body, + Err(result) => return Ok(result), + }; + payload.insert("body".to_string(), Value::String(updated)); + } + if !payload.is_empty() { + debug!("Updating GitHub pull request {repository}#{number}"); + let response = client + .send( + Method::PATCH, + repository_route(&client, &repository, &["pulls", &number.to_string()])?, + Some(&Value::Object(payload)), + ) + .await?; + if !response.is_success() { + let error = response + .require_success("Failed to update GitHub pull request") + .expect_err("non-success response must produce an API error"); + return Ok(ExecutionResult::failure(error.to_string())); + } + } + + info!("Updated GitHub pull request {repository}#{number}"); + Ok(ExecutionResult::success_with_data( + format!( + "Updated GitHub pull request {repository}#{number}: {}", + self.requested_fields(&config).join(", ") + ), + serde_json::json!({ + "number": number, + "target_repo": repository, + "pull_request_url": metadata.html_url, + "fields": self.requested_fields(&config), + }), + )) + } +} From 98559d5b20b92dab115bcab1952761faabb855f2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:07:07 +0000 Subject: [PATCH 02/43] add close pull request safe output core Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- docs/safe-outputs.md | 20 + src/compile/common.rs | 5 + src/compile/types.rs | 23 + src/execute.rs | 14 +- src/mcp.rs | 21 +- src/safe_outputs/close_pull_request.rs | 565 +++++++++++++++++++++++++ src/safe_outputs/github_api.rs | 5 + src/safe_outputs/mod.rs | 4 + src/safe_outputs/result.rs | 6 + 9 files changed, 654 insertions(+), 9 deletions(-) create mode 100644 src/safe_outputs/close_pull_request.rs diff --git a/docs/safe-outputs.md b/docs/safe-outputs.md index c93a07934..f52d9d9b6 100644 --- a/docs/safe-outputs.md +++ b/docs/safe-outputs.md @@ -740,6 +740,7 @@ JSON uses the snake_case parameter names below. | `add-github-issue-labels` | `issue_number`, `labels`, optional `repository` | `allowed`, `blocked`, `issues`, `pull-requests` | 5 | | `remove-github-issue-labels` | `issue_number`, `labels`, optional `repository` | `allowed`, `blocked` | 5 | | `close-github-issue` | `issue_number`, optional `body`, `state_reason`, `duplicate_of`, `repository` | `state-reason`, `allowed-state-reason`, `allow-body` | 1 | +| `close-pull-request` | optional `pull_request_number`, optional `body`, optional `repository` | `target` (`"triggering"`, `"*"`, or number) | 1 | | `update-github-issue` | `issue_number`, one or more of `status`/`title`/`body`/`labels`/`assignees`/`milestone`, optional body `operation`, optional `repository` | `status`, `title`, `body`, `labels`, `assignees`, `milestone`, `allowed-labels`, `footer`, `issues`, `pull-requests` | 1 | | `set-github-issue-field` | `issue_number`, `value`, exactly one of `field_name`/`field_node_id`, optional `repository` | `allowed-fields` | 5 | | `assign-github-issue-milestone` | `issue_number`, exactly one of `milestone_number`/`milestone_title`, optional `repository` | `allowed`, `auto-create` | 1 | @@ -786,6 +787,25 @@ is already absent as success. close, then creates the native duplicate relationship. Already-closed targets are idempotent success. +`close-pull-request` matches gh-aw's front matter interface for closing GitHub +PRs without merging: + +```yaml +safe-outputs: + close-pull-request: + target: "triggering" # "triggering" (default), "*", or number + required-labels: [automated, stale] + required-title-prefix: "[bot]" + max: 10 + target-repo: octo-org/octo-repo +``` + +When `target` is `"*"`, the agent must pass `pull_request_number`; when it is a +number, that configured PR is used. The default `"triggering"` target uses the +GitHub PR number exposed by Azure Pipelines PR context. `body` posts an optional +closing comment before the PR is closed. `target-repo` and `allowed-repos` +follow the same repository policy as the GitHub issue tools. + `update-github-issue` requires at least one of `status`, `title`, `body`, `labels`, `assignees`, or `milestone`. Every mutable field is independently disabled by default: the operator must set the matching `status`, `title`, diff --git a/src/compile/common.rs b/src/compile/common.rs index b24239e47..a640f4773 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -2422,6 +2422,11 @@ pub fn validate_github_issue_outputs_config(front_matter: &FrontMatter) -> Resul crate::safe_outputs::validate_close_github_issue_config(&config)?; } } + "close-pull-request" => { + if let Some(config) = front_matter.close_pull_request_config()? { + crate::safe_outputs::validate_close_pull_request_config(&config)?; + } + } "update-github-issue" => { if let Some(config) = front_matter.update_github_issue_config()? { crate::safe_outputs::validate_update_github_issue_config(&config)?; diff --git a/src/compile/types.rs b/src/compile/types.rs index 08eb3afe4..1a4d72abc 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -715,6 +715,7 @@ pub const GITHUB_ISSUE_SAFE_OUTPUT_TOOLS: &[&str] = &[ "add-github-issue-labels", "remove-github-issue-labels", "close-github-issue", + "close-pull-request", "update-github-issue", "set-github-issue-field", "assign-github-issue-milestone", @@ -2291,6 +2292,12 @@ impl FrontMatter { self.typed_safe_output_config("close-github-issue") } + pub fn close_pull_request_config( + &self, + ) -> anyhow::Result> { + self.typed_safe_output_config("close-pull-request") + } + pub fn update_github_issue_config( &self, ) -> anyhow::Result> { @@ -2415,6 +2422,19 @@ impl FrontMatter { ..Default::default() })) } + "close-pull-request" => { + Ok(self + .close_pull_request_config()? + .map(|config| GithubIssueCompilerConfig { + target_repo: config.target_repo, + allowed_repos: config.allowed_repos, + required_labels: config.required_labels, + required_title_prefix: config.required_title_prefix, + pull_requests: Some(true), + issues: Some(false), + ..Default::default() + })) + } "update-github-issue" => { Ok(self .update_github_issue_config()? @@ -2511,6 +2531,9 @@ impl FrontMatter { pull_requests = true; discussions |= config.discussions.unwrap_or(false); } + "close-pull-request" => { + pull_requests = true; + } "remove-github-issue-labels" => { issues = true; pull_requests = true; diff --git a/src/execute.rs b/src/execute.rs index 7740caa53..25ac2375f 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -18,11 +18,11 @@ use crate::ndjson::{self, EXECUTED_NDJSON_FILENAME, SAFE_OUTPUT_FILENAME}; use crate::safe_outputs::{ AddBuildTagResult, AddGithubIssueLabelsResult, AddPrCommentResult, AssignGithubIssueMilestoneResult, AssignGithubIssueToUserResult, AssignWorkItemResult, - CloseGithubIssueResult, CommentOnGithubIssueResult, CommentOnWorkItemResult, - CreateBranchResult, CreateGitTagResult, CreateGithubIssueResult, CreatePrResult, - CreateWikiPageResult, CreateWorkItemResult, ExecutionContext, ExecutionResult, Executor, - HideGithubIssueCommentResult, LinkGithubSubIssueResult, LinkWorkItemsResult, MissingDataResult, - MissingToolResult, NoopResult, QueueBuildResult, RemoveGithubIssueLabelsResult, + CloseGithubIssueResult, ClosePullRequestResult, CommentOnGithubIssueResult, + CommentOnWorkItemResult, CreateBranchResult, CreateGitTagResult, CreateGithubIssueResult, + CreatePrResult, CreateWikiPageResult, CreateWorkItemResult, ExecutionContext, ExecutionResult, + Executor, HideGithubIssueCommentResult, LinkGithubSubIssueResult, LinkWorkItemsResult, + MissingDataResult, MissingToolResult, NoopResult, QueueBuildResult, RemoveGithubIssueLabelsResult, ReplyToPrCommentResult, ReportIncompleteResult, ResolvePrThreadResult, SetGithubIssueFieldResult, SetGithubIssueTypeResult, SubmitPrReviewResult, ToolResult, UnassignGithubIssueFromUserResult, UpdateGithubIssueResult, UpdatePrResult, @@ -777,6 +777,7 @@ async fn dispatch_github_tools( "add-github-issue-labels" => AddGithubIssueLabelsResult, "remove-github-issue-labels" => RemoveGithubIssueLabelsResult, "close-github-issue" => CloseGithubIssueResult, + "close-pull-request" => ClosePullRequestResult, "update-github-issue" => UpdateGithubIssueResult, "set-github-issue-field" => SetGithubIssueFieldResult, "assign-github-issue-milestone" => AssignGithubIssueMilestoneResult, @@ -804,6 +805,9 @@ fn extract_entry_context(entry: &Value) -> String { if let Some(issue) = entry.get("issue_number") { return format!(" (GitHub issue {})", safe_json_identifier(issue)); } + if let Some(pr) = entry.get("pull_request_number") { + return format!(" (GitHub pull request {})", safe_json_identifier(pr)); + } if let (Some(parent), Some(sub_issue)) = ( entry.get("parent_issue_number"), entry.get("sub_issue_number"), diff --git a/src/mcp.rs b/src/mcp.rs index 17af63e48..859099111 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -15,10 +15,11 @@ use crate::safe_outputs::{ AddPrCommentParams, AddPrCommentResult, AssignGithubIssueMilestoneParams, AssignGithubIssueMilestoneResult, AssignGithubIssueToUserParams, AssignGithubIssueToUserResult, AssignWorkItemParams, AssignWorkItemResult, CloseGithubIssueParams, CloseGithubIssueResult, - CommentOnGithubIssueParams, CommentOnGithubIssueResult, CommentOnWorkItemParams, - CommentOnWorkItemResult, CreateBranchParams, CreateBranchResult, CreateGitTagParams, - CreateGitTagResult, CreateGithubIssueParams, CreateGithubIssueResult, CreatePrParams, - CreatePrResult, CreateWikiPageParams, CreateWikiPageResult, CreateWorkItemParams, + ClosePullRequestParams, ClosePullRequestResult, CommentOnGithubIssueParams, + CommentOnGithubIssueResult, CommentOnWorkItemParams, CommentOnWorkItemResult, + CreateBranchParams, CreateBranchResult, CreateGitTagParams, CreateGitTagResult, + CreateGithubIssueParams, CreateGithubIssueResult, CreatePrParams, CreatePrResult, + CreateWikiPageParams, CreateWikiPageResult, CreateWorkItemParams, CreateWorkItemResult, DEFAULT_MAX_FILE_SIZE, HideGithubIssueCommentParams, HideGithubIssueCommentResult, LinkGithubSubIssueParams, LinkGithubSubIssueResult, LinkWorkItemsParams, LinkWorkItemsResult, MissingDataParams, MissingDataResult, @@ -945,6 +946,18 @@ issue_number may be a positive number or a temporary_id from create-github-issue self.queue_sanitized_output(result).await } + #[tool( + name = "close-pull-request", + description = "Close a configured GitHub pull request without merging, optionally with a comment." + )] + async fn close_pull_request( + &self, + params: Parameters, + ) -> Result { + let result: ClosePullRequestResult = params.0.try_into()?; + self.queue_sanitized_output(result).await + } + #[tool( name = "update-github-issue", description = "Update operator-enabled fields on a configured GitHub issue or pull request." diff --git a/src/safe_outputs/close_pull_request.rs b/src/safe_outputs/close_pull_request.rs new file mode 100644 index 000000000..9e6fdcfdf --- /dev/null +++ b/src/safe_outputs/close_pull_request.rs @@ -0,0 +1,565 @@ +//! `close-pull-request` safe output. + +use anyhow::ensure; +use log::{info, warn}; +use reqwest::Method; +use schemars::JsonSchema; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::safe_outputs::{ + ExecutionContext, ExecutionResult, Executor, GithubClient, GithubMutationFilters, + GithubRepositoryPolicy, GithubTargetCapabilities, Validate, resolve_github_repository, + validate_github_mutation_filter_config, validate_github_mutation_filters, + validate_github_repository, validate_github_target_capability, +}; +use crate::sanitize::{SanitizeContent, sanitize as sanitize_text, sanitize_config}; +use crate::tool_result; +use ado_aw_derive::SanitizeConfig; + +const MAX_COMMENT_LEN: usize = 65_536; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClosePullRequestTarget { + Triggering, + Any, + Number(u64), +} + +impl Default for ClosePullRequestTarget { + fn default() -> Self { + Self::Triggering + } +} + +impl Serialize for ClosePullRequestTarget { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::Triggering => serializer.serialize_str("triggering"), + Self::Any => serializer.serialize_str("*"), + Self::Number(number) => serializer.serialize_u64(*number), + } + } +} + +impl<'de> Deserialize<'de> for ClosePullRequestTarget { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct Visitor; + + impl serde::de::Visitor<'_> for Visitor { + type Value = ClosePullRequestTarget; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(r#""triggering", "*", or a positive pull request number"#) + } + + fn visit_u64(self, value: u64) -> Result + where + E: serde::de::Error, + { + if value == 0 { + return Err(E::custom("target pull request number must be positive")); + } + Ok(ClosePullRequestTarget::Number(value)) + } + + fn visit_i64(self, value: i64) -> Result + where + E: serde::de::Error, + { + if value <= 0 { + return Err(E::custom("target pull request number must be positive")); + } + Ok(ClosePullRequestTarget::Number(value as u64)) + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + match value { + "triggering" => Ok(ClosePullRequestTarget::Triggering), + "*" => Ok(ClosePullRequestTarget::Any), + other => { + let number = other + .parse::() + .map_err(|_| E::custom("target must be \"triggering\", \"*\", or a positive pull request number"))?; + if number == 0 { + return Err(E::custom("target pull request number must be positive")); + } + Ok(ClosePullRequestTarget::Number(number)) + } + } + } + } + + deserializer.deserialize_any(Visitor) + } +} + +#[derive(Deserialize, JsonSchema)] +pub struct ClosePullRequestParams { + /// Positive GitHub pull request number. Required when config target is "*". + #[serde(default)] + pub pull_request_number: Option, + /// Optional closing comment. + #[serde(default)] + pub body: Option, + /// Optional target repository. + #[serde(default)] + pub repository: Option, +} + +impl Validate for ClosePullRequestParams { + fn validate(&self) -> anyhow::Result<()> { + if let Some(number) = self.pull_request_number { + ensure!(number > 0, "pull_request_number must be positive"); + } + if let Some(body) = self.body.as_deref() { + ensure!(!body.trim().is_empty(), "body must not be empty"); + ensure!( + body.len() <= MAX_COMMENT_LEN, + "body must be {MAX_COMMENT_LEN} characters or fewer" + ); + } + if let Some(repository) = self.repository.as_deref() { + validate_github_repository(repository)?; + } + Ok(()) + } +} + +tool_result! { + name = "close-pull-request", + write = true, + params = ClosePullRequestParams, + default_max = 1, + /// Result of closing a GitHub pull request. + pub struct ClosePullRequestResult { + #[serde(default)] + pull_request_number: Option, + #[serde(default)] + body: Option, + #[serde(default)] + repository: Option, + } +} + +impl SanitizeContent for ClosePullRequestResult { + fn sanitize_content_fields(&mut self) { + self.body = self.body.as_deref().map(sanitize_text); + self.repository = self.repository.as_deref().map(sanitize_config); + } +} + +#[derive(Debug, Clone, SanitizeConfig, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ClosePullRequestConfig { + #[serde(default)] + #[sanitize_config(skip)] + pub target: ClosePullRequestTarget, + #[serde(default, rename = "target-repo")] + pub target_repo: Option, + #[serde(default, rename = "allowed-repos")] + pub allowed_repos: Vec, + #[serde(default, rename = "required-labels")] + pub required_labels: Vec, + #[serde(default, rename = "required-title-prefix")] + pub required_title_prefix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[sanitize_config(skip)] + pub max: Option, +} + +impl Default for ClosePullRequestConfig { + fn default() -> Self { + Self { + target: ClosePullRequestTarget::Triggering, + target_repo: None, + allowed_repos: Vec::new(), + required_labels: Vec::new(), + required_title_prefix: None, + max: None, + } + } +} + +pub(crate) fn validate_close_pull_request_config( + config: &ClosePullRequestConfig, +) -> anyhow::Result<()> { + validate_github_mutation_filter_config(GithubMutationFilters { + required_labels: &config.required_labels, + required_title_prefix: config.required_title_prefix.as_deref(), + })?; + Ok(()) +} + +fn parse_positive(value: Option<&str>) -> Option { + value.and_then(|value| value.parse::().ok()) + .filter(|number| *number > 0) +} + +impl ClosePullRequestResult { + fn resolve_target_number( + &self, + config: &ClosePullRequestConfig, + ctx: &ExecutionContext, + ) -> Result { + match config.target { + ClosePullRequestTarget::Number(number) => Ok(number), + ClosePullRequestTarget::Any => self.pull_request_number.ok_or_else(|| { + ExecutionResult::failure( + "pull_request_number is required when safe-outputs.close-pull-request.target is '*'", + ) + }), + ClosePullRequestTarget::Triggering => parse_positive(ctx.pull_request_number.as_deref()) + .or_else(|| parse_positive(ctx.pull_request_id.as_deref())) + .ok_or_else(|| { + ExecutionResult::failure( + "safe-outputs.close-pull-request.target is 'triggering' but no GitHub pull request context is available; use target: '*' and pass pull_request_number, or configure a numeric target", + ) + }), + } + } + + fn resolve_repository( + &self, + config: &ClosePullRequestConfig, + ctx: &ExecutionContext, + ) -> Result { + resolve_github_repository( + self.repository.as_deref(), + GithubRepositoryPolicy::new(config.target_repo.as_deref(), &config.allowed_repos), + ctx, + ) + } +} + +async fn post_comment( + client: &GithubClient, + repository: &str, + number: u64, + body: Option<&str>, +) -> anyhow::Result> { + let Some(body) = body else { + return Ok(Ok(false)); + }; + let response = client + .send( + Method::POST, + client.issue_comments_url(repository, number)?, + Some(&serde_json::json!({ "body": body })), + ) + .await?; + if !response.is_success() { + let error = response + .require_success("Failed to add GitHub pull request closing comment") + .expect_err("non-success response must produce an API error"); + return Ok(Err(ExecutionResult::failure(error.to_string()))); + } + Ok(Ok(true)) +} + +async fn close_pull_request( + client: &GithubClient, + repository: &str, + number: u64, + already_closed: bool, +) -> anyhow::Result> { + if already_closed { + return Ok(Ok(())); + } + let response = client + .send( + Method::PATCH, + client.pull_request_url(repository, number)?, + Some(&serde_json::json!({ "state": "closed" })), + ) + .await?; + if !response.is_success() { + let error = response + .require_success("Failed to close GitHub pull request") + .expect_err("non-success response must produce an API error"); + return Ok(Err(ExecutionResult::failure(error.to_string()))); + } + Ok(Ok(())) +} + +#[async_trait::async_trait] +impl Executor for ClosePullRequestResult { + fn dry_run_summary(&self) -> String { + let target = self + .pull_request_number + .map(|number| format!("#{number}")) + .unwrap_or_else(|| "configured target".to_string()); + format!("close GitHub pull request {target}") + } + + async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + if !ctx.tool_configs.contains_key("close-pull-request") { + return Ok(ExecutionResult::failure( + "close-pull-request is not configured for this workflow", + )); + } + let Some(token) = ctx.github_token.as_ref() else { + return Ok(ExecutionResult::failure( + "ADO_AW_GITHUB_TOKEN is not set; configure safe-outputs.github-token \ + or safe-outputs.github-app", + )); + }; + let config: ClosePullRequestConfig = ctx.get_tool_config("close-pull-request")?; + validate_close_pull_request_config(&config)?; + let filters = GithubMutationFilters { + required_labels: &config.required_labels, + required_title_prefix: config.required_title_prefix.as_deref(), + }; + if let Err(error) = validate_github_mutation_filter_config(filters) { + return Ok(ExecutionResult::failure(error.to_string())); + } + let target_number = match self.resolve_target_number(&config, ctx) { + Ok(number) => number, + Err(result) => return Ok(result), + }; + let repository = match self.resolve_repository(&config, ctx) { + Ok(repository) => repository, + Err(result) => return Ok(result), + }; + let client = GithubClient::new(&ctx.github_api_url, token)?; + let metadata = match client.get_issue(&repository, target_number).await? { + Ok(metadata) => metadata, + Err(error) => return Ok(ExecutionResult::failure(error.to_string())), + }; + if let Err(result) = + validate_github_target_capability(&metadata, GithubTargetCapabilities { + issues: false, + pull_requests: true, + }) + { + return Ok(result); + } + if let Err(result) = validate_github_mutation_filters(&metadata, filters) { + return Ok(result); + } + + let already_closed = metadata.state.eq_ignore_ascii_case("closed"); + let comment_posted = match post_comment(&client, &repository, target_number, self.body.as_deref()).await? { + Ok(posted) => posted, + Err(result) => return Ok(result), + }; + if let Err(result) = + close_pull_request(&client, &repository, target_number, already_closed).await? + { + return Ok(result); + } + + let action = if already_closed { + "GitHub pull request was already closed" + } else { + "Closed GitHub pull request" + }; + if already_closed { + warn!("GitHub pull request {}#{} was already closed", repository, target_number); + } else { + info!("Closed GitHub pull request {}#{}", repository, target_number); + } + Ok(ExecutionResult::success_with_data( + format!("{action} {repository}#{target_number}"), + serde_json::json!({ + "number": target_number, + "target_repo": repository, + "already_closed": already_closed, + "comment_posted": comment_posted, + }), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::safe_outputs::ToolResult; + use std::collections::HashMap; + use wiremock::matchers::{body_json, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn context(server: &MockServer, config: serde_json::Value) -> ExecutionContext { + let mut tool_configs = HashMap::new(); + tool_configs.insert("close-pull-request".to_string(), config); + ExecutionContext { + github_token: Some("token".to_string()), + github_api_url: server.uri(), + tool_configs, + repository_provider: Some("github".to_string()), + repository_name: Some("octo/repo".to_string()), + ..Default::default() + } + } + + fn open_pr(number: u64) -> serde_json::Value { + serde_json::json!({ + "number": number, + "node_id": format!("PR_{number}"), + "title": "[bot] stale PR", + "state": "open", + "labels": [{"name": "automated"}, {"name": "stale"}], + "html_url": format!("https://github.example/octo/repo/pull/{number}"), + "pull_request": {"url": format!("https://api.github.example/repos/octo/repo/pulls/{number}")} + }) + } + + #[test] + fn contract_name_and_budget() { + assert_eq!(ClosePullRequestResult::NAME, "close-pull-request"); + assert_eq!(ClosePullRequestResult::DEFAULT_MAX, 1); + } + + #[test] + fn config_accepts_gh_aw_target_forms() { + let triggering: ClosePullRequestConfig = + serde_json::from_value(serde_json::json!({"target": "triggering"})).unwrap(); + assert_eq!(triggering.target, ClosePullRequestTarget::Triggering); + let any: ClosePullRequestConfig = + serde_json::from_value(serde_json::json!({"target": "*"})).unwrap(); + assert_eq!(any.target, ClosePullRequestTarget::Any); + let number: ClosePullRequestConfig = + serde_json::from_value(serde_json::json!({"target": 42})).unwrap(); + assert_eq!(number.target, ClosePullRequestTarget::Number(42)); + assert!(serde_json::from_value::(serde_json::json!({ + "target": 0 + })).is_err()); + } + + #[test] + fn validates_optional_number_body_and_repository() { + assert!( + ClosePullRequestParams { + pull_request_number: Some(42), + body: Some("Closing as stale.".to_string()), + repository: Some("octo/repo".to_string()), + } + .validate() + .is_ok() + ); + assert!( + ClosePullRequestParams { + pull_request_number: Some(0), + body: None, + repository: None, + } + .validate() + .is_err() + ); + } + + #[tokio::test] + async fn closes_with_comment_and_filters() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(open_pr(7))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/repos/octo/repo/issues/7/comments")) + .and(body_json(serde_json::json!({"body": "Closing as stale."}))) + .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({}))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PATCH")) + .and(path("/repos/octo/repo/pulls/7")) + .and(body_json(serde_json::json!({"state": "closed"}))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) + .expect(1) + .mount(&server) + .await; + let ctx = context( + &server, + serde_json::json!({ + "target": "*", + "target-repo": "octo/repo", + "required-labels": ["automated", "stale"], + "required-title-prefix": "[bot]" + }), + ); + let mut result: ClosePullRequestResult = ClosePullRequestParams { + pull_request_number: Some(7), + body: Some("Closing as stale.".to_string()), + repository: None, + } + .try_into() + .unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(execution.success, "{}", execution.message); + assert_eq!( + execution.data.as_ref().unwrap()["comment_posted"], + serde_json::json!(true) + ); + } + + #[tokio::test] + async fn triggering_target_uses_context_pull_request_number() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/9")) + .respond_with(ResponseTemplate::new(200).set_body_json(open_pr(9))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PATCH")) + .and(path("/repos/octo/repo/pulls/9")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + let mut ctx = context(&server, serde_json::json!({"target-repo": "octo/repo"})); + ctx.pull_request_number = Some("9".to_string()); + let mut result: ClosePullRequestResult = ClosePullRequestParams { + pull_request_number: None, + body: None, + repository: None, + } + .try_into() + .unwrap(); + assert!(result.execute_sanitized(&ctx).await.unwrap().success); + } + + #[tokio::test] + async fn non_pull_request_target_is_rejected_before_patch() { + let server = MockServer::start().await; + let mut issue = open_pr(7); + issue.as_object_mut().unwrap().remove("pull_request"); + Mock::given(method("GET")) + .and(path("/repos/octo/repo/issues/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(issue)) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PATCH")) + .and(path("/repos/octo/repo/pulls/7")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + let ctx = context( + &server, + serde_json::json!({"target": "*", "target-repo": "octo/repo"}), + ); + let mut result: ClosePullRequestResult = ClosePullRequestParams { + pull_request_number: Some(7), + body: None, + repository: None, + } + .try_into() + .unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + server.verify().await; + } +} diff --git a/src/safe_outputs/github_api.rs b/src/safe_outputs/github_api.rs index 0d30c2d53..a6f41d422 100644 --- a/src/safe_outputs/github_api.rs +++ b/src/safe_outputs/github_api.rs @@ -185,6 +185,11 @@ impl GithubClient { self.repository_route(repository, &["issues", &number.to_string(), "comments"]) } + pub fn pull_request_url(&self, repository: &str, number: u64) -> anyhow::Result { + ensure!(number > 0, "GitHub pull request number must be positive"); + self.repository_route(repository, &["pulls", &number.to_string()]) + } + pub fn issue_comment_url(&self, repository: &str, comment_id: u64) -> anyhow::Result { ensure!(comment_id > 0, "GitHub comment ID must be positive"); self.repository_route(repository, &["issues", "comments", &comment_id.to_string()]) diff --git a/src/safe_outputs/mod.rs b/src/safe_outputs/mod.rs index bd86a5085..fec2db19a 100644 --- a/src/safe_outputs/mod.rs +++ b/src/safe_outputs/mod.rs @@ -54,6 +54,7 @@ pub const CONFIGURED_ONLY_TOOLS: &[&str] = tool_names![ AddGithubIssueLabelsResult, RemoveGithubIssueLabelsResult, CloseGithubIssueResult, + ClosePullRequestResult, UpdateGithubIssueResult, SetGithubIssueFieldResult, AssignGithubIssueMilestoneResult, @@ -98,6 +99,7 @@ pub const ALL_KNOWN_SAFE_OUTPUTS: &[&str] = all_safe_output_names![ AddGithubIssueLabelsResult, RemoveGithubIssueLabelsResult, CloseGithubIssueResult, + ClosePullRequestResult, UpdateGithubIssueResult, SetGithubIssueFieldResult, AssignGithubIssueMilestoneResult, @@ -769,6 +771,7 @@ mod assign_work_item; mod assign_github_issue_milestone; mod assign_github_issue_to_user; mod close_github_issue; +mod close_pull_request; mod comment_on_github_issue; mod comment_on_work_item; mod create_branch; @@ -810,6 +813,7 @@ pub use assign_work_item::*; pub use assign_github_issue_milestone::*; pub use assign_github_issue_to_user::*; pub use close_github_issue::*; +pub use close_pull_request::*; pub use comment_on_github_issue::*; pub use comment_on_work_item::*; pub use create_branch::*; diff --git a/src/safe_outputs/result.rs b/src/safe_outputs/result.rs index bd5375957..1b7b36839 100644 --- a/src/safe_outputs/result.rs +++ b/src/safe_outputs/result.rs @@ -251,6 +251,8 @@ pub struct ExecutionContext { /// PR ID when `BUILD_REASON=PullRequest` (`SYSTEM_PULLREQUEST_PULLREQUESTID`) #[allow(dead_code)] pub pull_request_id: Option, + /// GitHub PR number when available (`SYSTEM_PULLREQUEST_PULLREQUESTNUMBER`) + pub pull_request_number: Option, /// PR source branch (`SYSTEM_PULLREQUEST_SOURCEBRANCH`) #[allow(dead_code)] pub pull_request_source_branch: Option, @@ -489,6 +491,7 @@ impl ExecutionContext { // Pull request variables pull_request_id: env("SYSTEM_PULLREQUEST_PULLREQUESTID"), + pull_request_number: env("SYSTEM_PULLREQUEST_PULLREQUESTNUMBER"), pull_request_source_branch: env("SYSTEM_PULLREQUEST_SOURCEBRANCH"), pull_request_target_branch: env("SYSTEM_PULLREQUEST_TARGETBRANCH"), @@ -1354,10 +1357,12 @@ mod tests { let ctx = ExecutionContext::from_env_lookup(env_from(&[ ("BUILD_REASON", "PullRequest"), ("SYSTEM_PULLREQUEST_PULLREQUESTID", "789"), + ("SYSTEM_PULLREQUEST_PULLREQUESTNUMBER", "123"), ("SYSTEM_PULLREQUEST_SOURCEBRANCH", "refs/heads/feature"), ("SYSTEM_PULLREQUEST_TARGETBRANCH", "refs/heads/main"), ])); assert_eq!(ctx.pull_request_id.as_deref(), Some("789")); + assert_eq!(ctx.pull_request_number.as_deref(), Some("123")); assert_eq!( ctx.pull_request_source_branch.as_deref(), Some("refs/heads/feature") @@ -1372,6 +1377,7 @@ mod tests { fn test_from_env_lookup_pull_request_none_when_unset() { let ctx = ExecutionContext::from_env_lookup(env_from(&[])); assert!(ctx.pull_request_id.is_none()); + assert!(ctx.pull_request_number.is_none()); assert!(ctx.pull_request_source_branch.is_none()); assert!(ctx.pull_request_target_branch.is_none()); } From 772282eafd485914cff27ff0e4c8445854b26007 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:10:52 +0000 Subject: [PATCH 03/43] Document and test update-pull-request safe output Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- docs/safe-outputs.md | 42 +++++ src/safe_outputs/update_pull_request.rs | 239 ++++++++++++++++++++++-- 2 files changed, 261 insertions(+), 20 deletions(-) diff --git a/docs/safe-outputs.md b/docs/safe-outputs.md index c93a07934..e6f40f163 100644 --- a/docs/safe-outputs.md +++ b/docs/safe-outputs.md @@ -828,6 +828,48 @@ their complete existing lists, and `milestone` selects an existing milestone by positive number. All requested changes are preflighted before the first write. +#### Pull request updates (`update-pull-request`) + +`update-pull-request` matches gh-aw's front matter configuration for updating +GitHub pull requests. It updates the PR title or body; both fields are enabled +by default. The `operation` field controls body updates: `replace` (default), +`append`, `prepend`, or `replace-island`. + +```yaml +safe-outputs: + update-pull-request: + title: true # enable title updates (default: true) + body: true # enable body updates (default: true) + update-branch: false # update the PR branch from its base first (default: false) + sync-stack: true # accepted for gh-aw configuration parity + footer: false # omit the ado-aw trace footer from body updates (default: true) + operation: replace # replace, append, prepend, or replace-island + max: 1 # maximum updates per run (default: 1) + target: "*" # "triggering" (default), "*", or a PR number + target-repo: octo-org/octo-repo + allowed-repos: [octo-org/octo-repo] + required-labels: [automated] + required-title-prefix: "[bot] " +``` + +**Agent parameters:** + +- `title` *(optional)* - Replacement PR title. +- `body` *(optional)* - PR body content in Markdown. +- `operation` *(optional)* - Overrides the configured body operation for this + update. +- `update_branch` *(optional)* - When `true`, update the PR branch with the + latest base branch changes before title/body updates. +- `pull_request_number`, `pr_number`, or `pr` - Required when `target: "*"` is + configured. With `target: "triggering"`, any supplied number must match the + triggering PR. +- `repository` *(optional)* - Target repository, constrained by `target-repo` + and `allowed-repos`. + +`update-branch` calls GitHub's pull request branch update API. GitHub's benign +"already up to date", merge-conflict, and missing-head responses are treated as +non-fatal, matching gh-aw's best-effort behavior. + #### Fields, milestones, and assignees `set-github-issue-field` rejects built-in fields and limits repository-defined diff --git a/src/safe_outputs/update_pull_request.rs b/src/safe_outputs/update_pull_request.rs index 3b928f7df..683864908 100644 --- a/src/safe_outputs/update_pull_request.rs +++ b/src/safe_outputs/update_pull_request.rs @@ -445,6 +445,7 @@ impl UpdatePullRequestResult { fn resolve_number( &self, config: &UpdatePullRequestConfig, + ctx: &ExecutionContext, ) -> Result { let requested = self .requested_number() @@ -468,11 +469,15 @@ impl UpdatePullRequestResult { }) } UpdatePullRequestTarget::Named(target) if target == "triggering" => { - requested.ok_or_else(|| { - ExecutionResult::failure( - "SYSTEM_PULLREQUEST_PULLREQUESTID is required for target \"triggering\"", - ) - }) + let triggering = ctx_pull_request_id_from(ctx)?; + if let Some(requested) = requested + && requested != triggering + { + return Err(ExecutionResult::failure(format!( + "requested pull_request_number #{requested} does not match triggering pull request #{triggering}" + ))); + } + Ok(triggering) } UpdatePullRequestTarget::Named(target) => Err(ExecutionResult::failure(format!( "unsupported update-pull-request target '{}'", @@ -541,7 +546,9 @@ impl UpdatePullRequestResult { fn ctx_pull_request_id_from(ctx: &ExecutionContext) -> Result { let raw = ctx.pull_request_id.as_deref().ok_or_else(|| { - ExecutionResult::failure("SYSTEM_PULLREQUEST_PULLREQUESTID is required for target \"triggering\"") + ExecutionResult::failure( + "SYSTEM_PULLREQUEST_PULLREQUESTID is required for target \"triggering\"", + ) })?; raw.parse::() .ok() @@ -561,6 +568,19 @@ impl Executor for UpdatePullRequestResult { } async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + let params = UpdatePullRequestParams { + title: self.title.clone(), + body: self.body.clone(), + operation: self.operation, + update_branch: self.update_branch, + pull_request_number: self.pull_request_number.clone(), + pr_number: self.pr_number.clone(), + pr: self.pr.clone(), + repository: self.repository.clone(), + }; + if let Err(error) = params.validate() { + return Ok(ExecutionResult::failure(error.to_string())); + } if !ctx.tool_configs.contains_key("update-pull-request") { return Ok(ExecutionResult::failure( "update-pull-request is not configured for this workflow", @@ -584,20 +604,9 @@ impl Executor for UpdatePullRequestResult { "update-pull-request field 'body' is not enabled by configuration", )); } - let number = match &config.target { - UpdatePullRequestTarget::Named(target) if target == "triggering" => { - match self.requested_number()? { - Some(number) => number, - None => match ctx_pull_request_id_from(ctx) { - Ok(number) => number, - Err(result) => return Ok(result), - }, - } - } - _ => match self.resolve_number(&config) { - Ok(number) => number, - Err(result) => return Ok(result), - }, + let number = match self.resolve_number(&config, ctx) { + Ok(number) => number, + Err(result) => return Ok(result), }; let repository = match resolve_github_repository( self.repository.as_deref(), @@ -685,3 +694,193 @@ impl Executor for UpdatePullRequestResult { )) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::safe_outputs::ToolResult; + use std::collections::HashMap; + use wiremock::matchers::{body_json, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + fn context(server: &MockServer, config: serde_json::Value) -> ExecutionContext { + let mut tool_configs = HashMap::new(); + tool_configs.insert("update-pull-request".to_string(), config); + ExecutionContext { + github_token: Some("token".to_string()), + github_api_url: server.uri(), + repository_provider: Some("GitHub".to_string()), + repository_name: Some("octo/repo".to_string()), + pull_request_id: Some("7".to_string()), + tool_configs, + definition_id: Some(123), + ..Default::default() + } + } + + fn pull_request(number: u64) -> serde_json::Value { + serde_json::json!({ + "number": number, + "title": "[bot] Existing", + "body": "Existing body", + "state": "open", + "labels": [{"name": "automated"}], + "html_url": format!("https://github.example/octo/repo/pull/{number}") + }) + } + + fn params() -> UpdatePullRequestParams { + UpdatePullRequestParams { + title: Some("Updated title".to_string()), + body: None, + operation: None, + update_branch: None, + pull_request_number: None, + pr_number: None, + pr: None, + repository: None, + } + } + + #[test] + fn contract_name_and_budget() { + assert_eq!(UpdatePullRequestResult::NAME, "update-pull-request"); + assert_eq!(UpdatePullRequestResult::DEFAULT_MAX, 1); + } + + #[test] + fn config_matches_gh_aw_defaults_and_rejects_unknown_fields() { + let config: UpdatePullRequestConfig = + serde_json::from_value(serde_json::json!({})).unwrap(); + assert!(config.title); + assert!(config.body); + assert!(!config.update_branch); + assert!(config.sync_stack); + assert!(config.footer); + assert_eq!(config.operation, GithubBodyOperation::Replace); + assert!( + serde_json::from_value::(serde_json::json!({ + "allow-title": true + })) + .is_err() + ); + } + + #[test] + fn validates_meaningful_update_and_number_aliases() { + let mut empty = params(); + empty.title = None; + assert!(empty.validate().is_err()); + + let mut aliases = params(); + aliases.pull_request_number = Some(GithubPullRequestNumber::Number(1)); + aliases.pr_number = Some(GithubPullRequestNumber::String("#2".to_string())); + assert!(aliases.validate().is_err()); + } + + #[tokio::test] + async fn updates_triggering_pull_request_title_and_body() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/pulls/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(pull_request(7))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PATCH")) + .and(path("/repos/octo/repo/pulls/7")) + .and(body_json(serde_json::json!({ + "title": "Updated title", + "body": "Existing body\n\n---\n\nNew body" + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(pull_request(7))) + .expect(1) + .mount(&server) + .await; + let ctx = context( + &server, + serde_json::json!({ + "target-repo": "octo/repo", + "title": true, + "body": true, + "footer": false, + "operation": "append", + "required-labels": ["automated"], + "required-title-prefix": "[bot]" + }), + ); + let mut result: UpdatePullRequestResult = UpdatePullRequestParams { + title: Some("Updated title".to_string()), + body: Some("New body".to_string()), + operation: None, + update_branch: None, + pull_request_number: None, + pr_number: None, + pr: None, + repository: None, + } + .try_into() + .unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(execution.success, "{}", execution.message); + } + + #[tokio::test] + async fn target_star_requires_agent_pull_request_number() { + let server = MockServer::start().await; + let ctx = context( + &server, + serde_json::json!({ + "target-repo": "octo/repo", + "target": "*" + }), + ); + let mut result: UpdatePullRequestResult = params().try_into().unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + assert!( + execution + .message + .contains("pull_request_number is required") + ); + assert!(server.received_requests().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn update_branch_only_calls_update_branch_endpoint() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/repos/octo/repo/pulls/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(pull_request(7))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PUT")) + .and(path("/repos/octo/repo/pulls/7/update-branch")) + .respond_with(ResponseTemplate::new(202).set_body_json(serde_json::json!({}))) + .expect(1) + .mount(&server) + .await; + let ctx = context( + &server, + serde_json::json!({ + "target-repo": "octo/repo", + "update-branch": true + }), + ); + let mut result: UpdatePullRequestResult = UpdatePullRequestParams { + title: None, + body: None, + operation: None, + update_branch: Some(true), + pull_request_number: None, + pr_number: None, + pr: None, + repository: None, + } + .try_into() + .unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(execution.success, "{}", execution.message); + } +} From 89b66087b1611fcf43bac6b26fd555bdbdc9a57f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:11:25 +0000 Subject: [PATCH 04/43] format and fix close pull request tests Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- src/execute.rs | 10 +++--- src/mcp.rs | 32 ++++++++--------- src/safe_outputs/close_pull_request.rs | 40 +++++++++++++-------- src/safe_outputs/create_pull_request.rs | 1 + src/safe_outputs/mod.rs | 27 ++++++++------ src/safe_outputs/upload_build_attachment.rs | 1 + 6 files changed, 64 insertions(+), 47 deletions(-) diff --git a/src/execute.rs b/src/execute.rs index 25ac2375f..6d6e32584 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -22,11 +22,11 @@ use crate::safe_outputs::{ CommentOnWorkItemResult, CreateBranchResult, CreateGitTagResult, CreateGithubIssueResult, CreatePrResult, CreateWikiPageResult, CreateWorkItemResult, ExecutionContext, ExecutionResult, Executor, HideGithubIssueCommentResult, LinkGithubSubIssueResult, LinkWorkItemsResult, - MissingDataResult, MissingToolResult, NoopResult, QueueBuildResult, RemoveGithubIssueLabelsResult, - ReplyToPrCommentResult, ReportIncompleteResult, ResolvePrThreadResult, - SetGithubIssueFieldResult, SetGithubIssueTypeResult, SubmitPrReviewResult, ToolResult, - UnassignGithubIssueFromUserResult, UpdateGithubIssueResult, UpdatePrResult, - UpdateWikiPageResult, UpdateWorkItemResult, UploadBuildAttachmentResult, + MissingDataResult, MissingToolResult, NoopResult, QueueBuildResult, + RemoveGithubIssueLabelsResult, ReplyToPrCommentResult, ReportIncompleteResult, + ResolvePrThreadResult, SetGithubIssueFieldResult, SetGithubIssueTypeResult, + SubmitPrReviewResult, ToolResult, UnassignGithubIssueFromUserResult, UpdateGithubIssueResult, + UpdatePrResult, UpdateWikiPageResult, UpdateWorkItemResult, UploadBuildAttachmentResult, UploadPipelineArtifactResult, UploadWorkitemAttachmentResult, }; use crate::sanitize::neutralize_pipeline_commands; diff --git a/src/mcp.rs b/src/mcp.rs index 859099111..ab0a4d87d 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -19,22 +19,21 @@ use crate::safe_outputs::{ CommentOnGithubIssueResult, CommentOnWorkItemParams, CommentOnWorkItemResult, CreateBranchParams, CreateBranchResult, CreateGitTagParams, CreateGitTagResult, CreateGithubIssueParams, CreateGithubIssueResult, CreatePrParams, CreatePrResult, - CreateWikiPageParams, CreateWikiPageResult, CreateWorkItemParams, - CreateWorkItemResult, DEFAULT_MAX_FILE_SIZE, HideGithubIssueCommentParams, - HideGithubIssueCommentResult, LinkGithubSubIssueParams, LinkGithubSubIssueResult, - LinkWorkItemsParams, LinkWorkItemsResult, MissingDataParams, MissingDataResult, - MissingToolParams, MissingToolResult, NoopParams, NoopResult, - PIPELINE_ARTIFACT_DEFAULT_MAX_FILE_SIZE, QueueBuildParams, QueueBuildResult, + CreateWikiPageParams, CreateWikiPageResult, CreateWorkItemParams, CreateWorkItemResult, + DEFAULT_MAX_FILE_SIZE, HideGithubIssueCommentParams, HideGithubIssueCommentResult, + LinkGithubSubIssueParams, LinkGithubSubIssueResult, LinkWorkItemsParams, LinkWorkItemsResult, + MissingDataParams, MissingDataResult, MissingToolParams, MissingToolResult, NoopParams, + NoopResult, PIPELINE_ARTIFACT_DEFAULT_MAX_FILE_SIZE, QueueBuildParams, QueueBuildResult, RemoveGithubIssueLabelsParams, RemoveGithubIssueLabelsResult, ReplyToPrCommentParams, - ReplyToPrCommentResult, ReportIncompleteParams, ReportIncompleteResult, - ResolvePrThreadParams, ResolvePrThreadResult, SetGithubIssueFieldParams, - SetGithubIssueFieldResult, SetGithubIssueTypeParams, SetGithubIssueTypeResult, - SubmitPrReviewParams, SubmitPrReviewResult, ToolResult, UnassignGithubIssueFromUserParams, - UnassignGithubIssueFromUserResult, UpdateGithubIssueParams, UpdateGithubIssueResult, - UpdatePrParams, UpdatePrResult, UpdateWikiPageParams, UpdateWikiPageResult, - UpdateWorkItemParams, UpdateWorkItemResult, UploadBuildAttachmentParams, - UploadBuildAttachmentResult, UploadPipelineArtifactParams, UploadPipelineArtifactResult, - UploadWorkitemAttachmentParams, UploadWorkitemAttachmentResult, Validate, anyhow_to_mcp_error, + ReplyToPrCommentResult, ReportIncompleteParams, ReportIncompleteResult, ResolvePrThreadParams, + ResolvePrThreadResult, SetGithubIssueFieldParams, SetGithubIssueFieldResult, + SetGithubIssueTypeParams, SetGithubIssueTypeResult, SubmitPrReviewParams, SubmitPrReviewResult, + ToolResult, UnassignGithubIssueFromUserParams, UnassignGithubIssueFromUserResult, + UpdateGithubIssueParams, UpdateGithubIssueResult, UpdatePrParams, UpdatePrResult, + UpdateWikiPageParams, UpdateWikiPageResult, UpdateWorkItemParams, UpdateWorkItemResult, + UploadBuildAttachmentParams, UploadBuildAttachmentResult, UploadPipelineArtifactParams, + UploadPipelineArtifactResult, UploadWorkitemAttachmentParams, UploadWorkitemAttachmentResult, + Validate, anyhow_to_mcp_error, }; use crate::sanitize::{SanitizeContent, sanitize as sanitize_text, sanitize_markdown}; use crate::secure::WorkItemTemporaryId; @@ -538,8 +537,7 @@ impl SafeOutputs { WorkItemTemporaryId::parse(format!("#aw_{}", generate_short_id())).ok()?; let canonical = candidate.canonical(); let collision = existing.iter().any(|proposal| { - proposal.get("name").and_then(Value::as_str) - == Some(CreateWorkItemResult::NAME) + proposal.get("name").and_then(Value::as_str) == Some(CreateWorkItemResult::NAME) && proposal.get("temporary_id").and_then(Value::as_str) == Some(canonical.as_str()) }); diff --git a/src/safe_outputs/close_pull_request.rs b/src/safe_outputs/close_pull_request.rs index 9e6fdcfdf..36de9c8d1 100644 --- a/src/safe_outputs/close_pull_request.rs +++ b/src/safe_outputs/close_pull_request.rs @@ -200,7 +200,8 @@ pub(crate) fn validate_close_pull_request_config( } fn parse_positive(value: Option<&str>) -> Option { - value.and_then(|value| value.parse::().ok()) + value + .and_then(|value| value.parse::().ok()) .filter(|number| *number > 0) } @@ -334,12 +335,13 @@ impl Executor for ClosePullRequestResult { Ok(metadata) => metadata, Err(error) => return Ok(ExecutionResult::failure(error.to_string())), }; - if let Err(result) = - validate_github_target_capability(&metadata, GithubTargetCapabilities { + if let Err(result) = validate_github_target_capability( + &metadata, + GithubTargetCapabilities { issues: false, pull_requests: true, - }) - { + }, + ) { return Ok(result); } if let Err(result) = validate_github_mutation_filters(&metadata, filters) { @@ -347,10 +349,11 @@ impl Executor for ClosePullRequestResult { } let already_closed = metadata.state.eq_ignore_ascii_case("closed"); - let comment_posted = match post_comment(&client, &repository, target_number, self.body.as_deref()).await? { - Ok(posted) => posted, - Err(result) => return Ok(result), - }; + let comment_posted = + match post_comment(&client, &repository, target_number, self.body.as_deref()).await? { + Ok(posted) => posted, + Err(result) => return Ok(result), + }; if let Err(result) = close_pull_request(&client, &repository, target_number, already_closed).await? { @@ -363,9 +366,15 @@ impl Executor for ClosePullRequestResult { "Closed GitHub pull request" }; if already_closed { - warn!("GitHub pull request {}#{} was already closed", repository, target_number); + warn!( + "GitHub pull request {}#{} was already closed", + repository, target_number + ); } else { - info!("Closed GitHub pull request {}#{}", repository, target_number); + info!( + "Closed GitHub pull request {}#{}", + repository, target_number + ); } Ok(ExecutionResult::success_with_data( format!("{action} {repository}#{target_number}"), @@ -429,9 +438,12 @@ mod tests { let number: ClosePullRequestConfig = serde_json::from_value(serde_json::json!({"target": 42})).unwrap(); assert_eq!(number.target, ClosePullRequestTarget::Number(42)); - assert!(serde_json::from_value::(serde_json::json!({ - "target": 0 - })).is_err()); + assert!( + serde_json::from_value::(serde_json::json!({ + "target": 0 + })) + .is_err() + ); } #[test] diff --git a/src/safe_outputs/create_pull_request.rs b/src/safe_outputs/create_pull_request.rs index eaf16c8ce..f1811b20a 100644 --- a/src/safe_outputs/create_pull_request.rs +++ b/src/safe_outputs/create_pull_request.rs @@ -3432,6 +3432,7 @@ index 0000000..abcdefg triggered_by_build_number: None, triggered_by_project_id: None, pull_request_id: None, + pull_request_number: None, pull_request_source_branch: None, pull_request_target_branch: None, build_container_id: None, diff --git a/src/safe_outputs/mod.rs b/src/safe_outputs/mod.rs index fec2db19a..669237524 100644 --- a/src/safe_outputs/mod.rs +++ b/src/safe_outputs/mod.rs @@ -444,12 +444,14 @@ pub(crate) fn resolve_repository_write_target( "Repository '{selector}' is not in the allowed repository list" ))); }; - let current_org_url = ctx.ado_org_url.as_deref().ok_or_else(|| { - ExecutionResult::failure("Azure DevOps organization URL not configured") - })?; - let current_organization = ctx.ado_organization.as_deref().ok_or_else(|| { - ExecutionResult::failure("Azure DevOps organization name not configured") - })?; + let current_org_url = ctx + .ado_org_url + .as_deref() + .ok_or_else(|| ExecutionResult::failure("Azure DevOps organization URL not configured"))?; + let current_organization = ctx + .ado_organization + .as_deref() + .ok_or_else(|| ExecutionResult::failure("Azure DevOps organization name not configured"))?; let current_project = ctx .ado_project .as_deref() @@ -509,8 +511,7 @@ pub(crate) fn resolve_repository_write_target( ))); } - let (project, repository_name) = - split_repository_target_name(&config.name, current_project)?; + let (project, repository_name) = split_repository_target_name(&config.name, current_project)?; let organization = config .organization .as_deref() @@ -767,9 +768,9 @@ macro_rules! impl_temporary_reference_deserialize { mod add_build_tag; mod add_github_issue_labels; mod add_pr_comment; -mod assign_work_item; mod assign_github_issue_milestone; mod assign_github_issue_to_user; +mod assign_work_item; mod close_github_issue; mod close_pull_request; mod comment_on_github_issue; @@ -809,9 +810,9 @@ mod upload_workitem_attachment; pub use add_build_tag::*; pub use add_github_issue_labels::*; pub use add_pr_comment::*; -pub use assign_work_item::*; pub use assign_github_issue_milestone::*; pub use assign_github_issue_to_user::*; +pub use assign_work_item::*; pub use close_github_issue::*; pub use close_pull_request::*; pub use comment_on_github_issue::*; @@ -1507,7 +1508,11 @@ mod tests { let error = resolve_repository_write_target(Some("target"), &ctx).unwrap_err(); - assert!(error.message.contains("declares the pipeline's current organization")); + assert!( + error + .message + .contains("declares the pipeline's current organization") + ); } #[test] diff --git a/src/safe_outputs/upload_build_attachment.rs b/src/safe_outputs/upload_build_attachment.rs index 70dd486e0..a0d7841b8 100644 --- a/src/safe_outputs/upload_build_attachment.rs +++ b/src/safe_outputs/upload_build_attachment.rs @@ -951,6 +951,7 @@ attachment-type: "agent-artifact" triggered_by_build_number: None, triggered_by_project_id: None, pull_request_id: None, + pull_request_number: None, pull_request_source_branch: None, pull_request_target_branch: None, build_container_id: None, From 860b6bfe02162cb5f7f7754bcf20196365bcee1f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:17:25 +0000 Subject: [PATCH 05/43] address close pull request review feedback Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- src/safe_outputs/close_pull_request.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/safe_outputs/close_pull_request.rs b/src/safe_outputs/close_pull_request.rs index 36de9c8d1..af4a3db43 100644 --- a/src/safe_outputs/close_pull_request.rs +++ b/src/safe_outputs/close_pull_request.rs @@ -218,13 +218,13 @@ impl ClosePullRequestResult { "pull_request_number is required when safe-outputs.close-pull-request.target is '*'", ) }), - ClosePullRequestTarget::Triggering => parse_positive(ctx.pull_request_number.as_deref()) - .or_else(|| parse_positive(ctx.pull_request_id.as_deref())) - .ok_or_else(|| { + ClosePullRequestTarget::Triggering => { + parse_positive(ctx.pull_request_number.as_deref()).ok_or_else(|| { ExecutionResult::failure( "safe-outputs.close-pull-request.target is 'triggering' but no GitHub pull request context is available; use target: '*' and pass pull_request_number, or configure a numeric target", ) - }), + }) + } } } @@ -297,7 +297,7 @@ impl Executor for ClosePullRequestResult { let target = self .pull_request_number .map(|number| format!("#{number}")) - .unwrap_or_else(|| "configured target".to_string()); + .unwrap_or_else(|| "the configured or triggering target".to_string()); format!("close GitHub pull request {target}") } @@ -314,7 +314,6 @@ impl Executor for ClosePullRequestResult { )); }; let config: ClosePullRequestConfig = ctx.get_tool_config("close-pull-request")?; - validate_close_pull_request_config(&config)?; let filters = GithubMutationFilters { required_labels: &config.required_labels, required_title_prefix: config.required_title_prefix.as_deref(), From 2b086575246472d650b273570ffef07fdd8aa6ac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:18:19 +0000 Subject: [PATCH 06/43] Address update-pull-request review feedback Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- src/safe_outputs/mod.rs | 4 +- src/safe_outputs/update_pull_request.rs | 88 ++++++++++++++++++++----- 2 files changed, 74 insertions(+), 18 deletions(-) diff --git a/src/safe_outputs/mod.rs b/src/safe_outputs/mod.rs index 38767834f..b381c52e4 100644 --- a/src/safe_outputs/mod.rs +++ b/src/safe_outputs/mod.rs @@ -798,8 +798,8 @@ mod set_github_issue_type; mod submit_pr_review; mod unassign_github_issue_from_user; mod update_github_issue; -mod update_pull_request; mod update_pr; +mod update_pull_request; mod update_wiki_page; mod update_work_item; mod upload_build_attachment; @@ -843,8 +843,8 @@ pub use set_github_issue_type::*; pub use submit_pr_review::*; pub use unassign_github_issue_from_user::*; pub use update_github_issue::*; -pub use update_pull_request::*; pub use update_pr::*; +pub use update_pull_request::*; pub use update_wiki_page::*; pub use update_work_item::*; pub use upload_build_attachment::*; diff --git a/src/safe_outputs/update_pull_request.rs b/src/safe_outputs/update_pull_request.rs index 683864908..4df698124 100644 --- a/src/safe_outputs/update_pull_request.rs +++ b/src/safe_outputs/update_pull_request.rs @@ -20,8 +20,8 @@ use crate::safe_outputs::{ use crate::sanitize::{SanitizeContent, sanitize as sanitize_text, sanitize_config}; use crate::tool_result; -const MAX_TITLE_LEN: usize = 256; -const MAX_BODY_LEN: usize = 65_536; +const MAX_TITLE_CHARS: usize = 256; +const MAX_BODY_CHARS: usize = 65_536; #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(untagged)] @@ -99,21 +99,17 @@ impl UpdatePullRequestParams { impl Validate for UpdatePullRequestParams { fn validate(&self) -> anyhow::Result<()> { - ensure!( - self.title.is_some() || self.body.is_some() || self.update_branch == Some(true), - "at least one of title, body, or update_branch: true is required" - ); if let Some(title) = self.title.as_deref() { ensure!(!title.trim().is_empty(), "title must not be empty"); ensure!( - title.len() <= MAX_TITLE_LEN, - "title must be {MAX_TITLE_LEN} characters or fewer" + title.chars().count() <= MAX_TITLE_CHARS, + "title must be {MAX_TITLE_CHARS} characters or fewer" ); } if let Some(body) = self.body.as_deref() { ensure!( - body.len() <= MAX_BODY_LEN, - "body must be {MAX_BODY_LEN} characters or fewer" + body.chars().count() <= MAX_BODY_CHARS, + "body must be {MAX_BODY_CHARS} characters or fewer" ); } else { ensure!( @@ -405,9 +401,9 @@ fn build_updated_body( GithubBodyOperation::Replace => section, GithubBodyOperation::ReplaceIsland => replace_island(current, §ion, ctx)?, }; - if updated.len() > MAX_BODY_LEN { + if updated.chars().count() > MAX_BODY_CHARS { return Err(ExecutionResult::failure(format!( - "updated body exceeds GitHub's {MAX_BODY_LEN}-character limit" + "updated body exceeds GitHub's {MAX_BODY_CHARS}-character limit" ))); } Ok(updated) @@ -604,6 +600,12 @@ impl Executor for UpdatePullRequestResult { "update-pull-request field 'body' is not enabled by configuration", )); } + let update_branch = self.update_branch.unwrap_or(config.update_branch); + if self.title.is_none() && self.body.is_none() && !update_branch { + return Ok(ExecutionResult::failure( + "at least one of title, body, or effective update_branch: true is required", + )); + } let number = match self.resolve_number(&config, ctx) { Ok(number) => number, Err(result) => return Ok(result), @@ -638,7 +640,6 @@ impl Executor for UpdatePullRequestResult { if let Err(result) = validate_github_mutation_filters(&metadata, filters) { return Ok(result); } - let update_branch = self.update_branch.unwrap_or(config.update_branch); if update_branch && let Err(result) = self.update_branch(&client, &repository, number).await? { @@ -767,10 +768,10 @@ mod tests { } #[test] - fn validates_meaningful_update_and_number_aliases() { + fn validates_number_aliases() { let mut empty = params(); empty.title = None; - assert!(empty.validate().is_err()); + assert!(empty.validate().is_ok()); let mut aliases = params(); aliases.pull_request_number = Some(GithubPullRequestNumber::Number(1)); @@ -778,6 +779,61 @@ mod tests { assert!(aliases.validate().is_err()); } + #[test] + fn replace_island_appends_then_replaces_pipeline_scoped_section() { + let ctx = ExecutionContext { + definition_id: Some(123), + ..Default::default() + }; + let first = build_updated_body( + "before", + "new", + GithubBodyOperation::ReplaceIsland, + false, + &ctx, + ) + .unwrap(); + assert!(first.contains("before\n\n---\n\n")); + assert!(first.contains("")); + assert!(first.contains("\nnew\n")); + + let second = build_updated_body( + &first, + "next", + GithubBodyOperation::ReplaceIsland, + false, + &ctx, + ) + .unwrap(); + assert!(second.contains("\nnext\n")); + assert!(!second.contains("\nnew\n")); + } + + #[test] + fn replace_island_requires_definition_id_and_ordered_markers() { + let missing_id = ExecutionContext::default(); + assert!( + build_updated_body( + "", + "new", + GithubBodyOperation::ReplaceIsland, + false, + &missing_id + ) + .is_err() + ); + + let ctx = ExecutionContext { + definition_id: Some(123), + ..Default::default() + }; + let bad = "\nold\n"; + assert!( + build_updated_body(bad, "new", GithubBodyOperation::ReplaceIsland, false, &ctx) + .is_err() + ); + } + #[tokio::test] async fn updates_triggering_pull_request_title_and_body() { let server = MockServer::start().await; @@ -872,7 +928,7 @@ mod tests { title: None, body: None, operation: None, - update_branch: Some(true), + update_branch: None, pull_request_number: None, pr_number: None, pr: None, From 44e3cdf69d661348a1efb7773ae64f851bfa7caf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:05:49 +0000 Subject: [PATCH 07/43] switch close pull request safe output to ado Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- docs/safe-outputs.md | 46 +- src/compile/types.rs | 17 - src/execute.rs | 4 +- src/safe_outputs/close_pull_request.rs | 540 +++++++++++++------- src/safe_outputs/create_pull_request.rs | 1 - src/safe_outputs/github_api.rs | 5 - src/safe_outputs/mod.rs | 3 +- src/safe_outputs/result.rs | 6 - src/safe_outputs/upload_build_attachment.rs | 1 - 9 files changed, 380 insertions(+), 243 deletions(-) diff --git a/docs/safe-outputs.md b/docs/safe-outputs.md index f52d9d9b6..26dae519b 100644 --- a/docs/safe-outputs.md +++ b/docs/safe-outputs.md @@ -740,7 +740,6 @@ JSON uses the snake_case parameter names below. | `add-github-issue-labels` | `issue_number`, `labels`, optional `repository` | `allowed`, `blocked`, `issues`, `pull-requests` | 5 | | `remove-github-issue-labels` | `issue_number`, `labels`, optional `repository` | `allowed`, `blocked` | 5 | | `close-github-issue` | `issue_number`, optional `body`, `state_reason`, `duplicate_of`, `repository` | `state-reason`, `allowed-state-reason`, `allow-body` | 1 | -| `close-pull-request` | optional `pull_request_number`, optional `body`, optional `repository` | `target` (`"triggering"`, `"*"`, or number) | 1 | | `update-github-issue` | `issue_number`, one or more of `status`/`title`/`body`/`labels`/`assignees`/`milestone`, optional body `operation`, optional `repository` | `status`, `title`, `body`, `labels`, `assignees`, `milestone`, `allowed-labels`, `footer`, `issues`, `pull-requests` | 1 | | `set-github-issue-field` | `issue_number`, `value`, exactly one of `field_name`/`field_node_id`, optional `repository` | `allowed-fields` | 5 | | `assign-github-issue-milestone` | `issue_number`, exactly one of `milestone_number`/`milestone_title`, optional `repository` | `allowed`, `auto-create` | 1 | @@ -787,25 +786,6 @@ is already absent as success. close, then creates the native duplicate relationship. Already-closed targets are idempotent success. -`close-pull-request` matches gh-aw's front matter interface for closing GitHub -PRs without merging: - -```yaml -safe-outputs: - close-pull-request: - target: "triggering" # "triggering" (default), "*", or number - required-labels: [automated, stale] - required-title-prefix: "[bot]" - max: 10 - target-repo: octo-org/octo-repo -``` - -When `target` is `"*"`, the agent must pass `pull_request_number`; when it is a -number, that configured PR is used. The default `"triggering"` target uses the -GitHub PR number exposed by Azure Pipelines PR context. `body` posts an optional -closing comment before the PR is closed. `target-repo` and `allowed-repos` -follow the same repository policy as the GitHub issue tools. - `update-github-issue` requires at least one of `status`, `title`, `body`, `labels`, `assignees`, or `milestone`. Every mutable field is independently disabled by default: the operator must set the matching `status`, `title`, @@ -1317,6 +1297,32 @@ safe-outputs: max: 1 # Maximum per run (default: 1) ``` +### close-pull-request +Abandons an Azure DevOps pull request without merging it. + +**Agent parameters:** +- `pull_request_id` - The PR ID to close (required when `target: "*"`) +- `body` *(optional)* - Closing comment posted before abandoning the PR +- `repository` - Repository alias (default: configured `target-repo`, then `"self"`) + +**Configuration options (front matter):** +```yaml +safe-outputs: + close-pull-request: + target: "triggering" # "triggering" (default), "*", or PR ID + required-labels: [automated, stale] + required-title-prefix: "[bot]" + allowed-repositories: [] # Optional — restrict which repos can be closed + target-repo: self # Optional default repository alias/name + max: 1 # Maximum per run (default: 1) +``` + +When `target` is `"triggering"`, Stage 3 uses +`SYSTEM_PULLREQUEST_PULLREQUESTID`. When `target` is a number, that configured +ADO PR ID is used. The tool fetches the PR first, applies the optional +title/label filters, optionally posts `body` as a PR thread comment, then +patches the PR status to `abandoned`. + ### link-work-items Links two Azure DevOps work items together. diff --git a/src/compile/types.rs b/src/compile/types.rs index 1a4d72abc..e13d11f54 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -715,7 +715,6 @@ pub const GITHUB_ISSUE_SAFE_OUTPUT_TOOLS: &[&str] = &[ "add-github-issue-labels", "remove-github-issue-labels", "close-github-issue", - "close-pull-request", "update-github-issue", "set-github-issue-field", "assign-github-issue-milestone", @@ -2422,19 +2421,6 @@ impl FrontMatter { ..Default::default() })) } - "close-pull-request" => { - Ok(self - .close_pull_request_config()? - .map(|config| GithubIssueCompilerConfig { - target_repo: config.target_repo, - allowed_repos: config.allowed_repos, - required_labels: config.required_labels, - required_title_prefix: config.required_title_prefix, - pull_requests: Some(true), - issues: Some(false), - ..Default::default() - })) - } "update-github-issue" => { Ok(self .update_github_issue_config()? @@ -2531,9 +2517,6 @@ impl FrontMatter { pull_requests = true; discussions |= config.discussions.unwrap_or(false); } - "close-pull-request" => { - pull_requests = true; - } "remove-github-issue-labels" => { issues = true; pull_requests = true; diff --git a/src/execute.rs b/src/execute.rs index 6d6e32584..4780e5f1f 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -805,8 +805,8 @@ fn extract_entry_context(entry: &Value) -> String { if let Some(issue) = entry.get("issue_number") { return format!(" (GitHub issue {})", safe_json_identifier(issue)); } - if let Some(pr) = entry.get("pull_request_number") { - return format!(" (GitHub pull request {})", safe_json_identifier(pr)); + if let Some(pr) = entry.get("pull_request_id") { + return format!(" (pull request {})", safe_json_identifier(pr)); } if let (Some(parent), Some(sub_issue)) = ( entry.get("parent_issue_number"), diff --git a/src/safe_outputs/close_pull_request.rs b/src/safe_outputs/close_pull_request.rs index af4a3db43..62e3a49ec 100644 --- a/src/safe_outputs/close_pull_request.rs +++ b/src/safe_outputs/close_pull_request.rs @@ -1,20 +1,17 @@ -//! `close-pull-request` safe output. +//! `close-pull-request` Azure DevOps safe output. use anyhow::ensure; -use log::{info, warn}; -use reqwest::Method; +use log::{debug, info, warn}; use schemars::JsonSchema; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::safe_outputs::{ - ExecutionContext, ExecutionResult, Executor, GithubClient, GithubMutationFilters, - GithubRepositoryPolicy, GithubTargetCapabilities, Validate, resolve_github_repository, - validate_github_mutation_filter_config, validate_github_mutation_filters, - validate_github_repository, validate_github_target_capability, + ExecutionContext, ExecutionResult, Executor, PATH_SEGMENT, Validate, resolve_repo_name, }; use crate::sanitize::{SanitizeContent, sanitize as sanitize_text, sanitize_config}; use crate::tool_result; use ado_aw_derive::SanitizeConfig; +use percent_encoding::utf8_percent_encode; const MAX_COMMENT_LEN: usize = 65_536; @@ -22,7 +19,7 @@ const MAX_COMMENT_LEN: usize = 65_536; pub enum ClosePullRequestTarget { Triggering, Any, - Number(u64), + Id(u64), } impl Default for ClosePullRequestTarget { @@ -39,7 +36,7 @@ impl Serialize for ClosePullRequestTarget { match self { Self::Triggering => serializer.serialize_str("triggering"), Self::Any => serializer.serialize_str("*"), - Self::Number(number) => serializer.serialize_u64(*number), + Self::Id(id) => serializer.serialize_u64(*id), } } } @@ -55,7 +52,7 @@ impl<'de> Deserialize<'de> for ClosePullRequestTarget { type Value = ClosePullRequestTarget; fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str(r#""triggering", "*", or a positive pull request number"#) + formatter.write_str(r#""triggering", "*", or a positive pull request ID"#) } fn visit_u64(self, value: u64) -> Result @@ -63,9 +60,9 @@ impl<'de> Deserialize<'de> for ClosePullRequestTarget { E: serde::de::Error, { if value == 0 { - return Err(E::custom("target pull request number must be positive")); + return Err(E::custom("target pull request ID must be positive")); } - Ok(ClosePullRequestTarget::Number(value)) + Ok(ClosePullRequestTarget::Id(value)) } fn visit_i64(self, value: i64) -> Result @@ -73,9 +70,9 @@ impl<'de> Deserialize<'de> for ClosePullRequestTarget { E: serde::de::Error, { if value <= 0 { - return Err(E::custom("target pull request number must be positive")); + return Err(E::custom("target pull request ID must be positive")); } - Ok(ClosePullRequestTarget::Number(value as u64)) + Ok(ClosePullRequestTarget::Id(value as u64)) } fn visit_str(self, value: &str) -> Result @@ -86,13 +83,13 @@ impl<'de> Deserialize<'de> for ClosePullRequestTarget { "triggering" => Ok(ClosePullRequestTarget::Triggering), "*" => Ok(ClosePullRequestTarget::Any), other => { - let number = other - .parse::() - .map_err(|_| E::custom("target must be \"triggering\", \"*\", or a positive pull request number"))?; - if number == 0 { - return Err(E::custom("target pull request number must be positive")); + let id = other.parse::().map_err(|_| { + E::custom("target must be \"triggering\", \"*\", or a positive pull request ID") + })?; + if id == 0 { + return Err(E::custom("target pull request ID must be positive")); } - Ok(ClosePullRequestTarget::Number(number)) + Ok(ClosePullRequestTarget::Id(id)) } } } @@ -104,21 +101,21 @@ impl<'de> Deserialize<'de> for ClosePullRequestTarget { #[derive(Deserialize, JsonSchema)] pub struct ClosePullRequestParams { - /// Positive GitHub pull request number. Required when config target is "*". - #[serde(default)] - pub pull_request_number: Option, + /// Positive Azure DevOps pull request ID. Required when config target is "*". + #[serde(default, alias = "pull_request_number")] + pub pull_request_id: Option, /// Optional closing comment. #[serde(default)] pub body: Option, - /// Optional target repository. + /// Optional repository alias/name. #[serde(default)] pub repository: Option, } impl Validate for ClosePullRequestParams { fn validate(&self) -> anyhow::Result<()> { - if let Some(number) = self.pull_request_number { - ensure!(number > 0, "pull_request_number must be positive"); + if let Some(id) = self.pull_request_id { + ensure!(id > 0, "pull_request_id must be positive"); } if let Some(body) = self.body.as_deref() { ensure!(!body.trim().is_empty(), "body must not be empty"); @@ -128,7 +125,10 @@ impl Validate for ClosePullRequestParams { ); } if let Some(repository) = self.repository.as_deref() { - validate_github_repository(repository)?; + ensure!( + !repository.trim().is_empty(), + "repository must not be empty" + ); } Ok(()) } @@ -139,10 +139,10 @@ tool_result! { write = true, params = ClosePullRequestParams, default_max = 1, - /// Result of closing a GitHub pull request. + /// Result of abandoning an Azure DevOps pull request. pub struct ClosePullRequestResult { - #[serde(default)] - pull_request_number: Option, + #[serde(default, alias = "pull_request_number")] + pull_request_id: Option, #[serde(default)] body: Option, #[serde(default)] @@ -163,10 +163,11 @@ pub struct ClosePullRequestConfig { #[serde(default)] #[sanitize_config(skip)] pub target: ClosePullRequestTarget, - #[serde(default, rename = "target-repo")] + /// Default repository alias/name when the agent does not pass `repository`. + #[serde(default, rename = "target-repo", alias = "repository")] pub target_repo: Option, - #[serde(default, rename = "allowed-repos")] - pub allowed_repos: Vec, + #[serde(default, rename = "allowed-repositories", alias = "allowed-repos")] + pub allowed_repositories: Vec, #[serde(default, rename = "required-labels")] pub required_labels: Vec, #[serde(default, rename = "required-title-prefix")] @@ -181,7 +182,7 @@ impl Default for ClosePullRequestConfig { Self { target: ClosePullRequestTarget::Triggering, target_repo: None, - allowed_repos: Vec::new(), + allowed_repositories: Vec::new(), required_labels: Vec::new(), required_title_prefix: None, max: None, @@ -192,113 +193,240 @@ impl Default for ClosePullRequestConfig { pub(crate) fn validate_close_pull_request_config( config: &ClosePullRequestConfig, ) -> anyhow::Result<()> { - validate_github_mutation_filter_config(GithubMutationFilters { - required_labels: &config.required_labels, - required_title_prefix: config.required_title_prefix.as_deref(), - })?; + for label in &config.required_labels { + ensure!( + !label.trim().is_empty(), + "required-labels must not contain empty labels" + ); + } + if let Some(prefix) = config.required_title_prefix.as_deref() { + ensure!( + !prefix.is_empty(), + "required-title-prefix must not be empty when set" + ); + } Ok(()) } fn parse_positive(value: Option<&str>) -> Option { value .and_then(|value| value.parse::().ok()) - .filter(|number| *number > 0) + .filter(|id| *id > 0) +} + +fn pr_labels(pr: &serde_json::Value) -> Vec { + pr.get("labels") + .and_then(|labels| labels.as_array()) + .into_iter() + .flatten() + .filter_map(|label| label.get("name").and_then(|name| name.as_str())) + .map(ToOwned::to_owned) + .collect() } impl ClosePullRequestResult { - fn resolve_target_number( + fn resolve_target_id( &self, config: &ClosePullRequestConfig, ctx: &ExecutionContext, ) -> Result { match config.target { - ClosePullRequestTarget::Number(number) => Ok(number), - ClosePullRequestTarget::Any => self.pull_request_number.ok_or_else(|| { + ClosePullRequestTarget::Id(id) => Ok(id), + ClosePullRequestTarget::Any => self.pull_request_id.ok_or_else(|| { ExecutionResult::failure( - "pull_request_number is required when safe-outputs.close-pull-request.target is '*'", + "pull_request_id is required when safe-outputs.close-pull-request.target is '*'", ) }), ClosePullRequestTarget::Triggering => { - parse_positive(ctx.pull_request_number.as_deref()).ok_or_else(|| { + parse_positive(ctx.pull_request_id.as_deref()).ok_or_else(|| { ExecutionResult::failure( - "safe-outputs.close-pull-request.target is 'triggering' but no GitHub pull request context is available; use target: '*' and pass pull_request_number, or configure a numeric target", + "safe-outputs.close-pull-request.target is 'triggering' but no Azure DevOps pull request context is available; use target: '*' and pass pull_request_id, or configure a numeric target", ) }) } } } + fn repository_selector<'a>(&'a self, config: &'a ClosePullRequestConfig) -> &'a str { + self.repository + .as_deref() + .or(config.target_repo.as_deref()) + .unwrap_or("self") + } + fn resolve_repository( &self, config: &ClosePullRequestConfig, ctx: &ExecutionContext, ) -> Result { - resolve_github_repository( - self.repository.as_deref(), - GithubRepositoryPolicy::new(config.target_repo.as_deref(), &config.allowed_repos), - ctx, - ) + let selector = self.repository_selector(config); + if !config.allowed_repositories.is_empty() + && !config + .allowed_repositories + .iter() + .any(|allowed| allowed == selector) + { + return Err(ExecutionResult::failure(format!( + "Repository '{}' is not in the allowed-repositories list: [{}]", + selector, + config.allowed_repositories.join(", ") + ))); + } + resolve_repo_name(Some(selector), ctx).map_err(ExecutionResult::failure) } + + fn validate_filters( + &self, + pr: &serde_json::Value, + config: &ClosePullRequestConfig, + ) -> Result<(), ExecutionResult> { + if let Some(prefix) = config.required_title_prefix.as_deref() { + let title = pr.get("title").and_then(|v| v.as_str()).unwrap_or_default(); + if !title.starts_with(prefix) { + return Err(ExecutionResult::failure(format!( + "Pull request title does not start with required prefix '{}'", + prefix + ))); + } + } + + if !config.required_labels.is_empty() { + let labels = pr_labels(pr); + let missing: Vec<&str> = config + .required_labels + .iter() + .map(String::as_str) + .filter(|required| !labels.iter().any(|actual| actual == required)) + .collect(); + if !missing.is_empty() { + return Err(ExecutionResult::failure(format!( + "Pull request is missing required label(s): {}", + missing.join(", ") + ))); + } + } + Ok(()) + } +} + +async fn fetch_pr( + client: &reqwest::Client, + url: &str, + token: &str, + ctx: &ExecutionContext, +) -> anyhow::Result> { + let response = crate::safe_outputs::authenticate_ado_request( + client.get(url), + token, + ctx.write_connection_type, + ) + .send() + .await + .map_err(|error| anyhow::anyhow!("Failed to fetch Azure DevOps pull request: {error}"))?; + + if response.status().is_success() { + return Ok(Ok(response.json().await.map_err(|error| { + anyhow::anyhow!("Failed to parse pull request response: {error}") + })?)); + } + + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + Ok(Err(ExecutionResult::failure(format!( + "Failed to fetch pull request (HTTP {}): {}", + status, body + )))) } async fn post_comment( - client: &GithubClient, - repository: &str, - number: u64, + client: &reqwest::Client, + base_url: &str, + repo_name: &str, + pull_request_id: u64, + token: &str, + ctx: &ExecutionContext, body: Option<&str>, ) -> anyhow::Result> { let Some(body) = body else { return Ok(Ok(false)); }; - let response = client - .send( - Method::POST, - client.issue_comments_url(repository, number)?, - Some(&serde_json::json!({ "body": body })), - ) - .await?; - if !response.is_success() { - let error = response - .require_success("Failed to add GitHub pull request closing comment") - .expect_err("non-success response must produce an API error"); - return Ok(Err(ExecutionResult::failure(error.to_string()))); + let url = format!( + "{}/{}/pullRequests/{}/threads?api-version=7.1", + base_url, + utf8_percent_encode(repo_name, PATH_SEGMENT), + pull_request_id, + ); + let thread_body = serde_json::json!({ + "comments": [{ + "parentCommentId": 0, + "content": body, + "commentType": 1, + }], + "status": 1, + }); + let response = crate::safe_outputs::authenticate_ado_request( + client + .post(&url) + .header("Content-Type", "application/json") + .json(&thread_body), + token, + ctx.write_connection_type, + ) + .send() + .await + .map_err(|error| { + anyhow::anyhow!("Failed to post Azure DevOps pull request comment: {error}") + })?; + + if response.status().is_success() { + return Ok(Ok(true)); } - Ok(Ok(true)) + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + Ok(Err(ExecutionResult::failure(format!( + "Failed to add closing comment to PR #{} (HTTP {}): {}", + pull_request_id, status, body + )))) } -async fn close_pull_request( - client: &GithubClient, - repository: &str, - number: u64, - already_closed: bool, +async fn abandon_pr( + client: &reqwest::Client, + url: &str, + pull_request_id: u64, + token: &str, + ctx: &ExecutionContext, ) -> anyhow::Result> { - if already_closed { + let response = crate::safe_outputs::authenticate_ado_request( + client + .patch(url) + .header("Content-Type", "application/json") + .json(&serde_json::json!({ "status": "abandoned" })), + token, + ctx.write_connection_type, + ) + .send() + .await + .map_err(|error| anyhow::anyhow!("Failed to abandon Azure DevOps pull request: {error}"))?; + + if response.status().is_success() { return Ok(Ok(())); } - let response = client - .send( - Method::PATCH, - client.pull_request_url(repository, number)?, - Some(&serde_json::json!({ "state": "closed" })), - ) - .await?; - if !response.is_success() { - let error = response - .require_success("Failed to close GitHub pull request") - .expect_err("non-success response must produce an API error"); - return Ok(Err(ExecutionResult::failure(error.to_string()))); - } - Ok(Ok(())) + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + Ok(Err(ExecutionResult::failure(format!( + "Failed to abandon PR #{} (HTTP {}): {}", + pull_request_id, status, body + )))) } #[async_trait::async_trait] impl Executor for ClosePullRequestResult { fn dry_run_summary(&self) -> String { let target = self - .pull_request_number - .map(|number| format!("#{number}")) + .pull_request_id + .map(|id| format!("#{id}")) .unwrap_or_else(|| "the configured or triggering target".to_string()); - format!("close GitHub pull request {target}") + format!("abandon Azure DevOps pull request {target}") } async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { @@ -307,80 +435,102 @@ impl Executor for ClosePullRequestResult { "close-pull-request is not configured for this workflow", )); } - let Some(token) = ctx.github_token.as_ref() else { - return Ok(ExecutionResult::failure( - "ADO_AW_GITHUB_TOKEN is not set; configure safe-outputs.github-token \ - or safe-outputs.github-app", - )); - }; + let org_url = ctx + .ado_org_url + .as_ref() + .ok_or_else(|| anyhow::anyhow!("AZURE_DEVOPS_ORG_URL not set"))?; + let project = ctx + .ado_project + .as_ref() + .ok_or_else(|| anyhow::anyhow!("SYSTEM_TEAMPROJECT not set"))?; + let token = ctx.access_token.as_ref().ok_or_else(|| { + anyhow::anyhow!( + "No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)" + ) + })?; let config: ClosePullRequestConfig = ctx.get_tool_config("close-pull-request")?; - let filters = GithubMutationFilters { - required_labels: &config.required_labels, - required_title_prefix: config.required_title_prefix.as_deref(), - }; - if let Err(error) = validate_github_mutation_filter_config(filters) { + if let Err(error) = validate_close_pull_request_config(&config) { return Ok(ExecutionResult::failure(error.to_string())); } - let target_number = match self.resolve_target_number(&config, ctx) { - Ok(number) => number, + + let pull_request_id = match self.resolve_target_id(&config, ctx) { + Ok(id) => id, Err(result) => return Ok(result), }; - let repository = match self.resolve_repository(&config, ctx) { - Ok(repository) => repository, + let repo_name = match self.resolve_repository(&config, ctx) { + Ok(repo_name) => repo_name, Err(result) => return Ok(result), }; - let client = GithubClient::new(&ctx.github_api_url, token)?; - let metadata = match client.get_issue(&repository, target_number).await? { - Ok(metadata) => metadata, - Err(error) => return Ok(ExecutionResult::failure(error.to_string())), + let client = reqwest::Client::new(); + let base_url = format!( + "{}/{}/_apis/git/repositories", + org_url.trim_end_matches('/'), + utf8_percent_encode(project, PATH_SEGMENT), + ); + let pr_url = format!( + "{}/{}/pullRequests/{}?api-version=7.1", + base_url, + utf8_percent_encode(&repo_name, PATH_SEGMENT), + pull_request_id, + ); + debug!("close-pull-request API URL: {}", pr_url); + + let pr = match fetch_pr(&client, &pr_url, token, ctx).await? { + Ok(pr) => pr, + Err(result) => return Ok(result), }; - if let Err(result) = validate_github_target_capability( - &metadata, - GithubTargetCapabilities { - issues: false, - pull_requests: true, - }, - ) { + if let Err(result) = self.validate_filters(&pr, &config) { return Ok(result); } - if let Err(result) = validate_github_mutation_filters(&metadata, filters) { - return Ok(result); + + let status = pr + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or_default(); + if status.eq_ignore_ascii_case("abandoned") { + warn!("Azure DevOps PR #{} was already abandoned", pull_request_id); + return Ok(ExecutionResult::success_with_data( + format!("Azure DevOps PR #{} was already abandoned", pull_request_id), + serde_json::json!({ + "pull_request_id": pull_request_id, + "repository": repo_name, + "already_closed": true, + "comment_posted": false, + }), + )); + } + if !status.is_empty() && !status.eq_ignore_ascii_case("active") { + return Ok(ExecutionResult::failure(format!( + "Cannot abandon PR #{} because its status is '{}' (expected active)", + pull_request_id, status + ))); } - let already_closed = metadata.state.eq_ignore_ascii_case("closed"); - let comment_posted = - match post_comment(&client, &repository, target_number, self.body.as_deref()).await? { - Ok(posted) => posted, - Err(result) => return Ok(result), - }; - if let Err(result) = - close_pull_request(&client, &repository, target_number, already_closed).await? + let comment_posted = match post_comment( + &client, + &base_url, + &repo_name, + pull_request_id, + token, + ctx, + self.body.as_deref(), + ) + .await? { + Ok(posted) => posted, + Err(result) => return Ok(result), + }; + if let Err(result) = abandon_pr(&client, &pr_url, pull_request_id, token, ctx).await? { return Ok(result); } - let action = if already_closed { - "GitHub pull request was already closed" - } else { - "Closed GitHub pull request" - }; - if already_closed { - warn!( - "GitHub pull request {}#{} was already closed", - repository, target_number - ); - } else { - info!( - "Closed GitHub pull request {}#{}", - repository, target_number - ); - } + info!("Abandoned Azure DevOps PR #{}", pull_request_id); Ok(ExecutionResult::success_with_data( - format!("{action} {repository}#{target_number}"), + format!("Abandoned Azure DevOps PR #{}", pull_request_id), serde_json::json!({ - "number": target_number, - "target_repo": repository, - "already_closed": already_closed, + "pull_request_id": pull_request_id, + "repository": repo_name, + "already_closed": false, "comment_posted": comment_posted, }), )) @@ -392,31 +542,29 @@ mod tests { use super::*; use crate::safe_outputs::ToolResult; use std::collections::HashMap; - use wiremock::matchers::{body_json, method, path}; + use wiremock::matchers::{body_json, method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; fn context(server: &MockServer, config: serde_json::Value) -> ExecutionContext { let mut tool_configs = HashMap::new(); tool_configs.insert("close-pull-request".to_string(), config); ExecutionContext { - github_token: Some("token".to_string()), - github_api_url: server.uri(), + ado_org_url: Some(server.uri()), + ado_project: Some("proj".to_string()), + access_token: Some("token".to_string()), tool_configs, - repository_provider: Some("github".to_string()), - repository_name: Some("octo/repo".to_string()), + repository_name: Some("repo".to_string()), + allowed_repositories: HashMap::from([("other".to_string(), "other-repo".to_string())]), ..Default::default() } } - fn open_pr(number: u64) -> serde_json::Value { + fn pr(status: &str) -> serde_json::Value { serde_json::json!({ - "number": number, - "node_id": format!("PR_{number}"), + "pullRequestId": 7, "title": "[bot] stale PR", - "state": "open", - "labels": [{"name": "automated"}, {"name": "stale"}], - "html_url": format!("https://github.example/octo/repo/pull/{number}"), - "pull_request": {"url": format!("https://api.github.example/repos/octo/repo/pulls/{number}")} + "status": status, + "labels": [{"name": "automated"}, {"name": "stale"}] }) } @@ -427,16 +575,16 @@ mod tests { } #[test] - fn config_accepts_gh_aw_target_forms() { + fn config_accepts_target_forms() { let triggering: ClosePullRequestConfig = serde_json::from_value(serde_json::json!({"target": "triggering"})).unwrap(); assert_eq!(triggering.target, ClosePullRequestTarget::Triggering); let any: ClosePullRequestConfig = serde_json::from_value(serde_json::json!({"target": "*"})).unwrap(); assert_eq!(any.target, ClosePullRequestTarget::Any); - let number: ClosePullRequestConfig = + let id: ClosePullRequestConfig = serde_json::from_value(serde_json::json!({"target": 42})).unwrap(); - assert_eq!(number.target, ClosePullRequestTarget::Number(42)); + assert_eq!(id.target, ClosePullRequestTarget::Id(42)); assert!( serde_json::from_value::(serde_json::json!({ "target": 0 @@ -446,19 +594,19 @@ mod tests { } #[test] - fn validates_optional_number_body_and_repository() { + fn validates_optional_id_body_and_repository() { assert!( ClosePullRequestParams { - pull_request_number: Some(42), + pull_request_id: Some(42), body: Some("Closing as stale.".to_string()), - repository: Some("octo/repo".to_string()), + repository: Some("self".to_string()), } .validate() .is_ok() ); assert!( ClosePullRequestParams { - pull_request_number: Some(0), + pull_request_id: Some(0), body: None, repository: None, } @@ -468,24 +616,36 @@ mod tests { } #[tokio::test] - async fn closes_with_comment_and_filters() { + async fn abandons_with_comment_and_filters() { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/repos/octo/repo/issues/7")) - .respond_with(ResponseTemplate::new(200).set_body_json(open_pr(7))) + .and(path("/proj/_apis/git/repositories/repo/pullRequests/7")) + .and(query_param("api-version", "7.1")) + .respond_with(ResponseTemplate::new(200).set_body_json(pr("active"))) .expect(1) .mount(&server) .await; Mock::given(method("POST")) - .and(path("/repos/octo/repo/issues/7/comments")) - .and(body_json(serde_json::json!({"body": "Closing as stale."}))) + .and(path( + "/proj/_apis/git/repositories/repo/pullRequests/7/threads", + )) + .and(query_param("api-version", "7.1")) + .and(body_json(serde_json::json!({ + "comments": [{ + "parentCommentId": 0, + "content": "Closing as stale.", + "commentType": 1, + }], + "status": 1, + }))) .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({}))) .expect(1) .mount(&server) .await; Mock::given(method("PATCH")) - .and(path("/repos/octo/repo/pulls/7")) - .and(body_json(serde_json::json!({"state": "closed"}))) + .and(path("/proj/_apis/git/repositories/repo/pullRequests/7")) + .and(query_param("api-version", "7.1")) + .and(body_json(serde_json::json!({"status": "abandoned"}))) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) .expect(1) .mount(&server) @@ -494,13 +654,12 @@ mod tests { &server, serde_json::json!({ "target": "*", - "target-repo": "octo/repo", "required-labels": ["automated", "stale"], "required-title-prefix": "[bot]" }), ); let mut result: ClosePullRequestResult = ClosePullRequestParams { - pull_request_number: Some(7), + pull_request_id: Some(7), body: Some("Closing as stale.".to_string()), repository: None, } @@ -512,58 +671,61 @@ mod tests { execution.data.as_ref().unwrap()["comment_posted"], serde_json::json!(true) ); + server.verify().await; } #[tokio::test] - async fn triggering_target_uses_context_pull_request_number() { + async fn triggering_target_uses_context_pull_request_id() { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/repos/octo/repo/issues/9")) - .respond_with(ResponseTemplate::new(200).set_body_json(open_pr(9))) + .and(path("/proj/_apis/git/repositories/repo/pullRequests/9")) + .and(query_param("api-version", "7.1")) + .respond_with(ResponseTemplate::new(200).set_body_json(pr("active"))) .expect(1) .mount(&server) .await; Mock::given(method("PATCH")) - .and(path("/repos/octo/repo/pulls/9")) - .respond_with(ResponseTemplate::new(200)) + .and(path("/proj/_apis/git/repositories/repo/pullRequests/9")) + .and(query_param("api-version", "7.1")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) .expect(1) .mount(&server) .await; - let mut ctx = context(&server, serde_json::json!({"target-repo": "octo/repo"})); - ctx.pull_request_number = Some("9".to_string()); + let mut ctx = context(&server, serde_json::json!({})); + ctx.pull_request_id = Some("9".to_string()); let mut result: ClosePullRequestResult = ClosePullRequestParams { - pull_request_number: None, + pull_request_id: None, body: None, repository: None, } .try_into() .unwrap(); assert!(result.execute_sanitized(&ctx).await.unwrap().success); + server.verify().await; } #[tokio::test] - async fn non_pull_request_target_is_rejected_before_patch() { + async fn missing_label_rejects_before_patch() { let server = MockServer::start().await; - let mut issue = open_pr(7); - issue.as_object_mut().unwrap().remove("pull_request"); Mock::given(method("GET")) - .and(path("/repos/octo/repo/issues/7")) - .respond_with(ResponseTemplate::new(200).set_body_json(issue)) + .and(path("/proj/_apis/git/repositories/repo/pullRequests/7")) + .and(query_param("api-version", "7.1")) + .respond_with(ResponseTemplate::new(200).set_body_json(pr("active"))) .expect(1) .mount(&server) .await; Mock::given(method("PATCH")) - .and(path("/repos/octo/repo/pulls/7")) + .and(path("/proj/_apis/git/repositories/repo/pullRequests/7")) .respond_with(ResponseTemplate::new(200)) .expect(0) .mount(&server) .await; let ctx = context( &server, - serde_json::json!({"target": "*", "target-repo": "octo/repo"}), + serde_json::json!({"target": "*", "required-labels": ["missing"]}), ); let mut result: ClosePullRequestResult = ClosePullRequestParams { - pull_request_number: Some(7), + pull_request_id: Some(7), body: None, repository: None, } diff --git a/src/safe_outputs/create_pull_request.rs b/src/safe_outputs/create_pull_request.rs index f1811b20a..eaf16c8ce 100644 --- a/src/safe_outputs/create_pull_request.rs +++ b/src/safe_outputs/create_pull_request.rs @@ -3432,7 +3432,6 @@ index 0000000..abcdefg triggered_by_build_number: None, triggered_by_project_id: None, pull_request_id: None, - pull_request_number: None, pull_request_source_branch: None, pull_request_target_branch: None, build_container_id: None, diff --git a/src/safe_outputs/github_api.rs b/src/safe_outputs/github_api.rs index a6f41d422..0d30c2d53 100644 --- a/src/safe_outputs/github_api.rs +++ b/src/safe_outputs/github_api.rs @@ -185,11 +185,6 @@ impl GithubClient { self.repository_route(repository, &["issues", &number.to_string(), "comments"]) } - pub fn pull_request_url(&self, repository: &str, number: u64) -> anyhow::Result { - ensure!(number > 0, "GitHub pull request number must be positive"); - self.repository_route(repository, &["pulls", &number.to_string()]) - } - pub fn issue_comment_url(&self, repository: &str, comment_id: u64) -> anyhow::Result { ensure!(comment_id > 0, "GitHub comment ID must be positive"); self.repository_route(repository, &["issues", "comments", &comment_id.to_string()]) diff --git a/src/safe_outputs/mod.rs b/src/safe_outputs/mod.rs index 669237524..c4dc1e20e 100644 --- a/src/safe_outputs/mod.rs +++ b/src/safe_outputs/mod.rs @@ -54,7 +54,6 @@ pub const CONFIGURED_ONLY_TOOLS: &[&str] = tool_names![ AddGithubIssueLabelsResult, RemoveGithubIssueLabelsResult, CloseGithubIssueResult, - ClosePullRequestResult, UpdateGithubIssueResult, SetGithubIssueFieldResult, AssignGithubIssueMilestoneResult, @@ -86,6 +85,7 @@ pub const ALL_KNOWN_SAFE_OUTPUTS: &[&str] = all_safe_output_names![ AddBuildTagResult, CreateBranchResult, UpdatePrResult, + ClosePullRequestResult, UploadBuildAttachmentResult, UploadPipelineArtifactResult, UploadWorkitemAttachmentResult, @@ -99,7 +99,6 @@ pub const ALL_KNOWN_SAFE_OUTPUTS: &[&str] = all_safe_output_names![ AddGithubIssueLabelsResult, RemoveGithubIssueLabelsResult, CloseGithubIssueResult, - ClosePullRequestResult, UpdateGithubIssueResult, SetGithubIssueFieldResult, AssignGithubIssueMilestoneResult, diff --git a/src/safe_outputs/result.rs b/src/safe_outputs/result.rs index 1b7b36839..bd5375957 100644 --- a/src/safe_outputs/result.rs +++ b/src/safe_outputs/result.rs @@ -251,8 +251,6 @@ pub struct ExecutionContext { /// PR ID when `BUILD_REASON=PullRequest` (`SYSTEM_PULLREQUEST_PULLREQUESTID`) #[allow(dead_code)] pub pull_request_id: Option, - /// GitHub PR number when available (`SYSTEM_PULLREQUEST_PULLREQUESTNUMBER`) - pub pull_request_number: Option, /// PR source branch (`SYSTEM_PULLREQUEST_SOURCEBRANCH`) #[allow(dead_code)] pub pull_request_source_branch: Option, @@ -491,7 +489,6 @@ impl ExecutionContext { // Pull request variables pull_request_id: env("SYSTEM_PULLREQUEST_PULLREQUESTID"), - pull_request_number: env("SYSTEM_PULLREQUEST_PULLREQUESTNUMBER"), pull_request_source_branch: env("SYSTEM_PULLREQUEST_SOURCEBRANCH"), pull_request_target_branch: env("SYSTEM_PULLREQUEST_TARGETBRANCH"), @@ -1357,12 +1354,10 @@ mod tests { let ctx = ExecutionContext::from_env_lookup(env_from(&[ ("BUILD_REASON", "PullRequest"), ("SYSTEM_PULLREQUEST_PULLREQUESTID", "789"), - ("SYSTEM_PULLREQUEST_PULLREQUESTNUMBER", "123"), ("SYSTEM_PULLREQUEST_SOURCEBRANCH", "refs/heads/feature"), ("SYSTEM_PULLREQUEST_TARGETBRANCH", "refs/heads/main"), ])); assert_eq!(ctx.pull_request_id.as_deref(), Some("789")); - assert_eq!(ctx.pull_request_number.as_deref(), Some("123")); assert_eq!( ctx.pull_request_source_branch.as_deref(), Some("refs/heads/feature") @@ -1377,7 +1372,6 @@ mod tests { fn test_from_env_lookup_pull_request_none_when_unset() { let ctx = ExecutionContext::from_env_lookup(env_from(&[])); assert!(ctx.pull_request_id.is_none()); - assert!(ctx.pull_request_number.is_none()); assert!(ctx.pull_request_source_branch.is_none()); assert!(ctx.pull_request_target_branch.is_none()); } diff --git a/src/safe_outputs/upload_build_attachment.rs b/src/safe_outputs/upload_build_attachment.rs index a0d7841b8..70dd486e0 100644 --- a/src/safe_outputs/upload_build_attachment.rs +++ b/src/safe_outputs/upload_build_attachment.rs @@ -951,7 +951,6 @@ attachment-type: "agent-artifact" triggered_by_build_number: None, triggered_by_project_id: None, pull_request_id: None, - pull_request_number: None, pull_request_source_branch: None, pull_request_target_branch: None, build_container_id: None, From 9277c06b80defbfde06063d488f4ed9ce1547200 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:07:10 +0000 Subject: [PATCH 08/43] Correct update-pull-request for Azure DevOps Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- docs/safe-outputs.md | 39 +- src/compile/common.rs | 8 +- src/compile/types.rs | 16 - src/execute.rs | 2 +- src/inspect/catalog.rs | 2 +- src/mcp.rs | 2 +- src/safe_outputs/mod.rs | 1 - src/safe_outputs/update_pull_request.rs | 757 +++++++++++------------- 8 files changed, 376 insertions(+), 451 deletions(-) diff --git a/docs/safe-outputs.md b/docs/safe-outputs.md index e6f40f163..04b6babd9 100644 --- a/docs/safe-outputs.md +++ b/docs/safe-outputs.md @@ -831,23 +831,22 @@ write. #### Pull request updates (`update-pull-request`) `update-pull-request` matches gh-aw's front matter configuration for updating -GitHub pull requests. It updates the PR title or body; both fields are enabled -by default. The `operation` field controls body updates: `replace` (default), -`append`, `prepend`, or `replace-island`. +Azure DevOps pull requests. It updates the PR title or description (`body`); +both fields are enabled by default. The `operation` field controls description +updates: `replace` (default), `append`, `prepend`, or `replace-island`. ```yaml safe-outputs: update-pull-request: title: true # enable title updates (default: true) - body: true # enable body updates (default: true) - update-branch: false # update the PR branch from its base first (default: false) - sync-stack: true # accepted for gh-aw configuration parity - footer: false # omit the ado-aw trace footer from body updates (default: true) + body: true # enable description updates (default: true) + update-branch: false # must be false; ADO has no equivalent branch-update API + sync-stack: true # accepted for gh-aw front matter parity + footer: false # omit agent stats from body updates (default: true) operation: replace # replace, append, prepend, or replace-island max: 1 # maximum updates per run (default: 1) - target: "*" # "triggering" (default), "*", or a PR number - target-repo: octo-org/octo-repo - allowed-repos: [octo-org/octo-repo] + target: "*" # "triggering" (default), "*", or an ADO PR ID + allowed-repositories: [self] required-labels: [automated] required-title-prefix: "[bot] " ``` @@ -855,20 +854,16 @@ safe-outputs: **Agent parameters:** - `title` *(optional)* - Replacement PR title. -- `body` *(optional)* - PR body content in Markdown. +- `body` *(optional)* - PR description content in Markdown. - `operation` *(optional)* - Overrides the configured body operation for this update. -- `update_branch` *(optional)* - When `true`, update the PR branch with the - latest base branch changes before title/body updates. -- `pull_request_number`, `pr_number`, or `pr` - Required when `target: "*"` is - configured. With `target: "triggering"`, any supplied number must match the - triggering PR. -- `repository` *(optional)* - Target repository, constrained by `target-repo` - and `allowed-repos`. - -`update-branch` calls GitHub's pull request branch update API. GitHub's benign -"already up to date", merge-conflict, and missing-head responses are treated as -non-fatal, matching gh-aw's best-effort behavior. +- `update_branch` *(optional)* - Must be omitted or `false`; Azure DevOps does + not expose the gh-aw branch-update behavior. +- `pull_request_id`, `pull_request_number`, `pr_number`, or `pr` - Required + when `target: "*"` is configured. With `target: "triggering"`, any supplied ID + must match the triggering PR. +- `repository` *(optional)* - Target repository alias, constrained by + `allowed-repositories`. #### Fields, milestones, and assignees diff --git a/src/compile/common.rs b/src/compile/common.rs index 53dda72e0..79f53e710 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -2427,11 +2427,6 @@ pub fn validate_github_issue_outputs_config(front_matter: &FrontMatter) -> Resul crate::safe_outputs::validate_update_github_issue_config(&config)?; } } - "update-pull-request" => { - if let Some(config) = front_matter.update_pull_request_config()? { - crate::safe_outputs::validate_update_pull_request_config(&config)?; - } - } "set-github-issue-field" => { if let Some(config) = front_matter.set_github_issue_field_config()? { crate::safe_outputs::validate_set_github_issue_field_config(&config)?; @@ -2460,6 +2455,9 @@ pub fn validate_github_issue_outputs_config(front_matter: &FrontMatter) -> Resul _ => {} } } + if let Some(config) = front_matter.update_pull_request_config()? { + crate::safe_outputs::validate_update_pull_request_config(&config)?; + } if let Some(config) = front_matter.create_github_issue_config()? { if let Some(prefix) = config.title_prefix.as_deref() { crate::validate::reject_pipeline_injection( diff --git a/src/compile/types.rs b/src/compile/types.rs index 7057c6664..a23061771 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -709,7 +709,6 @@ pub const SAFE_OUTPUTS_GITHUB_ACTOR_LOGIN_ENV: &str = "ADO_AW_GITHUB_ACTOR_LOGIN /// intentionally does not depend on the safe-output module registry. pub const GITHUB_ISSUE_SAFE_OUTPUT_TOOLS: &[&str] = &[ "create-github-issue", - "update-pull-request", "set-github-issue-type", "comment-on-github-issue", "hide-github-issue-comment", @@ -2351,18 +2350,6 @@ impl FrontMatter { ..Default::default() })) } - "update-pull-request" => { - Ok(self - .update_pull_request_config()? - .map(|config| GithubIssueCompilerConfig { - target_repo: config.target_repo, - allowed_repos: config.allowed_repos, - required_labels: config.required_labels, - required_title_prefix: config.required_title_prefix, - pull_requests: Some(true), - ..Default::default() - })) - } "set-github-issue-type" => { Ok(self .set_github_issue_type_config()? @@ -2513,9 +2500,6 @@ impl FrontMatter { continue; }; match tool.as_str() { - "update-pull-request" => { - pull_requests = true; - } "comment-on-github-issue" | "add-github-issue-labels" | "update-github-issue" => { let tool_issues = config.issues.unwrap_or(true); let tool_pull_requests = config.pull_requests.unwrap_or(false); diff --git a/src/execute.rs b/src/execute.rs index b3fb6afd5..9d2fa97c1 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -740,6 +740,7 @@ async fn dispatch_pr_tools( "create-pull-request" => CreatePrResult, "add-pr-comment" => AddPrCommentResult, "update-pr" => UpdatePrResult, + "update-pull-request" => UpdatePullRequestResult, "submit-pr-review" => SubmitPrReviewResult, "reply-to-pr-comment" => ReplyToPrCommentResult, "resolve-pr-thread" => ResolvePrThreadResult, @@ -778,7 +779,6 @@ async fn dispatch_github_tools( "add-github-issue-labels" => AddGithubIssueLabelsResult, "remove-github-issue-labels" => RemoveGithubIssueLabelsResult, "close-github-issue" => CloseGithubIssueResult, - "update-pull-request" => UpdatePullRequestResult, "update-github-issue" => UpdateGithubIssueResult, "set-github-issue-field" => SetGithubIssueFieldResult, "assign-github-issue-milestone" => AssignGithubIssueMilestoneResult, diff --git a/src/inspect/catalog.rs b/src/inspect/catalog.rs index d336c52a0..2864fbc1b 100644 --- a/src/inspect/catalog.rs +++ b/src/inspect/catalog.rs @@ -310,7 +310,7 @@ fn safe_output_description(name: &str) -> &'static str { "set-github-issue-field" => "Sets a repository-defined field on a GitHub issue", "submit-pr-review" => "Parameters for submitting a pull request review", "update-pr" => "Parameters for updating a pull request", - "update-pull-request" => "Updates a GitHub pull request title, body, or branch", + "update-pull-request" => "Updates an Azure DevOps pull request title or description", "unassign-github-issue-from-user" => { "Removes operator-permitted GitHub users from an issue" } diff --git a/src/mcp.rs b/src/mcp.rs index c1b80f9bb..c0829c459 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -960,7 +960,7 @@ issue_number may be a positive number or a temporary_id from create-github-issue #[tool( name = "update-pull-request", - description = "Update a configured GitHub pull request title or body, or update its branch from the base branch." + description = "Update an Azure DevOps pull request title or description. Uses gh-aw-style title/body/operation inputs." )] async fn update_pull_request( &self, diff --git a/src/safe_outputs/mod.rs b/src/safe_outputs/mod.rs index b381c52e4..0bfb47ccc 100644 --- a/src/safe_outputs/mod.rs +++ b/src/safe_outputs/mod.rs @@ -48,7 +48,6 @@ pub const DEBUG_ONLY_TOOLS: &[&str] = &[]; pub const CONFIGURED_ONLY_TOOLS: &[&str] = tool_names![ AssignWorkItemResult, CreateGithubIssueResult, - UpdatePullRequestResult, SetGithubIssueTypeResult, CommentOnGithubIssueResult, HideGithubIssueCommentResult, diff --git a/src/safe_outputs/update_pull_request.rs b/src/safe_outputs/update_pull_request.rs index 4df698124..e2ca5d195 100644 --- a/src/safe_outputs/update_pull_request.rs +++ b/src/safe_outputs/update_pull_request.rs @@ -1,96 +1,104 @@ -//! `update-pull-request` GitHub safe output. +//! `update-pull-request` Azure DevOps safe output. -use anyhow::ensure; -use log::{debug, info, warn}; -use reqwest::Method; +use anyhow::{Context, ensure}; +use log::{debug, info}; +use percent_encoding::utf8_percent_encode; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; -use url::Url; use ado_aw_derive::SanitizeConfig; -use crate::safe_outputs::{ - ExecutionContext, ExecutionResult, Executor, GithubBodyOperation, GithubClient, - GithubMutationFilters, GithubRepositoryPolicy, GithubTargetCapabilities, GithubTargetKind, - GithubTargetMetadata, Validate, build_github_trace_footer, resolve_github_repository, - validate_github_mutation_filter_config, validate_github_mutation_filters, - validate_github_repository, validate_github_target_capability, -}; +use super::{PATH_SEGMENT, resolve_repo_name}; +use crate::safe_outputs::{ExecutionContext, ExecutionResult, Executor, Validate}; use crate::sanitize::{SanitizeContent, sanitize as sanitize_text, sanitize_config}; use crate::tool_result; +use crate::validate::reject_pipeline_injection; const MAX_TITLE_CHARS: usize = 256; const MAX_BODY_CHARS: usize = 65_536; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum AdoPullRequestBodyOperation { + Replace, + Append, + Prepend, + ReplaceIsland, +} + #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(untagged)] -pub enum GithubPullRequestNumber { - Number(u64), +pub enum AdoPullRequestId { + Number(i32), String(String), } -impl GithubPullRequestNumber { - fn parse(&self, field: &str) -> anyhow::Result { - let number = match self { - Self::Number(number) => *number, +impl AdoPullRequestId { + fn parse(&self, field: &str) -> anyhow::Result { + let id = match self { + Self::Number(id) => *id, Self::String(value) => value .trim() .strip_prefix('#') .unwrap_or_else(|| value.trim()) - .parse::() - .map_err(|_| anyhow::anyhow!("{field} must be a positive pull request number"))?, + .parse::() + .map_err(|_| anyhow::anyhow!("{field} must be a positive pull request ID"))?, }; - ensure!(number > 0, "{field} must be positive"); - Ok(number) + ensure!(id > 0, "{field} must be positive"); + Ok(id) } } #[derive(Deserialize, JsonSchema)] pub struct UpdatePullRequestParams { - /// New pull request title. + /// Replacement Azure DevOps pull request title. #[serde(default)] pub title: Option, - /// Pull request body content in Markdown. + /// Pull request description content in Markdown. #[serde(default)] pub body: Option, - /// Body update operation. Defaults to the configured operation, then `replace`. + /// Description update operation. Defaults to the configured operation, then `replace`. #[serde(default)] - pub operation: Option, - /// When true, update the PR branch from the base branch before other updates. + pub operation: Option, + /// Not supported for Azure DevOps PRs; accepted for gh-aw schema compatibility but must be false/omitted. #[serde(default, rename = "update_branch", alias = "updateBranch")] pub update_branch: Option, - /// Pull request number. Required when front matter uses `target: "*"`. + /// Azure DevOps pull request ID. Required when front matter uses `target: "*"`. + #[serde(default, rename = "pull_request_id", alias = "pullRequestId")] + pub pull_request_id: Option, + /// gh-aw-compatible alias for pull_request_id. #[serde(default, rename = "pull_request_number", alias = "pullRequestNumber")] - pub pull_request_number: Option, - /// Alias for pull_request_number. + pub pull_request_number: Option, + /// Alias for pull_request_id. #[serde(default, rename = "pr_number", alias = "prNumber")] - pub pr_number: Option, - /// Alias for pull_request_number. + pub pr_number: Option, + /// Alias for pull_request_id. #[serde(default)] - pub pr: Option, - /// Optional target repository. + pub pr: Option, + /// Repository alias: "self" for the pipeline repo, or an alias from the checkout list. #[serde(default)] pub repository: Option, } impl UpdatePullRequestParams { - fn requested_number(&self) -> anyhow::Result> { + fn requested_id(&self) -> anyhow::Result> { let mut found = None; for (field, value) in [ + ("pull_request_id", self.pull_request_id.as_ref()), ("pull_request_number", self.pull_request_number.as_ref()), ("pr_number", self.pr_number.as_ref()), ("pr", self.pr.as_ref()), ] { if let Some(value) = value { - let number = value.parse(field)?; + let id = value.parse(field)?; if let Some(existing) = found { ensure!( - existing == number, - "pull request number aliases must all refer to the same PR" + existing == id, + "pull request ID aliases must all refer to the same PR" ); } - found = Some(number); + found = Some(id); } } Ok(found) @@ -118,9 +126,9 @@ impl Validate for UpdatePullRequestParams { ); } if let Some(repository) = self.repository.as_deref() { - validate_github_repository(repository)?; + reject_pipeline_injection(repository, "repository")?; } - let _ = self.requested_number()?; + let _ = self.requested_id()?; Ok(()) } } @@ -130,22 +138,24 @@ tool_result! { write = true, params = UpdatePullRequestParams, default_max = 1, - /// Result of updating a GitHub pull request. + /// Result of updating an Azure DevOps pull request. pub struct UpdatePullRequestResult { #[serde(default)] title: Option, #[serde(default)] body: Option, #[serde(default)] - operation: Option, + operation: Option, #[serde(default, rename = "update_branch")] update_branch: Option, + #[serde(default, rename = "pull_request_id")] + pull_request_id: Option, #[serde(default, rename = "pull_request_number")] - pull_request_number: Option, + pull_request_number: Option, #[serde(default, rename = "pr_number")] - pr_number: Option, + pr_number: Option, #[serde(default)] - pr: Option, + pr: Option, #[serde(default)] repository: Option, } @@ -163,14 +173,14 @@ fn default_true() -> bool { true } -fn default_operation() -> GithubBodyOperation { - GithubBodyOperation::Replace +fn default_operation() -> AdoPullRequestBodyOperation { + AdoPullRequestBodyOperation::Replace } #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum UpdatePullRequestTarget { - Number(u64), + Id(i32), Named(String), } @@ -183,37 +193,36 @@ impl Default for UpdatePullRequestTarget { #[derive(Debug, Clone, SanitizeConfig, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct UpdatePullRequestConfig { - /// Whether title updates are enabled. Defaults to true. + /// Whether title updates are enabled. Defaults to true to match gh-aw. #[serde(default = "default_true")] #[sanitize_config(skip)] pub title: bool, - /// Whether body updates are enabled. Defaults to true. + /// Whether description updates are enabled. Defaults to true to match gh-aw. #[serde(default = "default_true")] #[sanitize_config(skip)] pub body: bool, - /// Default branch update policy. Defaults to false. + /// Not supported for Azure DevOps PRs; must remain false. #[serde(default, rename = "update-branch")] #[sanitize_config(skip)] pub update_branch: bool, - /// gh-aw-compatible stacked-PR fallback knob. Parsed for config parity. + /// Accepted for gh-aw front matter parity; unused for Azure DevOps. #[serde(default = "default_true", rename = "sync-stack")] #[sanitize_config(skip)] pub sync_stack: bool, - /// Include the standard ado-aw trace footer in body updates. + /// Include agent stats in body updates. #[serde(default = "default_true")] #[sanitize_config(skip)] pub footer: bool, /// Body update operation. Defaults to replace. #[serde(default = "default_operation")] #[sanitize_config(skip)] - pub operation: GithubBodyOperation, - /// `"triggering"` (default), `"*"`, or a fixed PR number. + pub operation: AdoPullRequestBodyOperation, + /// `"triggering"` (default), `"*"`, or a fixed PR ID. #[serde(default)] pub target: UpdatePullRequestTarget, - #[serde(default, rename = "target-repo")] - pub target_repo: Option, - #[serde(default, rename = "allowed-repos")] - pub allowed_repos: Vec, + /// Repository aliases the agent may target. Empty means any checkout alias accepted by the compiler. + #[serde(default, rename = "allowed-repositories")] + pub allowed_repositories: Vec, #[serde(default, rename = "required-labels")] pub required_labels: Vec, #[serde(default, rename = "required-title-prefix")] @@ -231,10 +240,9 @@ impl Default for UpdatePullRequestConfig { update_branch: false, sync_stack: true, footer: true, - operation: GithubBodyOperation::Replace, + operation: AdoPullRequestBodyOperation::Replace, target: UpdatePullRequestTarget::default(), - target_repo: None, - allowed_repos: Vec::new(), + allowed_repositories: Vec::new(), required_labels: Vec::new(), required_title_prefix: None, max: None, @@ -245,34 +253,58 @@ impl Default for UpdatePullRequestConfig { pub(crate) fn validate_update_pull_request_config( config: &UpdatePullRequestConfig, ) -> anyhow::Result<()> { + ensure!( + !config.update_branch, + "safe-outputs.update-pull-request.update-branch is not supported for Azure DevOps PRs" + ); match &config.target { - UpdatePullRequestTarget::Number(number) => { - ensure!(*number > 0, "target PR number must be positive"); - } - UpdatePullRequestTarget::Named(target) => { - ensure!( - matches!(target.as_str(), "triggering" | "*"), - "target must be \"triggering\", \"*\", or a positive pull request number" - ); - } + UpdatePullRequestTarget::Id(id) => ensure!(*id > 0, "target PR ID must be positive"), + UpdatePullRequestTarget::Named(target) => ensure!( + matches!(target.as_str(), "triggering" | "*"), + "target must be \"triggering\", \"*\", or a positive pull request ID" + ), + } + for repository in &config.allowed_repositories { + ensure!( + !repository.trim().is_empty(), + "allowed-repositories entries must not be empty" + ); + reject_pipeline_injection( + repository, + "safe-outputs.update-pull-request.allowed-repositories", + )?; + } + for label in &config.required_labels { + ensure!( + !label.is_empty(), + "required-labels entries must not be empty" + ); + reject_pipeline_injection(label, "safe-outputs.update-pull-request.required-labels")?; + } + if let Some(prefix) = config.required_title_prefix.as_deref() { + ensure!( + !prefix.is_empty(), + "required-title-prefix must not be empty" + ); + reject_pipeline_injection( + prefix, + "safe-outputs.update-pull-request.required-title-prefix", + )?; } - validate_github_mutation_filter_config(GithubMutationFilters { - required_labels: &config.required_labels, - required_title_prefix: config.required_title_prefix.as_deref(), - })?; Ok(()) } #[derive(Debug, Deserialize)] -struct RawPullRequestTarget { - number: u64, +struct RawPullRequest { + #[serde(rename = "pullRequestId")] + pull_request_id: i32, title: String, #[serde(default)] - body: Option, - state: String, + description: Option, #[serde(default)] labels: Vec, - html_url: Option, + #[serde(default)] + url: Option, } #[derive(Debug, Deserialize)] @@ -280,49 +312,6 @@ struct RawLabel { name: String, } -impl RawPullRequestTarget { - fn metadata(&self) -> GithubTargetMetadata { - GithubTargetMetadata { - number: self.number, - node_id: None, - title: self.title.clone(), - state: self.state.clone(), - labels: self.labels.iter().map(|label| label.name.clone()).collect(), - kind: GithubTargetKind::PullRequest, - html_url: self.html_url.clone(), - } - } -} - -fn repository_route(client: &GithubClient, repository: &str, tail: &[&str]) -> anyhow::Result { - validate_github_repository(repository)?; - let (owner, name) = repository - .split_once('/') - .expect("validated GitHub repository contains slash"); - let mut url = client.rest_api_url().clone(); - { - let mut path = url - .path_segments_mut() - .map_err(|_| anyhow::anyhow!("GitHub API URL cannot be a base URL"))?; - path.pop_if_empty(); - path.push("repos"); - path.push(owner); - path.push(name); - for segment in tail { - path.push(segment); - } - } - Ok(url) -} - -fn body_with_footer(body: &str, include_footer: bool, ctx: &ExecutionContext) -> String { - if include_footer { - format!("{body}\n\n{}", build_github_trace_footer(ctx)) - } else { - body.to_string() - } -} - fn island_markers(ctx: &ExecutionContext) -> Result<(String, String), ExecutionResult> { let Some(definition_id) = ctx.definition_id else { return Err(ExecutionResult::failure( @@ -375,102 +364,109 @@ fn replace_island( )) } +fn body_with_footer(body: &str, include_footer: bool, ctx: &ExecutionContext) -> String { + if include_footer { + crate::agent_stats::append_stats_to_body(body, ctx, true) + } else { + body.to_string() + } +} + fn build_updated_body( current: &str, new_content: &str, - operation: GithubBodyOperation, + operation: AdoPullRequestBodyOperation, include_footer: bool, ctx: &ExecutionContext, ) -> Result { let section = body_with_footer(new_content, include_footer, ctx); let updated = match operation { - GithubBodyOperation::Append => { + AdoPullRequestBodyOperation::Append => { if current.is_empty() { section } else { format!("{current}\n\n---\n\n{section}") } } - GithubBodyOperation::Prepend => { + AdoPullRequestBodyOperation::Prepend => { if current.is_empty() { section } else { format!("{section}\n\n---\n\n{current}") } } - GithubBodyOperation::Replace => section, - GithubBodyOperation::ReplaceIsland => replace_island(current, §ion, ctx)?, + AdoPullRequestBodyOperation::Replace => section, + AdoPullRequestBodyOperation::ReplaceIsland => replace_island(current, §ion, ctx)?, }; if updated.chars().count() > MAX_BODY_CHARS { return Err(ExecutionResult::failure(format!( - "updated body exceeds GitHub's {MAX_BODY_CHARS}-character limit" + "updated body exceeds Azure DevOps' {MAX_BODY_CHARS}-character limit" ))); } Ok(updated) } +fn ctx_pull_request_id(ctx: &ExecutionContext) -> Result { + let raw = ctx.pull_request_id.as_deref().ok_or_else(|| { + ExecutionResult::failure( + "SYSTEM_PULLREQUEST_PULLREQUESTID is required for target \"triggering\"", + ) + })?; + raw.parse::().ok().filter(|id| *id > 0).ok_or_else(|| { + ExecutionResult::failure(format!( + "SYSTEM_PULLREQUEST_PULLREQUESTID '{}' is not a positive pull request ID", + crate::sanitize::neutralize_pipeline_commands(raw) + )) + }) +} + impl UpdatePullRequestResult { - fn requested_number(&self) -> anyhow::Result> { - let params = UpdatePullRequestParams { + fn requested_id(&self) -> anyhow::Result> { + UpdatePullRequestParams { title: self.title.clone(), body: self.body.clone(), operation: self.operation, update_branch: self.update_branch, + pull_request_id: self.pull_request_id.clone(), pull_request_number: self.pull_request_number.clone(), pr_number: self.pr_number.clone(), pr: self.pr.clone(), repository: self.repository.clone(), - }; - params.requested_number() - } - - fn requested_fields(&self, config: &UpdatePullRequestConfig) -> Vec<&'static str> { - let mut fields = Vec::new(); - if self.title.is_some() { - fields.push("title"); } - if self.body.is_some() { - fields.push("body"); - } - if self.update_branch.unwrap_or(config.update_branch) { - fields.push("update_branch"); - } - fields + .requested_id() } - fn resolve_number( + fn resolve_id( &self, config: &UpdatePullRequestConfig, ctx: &ExecutionContext, - ) -> Result { + ) -> Result { let requested = self - .requested_number() + .requested_id() .map_err(|error| ExecutionResult::failure(error.to_string()))?; match &config.target { - UpdatePullRequestTarget::Number(number) => { + UpdatePullRequestTarget::Id(id) => { if let Some(requested) = requested - && requested != *number + && requested != *id { return Err(ExecutionResult::failure(format!( - "requested pull_request_number #{requested} does not match configured target #{number}" + "requested pull_request_id #{requested} does not match configured target #{id}" ))); } - Ok(*number) - } - UpdatePullRequestTarget::Named(target) if target == "*" => { - requested.ok_or_else(|| { - ExecutionResult::failure( - "pull_request_number is required when safe-outputs.update-pull-request.target is \"*\"", - ) - }) + Ok(*id) } + UpdatePullRequestTarget::Named(target) if target == "*" => requested.ok_or_else(|| { + ExecutionResult::failure( + "pull_request_id is required when safe-outputs.update-pull-request.target is \"*\"", + ) + }), UpdatePullRequestTarget::Named(target) if target == "triggering" => { - let triggering = ctx_pull_request_id_from(ctx)?; + let triggering = ctx_pull_request_id(ctx)?; if let Some(requested) = requested && requested != triggering { return Err(ExecutionResult::failure(format!( - "requested pull_request_number #{requested} does not match triggering pull request #{triggering}" + "requested pull_request_id #{requested} does not match triggering pull request #{triggering}" ))); } Ok(triggering) @@ -482,85 +478,90 @@ impl UpdatePullRequestResult { } } - async fn fetch_target( - &self, - client: &GithubClient, - repository: &str, - number: u64, - ) -> anyhow::Result> { - let response = client - .send( - Method::GET, - repository_route(client, repository, &["pulls", &number.to_string()])?, - None, - ) - .await?; - let response = match response.require_success("Failed to fetch GitHub pull request") { - Ok(response) => response, - Err(error) => return Ok(Err(ExecutionResult::failure(error.to_string()))), - }; - match response.json("Failed to parse GitHub pull request") { - Ok(target) => Ok(Ok(target)), - Err(error) => Ok(Err(ExecutionResult::failure(error.to_string()))), + fn requested_fields(&self) -> Vec<&'static str> { + let mut fields = Vec::new(); + if self.title.is_some() { + fields.push("title"); } + if self.body.is_some() { + fields.push("body"); + } + fields } - async fn update_branch( + async fn fetch_pr( &self, - client: &GithubClient, - repository: &str, - number: u64, - ) -> anyhow::Result> { + client: &reqwest::Client, + base_url: &str, + repo_name: &str, + token: &str, + pr_id: i32, + ) -> anyhow::Result> { + let encoded_repo = utf8_percent_encode(repo_name, PATH_SEGMENT).to_string(); + let url = format!("{base_url}/{encoded_repo}/pullRequests/{pr_id}?api-version=7.1"); let response = client - .send( - Method::PUT, - repository_route( - client, - repository, - &["pulls", &number.to_string(), "update-branch"], - )?, - None, - ) - .await?; - if response.is_success() { - return Ok(Ok(())); - } - let error = response - .require_success("Failed to update GitHub pull request branch") - .expect_err("non-success response must produce an API error"); - let message = error.message.to_ascii_lowercase(); - if message.contains("there are no new commits on the base branch") - || message.contains("merge conflict between base and head") - || message.contains("head ref does not exist") + .get(&url) + .basic_auth("", Some(token)) + .send() + .await + .context("Failed to fetch Azure DevOps pull request")?; + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Ok(Err(ExecutionResult::failure(format!( + "Failed to fetch PR #{pr_id} (HTTP {status}): {body}" + )))); + } + match response.json::().await { + Ok(pr) => Ok(Ok(pr)), + Err(error) => Ok(Err(ExecutionResult::failure(format!( + "Failed to parse PR #{pr_id}: {error}" + )))), + } + } + + fn validate_filters( + &self, + pr: &RawPullRequest, + config: &UpdatePullRequestConfig, + ) -> Result<(), ExecutionResult> { + let missing: Vec<&str> = config + .required_labels + .iter() + .map(String::as_str) + .filter(|required| { + !pr.labels + .iter() + .any(|label| label.name.eq_ignore_ascii_case(required)) + }) + .collect(); + if !missing.is_empty() { + return Err(ExecutionResult::failure(format!( + "PR #{} is missing required labels: {}", + pr.pull_request_id, + missing.join(", ") + ))); + } + if let Some(prefix) = config.required_title_prefix.as_deref() + && !pr.title.starts_with(prefix) { - warn!("Non-fatal update-pull-request branch update failure: {error}"); - return Ok(Ok(())); + return Err(ExecutionResult::failure(format!( + "PR #{} title does not start with required-title-prefix '{}'", + pr.pull_request_id, + crate::sanitize::neutralize_pipeline_commands(prefix) + ))); } - Ok(Err(ExecutionResult::failure(error.to_string()))) + Ok(()) } } -fn ctx_pull_request_id_from(ctx: &ExecutionContext) -> Result { - let raw = ctx.pull_request_id.as_deref().ok_or_else(|| { - ExecutionResult::failure( - "SYSTEM_PULLREQUEST_PULLREQUESTID is required for target \"triggering\"", - ) - })?; - raw.parse::() - .ok() - .filter(|number| *number > 0) - .ok_or_else(|| { - ExecutionResult::failure(format!( - "SYSTEM_PULLREQUEST_PULLREQUESTID '{}' is not a positive pull request number", - crate::sanitize::neutralize_pipeline_commands(raw) - )) - }) -} - #[async_trait::async_trait] impl Executor for UpdatePullRequestResult { fn dry_run_summary(&self) -> String { - "update GitHub pull request".to_string() + "update Azure DevOps pull request".to_string() } async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { @@ -569,6 +570,7 @@ impl Executor for UpdatePullRequestResult { body: self.body.clone(), operation: self.operation, update_branch: self.update_branch, + pull_request_id: self.pull_request_id.clone(), pull_request_number: self.pull_request_number.clone(), pr_number: self.pr_number.clone(), pr: self.pr.clone(), @@ -582,12 +584,18 @@ impl Executor for UpdatePullRequestResult { "update-pull-request is not configured for this workflow", )); } - let Some(token) = ctx.github_token.as_ref() else { - return Ok(ExecutionResult::failure( - "ADO_AW_GITHUB_TOKEN is not set; configure safe-outputs.github-token \ - or safe-outputs.github-app", - )); - }; + let org_url = ctx + .ado_org_url + .as_ref() + .context("AZURE_DEVOPS_ORG_URL not set")?; + let project = ctx + .ado_project + .as_ref() + .context("SYSTEM_TEAMPROJECT not set")?; + let token = ctx + .access_token + .as_ref() + .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; let config: UpdatePullRequestConfig = ctx.get_tool_config("update-pull-request")?; validate_update_pull_request_config(&config)?; if self.title.is_some() && !config.title { @@ -600,97 +608,106 @@ impl Executor for UpdatePullRequestResult { "update-pull-request field 'body' is not enabled by configuration", )); } - let update_branch = self.update_branch.unwrap_or(config.update_branch); - if self.title.is_none() && self.body.is_none() && !update_branch { + if self.update_branch == Some(true) { return Ok(ExecutionResult::failure( - "at least one of title, body, or effective update_branch: true is required", + "update_branch is not supported for Azure DevOps PRs", )); } - let number = match self.resolve_number(&config, ctx) { - Ok(number) => number, + if self.title.is_none() && self.body.is_none() { + return Ok(ExecutionResult::failure( + "at least one of title or body is required", + )); + } + let pr_id = match self.resolve_id(&config, ctx) { + Ok(pr_id) => pr_id, Err(result) => return Ok(result), }; - let repository = match resolve_github_repository( - self.repository.as_deref(), - GithubRepositoryPolicy::new(config.target_repo.as_deref(), &config.allowed_repos), - ctx, - ) { - Ok(repository) => repository, - Err(result) => return Ok(result), + let repo_alias = self.repository.as_deref().unwrap_or("self"); + if !config.allowed_repositories.is_empty() + && !config + .allowed_repositories + .iter() + .any(|allowed| allowed == repo_alias) + { + return Ok(ExecutionResult::failure(format!( + "Repository '{}' is not in the allowed-repositories list: [{}]", + crate::sanitize::neutralize_pipeline_commands(repo_alias), + config.allowed_repositories.join(", ") + ))); + } + let repo_name = match resolve_repo_name(self.repository.as_deref(), ctx) { + Ok(name) => name, + Err(failure) => return Ok(failure), }; - let client = GithubClient::new(&ctx.github_api_url, token)?; - let current = match self.fetch_target(&client, &repository, number).await? { - Ok(target) => target, + let client = reqwest::Client::new(); + let encoded_project = utf8_percent_encode(project, PATH_SEGMENT).to_string(); + let base_url = format!( + "{}/{}/_apis/git/repositories", + org_url.trim_end_matches('/'), + encoded_project, + ); + let current = match self + .fetch_pr(&client, &base_url, &repo_name, token, pr_id) + .await? + { + Ok(pr) => pr, Err(result) => return Ok(result), }; - let metadata = current.metadata(); - if let Err(result) = validate_github_target_capability( - &metadata, - GithubTargetCapabilities { - issues: false, - pull_requests: true, - }, - ) { - return Ok(result); - } - let filters = GithubMutationFilters { - required_labels: &config.required_labels, - required_title_prefix: config.required_title_prefix.as_deref(), - }; - if let Err(result) = validate_github_mutation_filters(&metadata, filters) { - return Ok(result); - } - if update_branch - && let Err(result) = self.update_branch(&client, &repository, number).await? - { + if let Err(result) = self.validate_filters(¤t, &config) { return Ok(result); } - - let mut payload = Map::new(); + let mut patch = Map::new(); if let Some(title) = self.title.as_ref() { - payload.insert("title".to_string(), Value::String(title.clone())); + patch.insert("title".to_string(), Value::String(title.clone())); } if let Some(body) = self.body.as_deref() { - let updated = match build_updated_body( - current.body.as_deref().unwrap_or_default(), + let description = match build_updated_body( + current.description.as_deref().unwrap_or_default(), body, self.operation.unwrap_or(config.operation), config.footer, ctx, ) { - Ok(body) => body, + Ok(description) => description, Err(result) => return Ok(result), }; - payload.insert("body".to_string(), Value::String(updated)); - } - if !payload.is_empty() { - debug!("Updating GitHub pull request {repository}#{number}"); - let response = client - .send( - Method::PATCH, - repository_route(&client, &repository, &["pulls", &number.to_string()])?, - Some(&Value::Object(payload)), - ) - .await?; - if !response.is_success() { - let error = response - .require_success("Failed to update GitHub pull request") - .expect_err("non-success response must produce an API error"); - return Ok(ExecutionResult::failure(error.to_string())); - } + patch.insert("description".to_string(), Value::String(description)); } - - info!("Updated GitHub pull request {repository}#{number}"); + let encoded_repo = utf8_percent_encode(&repo_name, PATH_SEGMENT).to_string(); + let patch_url = format!("{base_url}/{encoded_repo}/pullRequests/{pr_id}?api-version=7.1"); + debug!( + "Updating Azure DevOps PR #{pr_id}: {}", + self.requested_fields().join(", ") + ); + let response = client + .patch(&patch_url) + .header("Content-Type", "application/json") + .basic_auth("", Some(token)) + .json(&Value::Object(patch)) + .send() + .await + .context("Failed to update Azure DevOps pull request")?; + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Ok(ExecutionResult::failure(format!( + "Failed to update PR #{pr_id} (HTTP {status}): {body}" + ))); + } + info!("Updated Azure DevOps PR #{pr_id}"); Ok(ExecutionResult::success_with_data( format!( - "Updated GitHub pull request {repository}#{number}: {}", - self.requested_fields(&config).join(", ") + "Updated Azure DevOps PR #{pr_id}: {}", + self.requested_fields().join(", ") ), serde_json::json!({ - "number": number, - "target_repo": repository, - "pull_request_url": metadata.html_url, - "fields": self.requested_fields(&config), + "pull_request_id": pr_id, + "operation": "update-pull-request", + "fields": self.requested_fields(), + "url": current.url, }), )) } @@ -708,10 +725,10 @@ mod tests { let mut tool_configs = HashMap::new(); tool_configs.insert("update-pull-request".to_string(), config); ExecutionContext { - github_token: Some("token".to_string()), - github_api_url: server.uri(), - repository_provider: Some("GitHub".to_string()), - repository_name: Some("octo/repo".to_string()), + ado_org_url: Some(server.uri()), + ado_project: Some("project".to_string()), + access_token: Some("token".to_string()), + repository_name: Some("repo".to_string()), pull_request_id: Some("7".to_string()), tool_configs, definition_id: Some(123), @@ -719,14 +736,13 @@ mod tests { } } - fn pull_request(number: u64) -> serde_json::Value { + fn pr(id: i32) -> serde_json::Value { serde_json::json!({ - "number": number, + "pullRequestId": id, "title": "[bot] Existing", - "body": "Existing body", - "state": "open", + "description": "Existing body", "labels": [{"name": "automated"}], - "html_url": format!("https://github.example/octo/repo/pull/{number}") + "url": format!("https://dev.azure.example/pr/{id}") }) } @@ -736,6 +752,7 @@ mod tests { body: None, operation: None, update_branch: None, + pull_request_id: None, pull_request_number: None, pr_number: None, pr: None, @@ -750,7 +767,7 @@ mod tests { } #[test] - fn config_matches_gh_aw_defaults_and_rejects_unknown_fields() { + fn config_matches_gh_aw_shape_but_rejects_update_branch_true() { let config: UpdatePullRequestConfig = serde_json::from_value(serde_json::json!({})).unwrap(); assert!(config.title); @@ -758,24 +775,26 @@ mod tests { assert!(!config.update_branch); assert!(config.sync_stack); assert!(config.footer); - assert_eq!(config.operation, GithubBodyOperation::Replace); - assert!( - serde_json::from_value::(serde_json::json!({ - "allow-title": true - })) - .is_err() - ); + assert_eq!(config.operation, AdoPullRequestBodyOperation::Replace); + + let unsupported: UpdatePullRequestConfig = serde_json::from_value(serde_json::json!({ + "update-branch": true + })) + .unwrap(); + assert!(validate_update_pull_request_config(&unsupported).is_err()); } #[test] - fn validates_number_aliases() { - let mut empty = params(); - empty.title = None; + fn validates_id_aliases() { + let empty = UpdatePullRequestParams { + title: None, + ..params() + }; assert!(empty.validate().is_ok()); let mut aliases = params(); - aliases.pull_request_number = Some(GithubPullRequestNumber::Number(1)); - aliases.pr_number = Some(GithubPullRequestNumber::String("#2".to_string())); + aliases.pull_request_id = Some(AdoPullRequestId::Number(1)); + aliases.pull_request_number = Some(AdoPullRequestId::String("#2".to_string())); assert!(aliases.validate().is_err()); } @@ -788,19 +807,17 @@ mod tests { let first = build_updated_body( "before", "new", - GithubBodyOperation::ReplaceIsland, + AdoPullRequestBodyOperation::ReplaceIsland, false, &ctx, ) .unwrap(); assert!(first.contains("before\n\n---\n\n")); assert!(first.contains("")); - assert!(first.contains("\nnew\n")); - let second = build_updated_body( &first, "next", - GithubBodyOperation::ReplaceIsland, + AdoPullRequestBodyOperation::ReplaceIsland, false, &ctx, ) @@ -809,54 +826,28 @@ mod tests { assert!(!second.contains("\nnew\n")); } - #[test] - fn replace_island_requires_definition_id_and_ordered_markers() { - let missing_id = ExecutionContext::default(); - assert!( - build_updated_body( - "", - "new", - GithubBodyOperation::ReplaceIsland, - false, - &missing_id - ) - .is_err() - ); - - let ctx = ExecutionContext { - definition_id: Some(123), - ..Default::default() - }; - let bad = "\nold\n"; - assert!( - build_updated_body(bad, "new", GithubBodyOperation::ReplaceIsland, false, &ctx) - .is_err() - ); - } - #[tokio::test] - async fn updates_triggering_pull_request_title_and_body() { + async fn updates_triggering_pr_title_and_body() { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/repos/octo/repo/pulls/7")) - .respond_with(ResponseTemplate::new(200).set_body_json(pull_request(7))) + .and(path("/project/_apis/git/repositories/repo/pullRequests/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(pr(7))) .expect(1) .mount(&server) .await; Mock::given(method("PATCH")) - .and(path("/repos/octo/repo/pulls/7")) + .and(path("/project/_apis/git/repositories/repo/pullRequests/7")) .and(body_json(serde_json::json!({ "title": "Updated title", - "body": "Existing body\n\n---\n\nNew body" + "description": "Existing body\n\n---\n\nNew body" }))) - .respond_with(ResponseTemplate::new(200).set_body_json(pull_request(7))) + .respond_with(ResponseTemplate::new(200).set_body_json(pr(7))) .expect(1) .mount(&server) .await; let ctx = context( &server, serde_json::json!({ - "target-repo": "octo/repo", "title": true, "body": true, "footer": false, @@ -870,6 +861,7 @@ mod tests { body: Some("New body".to_string()), operation: None, update_branch: None, + pull_request_id: None, pull_request_number: None, pr_number: None, pr: None, @@ -882,61 +874,18 @@ mod tests { } #[tokio::test] - async fn target_star_requires_agent_pull_request_number() { + async fn target_star_requires_agent_pr_id() { let server = MockServer::start().await; let ctx = context( &server, serde_json::json!({ - "target-repo": "octo/repo", "target": "*" }), ); let mut result: UpdatePullRequestResult = params().try_into().unwrap(); let execution = result.execute_sanitized(&ctx).await.unwrap(); assert!(!execution.success); - assert!( - execution - .message - .contains("pull_request_number is required") - ); + assert!(execution.message.contains("pull_request_id is required")); assert!(server.received_requests().await.unwrap().is_empty()); } - - #[tokio::test] - async fn update_branch_only_calls_update_branch_endpoint() { - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/repos/octo/repo/pulls/7")) - .respond_with(ResponseTemplate::new(200).set_body_json(pull_request(7))) - .expect(1) - .mount(&server) - .await; - Mock::given(method("PUT")) - .and(path("/repos/octo/repo/pulls/7/update-branch")) - .respond_with(ResponseTemplate::new(202).set_body_json(serde_json::json!({}))) - .expect(1) - .mount(&server) - .await; - let ctx = context( - &server, - serde_json::json!({ - "target-repo": "octo/repo", - "update-branch": true - }), - ); - let mut result: UpdatePullRequestResult = UpdatePullRequestParams { - title: None, - body: None, - operation: None, - update_branch: None, - pull_request_number: None, - pr_number: None, - pr: None, - repository: None, - } - .try_into() - .unwrap(); - let execution = result.execute_sanitized(&ctx).await.unwrap(); - assert!(execution.success, "{}", execution.message); - } } From dbfc6a13e5a4814009276aefc88ff312e43d7f69 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:09:43 +0000 Subject: [PATCH 09/43] fix ado close pull request tests Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- src/mcp.rs | 2 +- src/safe_outputs/close_pull_request.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mcp.rs b/src/mcp.rs index ab0a4d87d..c0f38cac2 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -946,7 +946,7 @@ issue_number may be a positive number or a temporary_id from create-github-issue #[tool( name = "close-pull-request", - description = "Close a configured GitHub pull request without merging, optionally with a comment." + description = "Abandon a configured Azure DevOps pull request without merging, optionally with a comment." )] async fn close_pull_request( &self, diff --git a/src/safe_outputs/close_pull_request.rs b/src/safe_outputs/close_pull_request.rs index 62e3a49ec..6019200d2 100644 --- a/src/safe_outputs/close_pull_request.rs +++ b/src/safe_outputs/close_pull_request.rs @@ -272,7 +272,7 @@ impl ClosePullRequestResult { config.allowed_repositories.join(", ") ))); } - resolve_repo_name(Some(selector), ctx).map_err(ExecutionResult::failure) + resolve_repo_name(Some(selector), ctx) } fn validate_filters( From fa84855870600aea0ddbc16606561863536d7d61 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:29:35 +0000 Subject: [PATCH 10/43] rename close pull request safe output to abandon Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- docs/safe-outputs.md | 10 +- src/compile/common.rs | 6 +- src/compile/types.rs | 6 +- src/execute.rs | 22 ++-- src/mcp.rs | 35 +++--- ...ull_request.rs => abandon_pull_request.rs} | 116 +++++++++--------- src/safe_outputs/mod.rs | 6 +- 7 files changed, 101 insertions(+), 100 deletions(-) rename src/safe_outputs/{close_pull_request.rs => abandon_pull_request.rs} (86%) diff --git a/docs/safe-outputs.md b/docs/safe-outputs.md index 26dae519b..4dcf70279 100644 --- a/docs/safe-outputs.md +++ b/docs/safe-outputs.md @@ -1297,22 +1297,22 @@ safe-outputs: max: 1 # Maximum per run (default: 1) ``` -### close-pull-request +### abandon-pull-request Abandons an Azure DevOps pull request without merging it. **Agent parameters:** -- `pull_request_id` - The PR ID to close (required when `target: "*"`) -- `body` *(optional)* - Closing comment posted before abandoning the PR +- `pull_request_id` - The PR ID to abandon (required when `target: "*"`) +- `body` *(optional)* - Comment posted before abandoning the PR - `repository` - Repository alias (default: configured `target-repo`, then `"self"`) **Configuration options (front matter):** ```yaml safe-outputs: - close-pull-request: + abandon-pull-request: target: "triggering" # "triggering" (default), "*", or PR ID required-labels: [automated, stale] required-title-prefix: "[bot]" - allowed-repositories: [] # Optional — restrict which repos can be closed + allowed-repositories: [] # Optional — restrict which repos can be abandoned target-repo: self # Optional default repository alias/name max: 1 # Maximum per run (default: 1) ``` diff --git a/src/compile/common.rs b/src/compile/common.rs index a640f4773..a86de2c63 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -2422,9 +2422,9 @@ pub fn validate_github_issue_outputs_config(front_matter: &FrontMatter) -> Resul crate::safe_outputs::validate_close_github_issue_config(&config)?; } } - "close-pull-request" => { - if let Some(config) = front_matter.close_pull_request_config()? { - crate::safe_outputs::validate_close_pull_request_config(&config)?; + "abandon-pull-request" => { + if let Some(config) = front_matter.abandon_pull_request_config()? { + crate::safe_outputs::validate_abandon_pull_request_config(&config)?; } } "update-github-issue" => { diff --git a/src/compile/types.rs b/src/compile/types.rs index e13d11f54..e3e92cfc9 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -2291,10 +2291,10 @@ impl FrontMatter { self.typed_safe_output_config("close-github-issue") } - pub fn close_pull_request_config( + pub fn abandon_pull_request_config( &self, - ) -> anyhow::Result> { - self.typed_safe_output_config("close-pull-request") + ) -> anyhow::Result> { + self.typed_safe_output_config("abandon-pull-request") } pub fn update_github_issue_config( diff --git a/src/execute.rs b/src/execute.rs index 4780e5f1f..81c3fca9a 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -16,17 +16,17 @@ use tokio::io::AsyncWriteExt; use crate::ndjson::{self, EXECUTED_NDJSON_FILENAME, SAFE_OUTPUT_FILENAME}; use crate::safe_outputs::{ - AddBuildTagResult, AddGithubIssueLabelsResult, AddPrCommentResult, + AbandonPullRequestResult, AddBuildTagResult, AddGithubIssueLabelsResult, AddPrCommentResult, AssignGithubIssueMilestoneResult, AssignGithubIssueToUserResult, AssignWorkItemResult, - CloseGithubIssueResult, ClosePullRequestResult, CommentOnGithubIssueResult, - CommentOnWorkItemResult, CreateBranchResult, CreateGitTagResult, CreateGithubIssueResult, - CreatePrResult, CreateWikiPageResult, CreateWorkItemResult, ExecutionContext, ExecutionResult, - Executor, HideGithubIssueCommentResult, LinkGithubSubIssueResult, LinkWorkItemsResult, - MissingDataResult, MissingToolResult, NoopResult, QueueBuildResult, - RemoveGithubIssueLabelsResult, ReplyToPrCommentResult, ReportIncompleteResult, - ResolvePrThreadResult, SetGithubIssueFieldResult, SetGithubIssueTypeResult, - SubmitPrReviewResult, ToolResult, UnassignGithubIssueFromUserResult, UpdateGithubIssueResult, - UpdatePrResult, UpdateWikiPageResult, UpdateWorkItemResult, UploadBuildAttachmentResult, + CloseGithubIssueResult, CommentOnGithubIssueResult, CommentOnWorkItemResult, + CreateBranchResult, CreateGitTagResult, CreateGithubIssueResult, CreatePrResult, + CreateWikiPageResult, CreateWorkItemResult, ExecutionContext, ExecutionResult, Executor, + HideGithubIssueCommentResult, LinkGithubSubIssueResult, LinkWorkItemsResult, MissingDataResult, + MissingToolResult, NoopResult, QueueBuildResult, RemoveGithubIssueLabelsResult, + ReplyToPrCommentResult, ReportIncompleteResult, ResolvePrThreadResult, + SetGithubIssueFieldResult, SetGithubIssueTypeResult, SubmitPrReviewResult, ToolResult, + UnassignGithubIssueFromUserResult, UpdateGithubIssueResult, UpdatePrResult, + UpdateWikiPageResult, UpdateWorkItemResult, UploadBuildAttachmentResult, UploadPipelineArtifactResult, UploadWorkitemAttachmentResult, }; use crate::sanitize::neutralize_pipeline_commands; @@ -739,6 +739,7 @@ async fn dispatch_pr_tools( "create-pull-request" => CreatePrResult, "add-pr-comment" => AddPrCommentResult, "update-pr" => UpdatePrResult, + "abandon-pull-request" => AbandonPullRequestResult, "submit-pr-review" => SubmitPrReviewResult, "reply-to-pr-comment" => ReplyToPrCommentResult, "resolve-pr-thread" => ResolvePrThreadResult, @@ -777,7 +778,6 @@ async fn dispatch_github_tools( "add-github-issue-labels" => AddGithubIssueLabelsResult, "remove-github-issue-labels" => RemoveGithubIssueLabelsResult, "close-github-issue" => CloseGithubIssueResult, - "close-pull-request" => ClosePullRequestResult, "update-github-issue" => UpdateGithubIssueResult, "set-github-issue-field" => SetGithubIssueFieldResult, "assign-github-issue-milestone" => AssignGithubIssueMilestoneResult, diff --git a/src/mcp.rs b/src/mcp.rs index c0f38cac2..66911a1ab 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -11,19 +11,20 @@ use std::sync::Arc; use crate::ndjson::{self, SAFE_OUTPUT_FILENAME}; use crate::safe_outputs::{ - AddBuildTagParams, AddBuildTagResult, AddGithubIssueLabelsParams, AddGithubIssueLabelsResult, - AddPrCommentParams, AddPrCommentResult, AssignGithubIssueMilestoneParams, - AssignGithubIssueMilestoneResult, AssignGithubIssueToUserParams, AssignGithubIssueToUserResult, - AssignWorkItemParams, AssignWorkItemResult, CloseGithubIssueParams, CloseGithubIssueResult, - ClosePullRequestParams, ClosePullRequestResult, CommentOnGithubIssueParams, - CommentOnGithubIssueResult, CommentOnWorkItemParams, CommentOnWorkItemResult, - CreateBranchParams, CreateBranchResult, CreateGitTagParams, CreateGitTagResult, - CreateGithubIssueParams, CreateGithubIssueResult, CreatePrParams, CreatePrResult, - CreateWikiPageParams, CreateWikiPageResult, CreateWorkItemParams, CreateWorkItemResult, - DEFAULT_MAX_FILE_SIZE, HideGithubIssueCommentParams, HideGithubIssueCommentResult, - LinkGithubSubIssueParams, LinkGithubSubIssueResult, LinkWorkItemsParams, LinkWorkItemsResult, - MissingDataParams, MissingDataResult, MissingToolParams, MissingToolResult, NoopParams, - NoopResult, PIPELINE_ARTIFACT_DEFAULT_MAX_FILE_SIZE, QueueBuildParams, QueueBuildResult, + AbandonPullRequestParams, AbandonPullRequestResult, AddBuildTagParams, AddBuildTagResult, + AddGithubIssueLabelsParams, AddGithubIssueLabelsResult, AddPrCommentParams, AddPrCommentResult, + AssignGithubIssueMilestoneParams, AssignGithubIssueMilestoneResult, + AssignGithubIssueToUserParams, AssignGithubIssueToUserResult, AssignWorkItemParams, + AssignWorkItemResult, CloseGithubIssueParams, CloseGithubIssueResult, + CommentOnGithubIssueParams, CommentOnGithubIssueResult, CommentOnWorkItemParams, + CommentOnWorkItemResult, CreateBranchParams, CreateBranchResult, CreateGitTagParams, + CreateGitTagResult, CreateGithubIssueParams, CreateGithubIssueResult, CreatePrParams, + CreatePrResult, CreateWikiPageParams, CreateWikiPageResult, CreateWorkItemParams, + CreateWorkItemResult, DEFAULT_MAX_FILE_SIZE, HideGithubIssueCommentParams, + HideGithubIssueCommentResult, LinkGithubSubIssueParams, LinkGithubSubIssueResult, + LinkWorkItemsParams, LinkWorkItemsResult, MissingDataParams, MissingDataResult, + MissingToolParams, MissingToolResult, NoopParams, NoopResult, + PIPELINE_ARTIFACT_DEFAULT_MAX_FILE_SIZE, QueueBuildParams, QueueBuildResult, RemoveGithubIssueLabelsParams, RemoveGithubIssueLabelsResult, ReplyToPrCommentParams, ReplyToPrCommentResult, ReportIncompleteParams, ReportIncompleteResult, ResolvePrThreadParams, ResolvePrThreadResult, SetGithubIssueFieldParams, SetGithubIssueFieldResult, @@ -945,14 +946,14 @@ issue_number may be a positive number or a temporary_id from create-github-issue } #[tool( - name = "close-pull-request", + name = "abandon-pull-request", description = "Abandon a configured Azure DevOps pull request without merging, optionally with a comment." )] - async fn close_pull_request( + async fn abandon_pull_request( &self, - params: Parameters, + params: Parameters, ) -> Result { - let result: ClosePullRequestResult = params.0.try_into()?; + let result: AbandonPullRequestResult = params.0.try_into()?; self.queue_sanitized_output(result).await } diff --git a/src/safe_outputs/close_pull_request.rs b/src/safe_outputs/abandon_pull_request.rs similarity index 86% rename from src/safe_outputs/close_pull_request.rs rename to src/safe_outputs/abandon_pull_request.rs index 6019200d2..828414862 100644 --- a/src/safe_outputs/close_pull_request.rs +++ b/src/safe_outputs/abandon_pull_request.rs @@ -1,4 +1,4 @@ -//! `close-pull-request` Azure DevOps safe output. +//! `abandon-pull-request` Azure DevOps safe output. use anyhow::ensure; use log::{debug, info, warn}; @@ -16,19 +16,19 @@ use percent_encoding::utf8_percent_encode; const MAX_COMMENT_LEN: usize = 65_536; #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ClosePullRequestTarget { +pub enum AbandonPullRequestTarget { Triggering, Any, Id(u64), } -impl Default for ClosePullRequestTarget { +impl Default for AbandonPullRequestTarget { fn default() -> Self { Self::Triggering } } -impl Serialize for ClosePullRequestTarget { +impl Serialize for AbandonPullRequestTarget { fn serialize(&self, serializer: S) -> Result where S: Serializer, @@ -41,7 +41,7 @@ impl Serialize for ClosePullRequestTarget { } } -impl<'de> Deserialize<'de> for ClosePullRequestTarget { +impl<'de> Deserialize<'de> for AbandonPullRequestTarget { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, @@ -49,7 +49,7 @@ impl<'de> Deserialize<'de> for ClosePullRequestTarget { struct Visitor; impl serde::de::Visitor<'_> for Visitor { - type Value = ClosePullRequestTarget; + type Value = AbandonPullRequestTarget; fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter.write_str(r#""triggering", "*", or a positive pull request ID"#) @@ -62,7 +62,7 @@ impl<'de> Deserialize<'de> for ClosePullRequestTarget { if value == 0 { return Err(E::custom("target pull request ID must be positive")); } - Ok(ClosePullRequestTarget::Id(value)) + Ok(AbandonPullRequestTarget::Id(value)) } fn visit_i64(self, value: i64) -> Result @@ -72,7 +72,7 @@ impl<'de> Deserialize<'de> for ClosePullRequestTarget { if value <= 0 { return Err(E::custom("target pull request ID must be positive")); } - Ok(ClosePullRequestTarget::Id(value as u64)) + Ok(AbandonPullRequestTarget::Id(value as u64)) } fn visit_str(self, value: &str) -> Result @@ -80,8 +80,8 @@ impl<'de> Deserialize<'de> for ClosePullRequestTarget { E: serde::de::Error, { match value { - "triggering" => Ok(ClosePullRequestTarget::Triggering), - "*" => Ok(ClosePullRequestTarget::Any), + "triggering" => Ok(AbandonPullRequestTarget::Triggering), + "*" => Ok(AbandonPullRequestTarget::Any), other => { let id = other.parse::().map_err(|_| { E::custom("target must be \"triggering\", \"*\", or a positive pull request ID") @@ -89,7 +89,7 @@ impl<'de> Deserialize<'de> for ClosePullRequestTarget { if id == 0 { return Err(E::custom("target pull request ID must be positive")); } - Ok(ClosePullRequestTarget::Id(id)) + Ok(AbandonPullRequestTarget::Id(id)) } } } @@ -100,11 +100,11 @@ impl<'de> Deserialize<'de> for ClosePullRequestTarget { } #[derive(Deserialize, JsonSchema)] -pub struct ClosePullRequestParams { +pub struct AbandonPullRequestParams { /// Positive Azure DevOps pull request ID. Required when config target is "*". #[serde(default, alias = "pull_request_number")] pub pull_request_id: Option, - /// Optional closing comment. + /// Optional abandonment comment. #[serde(default)] pub body: Option, /// Optional repository alias/name. @@ -112,7 +112,7 @@ pub struct ClosePullRequestParams { pub repository: Option, } -impl Validate for ClosePullRequestParams { +impl Validate for AbandonPullRequestParams { fn validate(&self) -> anyhow::Result<()> { if let Some(id) = self.pull_request_id { ensure!(id > 0, "pull_request_id must be positive"); @@ -135,12 +135,12 @@ impl Validate for ClosePullRequestParams { } tool_result! { - name = "close-pull-request", + name = "abandon-pull-request", write = true, - params = ClosePullRequestParams, + params = AbandonPullRequestParams, default_max = 1, /// Result of abandoning an Azure DevOps pull request. - pub struct ClosePullRequestResult { + pub struct AbandonPullRequestResult { #[serde(default, alias = "pull_request_number")] pull_request_id: Option, #[serde(default)] @@ -150,7 +150,7 @@ tool_result! { } } -impl SanitizeContent for ClosePullRequestResult { +impl SanitizeContent for AbandonPullRequestResult { fn sanitize_content_fields(&mut self) { self.body = self.body.as_deref().map(sanitize_text); self.repository = self.repository.as_deref().map(sanitize_config); @@ -159,10 +159,10 @@ impl SanitizeContent for ClosePullRequestResult { #[derive(Debug, Clone, SanitizeConfig, Serialize, Deserialize)] #[serde(deny_unknown_fields)] -pub struct ClosePullRequestConfig { +pub struct AbandonPullRequestConfig { #[serde(default)] #[sanitize_config(skip)] - pub target: ClosePullRequestTarget, + pub target: AbandonPullRequestTarget, /// Default repository alias/name when the agent does not pass `repository`. #[serde(default, rename = "target-repo", alias = "repository")] pub target_repo: Option, @@ -177,10 +177,10 @@ pub struct ClosePullRequestConfig { pub max: Option, } -impl Default for ClosePullRequestConfig { +impl Default for AbandonPullRequestConfig { fn default() -> Self { Self { - target: ClosePullRequestTarget::Triggering, + target: AbandonPullRequestTarget::Triggering, target_repo: None, allowed_repositories: Vec::new(), required_labels: Vec::new(), @@ -190,8 +190,8 @@ impl Default for ClosePullRequestConfig { } } -pub(crate) fn validate_close_pull_request_config( - config: &ClosePullRequestConfig, +pub(crate) fn validate_abandon_pull_request_config( + config: &AbandonPullRequestConfig, ) -> anyhow::Result<()> { for label in &config.required_labels { ensure!( @@ -224,30 +224,30 @@ fn pr_labels(pr: &serde_json::Value) -> Vec { .collect() } -impl ClosePullRequestResult { +impl AbandonPullRequestResult { fn resolve_target_id( &self, - config: &ClosePullRequestConfig, + config: &AbandonPullRequestConfig, ctx: &ExecutionContext, ) -> Result { match config.target { - ClosePullRequestTarget::Id(id) => Ok(id), - ClosePullRequestTarget::Any => self.pull_request_id.ok_or_else(|| { + AbandonPullRequestTarget::Id(id) => Ok(id), + AbandonPullRequestTarget::Any => self.pull_request_id.ok_or_else(|| { ExecutionResult::failure( - "pull_request_id is required when safe-outputs.close-pull-request.target is '*'", + "pull_request_id is required when safe-outputs.abandon-pull-request.target is '*'", ) }), - ClosePullRequestTarget::Triggering => { + AbandonPullRequestTarget::Triggering => { parse_positive(ctx.pull_request_id.as_deref()).ok_or_else(|| { ExecutionResult::failure( - "safe-outputs.close-pull-request.target is 'triggering' but no Azure DevOps pull request context is available; use target: '*' and pass pull_request_id, or configure a numeric target", + "safe-outputs.abandon-pull-request.target is 'triggering' but no Azure DevOps pull request context is available; use target: '*' and pass pull_request_id, or configure a numeric target", ) }) } } } - fn repository_selector<'a>(&'a self, config: &'a ClosePullRequestConfig) -> &'a str { + fn repository_selector<'a>(&'a self, config: &'a AbandonPullRequestConfig) -> &'a str { self.repository .as_deref() .or(config.target_repo.as_deref()) @@ -256,7 +256,7 @@ impl ClosePullRequestResult { fn resolve_repository( &self, - config: &ClosePullRequestConfig, + config: &AbandonPullRequestConfig, ctx: &ExecutionContext, ) -> Result { let selector = self.repository_selector(config); @@ -278,7 +278,7 @@ impl ClosePullRequestResult { fn validate_filters( &self, pr: &serde_json::Value, - config: &ClosePullRequestConfig, + config: &AbandonPullRequestConfig, ) -> Result<(), ExecutionResult> { if let Some(prefix) = config.required_title_prefix.as_deref() { let title = pr.get("title").and_then(|v| v.as_str()).unwrap_or_default(); @@ -384,7 +384,7 @@ async fn post_comment( let status = response.status(); let body = response.text().await.unwrap_or_default(); Ok(Err(ExecutionResult::failure(format!( - "Failed to add closing comment to PR #{} (HTTP {}): {}", + "Failed to add abandonment comment to PR #{} (HTTP {}): {}", pull_request_id, status, body )))) } @@ -420,7 +420,7 @@ async fn abandon_pr( } #[async_trait::async_trait] -impl Executor for ClosePullRequestResult { +impl Executor for AbandonPullRequestResult { fn dry_run_summary(&self) -> String { let target = self .pull_request_id @@ -430,9 +430,9 @@ impl Executor for ClosePullRequestResult { } async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { - if !ctx.tool_configs.contains_key("close-pull-request") { + if !ctx.tool_configs.contains_key("abandon-pull-request") { return Ok(ExecutionResult::failure( - "close-pull-request is not configured for this workflow", + "abandon-pull-request is not configured for this workflow", )); } let org_url = ctx @@ -448,8 +448,8 @@ impl Executor for ClosePullRequestResult { "No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)" ) })?; - let config: ClosePullRequestConfig = ctx.get_tool_config("close-pull-request")?; - if let Err(error) = validate_close_pull_request_config(&config) { + let config: AbandonPullRequestConfig = ctx.get_tool_config("abandon-pull-request")?; + if let Err(error) = validate_abandon_pull_request_config(&config) { return Ok(ExecutionResult::failure(error.to_string())); } @@ -473,7 +473,7 @@ impl Executor for ClosePullRequestResult { utf8_percent_encode(&repo_name, PATH_SEGMENT), pull_request_id, ); - debug!("close-pull-request API URL: {}", pr_url); + debug!("abandon-pull-request API URL: {}", pr_url); let pr = match fetch_pr(&client, &pr_url, token, ctx).await? { Ok(pr) => pr, @@ -494,7 +494,7 @@ impl Executor for ClosePullRequestResult { serde_json::json!({ "pull_request_id": pull_request_id, "repository": repo_name, - "already_closed": true, + "already_abandoned": true, "comment_posted": false, }), )); @@ -530,7 +530,7 @@ impl Executor for ClosePullRequestResult { serde_json::json!({ "pull_request_id": pull_request_id, "repository": repo_name, - "already_closed": false, + "already_abandoned": false, "comment_posted": comment_posted, }), )) @@ -547,7 +547,7 @@ mod tests { fn context(server: &MockServer, config: serde_json::Value) -> ExecutionContext { let mut tool_configs = HashMap::new(); - tool_configs.insert("close-pull-request".to_string(), config); + tool_configs.insert("abandon-pull-request".to_string(), config); ExecutionContext { ado_org_url: Some(server.uri()), ado_project: Some("proj".to_string()), @@ -570,23 +570,23 @@ mod tests { #[test] fn contract_name_and_budget() { - assert_eq!(ClosePullRequestResult::NAME, "close-pull-request"); - assert_eq!(ClosePullRequestResult::DEFAULT_MAX, 1); + assert_eq!(AbandonPullRequestResult::NAME, "abandon-pull-request"); + assert_eq!(AbandonPullRequestResult::DEFAULT_MAX, 1); } #[test] fn config_accepts_target_forms() { - let triggering: ClosePullRequestConfig = + let triggering: AbandonPullRequestConfig = serde_json::from_value(serde_json::json!({"target": "triggering"})).unwrap(); - assert_eq!(triggering.target, ClosePullRequestTarget::Triggering); - let any: ClosePullRequestConfig = + assert_eq!(triggering.target, AbandonPullRequestTarget::Triggering); + let any: AbandonPullRequestConfig = serde_json::from_value(serde_json::json!({"target": "*"})).unwrap(); - assert_eq!(any.target, ClosePullRequestTarget::Any); - let id: ClosePullRequestConfig = + assert_eq!(any.target, AbandonPullRequestTarget::Any); + let id: AbandonPullRequestConfig = serde_json::from_value(serde_json::json!({"target": 42})).unwrap(); - assert_eq!(id.target, ClosePullRequestTarget::Id(42)); + assert_eq!(id.target, AbandonPullRequestTarget::Id(42)); assert!( - serde_json::from_value::(serde_json::json!({ + serde_json::from_value::(serde_json::json!({ "target": 0 })) .is_err() @@ -596,7 +596,7 @@ mod tests { #[test] fn validates_optional_id_body_and_repository() { assert!( - ClosePullRequestParams { + AbandonPullRequestParams { pull_request_id: Some(42), body: Some("Closing as stale.".to_string()), repository: Some("self".to_string()), @@ -605,7 +605,7 @@ mod tests { .is_ok() ); assert!( - ClosePullRequestParams { + AbandonPullRequestParams { pull_request_id: Some(0), body: None, repository: None, @@ -658,7 +658,7 @@ mod tests { "required-title-prefix": "[bot]" }), ); - let mut result: ClosePullRequestResult = ClosePullRequestParams { + let mut result: AbandonPullRequestResult = AbandonPullRequestParams { pull_request_id: Some(7), body: Some("Closing as stale.".to_string()), repository: None, @@ -693,7 +693,7 @@ mod tests { .await; let mut ctx = context(&server, serde_json::json!({})); ctx.pull_request_id = Some("9".to_string()); - let mut result: ClosePullRequestResult = ClosePullRequestParams { + let mut result: AbandonPullRequestResult = AbandonPullRequestParams { pull_request_id: None, body: None, repository: None, @@ -724,7 +724,7 @@ mod tests { &server, serde_json::json!({"target": "*", "required-labels": ["missing"]}), ); - let mut result: ClosePullRequestResult = ClosePullRequestParams { + let mut result: AbandonPullRequestResult = AbandonPullRequestParams { pull_request_id: Some(7), body: None, repository: None, diff --git a/src/safe_outputs/mod.rs b/src/safe_outputs/mod.rs index c4dc1e20e..a530966b9 100644 --- a/src/safe_outputs/mod.rs +++ b/src/safe_outputs/mod.rs @@ -85,7 +85,7 @@ pub const ALL_KNOWN_SAFE_OUTPUTS: &[&str] = all_safe_output_names![ AddBuildTagResult, CreateBranchResult, UpdatePrResult, - ClosePullRequestResult, + AbandonPullRequestResult, UploadBuildAttachmentResult, UploadPipelineArtifactResult, UploadWorkitemAttachmentResult, @@ -771,7 +771,7 @@ mod assign_github_issue_milestone; mod assign_github_issue_to_user; mod assign_work_item; mod close_github_issue; -mod close_pull_request; +mod abandon_pull_request; mod comment_on_github_issue; mod comment_on_work_item; mod create_branch; @@ -813,7 +813,7 @@ pub use assign_github_issue_milestone::*; pub use assign_github_issue_to_user::*; pub use assign_work_item::*; pub use close_github_issue::*; -pub use close_pull_request::*; +pub use abandon_pull_request::*; pub use comment_on_github_issue::*; pub use comment_on_work_item::*; pub use create_branch::*; From 54a9faf78d89a2b714c24c47d21b5156818e9c7f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 22 Sep 2026 12:36:18 +0000 Subject: [PATCH 11/43] harden abandon pull request execution Co-authored-by: jamesadevine <4742697+jamesadevine@users.noreply.github.com> --- docs/safe-outputs.md | 6 +++--- src/safe_outputs/abandon_pull_request.rs | 26 ++++++++++++++++-------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/docs/safe-outputs.md b/docs/safe-outputs.md index 4dcf70279..ed42c9f11 100644 --- a/docs/safe-outputs.md +++ b/docs/safe-outputs.md @@ -1302,7 +1302,7 @@ Abandons an Azure DevOps pull request without merging it. **Agent parameters:** - `pull_request_id` - The PR ID to abandon (required when `target: "*"`) -- `body` *(optional)* - Comment posted before abandoning the PR +- `body` *(optional)* - Comment posted after abandoning the PR - `repository` - Repository alias (default: configured `target-repo`, then `"self"`) **Configuration options (front matter):** @@ -1320,8 +1320,8 @@ safe-outputs: When `target` is `"triggering"`, Stage 3 uses `SYSTEM_PULLREQUEST_PULLREQUESTID`. When `target` is a number, that configured ADO PR ID is used. The tool fetches the PR first, applies the optional -title/label filters, optionally posts `body` as a PR thread comment, then -patches the PR status to `abandoned`. +title/label filters, patches the PR status to `abandoned`, then optionally +posts `body` as a PR thread comment. ### link-work-items Links two Azure DevOps work items together. diff --git a/src/safe_outputs/abandon_pull_request.rs b/src/safe_outputs/abandon_pull_request.rs index 828414862..c76565e6e 100644 --- a/src/safe_outputs/abandon_pull_request.rs +++ b/src/safe_outputs/abandon_pull_request.rs @@ -483,10 +483,12 @@ impl Executor for AbandonPullRequestResult { return Ok(result); } - let status = pr - .get("status") - .and_then(|v| v.as_str()) - .unwrap_or_default(); + let Some(status) = pr.get("status").and_then(|v| v.as_str()) else { + return Ok(ExecutionResult::failure(format!( + "Cannot abandon PR #{} because the Azure DevOps response did not include a status", + pull_request_id + ))); + }; if status.eq_ignore_ascii_case("abandoned") { warn!("Azure DevOps PR #{} was already abandoned", pull_request_id); return Ok(ExecutionResult::success_with_data( @@ -499,13 +501,17 @@ impl Executor for AbandonPullRequestResult { }), )); } - if !status.is_empty() && !status.eq_ignore_ascii_case("active") { + if !status.eq_ignore_ascii_case("active") { return Ok(ExecutionResult::failure(format!( "Cannot abandon PR #{} because its status is '{}' (expected active)", pull_request_id, status ))); } + if let Err(result) = abandon_pr(&client, &pr_url, pull_request_id, token, ctx).await? { + return Ok(result); + } + let comment_posted = match post_comment( &client, &base_url, @@ -518,11 +524,13 @@ impl Executor for AbandonPullRequestResult { .await? { Ok(posted) => posted, - Err(result) => return Ok(result), + Err(result) => { + return Ok(ExecutionResult::warning(format!( + "Abandoned Azure DevOps PR #{} but failed to add comment: {}", + pull_request_id, result.message + ))); + } }; - if let Err(result) = abandon_pr(&client, &pr_url, pull_request_id, token, ctx).await? { - return Ok(result); - } info!("Abandoned Azure DevOps PR #{}", pull_request_id); Ok(ExecutionResult::success_with_data( From ba005bd2d9d751565a28f48e67cf3e6ba25c77e6 Mon Sep 17 00:00:00 2001 From: James Devine Date: Thu, 24 Sep 2026 10:16:56 +0100 Subject: [PATCH 12/43] feat(safe-outputs): add Azure DevOps PR mutations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 2 + docs/safe-outputs.md | 2 + src/compile/common.rs | 25 ++- src/execute.rs | 27 ++++ src/inspect/catalog.rs | 1 + src/mcp.rs | 4 +- src/safe_outputs/abandon_pull_request.rs | 188 +++++++++++++++++++++-- src/safe_outputs/mod.rs | 12 +- src/safe_outputs/update_pull_request.rs | 172 +++++++++++++++++++-- 9 files changed, 391 insertions(+), 42 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 9f231c073..5ef45b853 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -211,6 +211,7 @@ fail-closed and only pauses when the agent actually proposed a reviewed output. │ ├── hash.rs # SHA-256 utilities for safe-output file integrity │ ├── safe_outputs/ # Safe-output MCP tool implementations (Stage 1 → NDJSON → Stage 3) │ │ ├── mod.rs +│ │ ├── abandon_pull_request.rs │ │ ├── add_build_tag.rs │ │ ├── add_github_issue_labels.rs │ │ ├── add_pr_comment.rs @@ -246,6 +247,7 @@ fail-closed and only pauses when the agent actually proposed a reviewed output. │ │ ├── unassign_github_issue_from_user.rs │ │ ├── update_github_issue.rs │ │ ├── update_pr.rs +│ │ ├── update_pull_request.rs │ │ ├── update_wiki_page.rs │ │ ├── update_work_item.rs │ │ ├── upload_build_attachment.rs diff --git a/docs/safe-outputs.md b/docs/safe-outputs.md index 76ec57951..cc9ad3aa8 100644 --- a/docs/safe-outputs.md +++ b/docs/safe-outputs.md @@ -834,6 +834,8 @@ write. Azure DevOps pull requests. It updates the PR title or description (`body`); both fields are enabled by default. The `operation` field controls description updates: `replace` (default), `append`, `prepend`, or `replace-island`. +For `replace-island`, missing or duplicate markers cause a fresh pipeline-scoped +island to be appended; out-of-order markers fail the update. ```yaml safe-outputs: diff --git a/src/compile/common.rs b/src/compile/common.rs index 1f68247d6..23565a59a 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -2422,11 +2422,6 @@ pub fn validate_github_issue_outputs_config(front_matter: &FrontMatter) -> Resul crate::safe_outputs::validate_close_github_issue_config(&config)?; } } - "abandon-pull-request" => { - if let Some(config) = front_matter.abandon_pull_request_config()? { - crate::safe_outputs::validate_abandon_pull_request_config(&config)?; - } - } "update-github-issue" => { if let Some(config) = front_matter.update_github_issue_config()? { crate::safe_outputs::validate_update_github_issue_config(&config)?; @@ -2463,6 +2458,9 @@ pub fn validate_github_issue_outputs_config(front_matter: &FrontMatter) -> Resul if let Some(config) = front_matter.update_pull_request_config()? { crate::safe_outputs::validate_update_pull_request_config(&config)?; } + if let Some(config) = front_matter.abandon_pull_request_config()? { + crate::safe_outputs::validate_abandon_pull_request_config(&config)?; + } if let Some(config) = front_matter.create_github_issue_config()? { if let Some(prefix) = config.title_prefix.as_deref() { crate::validate::reject_pipeline_injection( @@ -5937,6 +5935,23 @@ safe-outputs: } } + #[test] + fn test_validate_rejects_invalid_abandon_pull_request_config() { + let yaml = r#"--- +name: test +description: test +safe-outputs: + abandon-pull-request: + required-labels: [""] +--- +"#; + let (fm, _) = parse_markdown(yaml).unwrap(); + let error = validate_github_issue_outputs_config(&fm) + .expect_err("invalid abandon-pull-request config must fail compilation") + .to_string(); + assert!(error.contains("required-labels"), "unexpected error: {error}"); + } + #[test] fn test_validate_safe_outputs_keys_accepts_known_keys() { let yaml = r#"--- diff --git a/src/execute.rs b/src/execute.rs index 9d030bd66..f22a14d65 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -246,6 +246,7 @@ pub async fn execute_safe_outputs( AddBuildTagResult, CreateBranchResult, UpdatePrResult, + AbandonPullRequestResult, UploadBuildAttachmentResult, UploadPipelineArtifactResult, UploadWorkitemAttachmentResult, @@ -2129,6 +2130,32 @@ mod tests { ); } + #[tokio::test] + async fn test_budget_enforcement_abandon_pull_request_max() { + let temp_dir = tempfile::tempdir().unwrap(); + let safe_output_path = temp_dir.path().join(SAFE_OUTPUT_FILENAME); + let ndjson = r#"{"name":"abandon-pull-request","pull_request_id":7} +{"name":"abandon-pull-request","pull_request_id":8} +"#; + tokio::fs::write(&safe_output_path, ndjson).await.unwrap(); + + let ctx = ExecutionContext { + dry_run: true, + tool_configs: HashMap::from([( + "abandon-pull-request".to_string(), + serde_json::json!({"target": "*", "max": 1}), + )]), + ..Default::default() + }; + let results = execute_safe_outputs(temp_dir.path(), &ctx, &ToolFilter::default()) + .await + .unwrap(); + + assert_eq!(results.len(), 2); + assert!(results[0].success); + assert!(results[1].is_budget_exhausted()); + } + #[tokio::test] async fn test_budget_enforcement_mixed_tools_independent_budgets() { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/src/inspect/catalog.rs b/src/inspect/catalog.rs index 2864fbc1b..0734c1ab7 100644 --- a/src/inspect/catalog.rs +++ b/src/inspect/catalog.rs @@ -291,6 +291,7 @@ fn safe_output_description(name: &str) -> &'static str { "hide-github-issue-comment" => { "Minimizes a configured GitHub issue, pull-request, or discussion comment" } + "abandon-pull-request" => "Abandons an Azure DevOps pull request without merging", "link-github-sub-issue" => "Links two GitHub issues as parent and sub-issue", "remove-github-issue-labels" => "Removes operator-permitted labels from a GitHub issue", "create-pull-request" => "Parameters for creating a pull request", diff --git a/src/mcp.rs b/src/mcp.rs index 43dff50a8..b5aa721ad 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -31,8 +31,8 @@ use crate::safe_outputs::{ SetGithubIssueTypeParams, SetGithubIssueTypeResult, SubmitPrReviewParams, SubmitPrReviewResult, ToolResult, UnassignGithubIssueFromUserParams, UnassignGithubIssueFromUserResult, UpdateGithubIssueParams, UpdateGithubIssueResult, UpdatePrParams, UpdatePrResult, - UpdatePrParams, UpdatePrResult, UpdatePullRequestParams, UpdatePullRequestResult, - UpdateWikiPageParams, UpdateWikiPageResult, UpdateWorkItemParams, UpdateWorkItemResult, + UpdatePullRequestParams, UpdatePullRequestResult, UpdateWikiPageParams, UpdateWikiPageResult, + UpdateWorkItemParams, UpdateWorkItemResult, UploadBuildAttachmentParams, UploadBuildAttachmentResult, UploadPipelineArtifactParams, UploadPipelineArtifactResult, UploadWorkitemAttachmentParams, UploadWorkitemAttachmentResult, Validate, anyhow_to_mcp_error, diff --git a/src/safe_outputs/abandon_pull_request.rs b/src/safe_outputs/abandon_pull_request.rs index c76565e6e..381afe7ba 100644 --- a/src/safe_outputs/abandon_pull_request.rs +++ b/src/safe_outputs/abandon_pull_request.rs @@ -6,7 +6,8 @@ use schemars::JsonSchema; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::safe_outputs::{ - ExecutionContext, ExecutionResult, Executor, PATH_SEGMENT, Validate, resolve_repo_name, + ExecutionContext, ExecutionResult, Executor, PATH_SEGMENT, Validate, + canonical_repository_alias, resolve_repo_name, }; use crate::sanitize::{SanitizeContent, sanitize as sanitize_text, sanitize_config}; use crate::tool_result; @@ -15,19 +16,14 @@ use percent_encoding::utf8_percent_encode; const MAX_COMMENT_LEN: usize = 65_536; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum AbandonPullRequestTarget { + #[default] Triggering, Any, Id(u64), } -impl Default for AbandonPullRequestTarget { - fn default() -> Self { - Self::Triggering - } -} - impl Serialize for AbandonPullRequestTarget { fn serialize(&self, serializer: S) -> Result where @@ -120,7 +116,7 @@ impl Validate for AbandonPullRequestParams { if let Some(body) = self.body.as_deref() { ensure!(!body.trim().is_empty(), "body must not be empty"); ensure!( - body.len() <= MAX_COMMENT_LEN, + body.chars().count() <= MAX_COMMENT_LEN, "body must be {MAX_COMMENT_LEN} characters or fewer" ); } @@ -231,18 +227,35 @@ impl AbandonPullRequestResult { ctx: &ExecutionContext, ) -> Result { match config.target { - AbandonPullRequestTarget::Id(id) => Ok(id), + AbandonPullRequestTarget::Id(id) => { + if let Some(requested) = self.pull_request_id + && requested != id + { + return Err(ExecutionResult::failure(format!( + "requested pull_request_id #{requested} does not match configured target #{id}" + ))); + } + Ok(id) + } AbandonPullRequestTarget::Any => self.pull_request_id.ok_or_else(|| { ExecutionResult::failure( "pull_request_id is required when safe-outputs.abandon-pull-request.target is '*'", ) }), AbandonPullRequestTarget::Triggering => { - parse_positive(ctx.pull_request_id.as_deref()).ok_or_else(|| { + let triggering = parse_positive(ctx.pull_request_id.as_deref()).ok_or_else(|| { ExecutionResult::failure( "safe-outputs.abandon-pull-request.target is 'triggering' but no Azure DevOps pull request context is available; use target: '*' and pass pull_request_id, or configure a numeric target", ) - }) + })?; + if let Some(requested) = self.pull_request_id + && requested != triggering + { + return Err(ExecutionResult::failure(format!( + "requested pull_request_id #{requested} does not match triggering pull request #{triggering}" + ))); + } + Ok(triggering) } } } @@ -260,19 +273,26 @@ impl AbandonPullRequestResult { ctx: &ExecutionContext, ) -> Result { let selector = self.repository_selector(config); + let Some(alias) = canonical_repository_alias(selector, ctx) else { + return Err(ExecutionResult::failure(format!( + "Repository '{}' is not in the configured checkout list", + crate::sanitize::neutralize_pipeline_commands(selector) + ))); + }; if !config.allowed_repositories.is_empty() && !config .allowed_repositories .iter() - .any(|allowed| allowed == selector) + .filter_map(|allowed| canonical_repository_alias(allowed, ctx)) + .any(|allowed| allowed == alias) { return Err(ExecutionResult::failure(format!( "Repository '{}' is not in the allowed-repositories list: [{}]", - selector, + crate::sanitize::neutralize_pipeline_commands(selector), config.allowed_repositories.join(", ") ))); } - resolve_repo_name(Some(selector), ctx) + resolve_repo_name(Some(&alias), ctx) } fn validate_filters( @@ -296,7 +316,11 @@ impl AbandonPullRequestResult { .required_labels .iter() .map(String::as_str) - .filter(|required| !labels.iter().any(|actual| actual == required)) + .filter(|required| { + !labels + .iter() + .any(|actual| actual.eq_ignore_ascii_case(required)) + }) .collect(); if !missing.is_empty() { return Err(ExecutionResult::failure(format!( @@ -430,6 +454,14 @@ impl Executor for AbandonPullRequestResult { } async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + let params = AbandonPullRequestParams { + pull_request_id: self.pull_request_id, + body: self.body.clone(), + repository: self.repository.clone(), + }; + if let Err(error) = params.validate() { + return Ok(ExecutionResult::failure(error.to_string())); + } if !ctx.tool_configs.contains_key("abandon-pull-request") { return Ok(ExecutionResult::failure( "abandon-pull-request is not configured for this workflow", @@ -743,4 +775,128 @@ mod tests { assert!(!execution.success); server.verify().await; } + + #[tokio::test] + async fn stage_three_revalidates_params() { + let server = MockServer::start().await; + let ctx = context(&server, serde_json::json!({"target": "*"})); + let mut result: AbandonPullRequestResult = serde_json::from_value(serde_json::json!({ + "name": "abandon-pull-request", + "pull_request_id": 7, + "body": " " + })) + .unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("body must not be empty")); + assert!(server.received_requests().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn rejects_disallowed_repository_before_network() { + let server = MockServer::start().await; + let ctx = context( + &server, + serde_json::json!({"target": "*", "allowed-repositories": ["other"]}), + ); + let mut result: AbandonPullRequestResult = AbandonPullRequestParams { + pull_request_id: Some(7), + body: None, + repository: None, + } + .try_into() + .unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("allowed-repositories")); + assert!(server.received_requests().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn rejects_title_prefix_before_patch() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/proj/_apis/git/repositories/repo/pullRequests/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(pr("active"))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PATCH")) + .and(path("/proj/_apis/git/repositories/repo/pullRequests/7")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + let ctx = context( + &server, + serde_json::json!({"target": "*", "required-title-prefix": "[manual]"}), + ); + let mut result: AbandonPullRequestResult = AbandonPullRequestParams { + pull_request_id: Some(7), + body: None, + repository: None, + } + .try_into() + .unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("required prefix")); + server.verify().await; + } + + #[tokio::test] + async fn handles_already_abandoned_and_rejects_completed() { + for (status, success, expected) in [ + ("abandoned", true, "already abandoned"), + ("completed", false, "expected active"), + ] { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/proj/_apis/git/repositories/repo/pullRequests/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(pr(status))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PATCH")) + .and(path("/proj/_apis/git/repositories/repo/pullRequests/7")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + let ctx = context(&server, serde_json::json!({"target": "*"})); + let mut result: AbandonPullRequestResult = AbandonPullRequestParams { + pull_request_id: Some(7), + body: None, + repository: None, + } + .try_into() + .unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert_eq!(execution.success, success); + assert!( + execution.message.contains(expected), + "{}", + execution.message + ); + server.verify().await; + } + } + + #[tokio::test] + async fn fixed_target_rejects_mismatched_request_id() { + let server = MockServer::start().await; + let ctx = context(&server, serde_json::json!({"target": 42})); + let mut result: AbandonPullRequestResult = AbandonPullRequestParams { + pull_request_id: Some(7), + body: None, + repository: None, + } + .try_into() + .unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("#7")); + assert!(execution.message.contains("#42")); + assert!(server.received_requests().await.unwrap().is_empty()); + } } diff --git a/src/safe_outputs/mod.rs b/src/safe_outputs/mod.rs index c999e73f7..53cf1a517 100644 --- a/src/safe_outputs/mod.rs +++ b/src/safe_outputs/mod.rs @@ -86,6 +86,7 @@ pub const ALL_KNOWN_SAFE_OUTPUTS: &[&str] = all_safe_output_names![ CreateBranchResult, UpdatePrResult, AbandonPullRequestResult, + UpdatePullRequestResult, UploadBuildAttachmentResult, UploadPipelineArtifactResult, UploadWorkitemAttachmentResult, @@ -93,7 +94,6 @@ pub const ALL_KNOWN_SAFE_OUTPUTS: &[&str] = all_safe_output_names![ ReplyToPrCommentResult, ResolvePrThreadResult, CreateGithubIssueResult, - UpdatePullRequestResult, SetGithubIssueTypeResult, CommentOnGithubIssueResult, HideGithubIssueCommentResult, @@ -765,6 +765,7 @@ macro_rules! impl_temporary_reference_deserialize { }; } +mod abandon_pull_request; mod add_build_tag; mod add_github_issue_labels; mod add_pr_comment; @@ -772,7 +773,6 @@ mod assign_github_issue_milestone; mod assign_github_issue_to_user; mod assign_work_item; mod close_github_issue; -mod abandon_pull_request; mod comment_on_github_issue; mod comment_on_work_item; mod create_branch; @@ -808,6 +808,7 @@ mod upload_build_attachment; mod upload_pipeline_artifact; mod upload_workitem_attachment; +pub use abandon_pull_request::*; pub use add_build_tag::*; pub use add_github_issue_labels::*; pub use add_pr_comment::*; @@ -815,7 +816,6 @@ pub use assign_github_issue_milestone::*; pub use assign_github_issue_to_user::*; pub use assign_work_item::*; pub use close_github_issue::*; -pub use abandon_pull_request::*; pub use comment_on_github_issue::*; pub use comment_on_work_item::*; pub use create_branch::*; @@ -906,6 +906,9 @@ mod tests { const { assert!(CreatePrResult::REQUIRES_WRITE); } + const { + assert!(AbandonPullRequestResult::REQUIRES_WRITE); + } const { assert!(CreateWikiPageResult::REQUIRES_WRITE); } @@ -933,6 +936,9 @@ mod tests { const { assert!(UpdatePrResult::REQUIRES_WRITE); } + const { + assert!(UpdatePullRequestResult::REQUIRES_WRITE); + } const { assert!(UploadBuildAttachmentResult::REQUIRES_WRITE); } diff --git a/src/safe_outputs/update_pull_request.rs b/src/safe_outputs/update_pull_request.rs index e2ca5d195..d50fa7623 100644 --- a/src/safe_outputs/update_pull_request.rs +++ b/src/safe_outputs/update_pull_request.rs @@ -9,7 +9,9 @@ use serde_json::{Map, Value}; use ado_aw_derive::SanitizeConfig; -use super::{PATH_SEGMENT, resolve_repo_name}; +use super::{ + PATH_SEGMENT, authenticate_ado_request, canonical_repository_alias, resolve_repo_name, +}; use crate::safe_outputs::{ExecutionContext, ExecutionResult, Executor, Validate}; use crate::sanitize::{SanitizeContent, sanitize as sanitize_text, sanitize_config}; use crate::tool_result; @@ -496,12 +498,11 @@ impl UpdatePullRequestResult { repo_name: &str, token: &str, pr_id: i32, + ctx: &ExecutionContext, ) -> anyhow::Result> { let encoded_repo = utf8_percent_encode(repo_name, PATH_SEGMENT).to_string(); let url = format!("{base_url}/{encoded_repo}/pullRequests/{pr_id}?api-version=7.1"); - let response = client - .get(&url) - .basic_auth("", Some(token)) + let response = authenticate_ado_request(client.get(&url), token, ctx.write_connection_type) .send() .await .context("Failed to fetch Azure DevOps pull request")?; @@ -622,20 +623,27 @@ impl Executor for UpdatePullRequestResult { Ok(pr_id) => pr_id, Err(result) => return Ok(result), }; - let repo_alias = self.repository.as_deref().unwrap_or("self"); + let repo_selector = self.repository.as_deref().unwrap_or("self"); + let Some(repo_alias) = canonical_repository_alias(repo_selector, ctx) else { + return Ok(ExecutionResult::failure(format!( + "Repository '{}' is not in the configured checkout list", + crate::sanitize::neutralize_pipeline_commands(repo_selector) + ))); + }; if !config.allowed_repositories.is_empty() && !config .allowed_repositories .iter() + .filter_map(|allowed| canonical_repository_alias(allowed, ctx)) .any(|allowed| allowed == repo_alias) { return Ok(ExecutionResult::failure(format!( "Repository '{}' is not in the allowed-repositories list: [{}]", - crate::sanitize::neutralize_pipeline_commands(repo_alias), + crate::sanitize::neutralize_pipeline_commands(repo_selector), config.allowed_repositories.join(", ") ))); } - let repo_name = match resolve_repo_name(self.repository.as_deref(), ctx) { + let repo_name = match resolve_repo_name(Some(&repo_alias), ctx) { Ok(name) => name, Err(failure) => return Ok(failure), }; @@ -647,7 +655,7 @@ impl Executor for UpdatePullRequestResult { encoded_project, ); let current = match self - .fetch_pr(&client, &base_url, &repo_name, token, pr_id) + .fetch_pr(&client, &base_url, &repo_name, token, pr_id, ctx) .await? { Ok(pr) => pr, @@ -679,14 +687,17 @@ impl Executor for UpdatePullRequestResult { "Updating Azure DevOps PR #{pr_id}: {}", self.requested_fields().join(", ") ); - let response = client - .patch(&patch_url) - .header("Content-Type", "application/json") - .basic_auth("", Some(token)) - .json(&Value::Object(patch)) - .send() - .await - .context("Failed to update Azure DevOps pull request")?; + let response = authenticate_ado_request( + client + .patch(&patch_url) + .header("Content-Type", "application/json") + .json(&Value::Object(patch)), + token, + ctx.write_connection_type, + ) + .send() + .await + .context("Failed to update Azure DevOps pull request")?; if !response.status().is_success() { let status = response.status(); let body = response @@ -888,4 +899,133 @@ mod tests { assert!(execution.message.contains("pull_request_id is required")); assert!(server.received_requests().await.unwrap().is_empty()); } + + #[tokio::test] + async fn rejects_disallowed_repository_before_network() { + let server = MockServer::start().await; + let ctx = context( + &server, + serde_json::json!({"allowed-repositories": ["other"]}), + ); + let mut result: UpdatePullRequestResult = params().try_into().unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("allowed-repositories")); + assert!(server.received_requests().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn repository_allowlist_uses_canonical_aliases() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/project/_apis/git/repositories/repo/pullRequests/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(pr(7))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PATCH")) + .and(path("/project/_apis/git/repositories/repo/pullRequests/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(pr(7))) + .expect(1) + .mount(&server) + .await; + let ctx = context( + &server, + serde_json::json!({"allowed-repositories": ["self"]}), + ); + let mut request = params(); + request.repository = Some("REPO".to_string()); + let mut result: UpdatePullRequestResult = request.try_into().unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(execution.success, "{}", execution.message); + server.verify().await; + } + + #[tokio::test] + async fn rejects_filters_before_patch() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/project/_apis/git/repositories/repo/pullRequests/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(pr(7))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PATCH")) + .and(path("/project/_apis/git/repositories/repo/pullRequests/7")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + let ctx = context(&server, serde_json::json!({"required-labels": ["missing"]})); + let mut result: UpdatePullRequestResult = params().try_into().unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("missing required labels")); + server.verify().await; + } + + #[tokio::test] + async fn rejects_request_level_update_branch() { + let server = MockServer::start().await; + let ctx = context(&server, serde_json::json!({})); + let mut request = params(); + request.update_branch = Some(true); + let mut result: UpdatePullRequestResult = request.try_into().unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("not supported")); + assert!(server.received_requests().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn reports_fetch_and_patch_failures() { + for (get_status, patch_status, expected) in [ + (404, 200, "Failed to fetch PR #7"), + (200, 500, "Failed to update PR #7"), + ] { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/project/_apis/git/repositories/repo/pullRequests/7")) + .respond_with(ResponseTemplate::new(get_status).set_body_json( + if get_status == 200 { + pr(7) + } else { + serde_json::json!({}) + }, + )) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PATCH")) + .and(path("/project/_apis/git/repositories/repo/pullRequests/7")) + .respond_with(ResponseTemplate::new(patch_status)) + .expect(if get_status == 200 { 1 } else { 0 }) + .mount(&server) + .await; + let ctx = context(&server, serde_json::json!({})); + let mut result: UpdatePullRequestResult = params().try_into().unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + assert!( + execution.message.contains(expected), + "{}", + execution.message + ); + server.verify().await; + } + } + + #[tokio::test] + async fn fixed_target_rejects_mismatched_request_id() { + let server = MockServer::start().await; + let ctx = context(&server, serde_json::json!({"target": 42})); + let mut request = params(); + request.pull_request_id = Some(AdoPullRequestId::Number(7)); + let mut result: UpdatePullRequestResult = request.try_into().unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("#7")); + assert!(execution.message.contains("#42")); + assert!(server.received_requests().await.unwrap().is_empty()); + } } From d6dba9c813de46aebfde28c43f60d2d4cd05b874 Mon Sep 17 00:00:00 2001 From: James Devine Date: Thu, 24 Sep 2026 13:31:17 +0100 Subject: [PATCH 13/43] feat(safe-outputs): split Azure DevOps PR tools with safe migration Add focused PR tools and automatically migrate update-pr while preserving repository policy, temporary references, review semantics, approval lanes, and shared budgets. Diagnose stale prompt references and complete content, preview, and regression coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: da8711de-7251-47cd-a006-6e4ece913b49 --- AGENTS.md | 7 +- README.md | 6 +- docs/ado-script.md | 7 + docs/codemods.md | 13 + docs/safe-output-permissions.md | 2 +- docs/safe-outputs.md | 140 +- .../approval-summary/__tests__/render.test.ts | 30 + .../ado-script/src/approval-summary/index.ts | 27 + .../ado-script/src/approval-summary/render.ts | 69 + .../__tests__/index.test.ts | 8 +- .../create-pull-request-scenarios.test.ts | 12 +- .../ado-script/src/executor-e2e/ado-rest.ts | 7 +- .../scenarios/create-pull-request.ts | 16 +- .../src/executor-e2e/scenarios/pr.ts | 197 +- src/compile/agentic_pipeline.rs | 25 + src/compile/codemods/0009_split_update_pr.rs | 27 + src/compile/codemods/mod.rs | 3 + src/compile/common.rs | 92 +- src/compile/custom_tools.rs | 1 + src/compile/mod.rs | 12 + src/compile/pr_migration.rs | 451 ++++ src/compile/types.rs | 4 +- src/execute.rs | 146 +- src/inspect/catalog.rs | 6 +- src/inspect/cli.rs | 17 + src/main.rs | 14 +- src/mcp.rs | 78 +- src/safe_outputs/abandon_pull_request.rs | 496 +++- src/safe_outputs/add_pr_labels.rs | 226 ++ src/safe_outputs/add_pr_reviewers.rs | 286 +++ src/safe_outputs/create_pull_request.rs | 1 + src/safe_outputs/mod.rs | 18 +- src/safe_outputs/pr_common.rs | 353 +++ src/safe_outputs/pr_mutations.rs | 1952 +++++++++++++++ src/safe_outputs/result.rs | 3 + src/safe_outputs/set_pr_auto_complete.rs | 248 ++ src/safe_outputs/submit_pr_review.rs | 524 ++-- src/safe_outputs/update_pr.rs | 2156 +---------------- src/safe_outputs/update_pull_request.rs | 468 +++- src/safe_outputs/upload_build_attachment.rs | 1 + tests/codemod_tests.rs | 46 + tests/compiler_tests.rs | 21 +- tests/executor-e2e/README.md | 9 + tests/safe-outputs/pr-tools-preview.md | 42 + tests/smoke/cases.json | 7 + 45 files changed, 5663 insertions(+), 2611 deletions(-) create mode 100644 src/compile/codemods/0009_split_update_pr.rs create mode 100644 src/compile/pr_migration.rs create mode 100644 src/safe_outputs/add_pr_labels.rs create mode 100644 src/safe_outputs/add_pr_reviewers.rs create mode 100644 src/safe_outputs/pr_common.rs create mode 100644 src/safe_outputs/pr_mutations.rs create mode 100644 src/safe_outputs/set_pr_auto_complete.rs create mode 100644 tests/safe-outputs/pr-tools-preview.md diff --git a/AGENTS.md b/AGENTS.md index b296ef704..eba6e21ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -216,6 +216,8 @@ fail-closed and only pauses when the agent actually proposed a reviewed output. │ │ ├── add_build_tag.rs │ │ ├── add_github_issue_labels.rs │ │ ├── add_pr_comment.rs +│ │ ├── add_pr_labels.rs +│ │ ├── add_pr_reviewers.rs │ │ ├── assign_github_issue_milestone.rs │ │ ├── assign_github_issue_to_user.rs │ │ ├── assign_work_item.rs @@ -236,6 +238,8 @@ fail-closed and only pauses when the agent actually proposed a reviewed output. │ │ ├── missing_data.rs │ │ ├── missing_tool.rs │ │ ├── noop.rs +│ │ ├── pr_common.rs # Shared PR references and target/policy resolution +│ │ ├── pr_mutations.rs # Shared PR mutations and historical update-pr executor │ │ ├── queue_build.rs │ │ ├── remove_github_issue_labels.rs │ │ ├── reply_to_pr_comment.rs @@ -244,10 +248,11 @@ fail-closed and only pauses when the agent actually proposed a reviewed output. │ │ ├── result.rs │ │ ├── set_github_issue_field.rs │ │ ├── set_github_issue_type.rs +│ │ ├── set_pr_auto_complete.rs │ │ ├── submit_pr_review.rs │ │ ├── unassign_github_issue_from_user.rs │ │ ├── update_github_issue.rs -│ │ ├── update_pr.rs +│ │ ├── update_pr.rs # Historical Stage 3 compatibility exports (not an MCP tool) │ │ ├── update_pull_request.rs │ │ ├── update_wiki_page.rs │ │ ├── update_work_item.rs diff --git a/README.md b/README.md index 51e8b8904..0937dc496 100644 --- a/README.md +++ b/README.md @@ -604,7 +604,11 @@ actions, and the executor processes them after threat analysis. | `reply-to-pr-comment` | Replies to an existing PR review comment thread | | `resolve-pr-thread` | Resolves or updates the status of a PR review thread | | `submit-pr-review` | Submits a review vote on a pull request | -| `update-pr` | Updates pull request metadata (reviewers, labels, auto-complete, vote, update-description) | +| `update-pull-request` | Updates PR title or description, including managed sections | +| `add-pr-reviewers` | Adds policy-permitted PR reviewers | +| `add-pr-labels` | Adds PR labels without replacing existing labels | +| `set-pr-auto-complete` | Enables PR auto-complete with configured completion options | +| `abandon-pull-request` | Abandons a PR without merging, optionally with a comment | | `link-work-items` | Links two ADO work items together | | `queue-build` | Queues a pipeline build by definition ID | | `create-git-tag` | Creates a git tag on a repository ref | diff --git a/docs/ado-script.md b/docs/ado-script.md index fa1c726e5..950846e1c 100644 --- a/docs/ado-script.md +++ b/docs/ado-script.md @@ -383,6 +383,13 @@ GitHub-typed repos return before any SDK load. ## Bundle env contract +The approval-summary bundle also receives `AW_PR_POLICIES`, a non-secret JSON +map of configured PR target/default-operation selectors. It uses the existing +`SYSTEM_PULLREQUEST_PULLREQUESTID` environment variable for triggering targets. +Missing target context is displayed as unresolved; same-run references identify +an earlier create proposal without inventing a real PR ID. This preview is not +an authorization decision. + Every compiler-emitted step that runs an ado-script bundle has an implicit environment contract — which `process.env` keys the bundle reads. That contract is modelled in [`src/compile/ado_bundle.rs`](../src/compile/ado_bundle.rs): diff --git a/docs/codemods.md b/docs/codemods.md index 5cc082ae0..2257a57e0 100644 --- a/docs/codemods.md +++ b/docs/codemods.md @@ -111,6 +111,19 @@ continues. ## Adding a codemod +### PR tool decomposition + +The `split_update_pr` codemod replaces the old operation-based `update-pr` +declaration with focused PR tools. It preserves the original aggregate `max` +in a persisted `budget-groups` declaration and retains operator-owned legacy +policy metadata to avoid broadening title, body, vote, or reviewer capabilities. +Existing conflicting focused declarations stop migration without rewriting. + +Prompt diagnostics run outside the mapping-only codemod. Explicit `update-pr` +tool references are highlighted with source locations and replacement guidance; +the markdown body is never automatically rewritten. The warning persists on +subsequent compile/lint passes until the author updates the instructions. + You need a codemod whenever you introduce a breaking change to the front-matter grammar: diff --git a/docs/safe-output-permissions.md b/docs/safe-output-permissions.md index 95e1636f8..ba5309b20 100644 --- a/docs/safe-output-permissions.md +++ b/docs/safe-output-permissions.md @@ -132,7 +132,7 @@ from group membership. | Safe-output tool | Permission required (bit) | |---|---| -| `add-pr-comment`, `submit-pr-review`, `reply-to-pr-comment`, `resolve-pr-thread`, `update-pr` | `PullRequestContribute` (16384) | +| `add-pr-comment`, `submit-pr-review`, `reply-to-pr-comment`, `resolve-pr-thread`, `update-pull-request`, `add-pr-reviewers`, `add-pr-labels`, `set-pr-auto-complete`, `abandon-pull-request` | `PullRequestContribute` (16384) | | `create-pull-request` | `PullRequestContribute` (16384) + `CreateBranch` (16) + `GenericContribute` (4) on the target repo | | `create-branch` | `CreateBranch` (16) + `GenericContribute` (4) | | `create-git-tag` | `CreateTag` (32) + `GenericContribute` (4) | diff --git a/docs/safe-outputs.md b/docs/safe-outputs.md index f96ab3fc2..4a79067bf 100644 --- a/docs/safe-outputs.md +++ b/docs/safe-outputs.md @@ -30,9 +30,7 @@ safe-outputs: - agent-created work-items: - 12345 - update-pr: - allowed-operations: - - add-reviewers + add-pr-reviewers: allowed-reviewers: - "user@example.com" max-reviewers: 3 @@ -844,12 +842,15 @@ write. #### Pull request updates (`update-pull-request`) -`update-pull-request` matches gh-aw's front matter configuration for updating -Azure DevOps pull requests. It updates the PR title or description (`body`); +`update-pull-request` edits Azure DevOps PR content, not reviewers, labels, +votes, or completion settings. It updates the title or description (`body`); both fields are enabled by default. The `operation` field controls description updates: `replace` (default), `append`, `prepend`, or `replace-island`. -For `replace-island`, missing or duplicate markers cause a fresh pipeline-scoped -island to be appended; out-of-order markers fail the update. +For `replace-island`, a section is appended only when both pipeline-scoped +markers are absent. One valid pair is replaced; incomplete, duplicate or +reversed markers fail without writing. Text outside the section is preserved. +The final description, including existing text, markers, and optional stats, +must fit 4,000 UTF-16 code units; oversized updates fail rather than truncate. ```yaml safe-outputs: @@ -857,8 +858,8 @@ safe-outputs: title: true # enable title updates (default: true) body: true # enable description updates (default: true) update-branch: false # must be false; ADO has no equivalent branch-update API - sync-stack: true # accepted for gh-aw front matter parity - footer: false # omit agent stats from body updates (default: true) + sync-stack: true # accepted legacy spelling; no ADO stack synchronization + include-stats: false # omit stats (default: true); footer is a legacy alias operation: replace # replace, append, prepend, or replace-island max: 1 # maximum updates per run (default: 1) target: "*" # "triggering" (default), "*", or an ADO PR ID @@ -881,6 +882,13 @@ safe-outputs: - `repository` *(optional)* - Target repository alias, constrained by `allowed-repositories`. +Numeric IDs, quoted numeric IDs and same-run temporary PR references are +accepted. Repository destinations resolve their configured organization and +project; cross-organization writes require the normal explicit write policy. +All supplied ID aliases must identify the same PR. `footer` and `include-stats` +must not both be specified. This is an ADO-native API, not a claim of full +gh-aw schema or behavior compatibility. + #### Fields, milestones, and assignees `set-github-issue-field` rejects built-in fields and limits repository-defined @@ -1161,8 +1169,8 @@ This hybrid approach combines: Note: The source branch name is auto-generated from a sanitized version of the PR title plus a unique suffix (e.g., `agent/fix-bug-in-parser-a1b2c3`). This format is human-readable while preventing injection attacks. The tool response includes a generated temporary PR ID such as `#aw_a1b2c3`. -The agent can pass that value as `pull_request_id` to later `update-pr` calls in -the same SafeOutputs job. The ID is generated by the MCP server and is not an +The agent can pass that value as `pull_request_id` to configured focused PR +follow-up tools in the same SafeOutputs job. The ID is generated by the MCP server and is not an input to `create-pull-request`. **Configuration options (front matter):** @@ -1333,33 +1341,44 @@ safe-outputs: submit-pr-review: allowed-events: [] # REQUIRED — empty list rejects all events allowed-repositories: [] # Optional — restrict which repos can be reviewed + allow-temporary-ids: false # Opt in to same-run create/follow-up references max: 1 # Maximum per run (default: 1) ``` -### update-pr -Updates pull request metadata (reviewers, labels, auto-complete, vote, description). +### Focused PR tools -**Agent parameters:** -- `pull_request_id` - A positive numeric PR ID, a quoted positive numeric ID, or a temporary ID (`#aw_...`) returned by an earlier `create-pull-request` call in the same SafeOutputs job (required) -- `operation` - Update operation: `add-reviewers`, `add-labels`, `set-auto-complete`, `vote`, or `update-description` (required) -- `reviewers` - Reviewer emails (required for `add-reviewers`) -- `labels` - Label names (required for `add-labels`) -- `vote` - Vote value: `approve`, `approve-with-suggestions`, `wait-for-author`, `reject`, or `reset` (required for `vote`) -- `description` - New PR description in markdown (required for `update-description`, at least 10 characters) -- `repository` - Repository alias (default: "self") +Each PR intent has one agent-facing tool: + +| Intent | Tool | +|---|---| +| Title/description | `update-pull-request` | +| Add reviewers | `add-pr-reviewers` | +| Add labels | `add-pr-labels` | +| Review/vote | `submit-pr-review` | +| Enable auto-complete | `set-pr-auto-complete` | +| Abandon | `abandon-pull-request` | + +Reviewer, label, auto-complete and review tools require `pull_request_id` and +accept an optional `repository`. Reviewer and label tools additionally require +`reviewers` and `labels`, respectively. These are additive operations. +Auto-complete uses the authenticated actor and does not bypass branch policy +or perform an immediate merge. -**Configuration options (front matter):** ```yaml safe-outputs: - update-pr: - allowed-operations: [] # Optional — restrict which operations are permitted (empty = all) - allowed-repositories: [] # Optional — restrict which repos can be updated - allowed-reviewers: [] # Optional — non-empty list restricts reviewers; empty or ["*"] permits any valid reviewer - max-reviewers: 3 # Maximum reviewers in one add-reviewers call (default: 3) - allowed-votes: [] # REQUIRED for vote operation — empty rejects all votes - delete-source-branch: true # For set-auto-complete (default: true) - merge-strategy: "squash" # For set-auto-complete: squash, noFastForward, rebase, rebaseMerge - max: 1 # Maximum per run (default: 1) + add-pr-reviewers: + allowed-repositories: [self] + allowed-reviewers: ["owner@example.com"] + max-reviewers: 3 + max: 1 + add-pr-labels: + allowed-repositories: [self] + max: 1 + set-pr-auto-complete: + allowed-repositories: [self] + delete-source-branch: true + merge-strategy: squash + max: 1 ``` When `allowed-reviewers` is omitted or empty, any otherwise-valid reviewer is @@ -1372,29 +1391,69 @@ with structured `added` and `failed` arrays. Invalid configuration, disallowed reviewers, and unresolved PR references fail before reviewer writes begin. Temporary PR references are resolved in safe-output proposal order, so -`create-pull-request` must appear before its `update-pr` entries. They are +`create-pull-request` must appear before its temporary-reference consumers. They are in-memory references scoped to one SafeOutputs job: automatic and manually reviewed safe outputs execute in separate jobs and cannot share a temporary ID. -When both tools are configured, the compiler therefore requires them to have +When producer and temporary-capable consumers are configured, the compiler requires them to have the same effective `require-approval` setting. The two tools must also have the same effective `staged` setting. A staged `create-pull-request` previews creation instead of producing the live PR that -`update-pr` would modify, while staging only `update-pr` would preview updates +the consumer would modify, while staging only the consumer would preview updates after live creation. The compiler rejects both split-process configurations. Section-level `safe-outputs.staged` defaults and per-tool `staged` overrides are resolved before this comparison. -Each follow-up call counts against `update-pr.max`. +Each follow-up counts against its tool budget and any shared budget group. +Existing `submit-pr-review` configurations remain numeric-only unless +`allow-temporary-ids: true` is configured. Automatic migration enables this for +legacy votes that already supported temporary references. Example agent call sequence: ```json {"title":"Update dependencies","description":"Refresh dependencies and related tests."} -{"pull_request_id":"#aw_a1b2c3","operation":"add-reviewers","reviewers":["user@example.com"]} +{"pull_request_id":"#aw_a1b2c3","reviewers":["user@example.com"]} ``` The first line represents the `create-pull-request` call; use the actual -temporary ID returned by that call in the later `update-pr` call. +temporary ID returned by that call in the later `add-pr-reviewers` call. + +### Migrating update-pr + +`compile` automatically migrates `safe-outputs.update-pr` to focused tools. +The catch-all is no longer advertised over MCP. Historical Stage 3 proposals +remain supported with trusted legacy configuration. + +| Old operation | Replacement | +|---|---| +| `update-description` | `update-pull-request` (`body`) | +| `add-reviewers` | `add-pr-reviewers` | +| `add-labels` | `add-pr-labels` | +| `vote` | `submit-pr-review` (`event`) | +| `set-auto-complete` | `set-pr-auto-complete` | + +Migration preserves enabled operations, reviewer/vote/repository policy, +temporary references, approval/staged settings and completion options. +Description-only migration does not enable title edits, append/prepend, +or stats. The persisted `legacy-update-pr` policy is operator-owned +compatibility metadata, not a parameter the agent can supply. + +The codemod writes `safe-outputs.budget-groups.update-pr` with the old `max` +and focused `tools` list. This is one shared limit, not a fresh allowance for +every extracted tool. Failed attempts consume it; group members must share +approval and staged settings. The creator's budget remains independent. +Do not remove migration metadata without reviewing the authority change. + +Conflicting old/new tool declarations require manual migration; no config is +silently merged or overwritten. Prompt bodies are preserved byte-for-byte. +Explicit `update-pr`/`update_pr` references produce located warnings with +replacement guidance, including on later compile/lint passes until corrected. +Review these warnings: front-matter migration cannot rewrite agent intent. + +Review votes retain their exact ADO meanings: approve=10, +approve-with-suggestions=5, wait-for-author/request-changes=-5, reject=-10, +reset/comment=0. Existing request-changes requires a rationale; migrated +wait-for-author does not. A discussion-only comment uses `add-pr-comment`. ### abandon-pull-request Abandons an Azure DevOps pull request without merging it. @@ -1413,6 +1472,7 @@ safe-outputs: required-title-prefix: "[bot]" allowed-repositories: [] # Optional — restrict which repos can be abandoned target-repo: self # Optional default repository alias/name + include-stats: true # Include stats when available (default: true) max: 1 # Maximum per run (default: 1) ``` @@ -1422,6 +1482,12 @@ ADO PR ID is used. The tool fetches the PR first, applies the optional title/label filters, patches the PR status to `abandoned`, then optionally posts `body` as a PR thread comment. +All required labels must match (case-insensitively). Completed PRs are rejected; +already-abandoned PRs are no-ops and do not post another comment. If abandonment +succeeds but comment posting fails, execution is a warning with structured +mutation/comment data. A transport error can leave comment delivery uncertain; +the tool does not blindly retry and risk duplicate comments. + ### link-work-items Links two Azure DevOps work items together. diff --git a/scripts/ado-script/src/approval-summary/__tests__/render.test.ts b/scripts/ado-script/src/approval-summary/__tests__/render.test.ts index 67d148cea..6d4fb56cb 100644 --- a/scripts/ado-script/src/approval-summary/__tests__/render.test.ts +++ b/scripts/ado-script/src/approval-summary/__tests__/render.test.ts @@ -14,6 +14,36 @@ function ndjson(...records: Record[]): string { return records.map((r) => JSON.stringify(r)).join("\n") + "\n"; } +describe("focused PR tools", () => { + it("shows long bodies as excerpts and uses trusted defaults for omitted targets", () => { + const body = "report ".repeat(100); + const summary = renderSummary( + parseProposals(ndjson({ name: "update-pull-request", body })), + new Set(["update-pull-request"]), + { + policies: new Map(), + prPolicies: new Map([["update-pull-request", { target: 42, operation: "append", "target-repo": "tools" }]]), + }, + ); + expect(summary).toContain("| PR | 42 |"); + expect(summary).toContain("| Body operation | append |"); + expect(summary).toContain("| Repository selector | tools |"); + expect(summary).toContain("```text\n" + body); + expect(summary).not.toContain("…(truncated)"); + }); + + it("links temporary targets only to earlier creates without inventing real IDs", () => { + const summary = renderSummary(parseProposals(ndjson( + { name: "create-pull-request", temporary_id: "#aw_created", repository: "tools" }, + { name: "add-pr-reviewers", pull_request_id: "#aw_created", reviewers: ["person@example.test"] }, + { name: "abandon-pull-request", pull_request_id: "#aw_missing", body: "reason" }, + )), new Set()); + expect(summary).toContain("real ID assigned at execution"); + expect(summary).toContain("no earlier create proposal"); + expect(summary).toContain("person@example.test"); + }); +}); + function repositoryContext( tool: string, targetRepo = "octo-org/octo-repo", diff --git a/scripts/ado-script/src/approval-summary/index.ts b/scripts/ado-script/src/approval-summary/index.ts index 8ea05c972..1387e9c4c 100644 --- a/scripts/ado-script/src/approval-summary/index.ts +++ b/scripts/ado-script/src/approval-summary/index.ts @@ -69,6 +69,7 @@ export function parseRepositoryPolicies( } catch { return policies; } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { return policies; } @@ -96,6 +97,30 @@ export function parseRepositoryPolicies( return policies; } +export function parsePrPolicies(value: string | undefined): Map>> { + if (!value) return new Map(); + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch (error) { + logWarning(`approval-summary: invalid trusted PR policies: ${String(error)}`); + return new Map(); + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + logWarning("approval-summary: trusted PR policies must be an object"); + return new Map(); + } + const policies = new Map>>(); + for (const [tool, policy] of Object.entries(parsed)) { + if (policy !== null && typeof policy === "object" && !Array.isArray(policy)) { + policies.set(tool, policy); + } else { + logWarning(`approval-summary: invalid trusted policy for ${tool}`); + } + } + return policies; +} + export function main(env: NodeJS.ProcessEnv = process.env): number { const ndjsonPath = env.AW_SAFE_OUTPUTS_NDJSON ?? ""; const outPath = env.AW_APPROVAL_SUMMARY_OUT ?? ""; @@ -130,6 +155,8 @@ export function main(env: NodeJS.ProcessEnv = process.env): number { currentRepository: env.AW_CURRENT_REPOSITORY, currentProvider: env.AW_CURRENT_REPOSITORY_PROVIDER, githubApiUrl: env.AW_GITHUB_API_URL, + prPolicies: parsePrPolicies(env.AW_PR_POLICIES), + triggeringPr: env.SYSTEM_PULLREQUEST_PULLREQUESTID, }; const markdown = renderSummary(proposals, reviewed, repositoryContext); if (markdown.length === 0) { diff --git a/scripts/ado-script/src/approval-summary/render.ts b/scripts/ado-script/src/approval-summary/render.ts index 47eb6052c..94d691427 100644 --- a/scripts/ado-script/src/approval-summary/render.ts +++ b/scripts/ado-script/src/approval-summary/render.ts @@ -53,6 +53,8 @@ export interface TrustedRepositoryContext { currentRepository?: string; currentProvider?: string; githubApiUrl?: string; + prPolicies?: ReadonlyMap>>; + triggeringPr?: string; } interface RepositoryResolution { @@ -77,6 +79,47 @@ const INLINE_MAX_CHARS = 300; * serialization (the `tool_result!` macro emits field names verbatim). */ const TOOL_SPECS: Record = { + "update-pull-request": { + title: "Update pull request content", + fields: [ + { label: "PR", key: "pull_request_id" }, + { label: "Title", key: "title" }, + { label: "Body operation", key: "operation" }, + { label: "Repository selector", key: "repository" }, + ], + body: "body", + }, + "abandon-pull-request": { + title: "Abandon pull request", + fields: [ + { label: "PR", key: "pull_request_id" }, + { label: "Repository selector", key: "repository" }, + ], + body: "body", + }, + "add-pr-reviewers": { + title: "Add pull request reviewers", + fields: [ + { label: "PR", key: "pull_request_id" }, + { label: "Reviewers", key: "reviewers" }, + { label: "Repository selector", key: "repository" }, + ], + }, + "add-pr-labels": { + title: "Add pull request labels", + fields: [ + { label: "PR", key: "pull_request_id" }, + { label: "Labels", key: "labels" }, + { label: "Repository selector", key: "repository" }, + ], + }, + "set-pr-auto-complete": { + title: "Enable pull request auto-complete", + fields: [ + { label: "PR", key: "pull_request_id" }, + { label: "Repository selector", key: "repository" }, + ], + }, "create-pull-request": { title: "Create pull request", fields: [ @@ -934,6 +977,32 @@ export function renderSummary( repositoryContext?: TrustedRepositoryContext, ): string { if (proposals.length === 0) return ""; + const producers = new Map(); + proposals = proposals.map((proposal) => { + const record = { ...proposal.record }; + if (proposal.name === "create-pull-request" && typeof record.temporary_id === "string") { + producers.set(record.temporary_id.replace(/^#/, ""), proposal); + } + const policy = repositoryContext?.prPolicies?.get(proposal.name); + if (policy) { + record.operation ??= policy.operation; + record.repository ??= policy["target-repo"] ?? "self"; + record.pull_request_id ??= record.pull_request_number ?? record.pr_number ?? record.pr + ?? (policy.target === "*" ? "" + : typeof policy.target === "number" ? policy.target + : repositoryContext?.triggeringPr || ""); + } + if (typeof record.pull_request_id === "string" && /^#?aw_/.test(record.pull_request_id)) { + const producer = producers.get(record.pull_request_id.replace(/^#/, "")); + record.pull_request_id = producer + ? `${record.pull_request_id} (from earlier create proposal ${producer.index + 1}; real ID assigned at execution)` + : `${record.pull_request_id} (unresolved: no earlier create proposal)`; + if (producer && !proposal.record.repository) { + record.repository = `${String(producer.record.repository ?? "self")} (producer's proposed selector; validated at execution)`; + } + } + return { ...proposal, record }; + }); const lines: string[] = ["# Proposed safe outputs", ""]; const repositoryResolutions = buildRepositoryResolutions( diff --git a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/index.test.ts b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/index.test.ts index 2606cf65a..5a717b333 100644 --- a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/index.test.ts +++ b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/index.test.ts @@ -271,6 +271,7 @@ describe("smoke-e2e index.main (happy path, candidate mode)", () => { "noop-target", "custom-safe-output", "multi-repo", + "pr-tools-preview", ]); expect(queuedCaseIds).not.toContain("janitor"); expect(compiledCasePaths).toEqual([ @@ -279,6 +280,7 @@ describe("smoke-e2e index.main (happy path, candidate mode)", () => { "tests/safe-outputs/noop-target.md", "tests/smoke/custom-safe-output.md", "tests/smoke/multi-repo.md", + "tests/safe-outputs/pr-tools-preview.md", ]); // Cleanup ordering: remote refs deleted BEFORE the local worktree is removed. @@ -297,9 +299,10 @@ describe("smoke-e2e index.main (happy path, candidate mode)", () => { "refs/heads/ado-aw-smoke-candidate/630001/noop-target", "refs/heads/ado-aw-smoke-candidate/630001/custom-safe-output", "refs/heads/ado-aw-smoke-candidate/630001/multi-repo", + "refs/heads/ado-aw-smoke-candidate/630001/pr-tools-preview", ]); // Every case is staged to the SAME path — the ref is what distinguishes them. - expect(stagedWrites.length).toBe(5); + expect(stagedWrites.length).toBe(6); for (const write of stagedWrites) { expect(write.to).toBe(join(WORKTREE, "candidate", ".smoke", "pipeline.yml")); // The compiler emits no trigger keys once `on:` is stripped, and a @@ -329,7 +332,7 @@ describe("smoke-e2e index.main (happy path, candidate mode)", () => { const gitModule = await import("../git.js"); const resets = vi.mocked(gitModule.resetWorktree).mock.calls; - expect(resets.length).toBe(5); + expect(resets.length).toBe(6); for (const call of resets) { expect(call[0]).toMatchObject({ commitish: "basecommit" }); } @@ -456,6 +459,7 @@ describe("smoke-e2e index.main (per-case ref retention)", () => { "refs/heads/ado-aw-smoke-candidate/630001/noop-target", "refs/heads/ado-aw-smoke-candidate/630001/custom-safe-output", "refs/heads/ado-aw-smoke-candidate/630001/multi-repo", + "refs/heads/ado-aw-smoke-candidate/630001/pr-tools-preview", ]); expect(deletedRefs).not.toContain("refs/heads/ado-aw-smoke-candidate/630001/ado-proxy"); }); diff --git a/scripts/ado-script/src/executor-e2e/__tests__/create-pull-request-scenarios.test.ts b/scripts/ado-script/src/executor-e2e/__tests__/create-pull-request-scenarios.test.ts index cfe86862e..c4902bece 100644 --- a/scripts/ado-script/src/executor-e2e/__tests__/create-pull-request-scenarios.test.ts +++ b/scripts/ado-script/src/executor-e2e/__tests__/create-pull-request-scenarios.test.ts @@ -126,7 +126,6 @@ describe("create-pull-request add-reviewers handoff", () => { "configures and submits one $name reviewer", async ({ scenario, temporaryId, submittedReviewer }) => { expect(scenario.config(ctx, state)).toEqual({ - "allowed-operations": ["add-reviewers"], "allowed-repositories": ["agent-definitions"], "allowed-reviewers": [submittedReviewer], "max-reviewers": 1, @@ -134,7 +133,6 @@ describe("create-pull-request add-reviewers handoff", () => { }); await expect(scenario.ndjson(ctx, state)).resolves.toEqual({ pull_request_id: temporaryId, - operation: "add-reviewers", reviewers: [submittedReviewer], }); }, @@ -163,7 +161,7 @@ describe("create-pull-request add-reviewers handoff", () => { }, }; const updated: ExecutedRecord = { - name: "update_pr", + name: "add_pr_reviewers", status: "succeeded", result: { pull_request_id: 42, @@ -266,7 +264,7 @@ describe("create-pull-request add-reviewers handoff", () => { result: created, }, { - name: "update_pr", + name: "add_pr_reviewers", status: "succeeded", result: updated, }, @@ -296,7 +294,7 @@ describe("create-pull-request add-reviewers handoff", () => { }, }; const updated: ExecutedRecord = { - name: "update_pr", + name: "add_pr_reviewers", status: "succeeded", result: { pull_request_id: 42, @@ -408,7 +406,7 @@ describe("Rust executor payload contract", () => { it.each([ { target: "producer", tool: "create-pull-request", index: 0 }, - { target: "consumer", tool: "update-pr", index: 1 }, + { target: "consumer", tool: "add-pr-reviewers", index: 1 }, ] as const)( "rejects an overlong $target temporary ID through Rust deserialization", async ({ target, tool, index }) => { @@ -458,7 +456,7 @@ describe("Rust executor payload contract", () => { expect(result.records).toHaveLength(2); expect(result.records[0]?.status).toBe("succeeded"); expect(result.records[1]?.status).toBe("failed"); - expect(result.records[1]?.error).toContain("Failed to parse update-pr:"); + expect(result.records[1]?.error).toContain("Failed to parse add-pr-reviewers:"); expect(result.records[1]?.error).toContain("expected a sequence"); }); }); diff --git a/scripts/ado-script/src/executor-e2e/ado-rest.ts b/scripts/ado-script/src/executor-e2e/ado-rest.ts index def64f6d2..d6b210908 100644 --- a/scripts/ado-script/src/executor-e2e/ado-rest.ts +++ b/scripts/ado-script/src/executor-e2e/ado-rest.ts @@ -470,7 +470,10 @@ export class AdoRest { async getPullRequest( repo: string, prId: number, - ): Promise<{ pullRequestId: number; status: string; title: string; description?: string }> { + ): Promise<{ + pullRequestId: number; status: string; title: string; description?: string; + labels?: { name: string }[]; autoCompleteSetBy?: { id?: string }; + }> { const path = this.projPath( `_apis/git/repositories/${AdoRest.seg(repo)}/pullRequests/${prId}?api-version=7.1`, ); @@ -479,6 +482,8 @@ export class AdoRest { status: string; title: string; description?: string; + labels?: { name: string }[]; + autoCompleteSetBy?: { id?: string }; }>(path); if (!res) throw new Error(`getPullRequest(${prId}) returned no body`); return res; diff --git a/scripts/ado-script/src/executor-e2e/scenarios/create-pull-request.ts b/scripts/ado-script/src/executor-e2e/scenarios/create-pull-request.ts index 8300e17b3..3f4e13a55 100644 --- a/scripts/ado-script/src/executor-e2e/scenarios/create-pull-request.ts +++ b/scripts/ado-script/src/executor-e2e/scenarios/create-pull-request.ts @@ -420,14 +420,14 @@ async function cleanupTemporaryPrHandoff( } /** - * Runs create-pull-request and update-pr in one executor process. This is the + * Runs create-pull-request and content editing in one executor process. This is the * production handoff shape: the create result registers the real PR under a * temporary ID, then the following update resolves that ID without the model * ever knowing Azure DevOps' numeric PR ID. */ export const createPullRequestTemporaryIdHandoff: Scenario = { id: "create-pull-request-temporary-id-handoff", - tool: "update-pr", + tool: "update-pull-request", targetsAdoRepo: true, setup: (ctx) => setupCreatePullRequest(ctx, { @@ -437,7 +437,8 @@ export const createPullRequestTemporaryIdHandoff: Scenario = { changedFileSuffix: "-temporary-id-handoff", }), config: (_ctx, state) => ({ - "allowed-operations": ["update-description"], + target: "*", + "include-stats": false, "allowed-repositories": [state.repo], max: 1, }), @@ -470,8 +471,7 @@ export const createPullRequestTemporaryIdHandoff: Scenario = { }), ndjson: async (ctx) => ({ pull_request_id: HANDOFF_TEMPORARY_ID, - operation: "update-description", - description: `${detBody(ctx, "create-pull-request-temporary-id-handoff")} Updated through temporary ID.`, + body: `${detBody(ctx, "create-pull-request-temporary-id-handoff")} Updated through temporary ID.`, }), assert: async (ctx, state, record, records) => { const created = executedRecordForTool(records, "create-pull-request"); @@ -511,7 +511,7 @@ function createPullRequestAddReviewersScenario( return { id: options.id, - tool: "update-pr", + tool: "add-pr-reviewers", targetsAdoRepo: true, setup: async (ctx) => { const reviewer = resolveExecutorE2eReviewer(); @@ -540,7 +540,6 @@ function createPullRequestAddReviewersScenario( return { ...state, reviewer, reviewerId }; }, config: (_ctx, state) => ({ - "allowed-operations": ["add-reviewers"], "allowed-repositories": [state.repo], "allowed-reviewers": [submittedReviewer(state)], "max-reviewers": 1, @@ -577,7 +576,6 @@ function createPullRequestAddReviewersScenario( }), ndjson: async (_ctx, state) => ({ pull_request_id: options.temporaryId, - operation: "add-reviewers", reviewers: [submittedReviewer(state)], }), assert: async (_ctx, state, record, records) => { @@ -597,7 +595,7 @@ function createPullRequestAddReviewersScenario( ); } if (strResult(record, "operation") !== "add-reviewers") { - throw new Error("update-pr reported an unexpected operation"); + throw new Error("add-pr-reviewers reported an unexpected operation"); } const failed = stringArrayResult(record, "failed"); if (failed.length !== 0) { diff --git a/scripts/ado-script/src/executor-e2e/scenarios/pr.ts b/scripts/ado-script/src/executor-e2e/scenarios/pr.ts index 2eaf73d09..2984deb1c 100644 --- a/scripts/ado-script/src/executor-e2e/scenarios/pr.ts +++ b/scripts/ado-script/src/executor-e2e/scenarios/pr.ts @@ -1,7 +1,7 @@ /** * Pull-request safe-output scenarios against the ADO `agent-definitions` repo: * add-pr-comment, reply-to-pr-comment, resolve-pr-thread, submit-pr-review, - * update-pr. + * focused PR content editing and abandonment. * * Each scenario deterministically creates a transient PR (with a real commit, * so ADO accepts it) and, where needed, a comment thread; asserts the effect; @@ -219,10 +219,205 @@ export const updatePr: Scenario = { cleanup: teardownPr, }; +export const updatePullRequest: Scenario = { + tool: "update-pull-request", + targetsAdoRepo: true, + config: (ctx) => ({ + target: "*", + "allowed-repositories": [ctx.adoRepo], + "include-stats": false, + }), + setup: (ctx) => setupPr(ctx, "update-pull-request", false), + ndjson: async (ctx, state) => ({ + pull_request_id: state.prId, + repository: ctx.adoRepo, + title: `${ctx.prefix("update-pull-request")} updated`, + body: "x".repeat(4000), + }), + assert: async (ctx, state) => { + const pr = await ctx.rest.getPullRequest(state.repo, state.prId); + if (pr.description !== "x".repeat(4000) || !pr.title.endsWith(" updated")) { + throw new Error("PR title or exact 4000-character description was not persisted"); + } + }, + cleanup: teardownPr, +}; + +export const abandonPullRequest: Scenario = { + tool: "abandon-pull-request", + targetsAdoRepo: true, + config: (ctx) => ({ + target: "*", + "allowed-repositories": [ctx.adoRepo], + "include-stats": false, + }), + setup: (ctx) => setupPr(ctx, "abandon-pull-request", false), + ndjson: async (ctx, state) => ({ + pull_request_id: state.prId, + repository: ctx.adoRepo, + body: detBody(ctx, "abandon-pull-request"), + }), + assert: async (ctx, state) => { + const pr = await ctx.rest.getPullRequest(state.repo, state.prId); + if (pr.status !== "abandoned") throw new Error("PR was not abandoned"); + const threads = await ctx.rest.listThreads(state.repo, state.prId); + if (!threads.some((thread) => thread.comments?.some( + (comment) => comment.content === detBody(ctx, "abandon-pull-request"), + ))) throw new Error("Abandonment comment was not posted"); + }, + cleanup: teardownPr, +}; + +export const updatePullRequestIsland: Scenario = { + id: "update-pull-request-island", + tool: "update-pull-request", + targetsAdoRepo: true, + config: (ctx) => ({ + target: "*", + "allowed-repositories": [ctx.adoRepo], + operation: "replace-island", + "include-stats": false, + max: 2, + }), + setup: (ctx) => setupPr(ctx, "update-pull-request-island", false), + priorEntries: async (ctx, state) => [{ + tool: "update-pull-request", + config: { + target: "*", "allowed-repositories": [ctx.adoRepo], + operation: "replace-island", "include-stats": false, max: 2, + }, + entry: { pull_request_id: state.prId, repository: ctx.adoRepo, body: "first island report" }, + }], + env: async () => ({ SYSTEM_DEFINITIONID: "123" }), + ndjson: async (ctx, state) => ({ + pull_request_id: state.prId, repository: ctx.adoRepo, body: "updated island report", + }), + assert: async (ctx, state) => { + const pr = await ctx.rest.getPullRequest(state.repo, state.prId); + const body = pr.description ?? ""; + if (!body.startsWith(detBody(ctx, "update-pull-request-island")) + || !body.includes("updated island report") + || body.includes("first island report") + || body.split("ado-aw-pr-island-start:").length !== 2) { + throw new Error("PR island rerun did not preserve surrounding text and replace the one section"); + } + }, + cleanup: teardownPr, +}; + +export const updatePullRequestOversized: Scenario = { + id: "update-pull-request-oversized", + tool: "update-pull-request", + targetsAdoRepo: true, + config: (ctx) => ({ + target: "*", "allowed-repositories": [ctx.adoRepo], "include-stats": false, + }), + setup: (ctx) => setupPr(ctx, "update-pull-request-oversized", false), + ndjson: async (ctx, state) => ({ + pull_request_id: state.prId, repository: ctx.adoRepo, body: "x".repeat(4001), + }), + expectedFailure: { error: /4000|4,000/ }, + assert: async (ctx, state) => { + const pr = await ctx.rest.getPullRequest(state.repo, state.prId); + if (pr.description !== detBody(ctx, "update-pull-request-oversized")) { + throw new Error("Rejected oversized body changed the live description"); + } + }, + cleanup: teardownPr, +}; + +export const addPrLabels: Scenario = { + tool: "add-pr-labels", + targetsAdoRepo: true, + config: (ctx) => ({ "allowed-repositories": [ctx.adoRepo] }), + setup: async (ctx) => { + const state = await setupPr(ctx, "add-pr-labels", false); + try { + await ctx.rest.setPullRequestLabels(state.repo, state.prId, ["existing-label"]); + } catch (error) { + await teardownPr(ctx, state); + throw error; + } + return state; + }, + ndjson: async (ctx, state) => ({ + pull_request_id: state.prId, repository: ctx.adoRepo, labels: ["new-label"], + }), + assert: async (ctx, state) => { + const pr = await ctx.rest.getPullRequest(state.repo, state.prId); + const labels = pr.labels?.map((label) => label.name) ?? []; + if (!labels.includes("existing-label") || !labels.includes("new-label")) { + throw new Error("Label addition did not preserve the existing label"); + } + }, + cleanup: teardownPr, +}; + +interface AutoCompleteState extends PrState { targetBranch: string } + +export const setPrAutoComplete: Scenario = { + tool: "set-pr-auto-complete", + targetsAdoRepo: true, + config: (ctx) => ({ + "allowed-repositories": [ctx.adoRepo], + "delete-source-branch": false, + "merge-strategy": "squash", + }), + setup: async (ctx) => { + const repo = ctx.adoRepo; + const base = await defaultBranchShortName(ctx, repo); + const sha = await ctx.rest.getRefObjectId(repo, `heads/${base}`); + if (!sha) throw new Error("Default branch has no tip"); + const targetBranch = `${ctx.prefix("set-pr-auto-complete")}-target`; + const branch = `${ctx.prefix("set-pr-auto-complete")}-src`; + await ctx.rest.pushAddFileBranch(repo, targetBranch, sha, + `/ado-aw-det/${ctx.buildId}/autocomplete-target.md`, "isolated target", "prepare isolated completion target"); + let sourceCreated = false; + try { + const tip = await ctx.rest.getRefObjectId(repo, `heads/${targetBranch}`); + if (!tip) throw new Error("Isolated target branch has no tip"); + await ctx.rest.pushAddFileBranch(repo, branch, tip, + `/ado-aw-det/${ctx.buildId}/autocomplete-source.md`, "isolated source", "prepare completion source"); + sourceCreated = true; + const pr = await ctx.rest.createPullRequest(repo, branch, targetBranch, + ctx.prefix("set-pr-auto-complete"), "Completes only into a disposable test branch."); + return { repo, prId: pr.pullRequestId, branch, targetBranch }; + } catch (error) { + const cleanup = new Teardown(); + if (sourceCreated) cleanup.add("delete source", () => ctx.rest.deleteRef(repo, `refs/heads/${branch}`)); + await cleanup.add("delete target", () => ctx.rest.deleteRef(repo, `refs/heads/${targetBranch}`)).run(); + throw error; + } + }, + ndjson: async (ctx, state) => ({ pull_request_id: state.prId, repository: ctx.adoRepo }), + assert: async (ctx, state) => { + const pr = await ctx.rest.getPullRequest(state.repo, state.prId); + if (!pr.autoCompleteSetBy?.id && pr.status !== "completed") { + throw new Error("Auto-complete was neither set nor completed into the disposable target"); + } + }, + cleanup: async (ctx, state) => { + await new Teardown() + .add("abandon active PR", async () => { + const pr = await ctx.rest.getPullRequest(state.repo, state.prId); + if (pr.status === "active") await ctx.rest.abandonPullRequest(state.repo, state.prId); + }) + .add("delete source", () => ctx.rest.deleteRef(state.repo, `refs/heads/${state.branch}`)) + .add("delete isolated target", () => ctx.rest.deleteRef(state.repo, `refs/heads/${state.targetBranch}`)) + .run(); + }, +}; + export const prScenarios: Scenario[] = [ addPrComment, replyToPrComment, resolvePrThread, submitPrReview, updatePr, + updatePullRequest, + abandonPullRequest, + updatePullRequestIsland, + updatePullRequestOversized, + addPrLabels, + setPrAutoComplete, ]; diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index 176fb2e0b..3d8e02033 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -4836,6 +4836,27 @@ fn safe_outputs_summary_step(front_matter: &FrontMatter, reviewed: &[String]) -> use super::ir::env::EnvValue; let approval_summary_path = super::extensions::ado_script::APPROVAL_SUMMARY_PATH; let repository_policies = approval_summary_repository_policies(front_matter)?; + let pr_policies: serde_json::Map = front_matter + .safe_outputs + .iter() + .filter(|(tool, _)| { + super::pr_migration::PR_OPERATIONS + .iter() + .any(|(_, focused)| *focused == tool.as_str()) + || tool.as_str() == "abandon-pull-request" + }) + .map(|(tool, config)| { + let policy = ["target", "target-repo", "operation"] + .into_iter() + .filter_map(|key| { + config + .get(key) + .map(|value| (key.to_string(), value.clone())) + }) + .collect::>(); + (tool.clone(), serde_json::Value::Object(policy)) + }) + .collect(); let github_api_url = front_matter .github_safe_outputs_auth()? .map(|auth| auth.api_url().to_string()) @@ -4852,6 +4873,10 @@ fn safe_outputs_summary_step(front_matter: &FrontMatter, reviewed: &[String]) -> EnvValue::literal("$(Agent.TempDirectory)/ado-aw-safe-outputs.md"), ) .with_env("AW_REVIEWED_TOOLS", EnvValue::literal(reviewed.join("\n"))) + .with_env( + "AW_PR_POLICIES", + EnvValue::literal(serde_json::to_string(&pr_policies)?), + ) .with_env( "AW_GITHUB_REPOSITORY_POLICIES", EnvValue::literal(repository_policies), diff --git a/src/compile/codemods/0009_split_update_pr.rs b/src/compile/codemods/0009_split_update_pr.rs new file mode 100644 index 000000000..722b088da --- /dev/null +++ b/src/compile/codemods/0009_split_update_pr.rs @@ -0,0 +1,27 @@ +use anyhow::Result; +use serde_yaml::{Mapping, Value}; + +use super::{Codemod, CodemodContext}; + +pub static CODEMOD: Codemod = Codemod { + id: "split_update_pr", + summary: "split update-pr into focused PR tools while preserving policy and shared budgets", + introduced_in: env!("CARGO_PKG_VERSION"), + apply, +}; + +fn apply(front_matter: &mut Mapping, _ctx: &CodemodContext) -> Result { + let key = Value::String("safe-outputs".to_string()); + let Some(raw) = front_matter.get(&key) else { + return Ok(false); + }; + let value = serde_json::to_value(raw)?; + let Some(mut outputs) = value.as_object().cloned() else { + return Ok(false); + }; + if !crate::compile::pr_migration::migrate_safe_outputs(&mut outputs)? { + return Ok(false); + } + front_matter.insert(key, serde_yaml::to_value(outputs)?); + Ok(true) +} diff --git a/src/compile/codemods/mod.rs b/src/compile/codemods/mod.rs index 098bc46ba..9b3099820 100644 --- a/src/compile/codemods/mod.rs +++ b/src/compile/codemods/mod.rs @@ -49,6 +49,8 @@ mod m0006_explicit_push_trigger; mod m0007_promote_debug_create_github_issue; #[path = "0008_explicit_mcp_pipeline_env.rs"] mod m0008_explicit_mcp_pipeline_env; +#[path = "0009_split_update_pr.rs"] +mod m0009_split_update_pr; #[allow(unused_imports)] // Re-exported for future codemods; only `take_key` is in-tree use. pub use helpers::{ConflictPolicy, insert_no_overwrite, rename_key, take_key}; @@ -155,6 +157,7 @@ pub static CODEMODS: &[&Codemod] = &[ &m0006_explicit_push_trigger::CODEMOD, &m0007_promote_debug_create_github_issue::CODEMOD, &m0008_explicit_mcp_pipeline_env::CODEMOD, + &m0009_split_update_pr::CODEMOD, ]; /// Result of running the codemod registry on a single front-matter diff --git a/src/compile/common.rs b/src/compile/common.rs index 41dc36a53..52cffe951 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -2520,12 +2520,6 @@ fn validate_github_issue_tools(front_matter: &FrontMatter, github_tools: &[Strin validate_github_issue_shared_policy(tool, &config)?; validate_github_issue_tool_specific_config(front_matter, tool)?; } - if let Some(config) = front_matter.update_pull_request_config()? { - crate::safe_outputs::validate_update_pull_request_config(&config)?; - } - if let Some(config) = front_matter.abandon_pull_request_config()? { - crate::safe_outputs::validate_abandon_pull_request_config(&config)?; - } Ok(()) } @@ -3041,12 +3035,17 @@ pub fn validate_submit_pr_review_events(front_matter: &FrontMatter) -> Result<() Some(v) => v.as_array().is_none_or(|a| a.is_empty()), }; if is_empty { + if obj.contains_key(super::pr_migration::LEGACY_PR_CONFIG) { + anyhow::bail!( + "safe-outputs.update-pr enables vote but has no allowed-votes; restrict allowed-operations or configure allowed-votes" + ); + } anyhow::bail!( "safe-outputs.submit-pr-review requires a non-empty 'allowed-events' list \ to prevent agents from casting unrestricted review votes. Example:\n\n \ safe-outputs:\n submit-pr-review:\n allowed-events:\n \ - comment\n - approve-with-suggestions\n\n\ - Valid events: approve, approve-with-suggestions, request-changes, comment\n" + Valid events: approve, approve-with-suggestions, request-changes, wait-for-author, reject, reset, comment\n" ); } } else { @@ -3060,8 +3059,59 @@ pub fn validate_submit_pr_review_events(front_matter: &FrontMatter) -> Result<() Ok(()) } -/// Validate configuration shared by create-pull-request and update-pr. +/// Validate the PR tool family, including temporary-reference lanes and shared budgets. pub fn validate_pull_request_outputs_config(front_matter: &FrontMatter) -> Result<()> { + super::pr_migration::validate_budget_groups(front_matter)?; + if let Some(config) = front_matter + .typed_safe_output_config::("add-pr-labels")? + { + crate::safe_outputs::validate_add_pr_labels_config(&config)?; + } + if let Some(config) = front_matter + .typed_safe_output_config::( + "set-pr-auto-complete", + )? + { + crate::safe_outputs::validate_set_pr_auto_complete_config(&config)?; + } + if let Some(config) = front_matter + .typed_safe_output_config::("submit-pr-review")? + { + crate::safe_outputs::validate_submit_pr_review_config(&config)?; + } + if let Some(config) = front_matter.update_pull_request_config()? { + crate::safe_outputs::validate_update_pull_request_config(&config)?; + } + if let Some(config) = front_matter.abandon_pull_request_config()? { + crate::safe_outputs::validate_abandon_pull_request_config(&config)?; + } + for tool in [ + "add-pr-reviewers", + "add-pr-labels", + "set-pr-auto-complete", + "update-pull-request", + "abandon-pull-request", + "submit-pr-review", + ] { + if !front_matter.safe_outputs.contains_key(tool) { + continue; + } + let temporary_capable = tool != "submit-pr-review" + || front_matter + .safe_outputs + .get(tool) + .and_then(|config| config.get("allow-temporary-ids")) + .and_then(serde_json::Value::as_bool) + == Some(true); + if temporary_capable + && front_matter + .safe_outputs + .contains_key("create-pull-request") + { + require_same_approval_lane(front_matter, "create-pull-request", tool)?; + require_same_staged_lane(front_matter, "create-pull-request", tool)?; + } + } if front_matter .safe_outputs .contains_key("create-pull-request") @@ -3073,7 +3123,7 @@ pub fn validate_pull_request_outputs_config(front_matter: &FrontMatter) -> Resul if let Some(max_reviewers) = front_matter .safe_outputs - .get("update-pr") + .get("add-pr-reviewers") .and_then(serde_json::Value::as_object) .and_then(|object| object.get("max-reviewers")) { @@ -3088,6 +3138,11 @@ pub fn validate_pull_request_outputs_config(front_matter: &FrontMatter) -> Resul "safe-outputs.update-pr.max-reviewers must be a positive integer that fits in usize" ); } + if let Some(config) = front_matter + .typed_safe_output_config::("add-pr-reviewers")? + { + crate::safe_outputs::validate_add_pr_reviewers_config(&config)?; + } Ok(()) } @@ -3100,8 +3155,12 @@ pub fn validate_pull_request_outputs_config(front_matter: &FrontMatter) -> Resul /// runtime error. Catching this at compile time is consistent with how /// `validate_submit_pr_review_events` handles the analogous case. pub fn validate_update_pr_votes(front_matter: &FrontMatter) -> Result<()> { - if let Some(config_value) = front_matter.safe_outputs.get("update-pr") - && let Some(obj) = config_value.as_object() + if let Some(config_value) = front_matter.safe_outputs.get("update-pr").or_else(|| { + front_matter + .safe_outputs + .get("submit-pr-review") + .and_then(|config| config.get(super::pr_migration::LEGACY_PR_CONFIG)) + }) && let Some(obj) = config_value.as_object() { // Determine whether the vote operation is reachable: // - allowed-operations absent or empty → all operations allowed (includes vote) @@ -6063,10 +6122,13 @@ safe-outputs: --- "#; let (fm, _) = parse_markdown(yaml).unwrap(); - let error = validate_github_issue_outputs_config(&fm) + let error = validate_pull_request_outputs_config(&fm) .expect_err("invalid abandon-pull-request config must fail compilation") .to_string(); - assert!(error.contains("required-labels"), "unexpected error: {error}"); + assert!( + error.contains("required-labels"), + "unexpected error: {error}" + ); } #[test] @@ -6477,7 +6539,7 @@ safe-outputs: error.contains("same effective staged setting") && error.contains("temporary pull-request IDs") && error.contains("staged create-pull-request") - && error.contains("staged update-pr"), + && error.contains("staged update-pull-request"), "create staged={create_staged}, update staged={update_staged}: {error}" ); } @@ -6553,7 +6615,7 @@ safe-outputs: ] { let (mut fm, _) = parse_markdown(yaml).unwrap(); fm.safe_outputs - .get_mut("update-pr") + .get_mut("add-pr-reviewers") .unwrap() .as_object_mut() .unwrap() diff --git a/src/compile/custom_tools.rs b/src/compile/custom_tools.rs index f6ca8a552..4da04b02c 100644 --- a/src/compile/custom_tools.rs +++ b/src/compile/custom_tools.rs @@ -321,6 +321,7 @@ pub fn resolved_execution_config_json( serde_json::to_string_pretty(&json!({ "name": front_matter.name, "toolConfigs": tool_configs, + "budgetGroups": super::pr_migration::budget_groups(front_matter)?, "customTools": custom_tools, "repositories": repositories, "checkout": front_matter.checkout, diff --git a/src/compile/mod.rs b/src/compile/mod.rs index f2cf5a62a..2380a2c87 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -65,6 +65,7 @@ pub use common::{ reconstruct_source, }; pub use types::{CompileTarget, FrontMatter}; +pub(crate) mod pr_migration; /// Trait for pipeline compilers. /// @@ -177,6 +178,7 @@ async fn compile_pipeline_inner( registry, existing_version.as_deref(), )?; + pr_migration::warn_prompt_references(input_path, &content, &parsed.body_raw); let mut front_matter = parsed.front_matter; let mut markdown_body = parsed.markdown_body; let codemod_report = parsed.codemods; @@ -203,6 +205,15 @@ async fn compile_pipeline_inner( input_path, ) .await?; + if !imported_prompt_body.is_empty() { + for line in pr_migration::deprecated_pr_prompt_lines(&imported_prompt_body) { + eprintln!( + "warning: imported prompt for {} (combined import body line {line}): {}", + crate::sanitize::neutralize_pipeline_commands(&input_path.display().to_string()), + pr_migration::PR_PROMPT_GUIDANCE + ); + } + } markdown_body = merged_body; // Sanitize all front matter text fields before any further processing. @@ -655,6 +666,7 @@ pub async fn check_pipeline(pipeline_path: &str) -> Result<()> { &content, Some(header_meta.version.as_str()).filter(|v| !v.is_empty()), )?; + pr_migration::warn_prompt_references(&source_path, &content, &parsed.body_raw); // Pending-migration enforcement: `check` MUST NOT silently let // a stale source pass. The runtime integrity check inside diff --git a/src/compile/pr_migration.rs b/src/compile/pr_migration.rs new file mode 100644 index 000000000..a9d87dc53 --- /dev/null +++ b/src/compile/pr_migration.rs @@ -0,0 +1,451 @@ +use std::collections::{BTreeMap, HashSet}; + +use anyhow::{Context, Result, bail, ensure}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use super::types::FrontMatter; + +pub const LEGACY_PR_CONFIG: &str = "legacy-update-pr"; +pub const PR_OPERATIONS: &[(&str, &str)] = &[ + ("add-reviewers", "add-pr-reviewers"), + ("add-labels", "add-pr-labels"), + ("set-auto-complete", "set-pr-auto-complete"), + ("vote", "submit-pr-review"), + ("update-description", "update-pull-request"), +]; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct BudgetGroup { + pub max: usize, + pub tools: Vec, +} + +pub type BudgetGroups = BTreeMap; + +pub fn focused_pr_tool(operation: &str) -> Option<&'static str> { + PR_OPERATIONS + .iter() + .find_map(|(old, new)| (*old == operation).then_some(*new)) +} + +/// Pure, atomic normalization shared by the codemod and historical execution. +pub fn migrate_safe_outputs(outputs: &mut Map) -> Result { + let Some(raw) = outputs.get("update-pr") else { + return Ok(false); + }; + let original = match raw { + Value::Null | Value::Bool(true) => Map::new(), + Value::Object(config) => config.clone(), + _ => bail!("safe-outputs.update-pr must be an object or null before migration"), + }; + let operations: Vec = original + .get("allowed-operations") + .cloned() + .map(serde_json::from_value) + .transpose() + .context("update-pr.allowed-operations must be a list of operation names")? + .unwrap_or_default(); + for operation in &operations { + ensure!( + focused_pr_tool(operation).is_some(), + "cannot migrate unknown update-pr operation '{operation}'" + ); + } + let selected: Vec<_> = PR_OPERATIONS + .iter() + .filter(|(operation, _)| { + operations.is_empty() || operations.iter().any(|allowed| allowed == operation) + }) + .copied() + .collect(); + let max = original + .get("max") + .cloned() + .map(serde_json::from_value::) + .transpose() + .context("update-pr.max must be a non-negative integer fitting usize")? + .unwrap_or(1); + let votes: Vec = original + .get("allowed-votes") + .cloned() + .map(serde_json::from_value) + .transpose() + .context("update-pr.allowed-votes must be a list")? + .unwrap_or_default(); + + let mut migrated = outputs.clone(); + let mut groups: BudgetGroups = outputs + .get("budget-groups") + .cloned() + .map(serde_json::from_value) + .transpose() + .context("safe-outputs.budget-groups has invalid configuration")? + .unwrap_or_default(); + ensure!( + !groups.contains_key("update-pr"), + "manual migration required: update-pr budget group already exists" + ); + for (operation, tool) in &selected { + ensure!( + !migrated.contains_key(*tool), + "manual migration required: both update-pr and {tool} are configured; \ + their permissions, budgets and approval policies cannot be silently combined" + ); + let mut config = Map::new(); + for key in ["allowed-repositories", "max", "require-approval", "staged"] { + if let Some(value) = original.get(key) { + config.insert(key.to_string(), value.clone()); + } + } + match *operation { + "add-reviewers" => { + for key in ["allowed-reviewers", "max-reviewers"] { + if let Some(value) = original.get(key) { + config.insert(key.to_string(), value.clone()); + } + } + } + "set-auto-complete" => { + for key in ["delete-source-branch", "merge-strategy"] { + if let Some(value) = original.get(key) { + config.insert(key.to_string(), value.clone()); + } + } + } + "vote" => { + config.insert("allowed-events".to_string(), json!(votes)); + config.insert("allow-temporary-ids".to_string(), Value::Bool(true)); + } + "update-description" => { + config.insert("title".to_string(), Value::Bool(false)); + config.insert("body".to_string(), Value::Bool(true)); + config.insert("target".to_string(), json!("*")); + config.insert("operation".to_string(), json!("replace")); + config.insert("include-stats".to_string(), Value::Bool(false)); + } + _ => {} + } + config.insert( + LEGACY_PR_CONFIG.to_string(), + Value::Object(original.clone()), + ); + migrated.insert((*tool).to_string(), Value::Object(config)); + } + groups.insert( + "update-pr".to_string(), + BudgetGroup { + max, + tools: selected + .iter() + .map(|(_, tool)| (*tool).to_string()) + .collect(), + }, + ); + migrated.insert("budget-groups".to_string(), serde_json::to_value(groups)?); + migrated.remove("update-pr"); + *outputs = migrated; + Ok(true) +} + +pub fn budget_groups(front_matter: &FrontMatter) -> Result { + front_matter + .safe_outputs + .get("budget-groups") + .cloned() + .map(serde_json::from_value) + .transpose() + .context("safe-outputs.budget-groups has invalid configuration") + .map(Option::unwrap_or_default) +} + +pub fn validate_budget_groups(front_matter: &FrontMatter) -> Result<()> { + let mut members = HashSet::new(); + for (name, group) in budget_groups(front_matter)? { + ensure!( + !name.trim().is_empty(), + "budget group name must not be empty" + ); + ensure!( + !group.tools.is_empty(), + "budget group '{name}' must contain tools" + ); + crate::validate::reject_pipeline_injection(&name, "budget group")?; + let first = &group.tools[0]; + for tool in &group.tools { + ensure!( + front_matter + .safe_output_tool_names() + .any(|configured| configured == tool), + "budget group '{name}' references unconfigured tool '{tool}'" + ); + ensure!( + PR_OPERATIONS + .iter() + .any(|(_, candidate)| *candidate == tool), + "budget group '{name}' may only contain focused PR tools" + ); + ensure!( + members.insert(tool.clone()), + "tool '{tool}' belongs to multiple budget groups" + ); + ensure!( + front_matter.tool_requires_approval(tool).is_some() + == front_matter.tool_requires_approval(first).is_some() + && front_matter.tool_is_staged(tool) == front_matter.tool_is_staged(first), + "budget group '{name}' must share the same effective require-approval and staged settings" + ); + } + } + Ok(()) +} + +/// Keep historical proposals executable without advertising the old MCP tool. +pub fn normalize_execution_context(ctx: &mut crate::safe_outputs::ExecutionContext) -> Result<()> { + let mut outputs: Map = ctx.tool_configs.clone().into_iter().collect(); + if !ctx.budget_groups.is_empty() { + outputs.insert( + "budget-groups".to_string(), + serde_json::to_value(&ctx.budget_groups)?, + ); + } + migrate_safe_outputs(&mut outputs)?; + ctx.budget_groups = outputs + .remove("budget-groups") + .map(serde_json::from_value) + .transpose()? + .unwrap_or_default(); + let mut seen = HashSet::new(); + for (name, group) in &ctx.budget_groups { + ensure!( + !name.trim().is_empty(), + "budget group name must not be empty" + ); + crate::validate::reject_pipeline_injection(name, "budget group")?; + ensure!( + !group.tools.is_empty(), + "budget group '{name}' must contain tools" + ); + let first_staged = outputs + .get(&group.tools[0]) + .and_then(|config| config.get("staged")) + .and_then(Value::as_bool) + .unwrap_or(false); + for tool in &group.tools { + ensure!( + outputs.contains_key(tool), + "budget group '{name}' references unconfigured tool '{tool}'" + ); + ensure!( + PR_OPERATIONS.iter().any(|(_, member)| *member == tool), + "budget group '{name}' contains unsupported tool '{tool}'" + ); + ensure!( + seen.insert(tool), + "tool '{tool}' belongs to multiple budget groups" + ); + ensure!( + outputs + .get(tool) + .and_then(|config| config.get("staged")) + .and_then(Value::as_bool) + .unwrap_or(false) + == first_staged, + "budget group '{name}' must share the same staged setting" + ); + } + } + let legacy = outputs + .values() + .filter_map(|config| { + config + .get(LEGACY_PR_CONFIG) + .map(|original| (original, config)) + }) + .collect::>(); + if let Some((original, effective)) = legacy.first() { + ensure!( + legacy.iter().all(|(config, _)| config == original), + "conflicting legacy update-pr execution policies" + ); + let mut original = (*original).clone(); + if let (Some(object), Some(staged)) = (original.as_object_mut(), effective.get("staged")) { + object.insert("staged".to_string(), staged.clone()); + } + outputs.insert("update-pr".to_string(), original); + } + ctx.tool_configs = outputs.into_iter().collect(); + Ok(()) +} + +pub fn deprecated_pr_prompt_lines(body: &str) -> Vec { + body.lines() + .enumerate() + .filter_map(|(line, text)| { + text.split(|c: char| { + c.is_whitespace() || (!c.is_alphanumeric() && c != '-' && c != '_') + }) + .any(|word| matches!(word, "update-pr" | "update_pr")) + .then_some(line + 1) + }) + .collect() +} + +pub const PR_PROMPT_GUIDANCE: &str = "update-pr is no longer an agent tool: use update-pull-request for content, \ + add-pr-reviewers for reviewers, add-pr-labels for labels, set-pr-auto-complete \ + for auto-complete, and submit-pr-review for votes. Update the prompt manually; \ + its text has not been rewritten."; + +pub fn warn_prompt_references(source: &std::path::Path, content: &str, body: &str) { + let prefix = content.len().saturating_sub(body.len()); + let offset = content + .get(..prefix) + .map_or(0, |text| text.lines().count().saturating_sub(1)); + for line in deprecated_pr_prompt_lines(body) { + eprintln!( + "warning: {}:{}: deprecated-tool-reference: {PR_PROMPT_GUIDANCE}", + crate::sanitize::neutralize_pipeline_commands(&source.display().to_string()), + offset + line + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn migration_preserves_policy_and_shared_budget() { + let mut outputs = json!({ + "update-pr": { + "allowed-operations": ["add-reviewers", "update-description"], + "allowed-reviewers": ["owner@example.test"], + "max-reviewers": 2, + "max": 1, + "require-approval": true, + "staged": true + } + }) + .as_object() + .unwrap() + .clone(); + assert!(migrate_safe_outputs(&mut outputs).unwrap()); + assert!(!outputs.contains_key("update-pr")); + assert_eq!(outputs["add-pr-reviewers"]["max-reviewers"], 2); + assert_eq!(outputs["update-pull-request"]["title"], false); + assert_eq!(outputs["update-pull-request"]["include-stats"], false); + assert_eq!(outputs["update-pull-request"]["target"], "*"); + assert_eq!(outputs["budget-groups"]["update-pr"]["max"], 1); + assert_eq!( + outputs["budget-groups"]["update-pr"]["tools"] + .as_array() + .unwrap() + .len(), + 2 + ); + let snapshot = outputs.clone(); + assert!(!migrate_safe_outputs(&mut outputs).unwrap()); + assert_eq!(outputs, snapshot); + } + + #[test] + fn migration_conflict_is_atomic() { + let mut outputs = json!({ + "update-pr": {"allowed-operations": ["vote"], "allowed-votes": ["reset"]}, + "submit-pr-review": {"allowed-events": ["approve"]} + }) + .as_object() + .unwrap() + .clone(); + let before = outputs.clone(); + assert!( + migrate_safe_outputs(&mut outputs) + .unwrap_err() + .to_string() + .contains("manual migration") + ); + assert_eq!(outputs, before); + } + + #[test] + fn prompt_detection_includes_code_but_not_other_identifiers() { + assert_eq!( + deprecated_pr_prompt_lines( + "Call `update-pr`.\r\n```json\n{\"name\":\"update_pr\"}\n```\nupdate-pull-request update-pr-other prefix_update_pr" + ), + vec![1, 3] + ); + } + + #[test] + fn codemod_preserves_body_and_original_review_contract() { + let source = "---\nname: test\ndescription: test\nsafe-outputs:\n update-pr:\n allowed-operations: [vote]\n allowed-votes: [wait-for-author, reject, reset]\n max: 2\n---\r\nCall `update-pr` with #aw_created.\r\n"; + let parsed = crate::compile::parse_markdown_detailed(source).unwrap(); + assert!(parsed.codemods.changed()); + assert_eq!( + parsed.body_raw, + "\r\nCall `update-pr` with #aw_created.\r\n" + ); + let config = &parsed.front_matter.safe_outputs["submit-pr-review"]; + assert_eq!( + config["allowed-events"], + json!(["wait-for-author", "reject", "reset"]) + ); + assert_eq!(config["allow-temporary-ids"], true); + assert_eq!(config[LEGACY_PR_CONFIG]["max"], 2); + } + + #[test] + fn budget_group_survives_resolved_config_and_rejects_split_lanes() { + let source = "---\nname: test\ndescription: test\nsafe-outputs:\n update-pr:\n allowed-operations: [add-labels, update-description]\n max: 0\n---\nbody\n"; + let parsed = crate::compile::parse_markdown_detailed(source).unwrap(); + let mut fm = parsed.front_matter; + validate_budget_groups(&fm).unwrap(); + let raw = crate::compile::custom_tools::resolved_execution_config_json(&fm, &[]).unwrap(); + let config: Value = serde_json::from_str(&raw).unwrap(); + assert_eq!(config["budgetGroups"]["update-pr"]["max"], 0); + fm.safe_outputs.get_mut("add-pr-labels").unwrap()["require-approval"] = json!(true); + assert!( + validate_budget_groups(&fm) + .unwrap_err() + .to_string() + .contains("require-approval") + ); + } + + #[test] + fn focused_config_validators_run_during_compilation() { + for (tool, config, expected) in [ + ( + "add-pr-reviewers", + json!({"allowed-reviewers":[""]}), + "allowed-reviewers", + ), + ( + "add-pr-labels", + json!({"allowed-repositories":[""]}), + "allowed-repositories", + ), + ( + "set-pr-auto-complete", + json!({"merge-strategy":"invalid"}), + "merge-strategy", + ), + ( + "submit-pr-review", + json!({"allowed-events":["invalid"]}), + "event", + ), + ] { + let source = format!( + "---\nname: test\ndescription: test\nsafe-outputs:\n {tool}: {config}\n---\nbody\n" + ); + let parsed = crate::compile::parse_markdown_detailed(&source).unwrap(); + let error = + crate::compile::common::validate_pull_request_outputs_config(&parsed.front_matter) + .unwrap_err(); + assert!(error.to_string().contains(expected), "{tool}: {error}"); + } + } +} diff --git a/src/compile/types.rs b/src/compile/types.rs index ef129a1e1..9bef9753b 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -2000,6 +2000,7 @@ fn validate_import_literal(label: &str, value: &str) -> anyhow::Result<()> { /// validation). pub const THREAT_DETECTION_KEY: &str = "threat-detection"; pub const SAFE_OUTPUT_RESERVED_KEYS: &[&str] = &[ + "budget-groups", "require-approval", "staged", "scripts", @@ -2214,7 +2215,7 @@ impl FrontMatter { .collect() } - fn typed_safe_output_config(&self, key: &str) -> anyhow::Result> + pub(crate) fn typed_safe_output_config(&self, key: &str) -> anyhow::Result> where T: serde::de::DeserializeOwned + Default + SanitizeConfigTrait, { @@ -2230,6 +2231,7 @@ impl FrontMatter { if let Some(object) = raw.as_object_mut() { object.remove("require-approval"); object.remove("staged"); + object.remove(crate::compile::pr_migration::LEGACY_PR_CONFIG); } let mut config: T = serde_json::from_value(raw) .map_err(|e| anyhow::anyhow!("safe-outputs.{key} has invalid configuration: {e}"))?; diff --git a/src/execute.rs b/src/execute.rs index 4137d676b..d57001eca 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -29,6 +29,7 @@ use crate::safe_outputs::{ UpdatePullRequestResult, UpdateWikiPageResult, UpdateWorkItemResult, UploadBuildAttachmentResult, UploadPipelineArtifactResult, UploadWorkitemAttachmentResult, }; +use crate::safe_outputs::{AddPrLabelsResult, AddPrReviewersResult, SetPrAutoCompleteResult}; use crate::sanitize::neutralize_pipeline_commands; // Re-export memory types for use by main.rs @@ -204,6 +205,9 @@ pub async fn execute_safe_outputs( ctx: &ExecutionContext, filter: &ToolFilter, ) -> Result> { + let mut effective_ctx = ctx.clone(); + crate::compile::pr_migration::normalize_execution_context(&mut effective_ctx)?; + let ctx = &effective_ctx; let safe_output_path = safe_output_dir.join(SAFE_OUTPUT_FILENAME); log_execution_context(safe_output_dir, ctx); @@ -246,6 +250,9 @@ pub async fn execute_safe_outputs( AddBuildTagResult, CreateBranchResult, UpdatePrResult, + AddPrReviewersResult, + AddPrLabelsResult, + SetPrAutoCompleteResult, AbandonPullRequestResult, UploadBuildAttachmentResult, UploadPipelineArtifactResult, @@ -269,6 +276,7 @@ pub async fn execute_safe_outputs( LinkGithubSubIssueResult, ); + let mut group_counts = HashMap::::new(); let mut results = Vec::new(); for (i, entry) in entries.iter().enumerate() { if let Some(result) = process_one_entry( @@ -276,6 +284,7 @@ pub async fn execute_safe_outputs( entries.len(), entry, &mut budgets, + &mut group_counts, filter, ctx, safe_output_dir, @@ -334,6 +343,7 @@ async fn process_one_entry( total: usize, entry: &Value, budgets: &mut HashMap<&'static str, (usize, usize)>, + group_counts: &mut HashMap, filter: &ToolFilter, ctx: &ExecutionContext, safe_output_dir: &Path, @@ -346,7 +356,34 @@ async fn process_one_entry( // Skip entries the active filter excludes (manual-review split: the // auto job excludes reviewed tools; the reviewed job runs only them). - if !filter.allows(proposal_tool_name) { + let canonical = if proposal_tool_name == "update-pr" { + entry + .get("operation") + .and_then(Value::as_str) + .and_then(crate::compile::pr_migration::focused_pr_tool) + .unwrap_or(proposal_tool_name) + } else { + proposal_tool_name + }; + let matches_filter = |names: &[String]| { + names.iter().any(|name| { + name == canonical + || name == proposal_tool_name + || (name == "update-pr" + && ctx.tool_configs.get(canonical).is_some_and(|config| { + config + .get(crate::compile::pr_migration::LEGACY_PR_CONFIG) + .is_some() + })) + }) + }; + let allowed = if canonical == proposal_tool_name && !matches_filter(&["update-pr".to_string()]) + { + filter.allows(canonical) + } else { + (filter.only.is_empty() || matches_filter(&filter.only)) && !matches_filter(&filter.exclude) + }; + if !allowed { debug!( "[{}/{}] Skipping entry for tool '{}' (filtered out)", i + 1, @@ -362,7 +399,20 @@ async fn process_one_entry( // Generic budget enforcement: skip excess entries rather than aborting the whole batch. // Budget is consumed before execution so that failed attempts (target policy rejection, // network errors) still count — this prevents unbounded retries against a failing endpoint. - if let Some(result) = enforce_budget(entry, budgets, total, i) { + let group_failure = ctx.budget_groups.iter().find_map(|(name, group)| { + if (group.tools.iter().any(|tool| tool == canonical) + || (proposal_tool_name == "update-pr" && name == "update-pr")) + && group_counts.get(name).copied().unwrap_or(0) >= group.max + { + Some(ExecutionResult::budget_exhausted(format!( + "Skipped: shared budget group '{name}' limit ({}) already reached", + group.max + ))) + } else { + None + } + }); + if let Some(result) = group_failure.or_else(|| enforce_budget(entry, budgets, total, i)) { append_execution_record( safe_output_dir, proposal_tool_name, @@ -372,6 +422,13 @@ async fn process_one_entry( .await; return Some(result); } + for (name, group) in &ctx.budget_groups { + if group.tools.iter().any(|tool| tool == canonical) + || (proposal_tool_name == "update-pr" && name == "update-pr") + { + *group_counts.entry(name.clone()).or_default() += 1; + } + } let result = match execute_safe_output(entry, ctx).await { Ok((tool_name, result)) => { @@ -653,6 +710,10 @@ pub async fn execute_safe_output( .get("name") .and_then(|n| n.as_str()) .ok_or_else(|| anyhow::anyhow!("Safe output missing 'name' field"))?; + anyhow::ensure!( + tool_name != "update-pr" || ctx.tool_configs.contains_key("update-pr"), + "historical update-pr proposal has no trusted legacy configuration" + ); debug!("Dispatching tool: {}", tool_name); @@ -741,6 +802,9 @@ async fn dispatch_pr_tools( "create-pull-request" => CreatePrResult, "add-pr-comment" => AddPrCommentResult, "update-pr" => UpdatePrResult, + "add-pr-reviewers" => AddPrReviewersResult, + "add-pr-labels" => AddPrLabelsResult, + "set-pr-auto-complete" => SetPrAutoCompleteResult, "abandon-pull-request" => AbandonPullRequestResult, "update-pull-request" => UpdatePullRequestResult, "submit-pr-review" => SubmitPrReviewResult, @@ -911,6 +975,84 @@ mod tests { use std::collections::HashMap; use std::path::PathBuf; + #[tokio::test] + async fn migrated_pr_tools_share_original_budget_with_historical_records() { + for max in [0, 1, 2] { + let dir = tempfile::tempdir().unwrap(); + let entries = [ + serde_json::json!({"name":"add-pr-labels","pull_request_id":7,"labels":["first"]}), + serde_json::json!({"name":"update-pr","pull_request_id":7,"operation":"update-description","description":"legacy description"}), + serde_json::json!({"name":"update-pull-request","pull_request_id":7,"body":"canonical description"}), + ]; + let text = entries + .iter() + .map(Value::to_string) + .collect::>() + .join("\n"); + tokio::fs::write(dir.path().join(SAFE_OUTPUT_FILENAME), text) + .await + .unwrap(); + let ctx = ExecutionContext { + dry_run: true, + tool_configs: HashMap::from([( + "update-pr".to_string(), + serde_json::json!({ + "allowed-operations":["add-labels","update-description"],"max":max + }), + )]), + ..ExecutionContext::default() + }; + let results = execute_safe_outputs(dir.path(), &ctx, &ToolFilter::default()) + .await + .unwrap(); + assert_eq!(results.iter().filter(|result| result.success).count(), max); + assert_eq!( + results + .iter() + .filter(|result| result.is_budget_exhausted()) + .count(), + 3 - max + ); + } + } + + #[tokio::test] + async fn historical_pr_filters_use_canonical_review_lane() { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write(dir.path().join(SAFE_OUTPUT_FILENAME), + "{\"name\":\"update-pr\",\"pull_request_id\":7,\"operation\":\"update-description\",\"description\":\"legacy description\"}\n" + ).await.unwrap(); + let ctx = ExecutionContext { + dry_run: true, + tool_configs: HashMap::from([( + "update-pr".to_string(), + serde_json::json!({ + "allowed-operations":["update-description"],"require-approval":true + }), + )]), + ..ExecutionContext::default() + }; + let automatic = ToolFilter { + exclude: vec!["update-pull-request".to_string()], + ..Default::default() + }; + assert!( + execute_safe_outputs(dir.path(), &ctx, &automatic) + .await + .unwrap() + .is_empty() + ); + let reviewed = ToolFilter { + only: vec!["update-pull-request".to_string()], + ..Default::default() + }; + let results = execute_safe_outputs(dir.path(), &ctx, &reviewed) + .await + .unwrap(); + assert_eq!(results.len(), 1); + assert!(results[0].success); + } + async fn append_and_read_execution_record(result: ExecutionResult) -> Value { let dir = tempfile::tempdir().expect("create temp dir"); append_execution_record_impl(dir.path(), "update-pull-request", &result, Some("pr-42")) diff --git a/src/inspect/catalog.rs b/src/inspect/catalog.rs index bc306c7cc..632d4b85d 100644 --- a/src/inspect/catalog.rs +++ b/src/inspect/catalog.rs @@ -310,7 +310,11 @@ fn safe_output_description(name: &str) -> &'static str { "set-github-issue-type" => "Sets or clears the native type on a GitHub issue", "set-github-issue-field" => "Sets a repository-defined field on a GitHub issue", "submit-pr-review" => "Parameters for submitting a pull request review", - "update-pr" => "Parameters for updating a pull request", + "add-pr-reviewers" => "Adds policy-permitted Azure DevOps PR reviewers", + "add-pr-labels" => "Adds labels without replacing existing Azure DevOps PR labels", + "set-pr-auto-complete" => { + "Enables Azure DevOps PR auto-complete without bypassing branch policies" + } "update-pull-request" => "Updates an Azure DevOps pull request title or description", "unassign-github-issue-from-user" => { "Removes operator-permitted GitHub users from an issue" diff --git a/src/inspect/cli.rs b/src/inspect/cli.rs index 8b3ef52a1..2913d4421 100644 --- a/src/inspect/cli.rs +++ b/src/inspect/cli.rs @@ -294,6 +294,23 @@ pub async fn build_lint(source: &Path) -> Result { let mut findings = lint::lint(&summary); findings.extend(lint::lint_front_matter_tasks(&front_matter)?); + let content = tokio::fs::read_to_string(source).await?; + let parsed = crate::compile::parse_markdown_detailed(&content)?; + let prefix = content.len().saturating_sub(parsed.body_raw.len()); + let offset = content[..prefix].lines().count().saturating_sub(1); + for line in crate::compile::pr_migration::deprecated_pr_prompt_lines(&parsed.body_raw) { + findings.push(lint::LintFinding { + severity: lint::LintSeverity::Warning, + code: "deprecated-tool-reference".to_string(), + message: format!( + "{}:{}: {}", + source.display(), + offset + line, + crate::compile::pr_migration::PR_PROMPT_GUIDANCE + ), + location: None, + }); + } Ok(lint::report_from_findings(findings)) } diff --git a/src/main.rs b/src/main.rs index bb834fbb0..0b1566e26 100644 --- a/src/main.rs +++ b/src/main.rs @@ -806,6 +806,8 @@ struct ResolvedExecutionConfig { #[serde(default)] tool_configs: std::collections::HashMap, #[serde(default)] + budget_groups: compile::pr_migration::BudgetGroups, + #[serde(default)] repositories: Vec, #[serde(default)] checkout: Vec, @@ -890,6 +892,7 @@ async fn build_execution_context_from_resolved( } ctx.working_directory = safe_output_dir.to_path_buf(); ctx.tool_configs = config.tool_configs.clone(); + ctx.budget_groups = config.budget_groups.clone(); crate::safe_outputs::configure_repository_write_context( &mut ctx, &config.checkout, @@ -1033,7 +1036,7 @@ async fn run_execute(options: RunExecuteOptions) -> Result<()> { ado_project, dry_run, ) - .await; + .await?; let results = execute::execute_safe_outputs(&safe_output_dir, &ctx, &filter).await?; @@ -1063,8 +1066,11 @@ async fn build_execution_context( ado_org_url: Option, ado_project: Option, dry_run: bool, -) -> crate::safe_outputs::ExecutionContext { - let mut ctx = crate::safe_outputs::ExecutionContext::default(); +) -> Result { + let mut ctx = crate::safe_outputs::ExecutionContext { + budget_groups: compile::pr_migration::budget_groups(&front_matter)?, + ..Default::default() + }; // Only override env-derived values when CLI args are explicitly provided; // otherwise keep the defaults from SYSTEM_TEAMFOUNDATIONCOLLECTIONURI / // SYSTEM_TEAMPROJECT that ExecutionContext::default() already resolved. @@ -1155,7 +1161,7 @@ async fn build_execution_context( log::debug!("No OTel stats file found at {}", otel_path.display()); } - ctx + Ok(ctx) } async fn process_cache_memory( diff --git a/src/mcp.rs b/src/mcp.rs index 68ea22501..a65e4d821 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -30,12 +30,15 @@ use crate::safe_outputs::{ ResolvePrThreadResult, SetGithubIssueFieldParams, SetGithubIssueFieldResult, SetGithubIssueTypeParams, SetGithubIssueTypeResult, SubmitPrReviewParams, SubmitPrReviewResult, ToolResult, UnassignGithubIssueFromUserParams, UnassignGithubIssueFromUserResult, - UpdateGithubIssueParams, UpdateGithubIssueResult, UpdatePrParams, UpdatePrResult, - UpdatePullRequestParams, UpdatePullRequestResult, UpdateWikiPageParams, UpdateWikiPageResult, - UpdateWorkItemParams, UpdateWorkItemResult, - UploadBuildAttachmentParams, UploadBuildAttachmentResult, UploadPipelineArtifactParams, - UploadPipelineArtifactResult, UploadWorkitemAttachmentParams, UploadWorkitemAttachmentResult, - Validate, anyhow_to_mcp_error, + UpdateGithubIssueParams, UpdateGithubIssueResult, UpdatePullRequestParams, + UpdatePullRequestResult, UpdateWikiPageParams, UpdateWikiPageResult, UpdateWorkItemParams, + UpdateWorkItemResult, UploadBuildAttachmentParams, UploadBuildAttachmentResult, + UploadPipelineArtifactParams, UploadPipelineArtifactResult, UploadWorkitemAttachmentParams, + UploadWorkitemAttachmentResult, Validate, anyhow_to_mcp_error, +}; +use crate::safe_outputs::{ + AddPrLabelsParams, AddPrLabelsResult, AddPrReviewersParams, AddPrReviewersResult, + SetPrAutoCompleteParams, SetPrAutoCompleteResult, }; use crate::sanitize::{SanitizeContent, sanitize as sanitize_text, sanitize_markdown}; use crate::secure::{PullRequestTemporaryId, WorkItemTemporaryId}; @@ -433,6 +436,9 @@ fn apply_tool_filter(tool_router: &mut ToolRouter, enabled_tools: O if let Some(enabled) = enabled_tools { for name in enabled { if !all_tools.iter().any(|t| t == name) { + if name == "update-pr" { + warn!("{}", crate::compile::pr_migration::PR_PROMPT_GUIDANCE); + } warn!( "Enabled-tools entry '{}' has no matching route (ignored)", name @@ -1107,7 +1113,7 @@ and only the fields you want to update." description = "Create a new pull request to propose code changes. This tool captures all \ changes in the repository (both committed and uncommitted) and creates a PR from them. \ Use 'self' for the pipeline's own repository, or a repository alias from the checkout list. \ -Returns a generated temporary_id that can be passed as pull_request_id to later update-pr calls." +Returns a generated temporary_id for configured PR content, reviewer, label, review or auto-complete follow-up tools." )] async fn create_pr( &self, @@ -1205,7 +1211,7 @@ Returns a generated temporary_id that can be passed as pull_request_id to later let canonical = temporary_id.canonical(); let mut response = CallToolResult::success(vec![Content::text(format!( - "PR request saved for repository '{}'. Patch file: {}. Use temporary ID {} as pull_request_id in later update-pr calls.", + "PR request saved for repository '{}'. Patch file: {}. Use temporary ID {} as pull_request_id in configured focused PR follow-up tools.", repository, result.patch_file, canonical ))]); response.structured_content = Some(serde_json::json!({ @@ -1434,29 +1440,39 @@ pull request. The branch will be created during safe output processing." } #[tool( - name = "update-pr", - description = "Update pull request metadata in Azure DevOps. Supports operations: \ -add-reviewers, add-labels, set-auto-complete, vote, update-description. \ -Changes will be applied during safe output processing." + name = "add-pr-reviewers", + description = "Add policy-permitted reviewers to an Azure DevOps PR. Accepts a numeric or same-run temporary PR ID." )] - async fn update_pr( + async fn add_pr_reviewers( &self, - params: Parameters, + params: Parameters, ) -> Result { - info!( - "Tool called: update-pr - PR #{} operation '{}'", - params.0.pull_request_id, params.0.operation - ); - let mut sanitized = params.0; - sanitized.description = sanitized.description.map(|d| sanitize_text(&d)); - let result: UpdatePrResult = sanitized.try_into()?; - self.write_safe_output_file(&result).await.map_err(|e| { - anyhow_to_mcp_error(anyhow::anyhow!("Failed to write safe output: {}", e)) - })?; - Ok(CallToolResult::success(vec![Content::text(format!( - "PR #{} '{}' operation queued. Changes will be applied during safe output processing.", - result.pull_request_id, result.operation - ))])) + let result: AddPrReviewersResult = params.0.try_into()?; + self.queue_sanitized_output(result).await + } + + #[tool( + name = "add-pr-labels", + description = "Add labels to an Azure DevOps PR without replacing existing labels. Accepts a numeric or same-run temporary PR ID." + )] + async fn add_pr_labels( + &self, + params: Parameters, + ) -> Result { + let result: AddPrLabelsResult = params.0.try_into()?; + self.queue_sanitized_output(result).await + } + + #[tool( + name = "set-pr-auto-complete", + description = "Enable Azure DevOps PR auto-complete using configured completion options. Does not merge immediately or bypass branch policies." + )] + async fn set_pr_auto_complete( + &self, + params: Parameters, + ) -> Result { + let result: SetPrAutoCompleteResult = params.0.try_into()?; + self.queue_sanitized_output(result).await } #[tool( @@ -2645,7 +2661,7 @@ safe-outputs: #[tokio::test] async fn test_all_configured_only_tools_are_routes() { - assert_eq!(CONFIGURED_ONLY_TOOLS.len(), 14); + assert_eq!(CONFIGURED_ONLY_TOOLS.len(), 19); let temp_dir = tempfile::tempdir().unwrap(); let enabled: Vec = CONFIGURED_ONLY_TOOLS .iter() @@ -2655,6 +2671,10 @@ safe-outputs: .await .unwrap(); let tools = so.tool_router.list_all(); + assert!( + !tools.iter().any(|tool| tool.name.as_ref() == "update-pr"), + "historical update-pr must never be an advertised MCP route" + ); for configured_tool in CONFIGURED_ONLY_TOOLS { let route = tools .iter() diff --git a/src/safe_outputs/abandon_pull_request.rs b/src/safe_outputs/abandon_pull_request.rs index 381afe7ba..1a036d602 100644 --- a/src/safe_outputs/abandon_pull_request.rs +++ b/src/safe_outputs/abandon_pull_request.rs @@ -5,16 +5,16 @@ use log::{debug, info, warn}; use schemars::JsonSchema; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use crate::safe_outputs::{ - ExecutionContext, ExecutionResult, Executor, PATH_SEGMENT, Validate, - canonical_repository_alias, resolve_repo_name, +use super::pr_common::{ + PullRequestReference, repository_api_base, resolve_pr_target, resolved_reference_id, + validate_reference, }; -use crate::sanitize::{SanitizeContent, sanitize as sanitize_text, sanitize_config}; +use crate::safe_outputs::{ExecutionContext, ExecutionResult, Executor, Validate}; +use crate::sanitize::{SanitizeContent, sanitize_config, sanitize_markdown}; use crate::tool_result; use ado_aw_derive::SanitizeConfig; -use percent_encoding::utf8_percent_encode; -const MAX_COMMENT_LEN: usize = 65_536; +const MAX_COMMENT_LEN: usize = 4_000; #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub enum AbandonPullRequestTarget { @@ -97,9 +97,9 @@ impl<'de> Deserialize<'de> for AbandonPullRequestTarget { #[derive(Deserialize, JsonSchema)] pub struct AbandonPullRequestParams { - /// Positive Azure DevOps pull request ID. Required when config target is "*". + /// Positive Azure DevOps PR ID or same-run temporary ID. Required when target is "*". #[serde(default, alias = "pull_request_number")] - pub pull_request_id: Option, + pub pull_request_id: Option, /// Optional abandonment comment. #[serde(default)] pub body: Option, @@ -110,14 +110,14 @@ pub struct AbandonPullRequestParams { impl Validate for AbandonPullRequestParams { fn validate(&self) -> anyhow::Result<()> { - if let Some(id) = self.pull_request_id { - ensure!(id > 0, "pull_request_id must be positive"); + if let Some(id) = &self.pull_request_id { + validate_reference(id)?; } if let Some(body) = self.body.as_deref() { ensure!(!body.trim().is_empty(), "body must not be empty"); ensure!( - body.chars().count() <= MAX_COMMENT_LEN, - "body must be {MAX_COMMENT_LEN} characters or fewer" + body.encode_utf16().count() <= MAX_COMMENT_LEN, + "body must be {MAX_COMMENT_LEN} UTF-16 units or fewer" ); } if let Some(repository) = self.repository.as_deref() { @@ -125,6 +125,7 @@ impl Validate for AbandonPullRequestParams { !repository.trim().is_empty(), "repository must not be empty" ); + crate::validate::reject_pipeline_injection(repository, "repository")?; } Ok(()) } @@ -138,7 +139,7 @@ tool_result! { /// Result of abandoning an Azure DevOps pull request. pub struct AbandonPullRequestResult { #[serde(default, alias = "pull_request_number")] - pull_request_id: Option, + pull_request_id: Option, #[serde(default)] body: Option, #[serde(default)] @@ -148,7 +149,7 @@ tool_result! { impl SanitizeContent for AbandonPullRequestResult { fn sanitize_content_fields(&mut self) { - self.body = self.body.as_deref().map(sanitize_text); + self.body = self.body.as_deref().map(sanitize_markdown); self.repository = self.repository.as_deref().map(sanitize_config); } } @@ -156,6 +157,9 @@ impl SanitizeContent for AbandonPullRequestResult { #[derive(Debug, Clone, SanitizeConfig, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct AbandonPullRequestConfig { + #[serde(default = "default_true", rename = "include-stats")] + #[sanitize_config(skip)] + pub include_stats: bool, #[serde(default)] #[sanitize_config(skip)] pub target: AbandonPullRequestTarget, @@ -176,6 +180,7 @@ pub struct AbandonPullRequestConfig { impl Default for AbandonPullRequestConfig { fn default() -> Self { Self { + include_stats: true, target: AbandonPullRequestTarget::Triggering, target_repo: None, allowed_repositories: Vec::new(), @@ -186,9 +191,27 @@ impl Default for AbandonPullRequestConfig { } } +fn default_true() -> bool { + true +} + pub(crate) fn validate_abandon_pull_request_config( config: &AbandonPullRequestConfig, ) -> anyhow::Result<()> { + if let AbandonPullRequestTarget::Id(id) = config.target { + ensure!(id > 0, "target pull request ID must be positive"); + } + for repository in config + .allowed_repositories + .iter() + .chain(config.target_repo.iter()) + { + ensure!( + !repository.trim().is_empty(), + "repository must not be empty" + ); + crate::validate::reject_pipeline_injection(repository, "repository")?; + } for label in &config.required_labels { ensure!( !label.trim().is_empty(), @@ -226,9 +249,14 @@ impl AbandonPullRequestResult { config: &AbandonPullRequestConfig, ctx: &ExecutionContext, ) -> Result { + let requested = self + .pull_request_id + .as_ref() + .map(|reference| resolved_reference_id(reference, ctx)) + .transpose()?; match config.target { AbandonPullRequestTarget::Id(id) => { - if let Some(requested) = self.pull_request_id + if let Some(requested) = requested && requested != id { return Err(ExecutionResult::failure(format!( @@ -237,7 +265,7 @@ impl AbandonPullRequestResult { } Ok(id) } - AbandonPullRequestTarget::Any => self.pull_request_id.ok_or_else(|| { + AbandonPullRequestTarget::Any => requested.ok_or_else(|| { ExecutionResult::failure( "pull_request_id is required when safe-outputs.abandon-pull-request.target is '*'", ) @@ -248,7 +276,7 @@ impl AbandonPullRequestResult { "safe-outputs.abandon-pull-request.target is 'triggering' but no Azure DevOps pull request context is available; use target: '*' and pass pull_request_id, or configure a numeric target", ) })?; - if let Some(requested) = self.pull_request_id + if let Some(requested) = requested && requested != triggering { return Err(ExecutionResult::failure(format!( @@ -260,39 +288,8 @@ impl AbandonPullRequestResult { } } - fn repository_selector<'a>(&'a self, config: &'a AbandonPullRequestConfig) -> &'a str { - self.repository - .as_deref() - .or(config.target_repo.as_deref()) - .unwrap_or("self") - } - - fn resolve_repository( - &self, - config: &AbandonPullRequestConfig, - ctx: &ExecutionContext, - ) -> Result { - let selector = self.repository_selector(config); - let Some(alias) = canonical_repository_alias(selector, ctx) else { - return Err(ExecutionResult::failure(format!( - "Repository '{}' is not in the configured checkout list", - crate::sanitize::neutralize_pipeline_commands(selector) - ))); - }; - if !config.allowed_repositories.is_empty() - && !config - .allowed_repositories - .iter() - .filter_map(|allowed| canonical_repository_alias(allowed, ctx)) - .any(|allowed| allowed == alias) - { - return Err(ExecutionResult::failure(format!( - "Repository '{}' is not in the allowed-repositories list: [{}]", - crate::sanitize::neutralize_pipeline_commands(selector), - config.allowed_repositories.join(", ") - ))); - } - resolve_repo_name(Some(&alias), ctx) + fn repository_selector<'a>(&'a self, config: &'a AbandonPullRequestConfig) -> Option<&'a str> { + self.repository.as_deref().or(config.target_repo.as_deref()) } fn validate_filters( @@ -365,7 +362,6 @@ async fn fetch_pr( async fn post_comment( client: &reqwest::Client, base_url: &str, - repo_name: &str, pull_request_id: u64, token: &str, ctx: &ExecutionContext, @@ -375,10 +371,8 @@ async fn post_comment( return Ok(Ok(false)); }; let url = format!( - "{}/{}/pullRequests/{}/threads?api-version=7.1", - base_url, - utf8_percent_encode(repo_name, PATH_SEGMENT), - pull_request_id, + "{}/pullRequests/{}/threads?api-version=7.1", + base_url, pull_request_id, ); let thread_body = serde_json::json!({ "comments": [{ @@ -448,6 +442,7 @@ impl Executor for AbandonPullRequestResult { fn dry_run_summary(&self) -> String { let target = self .pull_request_id + .as_ref() .map(|id| format!("#{id}")) .unwrap_or_else(|| "the configured or triggering target".to_string()); format!("abandon Azure DevOps pull request {target}") @@ -455,7 +450,7 @@ impl Executor for AbandonPullRequestResult { async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { let params = AbandonPullRequestParams { - pull_request_id: self.pull_request_id, + pull_request_id: self.pull_request_id.clone(), body: self.body.clone(), repository: self.repository.clone(), }; @@ -467,14 +462,6 @@ impl Executor for AbandonPullRequestResult { "abandon-pull-request is not configured for this workflow", )); } - let org_url = ctx - .ado_org_url - .as_ref() - .ok_or_else(|| anyhow::anyhow!("AZURE_DEVOPS_ORG_URL not set"))?; - let project = ctx - .ado_project - .as_ref() - .ok_or_else(|| anyhow::anyhow!("SYSTEM_TEAMPROJECT not set"))?; let token = ctx.access_token.as_ref().ok_or_else(|| { anyhow::anyhow!( "No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)" @@ -489,21 +476,25 @@ impl Executor for AbandonPullRequestResult { Ok(id) => id, Err(result) => return Ok(result), }; - let repo_name = match self.resolve_repository(&config, ctx) { - Ok(repo_name) => repo_name, + let reference = self + .pull_request_id + .clone() + .unwrap_or(PullRequestReference::Number(pull_request_id)); + let (_, target) = match resolve_pr_target( + &reference, + self.repository_selector(&config), + &config.allowed_repositories, + ctx, + )? { + Ok(target) => target, Err(result) => return Ok(result), }; + let repo_name = target.qualified_repository(); let client = reqwest::Client::new(); - let base_url = format!( - "{}/{}/_apis/git/repositories", - org_url.trim_end_matches('/'), - utf8_percent_encode(project, PATH_SEGMENT), - ); + let base_url = repository_api_base(&target); let pr_url = format!( - "{}/{}/pullRequests/{}?api-version=7.1", - base_url, - utf8_percent_encode(&repo_name, PATH_SEGMENT), - pull_request_id, + "{}/pullRequests/{}?api-version=7.1", + base_url, pull_request_id, ); debug!("abandon-pull-request API URL: {}", pr_url); @@ -529,7 +520,9 @@ impl Executor for AbandonPullRequestResult { "pull_request_id": pull_request_id, "repository": repo_name, "already_abandoned": true, + "abandoned": true, "comment_posted": false, + "comment_status": "not-attempted", }), )); } @@ -540,6 +533,28 @@ impl Executor for AbandonPullRequestResult { ))); } + let comment = self.body.as_deref().map(|body| { + let body = sanitize_markdown(body); + if config.include_stats { + crate::agent_stats::append_stats_to_body(&body, ctx, true) + } else { + body + } + }); + if comment + .as_deref() + .is_some_and(|body| body.trim().is_empty()) + { + return Ok(ExecutionResult::failure("sanitized body must not be empty")); + } + if comment + .as_deref() + .is_some_and(|body| body.encode_utf16().count() > MAX_COMMENT_LEN) + { + return Ok(ExecutionResult::failure(format!( + "assembled abandonment comment exceeds {MAX_COMMENT_LEN} UTF-16 units" + ))); + } if let Err(result) = abandon_pr(&client, &pr_url, pull_request_id, token, ctx).await? { return Ok(result); } @@ -547,20 +562,29 @@ impl Executor for AbandonPullRequestResult { let comment_posted = match post_comment( &client, &base_url, - &repo_name, pull_request_id, token, ctx, - self.body.as_deref(), + comment.as_deref(), ) - .await? + .await { - Ok(posted) => posted, - Err(result) => { - return Ok(ExecutionResult::warning(format!( - "Abandoned Azure DevOps PR #{} but failed to add comment: {}", - pull_request_id, result.message - ))); + Ok(Ok(posted)) => posted, + Ok(Err(result)) => { + return Ok(abandoned_comment_warning( + pull_request_id, + &repo_name, + "failed", + &result.message, + )); + } + Err(error) => { + return Ok(abandoned_comment_warning( + pull_request_id, + &repo_name, + "uncertain", + &error.to_string(), + )); } }; @@ -571,12 +595,34 @@ impl Executor for AbandonPullRequestResult { "pull_request_id": pull_request_id, "repository": repo_name, "already_abandoned": false, + "abandoned": true, "comment_posted": comment_posted, + "comment_status": if comment_posted { "posted" } else { "not-requested" }, }), )) } } +fn abandoned_comment_warning( + pr_id: u64, + repository: &str, + status: &str, + reason: &str, +) -> ExecutionResult { + ExecutionResult::warning_with_data( + format!("Abandoned Azure DevOps PR #{pr_id} but failed to add comment: {reason}"), + serde_json::json!({ + "pull_request_id": pr_id, + "repository": repository, + "abandoned": true, + "already_abandoned": false, + "comment_posted": false, + "comment_status": status, + "comment_error": reason, + }), + ) +} + #[cfg(test)] mod tests { use super::*; @@ -590,6 +636,7 @@ mod tests { tool_configs.insert("abandon-pull-request".to_string(), config); ExecutionContext { ado_org_url: Some(server.uri()), + ado_organization: Some("org".to_string()), ado_project: Some("proj".to_string()), access_token: Some("token".to_string()), tool_configs, @@ -637,7 +684,7 @@ mod tests { fn validates_optional_id_body_and_repository() { assert!( AbandonPullRequestParams { - pull_request_id: Some(42), + pull_request_id: Some(PullRequestReference::Number(42)), body: Some("Closing as stale.".to_string()), repository: Some("self".to_string()), } @@ -646,7 +693,7 @@ mod tests { ); assert!( AbandonPullRequestParams { - pull_request_id: Some(0), + pull_request_id: Some(PullRequestReference::Number(0)), body: None, repository: None, } @@ -655,6 +702,270 @@ mod tests { ); } + #[tokio::test] + async fn comment_http_failure_retains_abandonment_data() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/proj/_apis/git/repositories/repo/pullRequests/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(pr("active"))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PATCH")) + .and(path("/proj/_apis/git/repositories/repo/pullRequests/7")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path( + "/proj/_apis/git/repositories/repo/pullRequests/7/threads", + )) + .respond_with(ResponseTemplate::new(500)) + .expect(1) + .mount(&server) + .await; + let ctx = context(&server, serde_json::json!({"target": "*"})); + let mut result: AbandonPullRequestResult = serde_json::from_value(serde_json::json!({ + "name": "abandon-pull-request", "pull_request_id": "7", "body": "Closing as stale." + })) + .unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(execution.success && execution.is_warning()); + let data = execution.data.unwrap(); + assert_eq!(data["abandoned"], true); + assert_eq!(data["comment_posted"], false); + assert_eq!(data["comment_status"], "failed"); + assert_eq!(data["pull_request_id"], 7); + } + + #[tokio::test] + async fn transport_failure_after_abandon_is_warning_without_retry() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let uri = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + for (index, expected_method) in ["GET", "PATCH", "POST"].iter().enumerate() { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let mut buffer = [0u8; 1024]; + loop { + let count = socket.read(&mut buffer).await.unwrap(); + assert!(count > 0); + request.extend_from_slice(&buffer[..count]); + if let Some(header_end) = + request.windows(4).position(|bytes| bytes == b"\r\n\r\n") + { + let header = String::from_utf8_lossy(&request[..header_end]); + let content_length = header + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().unwrap()) + }) + .unwrap_or(0); + if request.len() >= header_end + 4 + content_length { + break; + } + } + } + assert!(String::from_utf8_lossy(&request).starts_with(*expected_method)); + if index == 2 { + // The server may have received the comment, but its acknowledgement is lost. + drop(socket); + break; + } + let body = if index == 0 { + r#"{"status":"active"}"# + } else { + "{}" + }; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + socket.write_all(response.as_bytes()).await.unwrap(); + socket.shutdown().await.unwrap(); + } + }); + let mut ctx = ExecutionContext { + ado_org_url: Some(uri), + ado_organization: Some("org".into()), + ado_project: Some("proj".into()), + repository_name: Some("repo".into()), + access_token: Some("token".into()), + ..Default::default() + }; + ctx.tool_configs.insert( + "abandon-pull-request".into(), + serde_json::json!({"target": "*"}), + ); + let mut result: AbandonPullRequestResult = serde_json::from_value(serde_json::json!({ + "name": "abandon-pull-request", "pull_request_id": 7, "body": "Closing as stale." + })) + .unwrap(); + let execution = tokio::time::timeout( + std::time::Duration::from_secs(10), + result.execute_sanitized(&ctx), + ) + .await + .unwrap() + .unwrap(); + server.await.unwrap(); + assert!(execution.success && execution.is_warning()); + let data = execution.data.unwrap(); + assert_eq!(data["abandoned"], true); + assert_eq!(data["comment_status"], "uncertain"); + } + + #[tokio::test] + async fn abandon_temp_reference_uses_registered_target_and_bearer_auth() { + use wiremock::matchers::header; + let server = MockServer::start().await; + let route = "/Other/_apis/git/repositories/repo-id/pullRequests/4294967296"; + Mock::given(method("GET")) + .and(path(route)) + .and(header("authorization", "Bearer token")) + .respond_with(ResponseTemplate::new(200).set_body_json(pr("active"))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PATCH")) + .and(path(route)) + .and(header("authorization", "Bearer token")) + .and(body_json(serde_json::json!({"status": "abandoned"}))) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + let mut ctx = super::super::pr_common::tests::registered_context( + &server.uri(), + "abandon-pull-request", + serde_json::json!({"target": "*", "allowed-repositories": ["other"]}), + ); + ctx.write_connection_type = Some(crate::compile::types::WriteConnectionType::AzureDevOps); + let mut result: AbandonPullRequestResult = serde_json::from_value(serde_json::json!({ + "name": "abandon-pull-request", "pull_request_id": "#aw_pr123" + })) + .unwrap(); + assert!(result.execute_sanitized(&ctx).await.unwrap().success); + } + + #[tokio::test] + async fn abandon_temp_reference_cannot_bypass_target_or_filters() { + for target in [serde_json::json!(7), serde_json::json!("triggering")] { + let server = MockServer::start().await; + let mut ctx = super::super::pr_common::tests::registered_context( + &server.uri(), + "abandon-pull-request", + serde_json::json!({"target": target}), + ); + ctx.pull_request_id = Some("7".into()); + let mut result: AbandonPullRequestResult = serde_json::from_value(serde_json::json!({ + "name": "abandon-pull-request", "pull_request_id": "#aw_pr123" + })) + .unwrap(); + assert!(!result.execute_sanitized(&ctx).await.unwrap().success); + assert!(server.received_requests().await.unwrap().is_empty()); + } + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/Other/_apis/git/repositories/repo-id/pullRequests/4294967296", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(pr("active"))) + .expect(1) + .mount(&server) + .await; + let ctx = super::super::pr_common::tests::registered_context( + &server.uri(), + "abandon-pull-request", + serde_json::json!({"target": "*", "required-labels": ["missing"]}), + ); + let mut result: AbandonPullRequestResult = serde_json::from_value(serde_json::json!({ + "name": "abandon-pull-request", "pull_request_id": "#aw_pr123" + })) + .unwrap(); + assert!(!result.execute_sanitized(&ctx).await.unwrap().success); + assert_eq!(server.received_requests().await.unwrap().len(), 1); + } + + #[test] + fn abandonment_markdown_and_stats_respect_final_utf16_limit() { + assert!(AbandonPullRequestConfig::default().include_stats); + for (body, valid) in [("😀".repeat(2000), true), ("😀".repeat(2001), false)] { + assert_eq!( + AbandonPullRequestParams { + pull_request_id: None, + repository: None, + body: Some(body) + } + .validate() + .is_ok(), + valid + ); + } + } + + #[tokio::test] + async fn assembled_abandonment_comment_is_checked_before_mutation() { + for (body, expected_success) in [("``".to_string(), true), ("a".repeat(4000), false)] + { + let server = MockServer::start().await; + let route = "/proj/_apis/git/repositories/repo/pullRequests/7"; + Mock::given(method("GET")) + .and(path(route)) + .respond_with(ResponseTemplate::new(200).set_body_json(pr("active"))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PATCH")) + .and(path(route)) + .respond_with(ResponseTemplate::new(200)) + .expect(if expected_success { 1 } else { 0 }) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path(format!("{route}/threads"))) + .respond_with(ResponseTemplate::new(200)) + .expect(if expected_success { 1 } else { 0 }) + .mount(&server) + .await; + let mut ctx = context(&server, serde_json::json!({"target": "*"})); + ctx.agent_stats = Some(crate::agent_stats::AgentStats { + agent_name: "review-agent".into(), + model: None, + input_tokens: 1, + output_tokens: 1, + ai_credits: None, + duration_seconds: 1.0, + tool_calls: 1, + turns: 1, + }); + let mut result: AbandonPullRequestResult = serde_json::from_value(serde_json::json!({ + "name": "abandon-pull-request", "pull_request_id": 7, "body": body + })) + .unwrap(); + assert_eq!( + result.execute_sanitized(&ctx).await.unwrap().success, + expected_success + ); + if expected_success { + let requests = server.received_requests().await.unwrap(); + let posted = requests + .iter() + .find(|request| request.method.as_str() == "POST") + .unwrap(); + let body: serde_json::Value = serde_json::from_slice(&posted.body).unwrap(); + let content = body["comments"][0]["content"].as_str().unwrap(); + assert!(content.starts_with("``")); + assert!(content.contains("review-agent")); + } + } + } + #[tokio::test] async fn abandons_with_comment_and_filters() { let server = MockServer::start().await; @@ -694,12 +1005,13 @@ mod tests { &server, serde_json::json!({ "target": "*", + "include-stats": false, "required-labels": ["automated", "stale"], "required-title-prefix": "[bot]" }), ); let mut result: AbandonPullRequestResult = AbandonPullRequestParams { - pull_request_id: Some(7), + pull_request_id: Some(PullRequestReference::Number(7)), body: Some("Closing as stale.".to_string()), repository: None, } @@ -765,7 +1077,7 @@ mod tests { serde_json::json!({"target": "*", "required-labels": ["missing"]}), ); let mut result: AbandonPullRequestResult = AbandonPullRequestParams { - pull_request_id: Some(7), + pull_request_id: Some(PullRequestReference::Number(7)), body: None, repository: None, } @@ -800,7 +1112,7 @@ mod tests { serde_json::json!({"target": "*", "allowed-repositories": ["other"]}), ); let mut result: AbandonPullRequestResult = AbandonPullRequestParams { - pull_request_id: Some(7), + pull_request_id: Some(PullRequestReference::Number(7)), body: None, repository: None, } @@ -832,7 +1144,7 @@ mod tests { serde_json::json!({"target": "*", "required-title-prefix": "[manual]"}), ); let mut result: AbandonPullRequestResult = AbandonPullRequestParams { - pull_request_id: Some(7), + pull_request_id: Some(PullRequestReference::Number(7)), body: None, repository: None, } @@ -865,7 +1177,7 @@ mod tests { .await; let ctx = context(&server, serde_json::json!({"target": "*"})); let mut result: AbandonPullRequestResult = AbandonPullRequestParams { - pull_request_id: Some(7), + pull_request_id: Some(PullRequestReference::Number(7)), body: None, repository: None, } @@ -887,7 +1199,7 @@ mod tests { let server = MockServer::start().await; let ctx = context(&server, serde_json::json!({"target": 42})); let mut result: AbandonPullRequestResult = AbandonPullRequestParams { - pull_request_id: Some(7), + pull_request_id: Some(PullRequestReference::Number(7)), body: None, repository: None, } diff --git a/src/safe_outputs/add_pr_labels.rs b/src/safe_outputs/add_pr_labels.rs new file mode 100644 index 000000000..45d70fcf2 --- /dev/null +++ b/src/safe_outputs/add_pr_labels.rs @@ -0,0 +1,226 @@ +//! Add labels without replacing or removing existing Azure DevOps PR labels. + +use super::pr_common::{ + PullRequestReference, legacy_policy, resolve_pr_target, validate_reference, +}; +use super::pr_mutations::{UpdatePrContext, execute_add_labels}; +use super::{ExecutionContext, ExecutionResult, Executor, Validate}; +use crate::sanitize::{SanitizeContent, sanitize_config}; +use crate::tool_result; +use ado_aw_derive::SanitizeConfig; +use anyhow::{Context, ensure}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, JsonSchema)] +pub struct AddPrLabelsParams { + pub pull_request_id: PullRequestReference, + #[serde(default)] + pub repository: Option, + pub labels: Vec, +} + +impl Validate for AddPrLabelsParams { + fn validate(&self) -> anyhow::Result<()> { + validate_reference(&self.pull_request_id)?; + ensure!(!self.labels.is_empty(), "labels list must not be empty"); + if let Some(repository) = &self.repository { + crate::validate::reject_pipeline_injection(repository, "repository")?; + } + Ok(()) + } +} + +tool_result! { + name = "add-pr-labels", + write = true, + params = AddPrLabelsParams, + pub struct AddPrLabelsResult { + pull_request_id: PullRequestReference, + #[serde(default)] + repository: Option, + labels: Vec, + } +} + +impl SanitizeContent for AddPrLabelsResult { + fn sanitize_content_fields(&mut self) { + self.repository = self.repository.as_deref().map(sanitize_config); + self.labels = self + .labels + .iter() + .map(|value| sanitize_config(value)) + .collect(); + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, SanitizeConfig)] +#[serde(deny_unknown_fields)] +pub struct AddPrLabelsConfig { + #[serde(default, rename = "allowed-repositories")] + pub allowed_repositories: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[sanitize_config(skip)] + pub max: Option, +} + +pub(crate) fn validate_add_pr_labels_config(config: &AddPrLabelsConfig) -> anyhow::Result<()> { + for repository in &config.allowed_repositories { + ensure!( + !repository.trim().is_empty(), + "allowed-repositories entries must not be empty" + ); + crate::validate::reject_pipeline_injection(repository, "allowed-repositories")?; + } + Ok(()) +} + +#[async_trait::async_trait] +impl Executor for AddPrLabelsResult { + fn dry_run_summary(&self) -> String { + format!("add labels to PR #{}", self.pull_request_id) + } + async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + if let Err(error) = (AddPrLabelsParams { + pull_request_id: self.pull_request_id.clone(), + repository: self.repository.clone(), + labels: self.labels.clone(), + }) + .validate() + { + return Ok(ExecutionResult::failure(error.to_string())); + } + ensure!( + ctx.tool_configs.contains_key("add-pr-labels"), + "add-pr-labels is not configured" + ); + let config: AddPrLabelsConfig = ctx.get_tool_config("add-pr-labels")?; + validate_add_pr_labels_config(&config)?; + let (pr_id, target) = match resolve_pr_target( + &self.pull_request_id, + self.repository.as_deref(), + &config.allowed_repositories, + ctx, + )? { + Ok(target) => target, + Err(failure) => return Ok(failure), + }; + if let Some(legacy) = legacy_policy(ctx, "add-pr-labels", "add-labels")? + && let Err(failure) = resolve_pr_target( + &self.pull_request_id, + self.repository.as_deref(), + &legacy.allowed_repositories, + ctx, + )? + { + return Ok(failure); + } + let client = reqwest::Client::new(); + execute_add_labels( + &UpdatePrContext { + client: &client, + target, + pr_id, + token: ctx + .access_token + .as_deref() + .context("No access token available")?, + connection_type: ctx.write_connection_type, + }, + &self.labels, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{body_json, method, path}, + }; + + #[test] + fn typed_config_rejects_unknown_fields_and_invalid_allowlists() { + for value in [ + serde_json::json!({"replace-labels": true}), + serde_json::json!({"allowed-repositories": "self"}), + ] { + assert!(serde_json::from_value::(value).is_err()); + } + for repository in ["", " ", "##vso[task.setvariable variable=x]y"] { + let config = AddPrLabelsConfig { + allowed_repositories: vec![repository.into()], + ..Default::default() + }; + assert!(validate_add_pr_labels_config(&config).is_err()); + } + assert!(validate_add_pr_labels_config(&AddPrLabelsConfig::default()).is_ok()); + let config: AddPrLabelsConfig = + serde_json::from_value(serde_json::json!({"max": 0})).unwrap(); + assert_eq!(config.max, Some(0)); + } + + #[tokio::test] + async fn adds_labels_to_exact_cross_project_target() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path( + "/Other/_apis/git/repositories/repo/pullRequests/4294967296/labels", + )) + .and(body_json(serde_json::json!({"name": "ready"}))) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + let mut ctx = ExecutionContext { + ado_org_url: Some(server.uri()), + ado_organization: Some("org".into()), + ado_project: Some("Current".into()), + access_token: Some("token".into()), + repository_name: Some("self".into()), + ..Default::default() + }; + ctx.allowed_repositories + .insert("other".into(), "Other/repo".into()); + ctx.tool_configs + .insert("add-pr-labels".into(), serde_json::json!({})); + let result: AddPrLabelsResult = serde_json::from_value(serde_json::json!({ + "name": "add-pr-labels", "pull_request_id": "4294967296", + "repository": "other", "labels": ["ready"] + })) + .unwrap(); + assert!(result.execute_impl(&ctx).await.unwrap().success); + } + + #[tokio::test] + async fn temporary_labels_keep_registered_target_and_legacy_scope() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path( + "/Other/_apis/git/repositories/repo-id/pullRequests/4294967296/labels", + )) + .and(body_json(serde_json::json!({"name": "ready"}))) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + let mut ctx = super::super::pr_common::tests::registered_context( + &server.uri(), + "add-pr-labels", + serde_json::json!({"legacy-update-pr": {"allowed-repositories": ["other"]}}), + ); + let mut result: AddPrLabelsResult = serde_json::from_value(serde_json::json!({ + "name": "add-pr-labels", "pull_request_id": "#aw_pr123", "labels": ["ready"] + })) + .unwrap(); + assert!(result.execute_sanitized(&ctx).await.unwrap().success); + ctx.tool_configs.insert( + "add-pr-labels".into(), + serde_json::json!({"legacy-update-pr": {"allowed-repositories": ["self"]}}), + ); + assert!(!result.execute_sanitized(&ctx).await.unwrap().success); + assert_eq!(server.received_requests().await.unwrap().len(), 1); + } +} diff --git a/src/safe_outputs/add_pr_reviewers.rs b/src/safe_outputs/add_pr_reviewers.rs new file mode 100644 index 000000000..5e504edf7 --- /dev/null +++ b/src/safe_outputs/add_pr_reviewers.rs @@ -0,0 +1,286 @@ +//! Add operator-authorized, verified reviewers to an Azure DevOps PR. + +use ado_aw_derive::SanitizeConfig; +use anyhow::{Context, ensure}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::pr_common::{PullRequestReference, legacy_policy, resolve_pr_target}; +use super::pr_mutations::{ + UpdatePrContext, execute_add_reviewers, validate_and_normalize_reviewers, +}; +use super::update_pr::{UpdatePrConfig, UpdatePrParams}; +use super::{ExecutionContext, ExecutionResult, Executor, Validate}; +use crate::sanitize::{SanitizeContent, sanitize_config}; +use crate::tool_result; + +#[derive(Deserialize, JsonSchema)] +pub struct AddPrReviewersParams { + pub pull_request_id: PullRequestReference, + #[serde(default)] + pub repository: Option, + /// Reviewer GUIDs, exact identity names or email addresses. + pub reviewers: Vec, +} + +impl Validate for AddPrReviewersParams { + fn validate(&self) -> anyhow::Result<()> { + UpdatePrParams { + pull_request_id: self.pull_request_id.clone(), + repository: self.repository.clone(), + operation: "add-reviewers".into(), + reviewers: Some(self.reviewers.clone()), + labels: None, + vote: None, + description: None, + } + .validate() + } +} + +tool_result! { + name = "add-pr-reviewers", + write = true, + params = AddPrReviewersParams, + pub struct AddPrReviewersResult { + pull_request_id: PullRequestReference, + #[serde(default)] + repository: Option, + reviewers: Vec, + } +} + +impl SanitizeContent for AddPrReviewersResult { + fn sanitize_content_fields(&mut self) { + self.repository = self.repository.as_deref().map(sanitize_config); + self.reviewers = self.reviewers.iter().map(|v| sanitize_config(v)).collect(); + } +} + +fn default_max_reviewers() -> usize { + 3 +} + +#[derive(Debug, Clone, Serialize, Deserialize, SanitizeConfig)] +#[serde(deny_unknown_fields)] +pub struct AddPrReviewersConfig { + #[serde(default, rename = "allowed-repositories")] + pub allowed_repositories: Vec, + /// Empty or literal "*" permits any otherwise-valid reviewer. + #[serde(default, rename = "allowed-reviewers")] + pub allowed_reviewers: Vec, + #[serde(default = "default_max_reviewers", rename = "max-reviewers")] + #[sanitize_config(skip)] + pub max_reviewers: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[sanitize_config(skip)] + pub max: Option, +} + +impl Default for AddPrReviewersConfig { + fn default() -> Self { + Self { + allowed_repositories: Vec::new(), + allowed_reviewers: Vec::new(), + max_reviewers: default_max_reviewers(), + max: None, + } + } +} + +pub(crate) fn validate_add_pr_reviewers_config( + config: &AddPrReviewersConfig, +) -> anyhow::Result<()> { + ensure!( + config.max_reviewers > 0, + "add-pr-reviewers.max-reviewers must be greater than zero" + ); + for reviewer in &config.allowed_reviewers { + ensure!( + !reviewer.trim().is_empty(), + "allowed-reviewers entries must not be empty" + ); + ensure!( + reviewer.len() <= 256, + "allowed-reviewers entries must be 256 bytes or fewer" + ); + crate::validate::reject_pipeline_injection(reviewer, "allowed-reviewers")?; + } + for repository in &config.allowed_repositories { + ensure!( + !repository.trim().is_empty(), + "allowed-repositories entries must not be empty" + ); + crate::validate::reject_pipeline_injection(repository, "allowed-repositories")?; + } + Ok(()) +} + +#[async_trait::async_trait] +impl Executor for AddPrReviewersResult { + fn dry_run_summary(&self) -> String { + format!("add reviewers to PR #{}", self.pull_request_id) + } + + async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + let params = AddPrReviewersParams { + pull_request_id: self.pull_request_id.clone(), + repository: self.repository.clone(), + reviewers: self.reviewers.clone(), + }; + if let Err(error) = params.validate() { + return Ok(ExecutionResult::failure(error.to_string())); + } + ensure!( + ctx.tool_configs.contains_key("add-pr-reviewers"), + "add-pr-reviewers is not configured" + ); + let config: AddPrReviewersConfig = ctx.get_tool_config("add-pr-reviewers")?; + validate_add_pr_reviewers_config(&config)?; + let policy = UpdatePrConfig { + allowed_repositories: config.allowed_repositories, + allowed_reviewers: config.allowed_reviewers, + max_reviewers: config.max_reviewers, + ..Default::default() + }; + if let Err(failure) = validate_and_normalize_reviewers(&self.reviewers, &policy) { + return Ok(failure); + } + let (pr_id, target) = match resolve_pr_target( + &self.pull_request_id, + self.repository.as_deref(), + &policy.allowed_repositories, + ctx, + )? { + Ok(target) => target, + Err(failure) => return Ok(failure), + }; + let legacy = legacy_policy(ctx, "add-pr-reviewers", "add-reviewers")?; + if let Some(legacy) = &legacy + && let Err(failure) = resolve_pr_target( + &self.pull_request_id, + self.repository.as_deref(), + &legacy.allowed_repositories, + ctx, + )? + { + return Ok(failure); + } + let client = reqwest::Client::new(); + execute_add_reviewers( + &UpdatePrContext { + client: &client, + target, + pr_id, + token: ctx + .access_token + .as_deref() + .context("No access token available")?, + connection_type: ctx.write_connection_type, + }, + legacy.as_ref().unwrap_or(&policy), + &self.reviewers, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn typed_config_rejects_unknown_fields_and_invalid_policy() { + for value in [ + serde_json::json!({"allowed-reviewer": ["owner"]}), + serde_json::json!({"allowed-reviewers": "owner"}), + serde_json::json!({"allowed-repositories": "self"}), + serde_json::json!({"max-reviewers": -1}), + serde_json::json!({"max-reviewers": 1.5}), + ] { + assert!(serde_json::from_value::(value).is_err()); + } + for value in [ + serde_json::json!({"max-reviewers": 0}), + serde_json::json!({"allowed-reviewers": [" "]}), + serde_json::json!({"allowed-reviewers": ["x".repeat(257)]}), + serde_json::json!({"allowed-reviewers": ["##vso[task.setvariable variable=x]y"]}), + serde_json::json!({"allowed-repositories": [""]}), + serde_json::json!({"allowed-repositories": ["##vso[task.setvariable variable=x]y"]}), + ] { + let config = serde_json::from_value::(value).unwrap(); + assert!(validate_add_pr_reviewers_config(&config).is_err()); + } + for allowed in [vec![], vec!["*"], vec!["Owner@example.com"]] { + let config: AddPrReviewersConfig = serde_json::from_value(serde_json::json!({ + "allowed-reviewers": allowed, "allowed-repositories": ["self"], "max": 0 + })) + .unwrap(); + assert!(validate_add_pr_reviewers_config(&config).is_ok()); + assert_eq!(config.max_reviewers, 3); + assert_eq!(config.max, Some(0)); + } + } + + #[test] + fn defaults_and_input_limits_match_legacy() { + assert_eq!(AddPrReviewersConfig::default().max_reviewers, 3); + for reviewers in [ + vec![], + vec!["x".repeat(257)], + vec!["x".into(); 101], + vec![" ".into()], + ] { + assert!( + AddPrReviewersParams { + pull_request_id: PullRequestReference::Number(1), + repository: None, + reviewers, + } + .validate() + .is_err() + ); + } + } + + #[tokio::test] + async fn historical_metadata_cannot_widen_reviewer_allowlist() { + let mut ctx = ExecutionContext::default(); + ctx.tool_configs.insert( + "add-pr-reviewers".into(), + serde_json::json!({ + "allowed-reviewers": ["permitted"], "legacy-update-pr": {"allowed-reviewers": ["*"]} + }), + ); + let result: AddPrReviewersResult = AddPrReviewersParams { + pull_request_id: PullRequestReference::Number(1), + repository: None, + reviewers: vec!["forbidden".into()], + } + .try_into() + .unwrap(); + assert!(!result.execute_impl(&ctx).await.unwrap().success); + } + + #[tokio::test] + async fn legacy_reviewer_policy_remains_an_additional_restriction() { + let server = wiremock::MockServer::start().await; + for legacy in [ + serde_json::json!({"allowed-reviewers": ["permitted"]}), + serde_json::json!({"max-reviewers": 1}), + ] { + let ctx = super::super::pr_common::tests::registered_context( + &server.uri(), + "add-pr-reviewers", + serde_json::json!({"allowed-reviewers": ["*"], "max-reviewers": 3, "legacy-update-pr": legacy}), + ); + let mut result: AddPrReviewersResult = serde_json::from_value(serde_json::json!({ + "name": "add-pr-reviewers", "pull_request_id": "#aw_pr123", + "reviewers": ["forbidden", "second"] + })) + .unwrap(); + assert!(!result.execute_sanitized(&ctx).await.unwrap().success); + assert!(server.received_requests().await.unwrap().is_empty()); + } + } +} diff --git a/src/safe_outputs/create_pull_request.rs b/src/safe_outputs/create_pull_request.rs index 20cd0ab3e..e7b9b8876 100644 --- a/src/safe_outputs/create_pull_request.rs +++ b/src/safe_outputs/create_pull_request.rs @@ -3565,6 +3565,7 @@ index 0000000..abcdefg resolved_pull_requests: std::sync::Arc::new(std::sync::Mutex::new( std::collections::HashMap::new(), )), + budget_groups: Default::default(), triggered_by_build_id: None, triggered_by_definition_name: None, triggered_by_build_number: None, diff --git a/src/safe_outputs/mod.rs b/src/safe_outputs/mod.rs index 19d0109fd..78f641538 100644 --- a/src/safe_outputs/mod.rs +++ b/src/safe_outputs/mod.rs @@ -35,6 +35,7 @@ pub const NON_MCP_SAFE_OUTPUT_KEYS: &[&str] = &[]; /// deliberately absent from [`ALL_KNOWN_SAFE_OUTPUTS`] (they have no tool type) /// and must be explicitly allowed in `validate_safe_outputs_keys`. pub const SAFE_OUTPUT_CONFIG_KEYS: &[&str] = &[ + "budget-groups", "report-failure-as-work-item", "github-token", "github-api-url", @@ -46,6 +47,11 @@ pub const DEBUG_ONLY_TOOLS: &[&str] = &[]; /// Public tools exposed only when explicitly configured in `safe-outputs:`. pub const CONFIGURED_ONLY_TOOLS: &[&str] = tool_names![ + AddPrReviewersResult, + AddPrLabelsResult, + SetPrAutoCompleteResult, + UpdatePullRequestResult, + AbandonPullRequestResult, AssignWorkItemResult, CreateGithubIssueResult, SetGithubIssueTypeResult, @@ -84,7 +90,9 @@ pub const ALL_KNOWN_SAFE_OUTPUTS: &[&str] = all_safe_output_names![ CreateGitTagResult, AddBuildTagResult, CreateBranchResult, - UpdatePrResult, + AddPrReviewersResult, + AddPrLabelsResult, + SetPrAutoCompleteResult, AbandonPullRequestResult, UpdatePullRequestResult, UploadBuildAttachmentResult, @@ -769,6 +777,8 @@ mod abandon_pull_request; mod add_build_tag; mod add_github_issue_labels; mod add_pr_comment; +mod add_pr_labels; +mod add_pr_reviewers; mod assign_github_issue_milestone; mod assign_github_issue_to_user; mod assign_work_item; @@ -789,6 +799,8 @@ mod link_work_items; mod missing_data; mod missing_tool; mod noop; +pub(crate) mod pr_common; +pub(crate) mod pr_mutations; mod queue_build; mod remove_github_issue_labels; mod reply_to_pr_comment; @@ -797,6 +809,7 @@ mod resolve_pr_thread; mod result; mod set_github_issue_field; mod set_github_issue_type; +mod set_pr_auto_complete; mod submit_pr_review; mod unassign_github_issue_from_user; mod update_github_issue; @@ -812,6 +825,8 @@ pub use abandon_pull_request::*; pub use add_build_tag::*; pub use add_github_issue_labels::*; pub use add_pr_comment::*; +pub use add_pr_labels::*; +pub use add_pr_reviewers::*; pub use assign_github_issue_milestone::*; pub use assign_github_issue_to_user::*; pub use assign_work_item::*; @@ -843,6 +858,7 @@ pub use result::{ }; pub use set_github_issue_field::*; pub use set_github_issue_type::*; +pub use set_pr_auto_complete::*; pub use submit_pr_review::*; pub use unassign_github_issue_from_user::*; pub use update_github_issue::*; diff --git a/src/safe_outputs/pr_common.rs b/src/safe_outputs/pr_common.rs new file mode 100644 index 000000000..6dd7fb019 --- /dev/null +++ b/src/safe_outputs/pr_common.rs @@ -0,0 +1,353 @@ +//! Shared PR reference, target and trusted migration-policy handling. + +use anyhow::{Context, ensure}; +use percent_encoding::utf8_percent_encode; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::result::AdoRepositoryTarget; +use super::update_pr::UpdatePrConfig; +use super::{ + ExecutionContext, ExecutionResult, PATH_SEGMENT, canonical_repository_alias, + resolve_repository_write_target, +}; +use crate::sanitize::SanitizeConfig; +use crate::secure::PullRequestTemporaryId; + +pub(crate) const MAX_DESCRIPTION_UTF16: usize = 4_000; + +/// Positive Azure DevOps pull-request ID or a same-run temporary ID. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(untagged)] +pub enum PullRequestReference { + Number(u64), + Temporary(PullRequestTemporaryId), +} + +impl std::fmt::Display for PullRequestReference { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Number(id) => write!(formatter, "{id}"), + Self::Temporary(id) => formatter.write_str(&id.canonical()), + } + } +} + +impl<'de> Deserialize<'de> for PullRequestReference { + fn deserialize>(deserializer: D) -> Result { + struct ReferenceVisitor; + impl serde::de::Visitor<'_> for ReferenceVisitor { + type Value = PullRequestReference; + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("a positive pull-request ID or #aw_ temporary ID") + } + fn visit_u64(self, value: u64) -> Result { + Ok(PullRequestReference::Number(value)) + } + fn visit_i64(self, value: i64) -> Result { + u64::try_from(value) + .map(PullRequestReference::Number) + .map_err(|_| E::custom("pull_request_id must be positive")) + } + fn visit_str(self, value: &str) -> Result { + let value = value.trim(); + let numeric = value.strip_prefix('#').unwrap_or(value); + if !numeric.is_empty() && numeric.bytes().all(|c| c.is_ascii_digit()) { + return numeric + .parse::() + .map(PullRequestReference::Number) + .map_err(|_| E::custom("quoted pull_request_id is outside the u64 range")); + } + PullRequestTemporaryId::parse(value) + .map(PullRequestReference::Temporary) + .map_err(E::custom) + } + } + deserializer.deserialize_any(ReferenceVisitor) + } +} + +pub(crate) fn validate_reference(reference: &PullRequestReference) -> anyhow::Result<()> { + if let PullRequestReference::Number(id) = reference { + ensure!(*id > 0, "pull_request_id must be a positive integer"); + } + Ok(()) +} + +pub(crate) fn repository_api_base(target: &AdoRepositoryTarget) -> String { + format!( + "{}/{}/_apis/git/repositories/{}", + target.organization_url.trim_end_matches('/'), + utf8_percent_encode(&target.project, PATH_SEGMENT), + utf8_percent_encode(target.repository_locator(), PATH_SEGMENT) + ) +} + +fn repository_is_allowed(allowed: &[String], alias: &str, ctx: &ExecutionContext) -> bool { + allowed.is_empty() + || allowed.iter().any(|allowed| { + allowed.eq_ignore_ascii_case(alias) + || canonical_repository_alias(allowed, ctx) + .is_some_and(|candidate| candidate.eq_ignore_ascii_case(alias)) + }) +} + +pub(crate) fn resolve_pr_target( + reference: &PullRequestReference, + requested_repository: Option<&str>, + allowed_repositories: &[String], + ctx: &ExecutionContext, +) -> anyhow::Result> { + if let Err(error) = validate_reference(reference) { + return Ok(Err(ExecutionResult::failure(error.to_string()))); + } + let (id, target) = match reference { + PullRequestReference::Number(id) => { + let target = match resolve_repository_write_target(requested_repository, ctx) { + Ok(target) => target, + Err(failure) => return Ok(Err(failure)), + }; + (*id, target) + } + PullRequestReference::Temporary(temporary_id) => { + let Some(resolved) = ctx.resolve_pull_request(temporary_id)? else { + return Ok(Err(ExecutionResult::failure(format!( + "temporary pull-request ID '{}' has not been resolved; create-pull-request must succeed earlier in the same SafeOutputs job", + temporary_id.canonical() + )))); + }; + // The producer's exact destination is authoritative. Never reconstruct it from + // the consumer's default repository/current project when the selector is absent. + if let Some(selector) = requested_repository { + let alias = canonical_repository_alias(selector, ctx); + if alias + .as_deref() + .is_some_and(|alias| !alias.eq_ignore_ascii_case(&resolved.target.alias)) + { + return Ok(Err(ExecutionResult::failure(format!( + "temporary pull-request ID '{}' resolved to repository '{}', which does not match requested repository '{}'", + temporary_id.canonical(), + resolved.target.alias, + crate::sanitize::neutralize_pipeline_commands(selector) + )))); + } + let requested = match resolve_repository_write_target(Some(selector), ctx) { + Ok(target) => target, + Err(failure) => return Ok(Err(failure)), + }; + if !requested.alias.eq_ignore_ascii_case(&resolved.target.alias) + || !requested + .organization_url + .trim_end_matches('/') + .eq_ignore_ascii_case( + resolved.target.organization_url.trim_end_matches('/'), + ) + || !requested + .project + .eq_ignore_ascii_case(&resolved.target.project) + || !requested + .repository + .eq_ignore_ascii_case(&resolved.target.repository) + { + return Ok(Err(ExecutionResult::failure(format!( + "temporary pull-request ID '{}' resolved to repository '{}', which does not match requested repository '{}'", + temporary_id.canonical(), + resolved.target.alias, + crate::sanitize::neutralize_pipeline_commands(selector) + )))); + } + } + if resolved.id == 0 { + return Ok(Err(ExecutionResult::failure( + "resolved pull_request_id must be positive", + ))); + } + (resolved.id, resolved.target) + } + }; + if !repository_is_allowed(allowed_repositories, &target.alias, ctx) { + return Ok(Err(ExecutionResult::failure(format!( + "Repository '{}' is not in the allowed-repositories list: [{}]", + target.alias, + allowed_repositories.join(", ") + )))); + } + Ok(Ok((id, target))) +} + +pub(crate) fn resolved_reference_id( + reference: &PullRequestReference, + ctx: &ExecutionContext, +) -> Result { + match reference { + PullRequestReference::Number(id) if *id > 0 => Ok(*id), + PullRequestReference::Number(_) => Err(ExecutionResult::failure( + "pull_request_id must be positive", + )), + PullRequestReference::Temporary(id) => ctx + .resolve_pull_request(id) + .map_err(|error| ExecutionResult::failure(error.to_string()))? + .filter(|resolved| resolved.id > 0) + .map(|resolved| resolved.id) + .ok_or_else(|| ExecutionResult::failure(format!( + "temporary pull-request ID '{}' has not been resolved; create-pull-request must succeed earlier in the same SafeOutputs job", + id.canonical() + ))), + } +} + +/// Read compatibility policy only from trusted execution configuration, never proposal JSON. +pub(crate) fn legacy_policy( + ctx: &ExecutionContext, + tool: &str, + operation: &str, +) -> anyhow::Result> { + let Some(value) = ctx + .tool_configs + .get(tool) + .and_then(|config| config.get("legacy-update-pr")) + else { + return Ok(None); + }; + ensure!( + value.is_object(), + "{tool}.legacy-update-pr must be an object" + ); + let mut config: UpdatePrConfig = + serde_json::from_value(value.clone()).context("invalid legacy-update-pr policy")?; + config.sanitize_config_fields(); + ensure!( + config.allowed_operations.is_empty() + || config + .allowed_operations + .iter() + .any(|allowed| allowed == operation), + "Operation '{operation}' is not in the legacy allowed-operations list" + ); + Ok(Some(config)) +} + +pub(crate) fn validate_description(body: &str) -> anyhow::Result<()> { + ensure!( + body.encode_utf16().count() <= MAX_DESCRIPTION_UTF16, + "updated body exceeds Azure DevOps' {MAX_DESCRIPTION_UTF16}-UTF-16-unit limit" + ); + Ok(()) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + pub(crate) fn registered_context( + organization_url: &str, + tool: &str, + config: serde_json::Value, + ) -> ExecutionContext { + let mut ctx = ExecutionContext { + ado_org_url: Some("https://dev.azure.com/current-org".into()), + ado_organization: Some("current-org".into()), + ado_project: Some("Current".into()), + repository_name: Some("current".into()), + access_token: Some("token".into()), + ..Default::default() + }; + ctx.tool_configs.insert(tool.into(), config); + ctx.register_resolved_pull_request( + &PullRequestTemporaryId::parse("#aw_pr123").unwrap(), + super::super::ResolvedPullRequest { + id: 4_294_967_296, + url: format!("{organization_url}/pullrequest/4294967296"), + target: AdoRepositoryTarget { + alias: "other".into(), + organization: "other-org".into(), + organization_url: organization_url.into(), + project: "Other".into(), + repository: "repo".into(), + repository_id: Some("repo-id".into()), + cross_organization: true, + }, + }, + ) + .unwrap(); + ctx + } + + #[test] + fn reference_preserves_u64_and_compatibility_spellings() { + for json in [ + "18446744073709551615", + "\"18446744073709551615\"", + "\" #18446744073709551615 \"", + ] { + assert_eq!( + serde_json::from_str::(json).unwrap(), + PullRequestReference::Number(u64::MAX) + ); + } + assert!(serde_json::from_str::("\"18446744073709551616\"").is_err()); + assert!(serde_json::from_str::("-1").is_err()); + } + + #[test] + fn description_limit_counts_utf16_not_bytes_or_scalars() { + for size in [3999, 4000] { + assert!(validate_description(&"a".repeat(size)).is_ok()); + } + assert!(validate_description(&"a".repeat(4001)).is_err()); + assert!(validate_description(&"😀".repeat(2000)).is_ok()); + assert!(validate_description(&format!("{}a", "😀".repeat(2000))).is_err()); + } + + #[test] + fn temporary_reference_checks_exact_target_not_only_alias() { + let mut ctx = registered_context( + "https://dev.azure.com/other-org", + "add-pr-labels", + serde_json::json!({}), + ); + ctx.allowed_repositories + .insert("other".into(), "Changed/repo".into()); + let reference: PullRequestReference = serde_json::from_str("\"#aw_pr123\"").unwrap(); + let (_, preserved) = resolve_pr_target(&reference, None, &[], &ctx) + .unwrap() + .unwrap(); + assert_eq!(preserved.project, "Other"); + assert_eq!(preserved.repository_locator(), "repo-id"); + let failure = resolve_pr_target(&reference, Some("other"), &[], &ctx) + .unwrap() + .unwrap_err(); + assert!(failure.message.contains("does not match")); + assert!( + resolve_pr_target(&reference, None, &["self".into()], &ctx) + .unwrap() + .is_err() + ); + } + + #[test] + fn legacy_metadata_is_trusted_config_only_and_fail_closed() { + let mut ctx = ExecutionContext::default(); + ctx.tool_configs.insert( + "add-pr-labels".into(), + serde_json::json!({ + "legacy-update-pr": {"allowed-operations": ["vote"]} + }), + ); + assert!(legacy_policy(&ctx, "add-pr-labels", "add-labels").is_err()); + ctx.tool_configs.insert( + "add-pr-labels".into(), + serde_json::json!({ + "legacy-update-pr": null + }), + ); + assert!(legacy_policy(&ctx, "add-pr-labels", "add-labels").is_err()); + ctx.tool_configs + .insert("add-pr-labels".into(), serde_json::json!({})); + assert!( + legacy_policy(&ctx, "add-pr-labels", "add-labels") + .unwrap() + .is_none() + ); + } +} diff --git a/src/safe_outputs/pr_mutations.rs b/src/safe_outputs/pr_mutations.rs new file mode 100644 index 000000000..34e339fbe --- /dev/null +++ b/src/safe_outputs/pr_mutations.rs @@ -0,0 +1,1952 @@ +//! Shared Azure DevOps PR mutations and historical `update-pr` execution. + +pub use super::pr_common::PullRequestReference; +use super::pr_common::{repository_api_base, resolve_pr_target}; +use super::result::AdoRepositoryTarget; +use crate::safe_outputs::{ExecutionContext, ExecutionResult, Executor, Validate}; +use crate::sanitize::{SanitizeContent, sanitize as sanitize_text, sanitize_config}; +use crate::secure::Guid; +#[cfg(test)] +use crate::secure::PullRequestTemporaryId; +use crate::tool_result; +use crate::validate::reject_pipeline_injection; +use ado_aw_derive::SanitizeConfig; +use anyhow::{Context, ensure}; +use log::{debug, info, warn}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Valid operation names for update-pr +const VALID_OPERATIONS: &[&str] = &[ + "add-reviewers", + "add-labels", + "set-auto-complete", + "vote", + "update-description", +]; + +/// Valid vote values +const VALID_VOTES: &[&str] = &[ + "approve", + "approve-with-suggestions", + "wait-for-author", + "reject", + "reset", +]; + +/// Valid merge strategy values accepted by ADO's completionOptions.mergeStrategy +const VALID_MERGE_STRATEGIES: &[&str] = &["squash", "noFastForward", "rebase", "rebaseMerge"]; +const DEFAULT_MAX_REVIEWERS: usize = 3; +const MAX_REVIEWER_LEN: usize = 256; + +/// Map a vote string to its ADO numeric value +fn vote_to_ado_value(vote: &str) -> Option { + match vote { + "approve" => Some(10), + "approve-with-suggestions" => Some(5), + "wait-for-author" => Some(-5), + "reject" => Some(-10), + "reset" => Some(0), + _ => None, + } +} + +/// Parameters for updating a pull request +#[derive(Deserialize, JsonSchema)] +pub struct UpdatePrParams { + /// Positive pull request ID or a temporary ID from create-pull-request. + pub pull_request_id: PullRequestReference, + + /// Repository alias: "self" for the pipeline repo, or an alias from the checkout list + #[serde(default)] + pub repository: Option, + + /// Operation to perform: "add-reviewers", "add-labels", "set-auto-complete", "vote", or "update-description" + pub operation: String, + + /// Reviewer emails (required for add-reviewers operation) + pub reviewers: Option>, + + /// Label names (required for add-labels operation) + pub labels: Option>, + + /// Vote value: "approve", "approve-with-suggestions", "wait-for-author", "reject", or "reset" + pub vote: Option, + + /// New PR description in markdown (required for update-description, must be >= 10 chars) + pub description: Option, +} + +impl Validate for UpdatePrParams { + fn validate(&self) -> anyhow::Result<()> { + if let PullRequestReference::Number(id) = self.pull_request_id { + ensure!(id > 0, "pull_request_id must be positive"); + } + if let Some(repository) = &self.repository { + reject_pipeline_injection(repository, "repository")?; + } + ensure!( + VALID_OPERATIONS.contains(&self.operation.as_str()), + "operation must be one of: {}", + VALID_OPERATIONS.join(", ") + ); + + match self.operation.as_str() { + "add-reviewers" => { + let reviewers = self + .reviewers + .as_ref() + .context("reviewers must be provided for add-reviewers operation")?; + ensure!( + !reviewers.is_empty(), + "reviewers list must not be empty for add-reviewers operation" + ); + ensure!( + reviewers.len() <= 100, + "reviewers list must contain at most 100 entries" + ); + for reviewer in reviewers { + let reviewer = reviewer.trim(); + ensure!(!reviewer.is_empty(), "reviewer must not be empty"); + ensure!( + reviewer.len() <= MAX_REVIEWER_LEN, + "reviewer must be {MAX_REVIEWER_LEN} characters or fewer" + ); + reject_pipeline_injection(reviewer, "update-pr.reviewer")?; + } + } + "add-labels" => { + let labels = self + .labels + .as_ref() + .context("labels must be provided for add-labels operation")?; + ensure!( + !labels.is_empty(), + "labels list must not be empty for add-labels operation" + ); + } + "vote" => { + let vote = self + .vote + .as_ref() + .context("vote must be provided for vote operation")?; + ensure!( + VALID_VOTES.contains(&vote.as_str()), + "vote must be one of: {}", + VALID_VOTES.join(", ") + ); + } + "update-description" => { + let desc = self + .description + .as_ref() + .context("description must be provided for update-description operation")?; + ensure!( + desc.len() >= 10, + "description must be at least 10 characters" + ); + } + _ => {} // set-auto-complete has no extra required fields + } + Ok(()) + } +} + +tool_result! { + name = "update-pr", + write = true, + params = UpdatePrParams, + /// Result of updating a pull request + pub struct UpdatePrResult { + pull_request_id: PullRequestReference, + repository: Option, + operation: String, + reviewers: Option>, + labels: Option>, + vote: Option, + description: Option, + } +} + +impl SanitizeContent for UpdatePrResult { + fn sanitize_content_fields(&mut self) { + self.repository = self.repository.as_deref().map(sanitize_config); + self.operation = sanitize_config(&self.operation); + self.reviewers = self + .reviewers + .as_ref() + .map(|rs| rs.iter().map(|r| sanitize_config(r)).collect()); + self.labels = self + .labels + .as_ref() + .map(|ls| ls.iter().map(|l| sanitize_config(l)).collect()); + self.vote = self.vote.as_deref().map(sanitize_config); + self.description = self.description.as_deref().map(sanitize_text); + } +} + +/// Configuration for the update-pr tool (specified in front matter) +/// +/// **Allow-list semantics note:** `allowed-operations` and `allowed-repositories` use +/// permissive defaults (empty = all allowed), while `allowed-votes` uses a secure default +/// (empty = all rejected). This asymmetry is intentional — vote operations can auto-approve +/// PRs, so they require explicit opt-in to prevent accidental privilege escalation. +/// +/// Example front matter: +/// ```yaml +/// safe-outputs: +/// update-pr: +/// allowed-operations: +/// - add-reviewers +/// - set-auto-complete +/// allowed-repositories: +/// - self +/// allowed-votes: +/// - approve +/// - reject +/// ``` +#[derive(Debug, Clone, SanitizeConfig, Serialize, Deserialize)] +pub struct UpdatePrConfig { + /// Which operations are permitted. Empty list means all operations are allowed. + #[serde(default, rename = "allowed-operations")] + pub allowed_operations: Vec, + + /// Which repositories the agent may target. Empty list means all allowed repos. + #[serde(default, rename = "allowed-repositories")] + pub allowed_repositories: Vec, + + /// Which vote values are permitted. REQUIRED for vote operation — + /// empty list rejects all votes to prevent accidental auto-approve. + #[serde(default, rename = "allowed-votes")] + pub allowed_votes: Vec, + + /// Case-insensitive exact allowlist for model-selected reviewers. + /// Empty or a literal "*" allows any valid reviewer. + #[serde(default, rename = "allowed-reviewers")] + pub allowed_reviewers: Vec, + + /// Maximum reviewers accepted by one add-reviewers operation. + #[serde(default = "default_max_reviewers", rename = "max-reviewers")] + #[sanitize_config(skip)] + pub max_reviewers: usize, + + /// Whether to delete the source branch after merge (for set-auto-complete, default: true) + #[serde(default = "default_true", rename = "delete-source-branch")] + pub delete_source_branch: bool, + + /// Merge strategy for auto-complete: "squash", "noFastForward", "rebase", "rebaseMerge" (default: "squash") + #[serde(default = "default_merge_strategy", rename = "merge-strategy")] + pub merge_strategy: String, +} + +fn default_true() -> bool { + true +} + +fn default_merge_strategy() -> String { + "squash".to_string() +} + +fn default_max_reviewers() -> usize { + DEFAULT_MAX_REVIEWERS +} + +impl Default for UpdatePrConfig { + fn default() -> Self { + Self { + allowed_operations: Vec::new(), + allowed_repositories: Vec::new(), + allowed_votes: Vec::new(), + allowed_reviewers: Vec::new(), + max_reviewers: default_max_reviewers(), + delete_source_branch: true, + merge_strategy: "squash".to_string(), + } + } +} + +pub(crate) struct UpdatePrContext<'a> { + pub client: &'a reqwest::Client, + pub target: AdoRepositoryTarget, + pub pr_id: u64, + pub token: &'a str, + pub connection_type: Option, +} + +impl UpdatePrContext<'_> { + pub(crate) fn repository_api_base(&self) -> String { + repository_api_base(&self.target) + } +} + +fn resolve_update_pr_target( + reference: &PullRequestReference, + requested_repository: Option<&str>, + config: &UpdatePrConfig, + ctx: &ExecutionContext, +) -> anyhow::Result> { + resolve_pr_target( + reference, + requested_repository, + &config.allowed_repositories, + ctx, + ) +} + +#[async_trait::async_trait] +impl Executor for UpdatePrResult { + fn dry_run_summary(&self) -> String { + format!("{} on PR #{}", self.operation, self.pull_request_id) + } + + async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + let params = UpdatePrParams { + pull_request_id: self.pull_request_id.clone(), + repository: self.repository.clone(), + operation: self.operation.clone(), + reviewers: self.reviewers.clone(), + labels: self.labels.clone(), + vote: self.vote.clone(), + description: self.description.clone(), + }; + if let Err(error) = params.validate() { + return Ok(ExecutionResult::failure(error.to_string())); + } + info!( + "Updating PR #{} — operation: {}", + self.pull_request_id, self.operation + ); + debug!( + "update-pr: pr_id={}, operation='{}'", + self.pull_request_id, self.operation + ); + + let token = ctx + .access_token + .as_ref() + .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; + let config: UpdatePrConfig = ctx.get_tool_config("update-pr")?; + debug!("Config: {:?}", config); + + // Validate operation against allowed-operations + if !config.allowed_operations.is_empty() + && !config.allowed_operations.contains(&self.operation) + { + return Ok(ExecutionResult::failure(format!( + "Operation '{}' is not in the allowed-operations list: [{}]", + self.operation, + config.allowed_operations.join(", ") + ))); + } + + let (pr_id, target) = match resolve_update_pr_target( + &self.pull_request_id, + self.repository.as_deref(), + &config, + ctx, + )? { + Ok(target) => target, + Err(failure) => return Ok(failure), + }; + debug!("Resolved PR target: {} #{}", target.display_name(), pr_id); + + let client = reqwest::Client::new(); + let operation_ctx = UpdatePrContext { + client: &client, + target, + pr_id, + token, + connection_type: ctx.write_connection_type, + }; + + match self.operation.as_str() { + "set-auto-complete" => execute_set_auto_complete(&operation_ctx, &config).await, + "vote" => self.execute_vote(&operation_ctx, &config).await, + "add-reviewers" => { + execute_add_reviewers( + &operation_ctx, + &config, + self.reviewers + .as_deref() + .context("reviewers are required")?, + ) + .await + } + "add-labels" => { + execute_add_labels( + &operation_ctx, + self.labels.as_deref().context("labels are required")?, + ) + .await + } + "update-description" => { + execute_update_description( + &operation_ctx, + self.description + .as_deref() + .context("description is required")?, + ) + .await + } + _ => Ok(ExecutionResult::failure(format!( + "Unknown operation: {}", + self.operation + ))), + } + } +} + +/// Outcome of a single reviewer resolution + add attempt. +enum ReviewerAddResult { + Added, + Failed(String), +} + +fn reviewer_execution_result( + pr_id: u64, + added: Vec, + failed: Vec, +) -> ExecutionResult { + let mut message = format!("Added {} reviewer(s) to PR #{}", added.len(), pr_id); + if !failed.is_empty() { + message.push_str(&format!( + " ({} failed: {})", + failed.len(), + failed.join(", ") + )); + } + let has_failures = !failed.is_empty(); + let data = serde_json::json!({ + "pull_request_id": pr_id, + "operation": "add-reviewers", + "added": added, + "failed": failed, + }); + if has_failures { + ExecutionResult::warning_with_data(message, data) + } else { + ExecutionResult::success_with_data(message, data) + } +} + +pub(crate) fn validate_and_normalize_reviewers( + reviewers: &[String], + config: &UpdatePrConfig, +) -> Result, ExecutionResult> { + if config.max_reviewers == 0 { + return Err(ExecutionResult::failure( + "update-pr.max-reviewers must be greater than zero", + )); + } + let allow_any = config.allowed_reviewers.is_empty() + || config + .allowed_reviewers + .iter() + .any(|allowed| allowed == "*"); + + let mut normalized = Vec::new(); + for reviewer in reviewers { + let reviewer = reviewer.trim(); + if !allow_any + && !config + .allowed_reviewers + .iter() + .any(|allowed| allowed.eq_ignore_ascii_case(reviewer)) + { + return Err(ExecutionResult::failure(format!( + "Reviewer '{}' is not in update-pr.allowed-reviewers", + crate::sanitize::neutralize_pipeline_commands(reviewer) + ))); + } + if !normalized + .iter() + .any(|existing: &String| existing.eq_ignore_ascii_case(reviewer)) + { + normalized.push(reviewer.to_string()); + } + } + if normalized.len() > config.max_reviewers { + return Err(ExecutionResult::failure(format!( + "add-reviewers requested {} unique reviewers, exceeding max-reviewers: {}", + normalized.len(), + config.max_reviewers + ))); + } + Ok(normalized) +} + +/// Set auto-complete on a pull request. +/// +/// Resolves the authenticated user identity via `_apis/connectiondata`, then +/// patches the PR with `autoCompleteSetBy` and default completion options. +/// Uses the agent's own identity (not the PR creator) for proper audit trail. +pub(crate) async fn execute_set_auto_complete( + operation_ctx: &UpdatePrContext<'_>, + config: &UpdatePrConfig, +) -> anyhow::Result { + // Validate merge_strategy before any network I/O + if !VALID_MERGE_STRATEGIES.contains(&config.merge_strategy.as_str()) { + return Ok(ExecutionResult::failure(format!( + "Invalid merge-strategy '{}'. Must be one of: {}", + config.merge_strategy, + VALID_MERGE_STRATEGIES.join(", ") + ))); + } + + // Resolve the agent's identity via connection data + let connection_url = format!( + "{}/_apis/connectiondata", + operation_ctx.target.organization_url.trim_end_matches('/') + ); + let conn_response = crate::safe_outputs::authenticate_ado_request( + operation_ctx.client.get(&connection_url), + operation_ctx.token, + operation_ctx.connection_type, + ) + .send() + .await + .context("Failed to fetch connection data for auto-complete identity")?; + + if !conn_response.status().is_success() { + let status = conn_response.status(); + let error_body = conn_response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Ok(ExecutionResult::failure(format!( + "Failed to fetch connection data (HTTP {}): {}", + status, error_body + ))); + } + + let conn_body: serde_json::Value = conn_response + .json() + .await + .context("Failed to parse connection data response")?; + + let agent_user_id = conn_body + .get("authenticatedUser") + .and_then(|au| au.get("id")) + .and_then(|id| id.as_str()) + .context("Connection data response missing authenticatedUser.id")?; + debug!("Agent user ID for auto-complete: {}", agent_user_id); + + // PATCH to set auto-complete using the agent's identity + let patch_url = format!( + "{}/pullRequests/{}?api-version=7.1", + operation_ctx.repository_api_base(), + operation_ctx.pr_id + ); + let patch_body = serde_json::json!({ + "autoCompleteSetBy": { + "id": agent_user_id + }, + "completionOptions": { + "deleteSourceBranch": config.delete_source_branch, + "mergeStrategy": config.merge_strategy + } + }); + + info!("Setting auto-complete on PR #{}", operation_ctx.pr_id); + let response = crate::safe_outputs::authenticate_ado_request( + operation_ctx.client.patch(&patch_url), + operation_ctx.token, + operation_ctx.connection_type, + ) + .header("Content-Type", "application/json") + .json(&patch_body) + .send() + .await + .context("Failed to set auto-complete on PR")?; + + if response.status().is_success() { + info!("Auto-complete set on PR #{}", operation_ctx.pr_id); + Ok(ExecutionResult::success_with_data( + format!("Auto-complete set on PR #{}", operation_ctx.pr_id), + serde_json::json!({ + "pull_request_id": operation_ctx.pr_id, + "operation": "set-auto-complete", + }), + )) + } else { + let status = response.status(); + let error_body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + Ok(ExecutionResult::failure(format!( + "Failed to set auto-complete on PR #{} (HTTP {}): {}", + operation_ctx.pr_id, status, error_body + ))) + } +} + +impl UpdatePrResult { + /// Submit a vote on a pull request. + /// + /// Resolves the current user identity via `_apis/connectiondata`, then + /// PUTs the vote to the reviewers endpoint. + async fn execute_vote( + &self, + operation_ctx: &UpdatePrContext<'_>, + config: &UpdatePrConfig, + ) -> anyhow::Result { + let vote_str = self + .vote + .as_deref() + .context("vote value is required for vote operation")?; + + // Validate against allowed-votes — REQUIRED for vote operation. + // An empty allowed-votes list means the operator hasn't opted in, so reject. + if config.allowed_votes.is_empty() { + return Ok(ExecutionResult::failure( + "vote operation requires 'allowed-votes' to be configured in safe-outputs.update-pr. \ + This prevents agents from casting unrestricted votes (including approve). \ + Example:\n safe-outputs:\n update-pr:\n allowed-votes:\n - approve-with-suggestions\n - wait-for-author" + .to_string(), + )); + } + if !config.allowed_votes.contains(&vote_str.to_string()) { + return Ok(ExecutionResult::failure(format!( + "Vote '{}' is not in the allowed-votes list: [{}]", + vote_str, + config.allowed_votes.join(", ") + ))); + } + + let vote_value = vote_to_ado_value(vote_str).context(format!( + "Invalid vote value: '{}'. Must be one of: {}", + vote_str, + VALID_VOTES.join(", ") + ))?; + + if let Some(failure) = + super::submit_pr_review::execute_review_vote(operation_ctx, vote_str, vote_value) + .await? + { + return Ok(failure); + } + Ok(ExecutionResult::success_with_data( + format!( + "Vote '{}' submitted on PR #{}", + vote_str, operation_ctx.pr_id + ), + serde_json::json!({ + "pull_request_id": operation_ctx.pr_id, + "operation": "vote", + "vote": vote_str, + "vote_value": vote_value, + }), + )) + } +} + +/// Add reviewers to a pull request. +/// +/// Resolves and verifies each reviewer identity via VSSPS, then PUTs to the +/// reviewers endpoint with vote 0. +pub(crate) async fn execute_add_reviewers( + operation_ctx: &UpdatePrContext<'_>, + config: &UpdatePrConfig, + requested_reviewers: &[String], +) -> anyhow::Result { + let reviewers = match validate_and_normalize_reviewers(requested_reviewers, config) { + Ok(reviewers) => reviewers, + Err(failure) => return Ok(failure), + }; + + let mut added = Vec::new(); + let mut failed = Vec::new(); + + // Derive VSSPS base URL once, before the loop. + let trimmed_org = operation_ctx.target.organization_url.trim_end_matches('/'); + let vssps_base = trimmed_org.replace("://dev.azure.com/", "://vssps.dev.azure.com/"); + if vssps_base == trimmed_org { + return Ok(ExecutionResult::failure(format!( + "Cannot derive VSSPS identity endpoint from org URL '{}'. \ + The add-reviewers operation requires dev.azure.com-style URLs \ + to resolve reviewer identities. Legacy *.visualstudio.com \ + organizations are not currently supported for this operation.", + trimmed_org + ))); + } + + for reviewer in &reviewers { + match resolve_and_add_reviewer( + operation_ctx.client, + &vssps_base, + &operation_ctx.repository_api_base(), + operation_ctx.pr_id, + reviewer, + operation_ctx.token, + operation_ctx.connection_type, + ) + .await + { + ReviewerAddResult::Added => added.push(reviewer.clone()), + ReviewerAddResult::Failed(reason) => { + failed.push(format!("{} ({})", reviewer, reason)); + } + } + } + + Ok(reviewer_execution_result( + operation_ctx.pr_id, + added, + failed, + )) +} + +/// Add labels to a pull request. +/// +/// For each label, POSTs to the labels endpoint. +pub(crate) async fn execute_add_labels( + operation_ctx: &UpdatePrContext<'_>, + labels: &[String], +) -> anyhow::Result { + let labels_url = format!( + "{}/pullRequests/{}/labels?api-version=7.1", + operation_ctx.repository_api_base(), + operation_ctx.pr_id + ); + + let mut added = Vec::new(); + let mut failed = Vec::new(); + + for label in labels { + let label_body = serde_json::json!({ + "name": label + }); + + debug!("Adding label '{}' to PR #{}", label, operation_ctx.pr_id); + let response = crate::safe_outputs::authenticate_ado_request( + operation_ctx.client.post(&labels_url), + operation_ctx.token, + operation_ctx.connection_type, + ) + .header("Content-Type", "application/json") + .json(&label_body) + .send() + .await; + + match response { + Ok(resp) if resp.status().is_success() => { + info!("Added label '{}' to PR #{}", label, operation_ctx.pr_id); + added.push(label.clone()); + } + Ok(resp) => { + let status = resp.status(); + let error_body = resp + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + warn!( + "Failed to add label '{}' to PR #{} (HTTP {}): {}", + label, operation_ctx.pr_id, status, error_body + ); + failed.push(format!("{} (HTTP {})", label, status)); + } + Err(e) => { + warn!( + "Request failed for label '{}' on PR #{}: {}", + label, operation_ctx.pr_id, e + ); + failed.push(format!("{} (request error)", label)); + } + } + } + + if added.is_empty() && !failed.is_empty() { + Ok(ExecutionResult::failure(format!( + "Failed to add any labels to PR #{}: {}", + operation_ctx.pr_id, + failed.join(", ") + ))) + } else { + let mut message = format!( + "Added {} label(s) to PR #{}", + added.len(), + operation_ctx.pr_id + ); + if !failed.is_empty() { + message.push_str(&format!( + " ({} failed: {})", + failed.len(), + failed.join(", ") + )); + } + Ok(ExecutionResult::success_with_data( + message, + serde_json::json!({ + "pull_request_id": operation_ctx.pr_id, + "operation": "add-labels", + "added": added, + "failed": failed, + }), + )) + } +} + +/// Update the description of a pull request. +pub(crate) async fn execute_update_description( + operation_ctx: &UpdatePrContext<'_>, + description: &str, +) -> anyhow::Result { + if let Err(error) = super::pr_common::validate_description(description) { + return Ok(ExecutionResult::failure(error.to_string())); + } + + let patch_url = format!( + "{}/pullRequests/{}?api-version=7.1", + operation_ctx.repository_api_base(), + operation_ctx.pr_id + ); + let patch_body = serde_json::json!({ + "description": description + }); + + info!( + "Updating description on PR #{} ({} chars)", + operation_ctx.pr_id, + description.len() + ); + let response = crate::safe_outputs::authenticate_ado_request( + operation_ctx.client.patch(&patch_url), + operation_ctx.token, + operation_ctx.connection_type, + ) + .header("Content-Type", "application/json") + .json(&patch_body) + .send() + .await + .context("Failed to update PR description")?; + + if response.status().is_success() { + info!("Description updated on PR #{}", operation_ctx.pr_id); + Ok(ExecutionResult::success_with_data( + format!("Description updated on PR #{}", operation_ctx.pr_id), + serde_json::json!({ + "pull_request_id": operation_ctx.pr_id, + "operation": "update-description", + }), + )) + } else { + let status = response.status(); + let error_body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + Ok(ExecutionResult::failure(format!( + "Failed to update description on PR #{} (HTTP {}): {}", + operation_ctx.pr_id, status, error_body + ))) + } +} +/// Look up the Azure DevOps identity GUID for `reviewer` via the VSSPS +/// identities API. Returns `Some(guid)` on success or `None` if the identity +/// cannot be resolved (warning is logged in that case). +async fn lookup_reviewer_id( + client: &reqwest::Client, + vssps_base: &str, + reviewer: &str, + token: &str, + connection_type: Option, +) -> Option { + if let Ok(reviewer_id) = Guid::parse(reviewer) { + let identity_url = format!("{}/_apis/identities", vssps_base); + debug!( + "Verifying reviewer identity GUID '{}': {}", + reviewer, identity_url + ); + return match crate::safe_outputs::authenticate_ado_request( + client.get(&identity_url).query(&[ + ("identityIds", reviewer_id.as_str()), + ("api-version", "7.1"), + ]), + token, + connection_type, + ) + .send() + .await + { + Ok(resp) if resp.status().is_success() => { + match resp.json::().await { + Ok(body) => { + let Some(identities) = + body.get("value").and_then(serde_json::Value::as_array) + else { + warn!( + "Identity lookup for GUID '{}' response missing 'value' array", + reviewer + ); + return None; + }; + if identities.len() != 1 { + warn!( + "Identity lookup for GUID '{}' returned {} identities", + reviewer, + identities.len() + ); + return None; + } + identities[0] + .get("id") + .and_then(serde_json::Value::as_str) + .filter(|id| id.eq_ignore_ascii_case(reviewer_id.as_str())) + .map(str::to_string) + } + Err(error) => { + warn!( + "Identity lookup for GUID '{}' returned invalid JSON: {}", + reviewer, error + ); + None + } + } + } + Ok(resp) => { + warn!( + "Identity lookup for GUID '{}' returned HTTP {}", + reviewer, + resp.status() + ); + None + } + Err(e) => { + warn!("Identity lookup for GUID '{}' failed: {}", reviewer, e); + None + } + }; + } + + let identity_url = format!("{}/_apis/identities", vssps_base); + debug!("Resolving identity for '{}': {}", reviewer, identity_url); + + match crate::safe_outputs::authenticate_ado_request( + client.get(&identity_url).query(&[ + ("searchFilter", "General"), + ("filterValue", reviewer), + ("api-version", "7.1"), + ]), + token, + connection_type, + ) + .send() + .await + { + Ok(resp) if resp.status().is_success() => { + let body: serde_json::Value = resp.json().await.unwrap_or_default(); + let matching_ids = body + .get("value") + .and_then(|v| v.as_array()) + .into_iter() + .flatten() + .filter(|identity| { + let direct_match = ["providerDisplayName", "customDisplayName", "displayName"] + .iter() + .filter_map(|field| identity.get(field).and_then(serde_json::Value::as_str)) + .any(|value| value.eq_ignore_ascii_case(reviewer)); + let property_match = ["Account", "Mail"] + .iter() + .filter_map(|field| { + identity + .get("properties") + .and_then(|properties| properties.get(field)) + .and_then(|property| property.get("$value")) + .and_then(serde_json::Value::as_str) + }) + .any(|value| value.eq_ignore_ascii_case(reviewer)); + direct_match || property_match + }) + .filter_map(|entry| entry.get("id").and_then(serde_json::Value::as_str)) + .collect::>(); + if matching_ids.len() == 1 { + matching_ids.into_iter().next().map(str::to_string) + } else { + if matching_ids.len() > 1 { + warn!( + "Identity lookup for '{}' returned multiple exact matches", + reviewer + ); + } + None + } + } + Ok(resp) => { + warn!( + "Identity lookup for '{}' returned HTTP {}", + reviewer, + resp.status() + ); + None + } + Err(e) => { + warn!("Identity lookup for '{}' failed: {}", reviewer, e); + None + } + } +} + +/// PUT `reviewer_id` as a reviewer onto `pr_id`. Returns +/// [`ReviewerAddResult::Added`] on success or [`ReviewerAddResult::Failed`] +/// with a short reason string on any HTTP or transport error. +async fn add_reviewer_to_pr( + client: &reqwest::Client, + repository_api_base: &str, + pr_id: u64, + reviewer_id: &str, + reviewer: &str, + token: &str, + connection_type: Option, +) -> ReviewerAddResult { + let reviewer_url = format!( + "{}/pullRequests/{}/reviewers/{}?api-version=7.1", + repository_api_base, pr_id, reviewer_id, + ); + let reviewer_body = serde_json::json!({ "vote": 0, "isRequired": false }); + + debug!("Adding reviewer '{}' to PR #{}", reviewer, pr_id); + let response = crate::safe_outputs::authenticate_ado_request( + client.put(&reviewer_url), + token, + connection_type, + ) + .header("Content-Type", "application/json") + .json(&reviewer_body) + .send() + .await; + + match response { + Ok(resp) if resp.status().is_success() => { + info!("Added reviewer '{}' to PR #{}", reviewer, pr_id); + ReviewerAddResult::Added + } + Ok(resp) => { + let status = resp.status(); + let error_body = resp + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + warn!( + "Failed to add reviewer '{}' to PR #{} (HTTP {}): {}", + reviewer, pr_id, status, error_body + ); + ReviewerAddResult::Failed(format!("HTTP {}", status)) + } + Err(e) => { + warn!( + "Request failed for reviewer '{}' on PR #{}: {}", + reviewer, pr_id, e + ); + ReviewerAddResult::Failed("request error".to_string()) + } + } +} + +/// Resolve an ADO identity for `reviewer` via the VSSPS identities API, then +/// PUT the reviewer onto the PR. Returns [`ReviewerAddResult::Added`] on success +/// or [`ReviewerAddResult::Failed`] with a short reason string on any failure. +async fn resolve_and_add_reviewer( + client: &reqwest::Client, + vssps_base: &str, + repository_api_base: &str, + pr_id: u64, + reviewer: &str, + token: &str, + connection_type: Option, +) -> ReviewerAddResult { + let Some(reviewer_id) = + lookup_reviewer_id(client, vssps_base, reviewer, token, connection_type).await + else { + warn!("Could not resolve identity for '{}', skipping", reviewer); + return ReviewerAddResult::Failed("identity not found".to_string()); + }; + add_reviewer_to_pr( + client, + repository_api_base, + pr_id, + &reviewer_id, + reviewer, + token, + connection_type, + ) + .await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::safe_outputs::ToolResult; + + #[test] + fn test_result_has_correct_name() { + assert_eq!(UpdatePrResult::NAME, "update-pr"); + } + + #[test] + fn test_params_deserializes() { + let json = r#"{ + "pull_request_id": 42, + "operation": "set-auto-complete" + }"#; + let params: UpdatePrParams = serde_json::from_str(json).unwrap(); + assert_eq!(params.pull_request_id, PullRequestReference::Number(42)); + assert_eq!(params.operation, "set-auto-complete"); + assert!(params.repository.is_none()); + } + + #[test] + fn pull_request_reference_accepts_quoted_numbers_and_temporary_ids() { + let quoted: PullRequestReference = serde_json::from_str("\"42\"").unwrap(); + let temporary: PullRequestReference = serde_json::from_str("\"#aw_pr123\"").unwrap(); + assert_eq!(quoted, PullRequestReference::Number(42)); + assert!(matches!(temporary, PullRequestReference::Temporary(_))); + assert!(serde_json::from_str::("\"not-an-id\"").is_err()); + } + + #[test] + fn test_params_converts_to_result() { + let params = UpdatePrParams { + pull_request_id: PullRequestReference::Number(42), + repository: Some("self".to_string()), + operation: "set-auto-complete".to_string(), + reviewers: None, + labels: None, + vote: None, + description: None, + }; + let result: UpdatePrResult = params.try_into().unwrap(); + assert_eq!(result.name, "update-pr"); + assert_eq!(result.pull_request_id, PullRequestReference::Number(42)); + assert_eq!(result.operation, "set-auto-complete"); + } + + #[test] + fn test_validation_rejects_zero_pr_id() { + let params = UpdatePrParams { + pull_request_id: PullRequestReference::Number(0), + repository: None, + operation: "set-auto-complete".to_string(), + reviewers: None, + labels: None, + vote: None, + description: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_invalid_operation() { + let params = UpdatePrParams { + pull_request_id: PullRequestReference::Number(1), + repository: None, + operation: "delete-pr".to_string(), + reviewers: None, + labels: None, + vote: None, + description: None, + }; + let err: Result = params.try_into(); + let err = err.unwrap_err().to_string(); + assert!(err.contains("operation must be one of"), "got: {err}"); + } + + #[test] + fn test_validation_rejects_vote_without_value() { + let params = UpdatePrParams { + pull_request_id: PullRequestReference::Number(1), + repository: None, + operation: "vote".to_string(), + reviewers: None, + labels: None, + vote: None, + description: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_reviewers_without_list() { + let params = UpdatePrParams { + pull_request_id: PullRequestReference::Number(1), + repository: None, + operation: "add-reviewers".to_string(), + reviewers: None, + labels: None, + vote: None, + description: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_validation_rejects_more_than_100_reviewers() { + let params = UpdatePrParams { + pull_request_id: PullRequestReference::Number(1), + repository: None, + operation: "add-reviewers".to_string(), + reviewers: Some(vec!["reviewer@example.com".to_string(); 101]), + labels: None, + vote: None, + description: None, + }; + + let error = params.validate().unwrap_err().to_string(); + assert!( + error.contains("reviewers list must contain at most 100 entries"), + "got: {error}" + ); + } + + #[test] + fn test_validation_rejects_overlong_reviewer() { + let params = UpdatePrParams { + pull_request_id: PullRequestReference::Number(1), + repository: None, + operation: "add-reviewers".to_string(), + reviewers: Some(vec!["a".repeat(MAX_REVIEWER_LEN + 1)]), + labels: None, + vote: None, + description: None, + }; + + let error = params.validate().unwrap_err().to_string(); + assert!( + error.contains("reviewer must be 256 characters or fewer"), + "got: {error}" + ); + } + + #[test] + fn test_validation_rejects_reviewer_pipeline_command() { + let params = UpdatePrParams { + pull_request_id: PullRequestReference::Number(1), + repository: None, + operation: "add-reviewers".to_string(), + reviewers: Some(vec![ + "##vso[task.setvariable variable=REVIEWER]attacker@example.com".to_string(), + ]), + labels: None, + vote: None, + description: None, + }; + + let error = params.validate().unwrap_err().to_string(); + assert!( + error.contains("update-pr.reviewer") && error.contains("ADO pipeline command"), + "got: {error}" + ); + } + + #[test] + fn test_validation_rejects_repository_pipeline_command() { + let params = UpdatePrParams { + pull_request_id: PullRequestReference::Number(1), + repository: Some("##vso[task.setvariable variable=x]y".to_string()), + operation: "set-auto-complete".to_string(), + reviewers: None, + labels: None, + vote: None, + description: None, + }; + let result: Result = params.try_into(); + assert!(result.is_err()); + } + + #[test] + fn test_result_serializes_correctly() { + let params = UpdatePrParams { + pull_request_id: PullRequestReference::Number(99), + repository: Some("self".to_string()), + operation: "vote".to_string(), + reviewers: None, + labels: None, + vote: Some("approve".to_string()), + description: None, + }; + let result: UpdatePrResult = params.try_into().unwrap(); + let json = serde_json::to_string(&result).unwrap(); + + assert!(json.contains(r#""name":"update-pr""#)); + assert!(json.contains(r#""pull_request_id":99"#)); + assert!(json.contains(r#""operation":"vote""#)); + } + + #[test] + fn test_config_defaults() { + let config = UpdatePrConfig::default(); + assert!(config.allowed_operations.is_empty()); + assert!(config.allowed_repositories.is_empty()); + assert!(config.allowed_votes.is_empty()); + assert!(config.allowed_reviewers.is_empty()); + assert_eq!(config.max_reviewers, DEFAULT_MAX_REVIEWERS); + assert_eq!(config.merge_strategy, "squash"); + } + + #[test] + fn reviewer_policy_allows_omitted_allowlist_and_explicit_wildcard() { + let reviewers = vec!["owner@example.com".to_string()]; + assert_eq!( + validate_and_normalize_reviewers(&reviewers, &UpdatePrConfig::default()).unwrap(), + reviewers + ); + + let config = UpdatePrConfig { + allowed_reviewers: vec!["*".to_string()], + ..Default::default() + }; + assert_eq!( + validate_and_normalize_reviewers(&reviewers, &config).unwrap(), + reviewers + ); + } + + #[test] + fn reviewer_policy_restricts_non_empty_allowlist() { + let result = validate_and_normalize_reviewers( + &["other@example.com".to_string()], + &UpdatePrConfig { + allowed_reviewers: vec!["owner@example.com".to_string()], + ..Default::default() + }, + ); + assert!(result.unwrap_err().message.contains("allowed-reviewers")); + } + + #[test] + fn reviewer_policy_deduplicates_and_enforces_limit() { + let config = UpdatePrConfig { + allowed_reviewers: vec![ + "Owner@example.com".to_string(), + "other@example.com".to_string(), + ], + max_reviewers: 2, + ..Default::default() + }; + let reviewers = validate_and_normalize_reviewers( + &[ + "owner@example.com".to_string(), + "OWNER@example.com".to_string(), + ], + &config, + ) + .unwrap(); + assert_eq!(reviewers, ["owner@example.com"]); + + let too_many = validate_and_normalize_reviewers( + &[ + "owner@example.com".to_string(), + "other@example.com".to_string(), + "third@example.com".to_string(), + ], + &UpdatePrConfig { + allowed_reviewers: vec!["*".to_string()], + max_reviewers: 2, + ..Default::default() + }, + ); + assert!(too_many.unwrap_err().message.contains("max-reviewers")); + } + + #[test] + fn reviewer_results_warn_for_partial_and_total_failures() { + let partial = reviewer_execution_result( + 42, + vec!["added@example.com".to_string()], + vec!["failed@example.com (HTTP 403)".to_string()], + ); + assert!(partial.success); + assert!(partial.is_warning()); + assert_eq!( + partial.data.as_ref().unwrap()["added"][0], + "added@example.com" + ); + + let total = reviewer_execution_result( + 42, + Vec::new(), + vec!["failed@example.com (identity not found)".to_string()], + ); + assert!(total.success); + assert!(total.is_warning()); + assert_eq!( + total.data.as_ref().unwrap()["failed"] + .as_array() + .unwrap() + .len(), + 1 + ); + + let success = + reviewer_execution_result(42, vec!["added@example.com".to_string()], Vec::new()); + assert!(success.success); + assert!(!success.is_warning()); + } + + #[test] + fn temporary_reference_resolves_exact_registered_target() { + let temporary_id = PullRequestTemporaryId::parse("#aw_pr123").unwrap(); + let ctx = ExecutionContext::default(); + let target = AdoRepositoryTarget { + alias: "tools".to_string(), + organization: "other-org".to_string(), + organization_url: "https://dev.azure.com/other-org".to_string(), + project: "Other Project".to_string(), + repository: "tools".to_string(), + repository_id: Some("repo-id".to_string()), + cross_organization: true, + }; + ctx.register_resolved_pull_request( + &temporary_id, + crate::safe_outputs::ResolvedPullRequest { + id: 42, + url: "https://example.test/pr/42".to_string(), + target: target.clone(), + }, + ) + .unwrap(); + + let resolved = resolve_update_pr_target( + &PullRequestReference::Temporary(temporary_id), + None, + &UpdatePrConfig::default(), + &ctx, + ) + .unwrap() + .unwrap(); + assert_eq!(resolved, (42, target)); + } + + #[test] + fn temporary_reference_rejects_unresolved_id() { + let temporary_id = PullRequestTemporaryId::parse("#aw_pr123").unwrap(); + let result = resolve_update_pr_target( + &PullRequestReference::Temporary(temporary_id), + None, + &UpdatePrConfig::default(), + &ExecutionContext::default(), + ) + .unwrap() + .unwrap_err(); + + assert!( + result + .message + .contains("temporary pull-request ID '#aw_pr123' has not been resolved"), + "got: {}", + result.message + ); + } + + #[test] + fn temporary_reference_rejects_requested_repository_mismatch() { + let temporary_id = PullRequestTemporaryId::parse("#aw_pr123").unwrap(); + let ctx = ExecutionContext::default(); + ctx.register_resolved_pull_request( + &temporary_id, + crate::safe_outputs::ResolvedPullRequest { + id: 42, + url: "https://example.test/pr/42".to_string(), + target: AdoRepositoryTarget { + alias: "tools".to_string(), + organization: "other-org".to_string(), + organization_url: "https://dev.azure.com/other-org".to_string(), + project: "Other Project".to_string(), + repository: "tools".to_string(), + repository_id: Some("repo-id".to_string()), + cross_organization: true, + }, + }, + ) + .unwrap(); + + let result = resolve_update_pr_target( + &PullRequestReference::Temporary(temporary_id), + Some("self"), + &UpdatePrConfig::default(), + &ctx, + ) + .unwrap() + .unwrap_err(); + + assert!( + result.message.contains( + "resolved to repository 'tools', which does not match requested repository 'self'" + ), + "got: {}", + result.message + ); + } + + #[test] + fn temporary_reference_rejects_allowed_repositories_exclusion() { + let temporary_id = PullRequestTemporaryId::parse("#aw_pr123").unwrap(); + let ctx = ExecutionContext::default(); + ctx.register_resolved_pull_request( + &temporary_id, + crate::safe_outputs::ResolvedPullRequest { + id: 42, + url: "https://example.test/pr/42".to_string(), + target: AdoRepositoryTarget { + alias: "tools".to_string(), + organization: "other-org".to_string(), + organization_url: "https://dev.azure.com/other-org".to_string(), + project: "Other Project".to_string(), + repository: "tools".to_string(), + repository_id: Some("repo-id".to_string()), + cross_organization: true, + }, + }, + ) + .unwrap(); + + let result = resolve_update_pr_target( + &PullRequestReference::Temporary(temporary_id), + None, + &UpdatePrConfig { + allowed_repositories: vec!["self".to_string()], + ..Default::default() + }, + &ctx, + ) + .unwrap() + .unwrap_err(); + + assert!( + result + .message + .contains("Repository 'tools' is not in the allowed-repositories list: [self]"), + "got: {}", + result.message + ); + } + + #[tokio::test] + async fn reviewer_identity_lookup_requires_exact_match() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/_apis/identities")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "value": [ + { + "id": "wrong-id", + "providerDisplayName": "Similar Person", + "properties": {"Mail": {"$value": "similar@example.com"}} + }, + { + "id": "exact-id", + "providerDisplayName": "Exact Person", + "properties": {"Mail": {"$value": "owner@example.com"}} + } + ] + }))) + .mount(&server) + .await; + + let id = lookup_reviewer_id( + &reqwest::Client::new(), + &server.uri(), + "owner@example.com", + "token", + None, + ) + .await; + assert_eq!(id.as_deref(), Some("exact-id")); + + let missing = lookup_reviewer_id( + &reqwest::Client::new(), + &server.uri(), + "missing@example.com", + "token", + None, + ) + .await; + assert!(missing.is_none()); + } + + #[tokio::test] + async fn reviewer_guid_lookup_verifies_existing_identity() { + use wiremock::matchers::{method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + let reviewer = "12345678-1234-1234-1234-1234567890ab"; + Mock::given(method("GET")) + .and(path("/_apis/identities")) + .and(query_param("identityIds", reviewer)) + .and(query_param("api-version", "7.1")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "count": 1, + "value": [{ + "id": "12345678-1234-1234-1234-1234567890AB", + "displayName": "Exact Person" + }] + }))) + .expect(1) + .mount(&server) + .await; + + let id = lookup_reviewer_id( + &reqwest::Client::new(), + &server.uri(), + reviewer, + "token", + None, + ) + .await; + assert_eq!(id.as_deref(), Some("12345678-1234-1234-1234-1234567890AB")); + } + + #[tokio::test] + async fn reviewer_guid_lookup_rejects_missing_identity() { + use wiremock::matchers::{method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + let reviewer = "12345678-1234-1234-1234-1234567890ab"; + Mock::given(method("GET")) + .and(path("/_apis/identities")) + .and(query_param("identityIds", reviewer)) + .and(query_param("api-version", "7.1")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "count": 0, + "value": [] + }))) + .expect(1) + .mount(&server) + .await; + + let id = lookup_reviewer_id( + &reqwest::Client::new(), + &server.uri(), + reviewer, + "token", + None, + ) + .await; + assert!(id.is_none()); + } + + #[tokio::test] + async fn reviewer_guid_lookup_rejects_response_missing_value_array() { + use wiremock::matchers::{method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + let reviewer = "12345678-1234-1234-1234-1234567890ab"; + Mock::given(method("GET")) + .and(path("/_apis/identities")) + .and(query_param("identityIds", reviewer)) + .and(query_param("api-version", "7.1")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "count": 1 + }))) + .expect(1) + .mount(&server) + .await; + + let id = lookup_reviewer_id( + &reqwest::Client::new(), + &server.uri(), + reviewer, + "token", + None, + ) + .await; + assert!(id.is_none()); + } + + #[tokio::test] + async fn reviewer_guid_lookup_rejects_duplicate_identities() { + use wiremock::matchers::{method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + let reviewer = "12345678-1234-1234-1234-1234567890ab"; + Mock::given(method("GET")) + .and(path("/_apis/identities")) + .and(query_param("identityIds", reviewer)) + .and(query_param("api-version", "7.1")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "count": 2, + "value": [ + {"id": reviewer}, + {"id": reviewer} + ] + }))) + .expect(1) + .mount(&server) + .await; + + let id = lookup_reviewer_id( + &reqwest::Client::new(), + &server.uri(), + reviewer, + "token", + None, + ) + .await; + assert!(id.is_none()); + } + + #[tokio::test] + async fn reviewer_guid_lookup_rejects_mismatched_identity() { + use wiremock::matchers::{method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + let reviewer = "12345678-1234-1234-1234-1234567890ab"; + Mock::given(method("GET")) + .and(path("/_apis/identities")) + .and(query_param("identityIds", reviewer)) + .and(query_param("api-version", "7.1")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "count": 1, + "value": [{ + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + }] + }))) + .expect(1) + .mount(&server) + .await; + + let id = lookup_reviewer_id( + &reqwest::Client::new(), + &server.uri(), + reviewer, + "token", + None, + ) + .await; + assert!(id.is_none()); + } + + #[tokio::test] + async fn reviewer_guid_lookup_rejects_http_errors_and_invalid_json() { + use wiremock::matchers::{method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let reviewer = "12345678-1234-1234-1234-1234567890ab"; + let http_error_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/_apis/identities")) + .and(query_param("identityIds", reviewer)) + .and(query_param("api-version", "7.1")) + .respond_with(ResponseTemplate::new(500)) + .expect(1) + .mount(&http_error_server) + .await; + + let http_error = lookup_reviewer_id( + &reqwest::Client::new(), + &http_error_server.uri(), + reviewer, + "token", + None, + ) + .await; + assert!(http_error.is_none()); + + let invalid_json_server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/_apis/identities")) + .and(query_param("identityIds", reviewer)) + .and(query_param("api-version", "7.1")) + .respond_with(ResponseTemplate::new(200).set_body_raw("{", "application/json")) + .expect(1) + .mount(&invalid_json_server) + .await; + + let invalid_json = lookup_reviewer_id( + &reqwest::Client::new(), + &invalid_json_server.uri(), + reviewer, + "token", + None, + ) + .await; + assert!(invalid_json.is_none()); + } + + #[tokio::test] + async fn reviewer_guid_lookup_preserves_bearer_auth_routing() { + use wiremock::matchers::{header, method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + let reviewer = "12345678-1234-1234-1234-1234567890ab"; + Mock::given(method("GET")) + .and(path("/_apis/identities")) + .and(query_param("identityIds", reviewer)) + .and(query_param("api-version", "7.1")) + .and(header("authorization", "Bearer entra-token")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "count": 1, + "value": [{"id": reviewer}] + }))) + .expect(1) + .mount(&server) + .await; + + let id = lookup_reviewer_id( + &reqwest::Client::new(), + &server.uri(), + reviewer, + "entra-token", + Some(crate::compile::types::WriteConnectionType::AzureDevOps), + ) + .await; + assert_eq!(id.as_deref(), Some(reviewer)); + } + + #[tokio::test] + async fn malformed_guid_like_reviewer_uses_exact_identity_lookup() { + use wiremock::matchers::{method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + let reviewer = "12345678-1234-1234-1234-1234567890ag"; + Mock::given(method("GET")) + .and(path("/_apis/identities")) + .and(query_param("searchFilter", "General")) + .and(query_param("filterValue", reviewer)) + .and(query_param("api-version", "7.1")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "value": [] + }))) + .expect(1) + .mount(&server) + .await; + + let id = lookup_reviewer_id( + &reqwest::Client::new(), + &server.uri(), + reviewer, + "token", + None, + ) + .await; + assert!(id.is_none()); + } + + #[tokio::test] + async fn reviewer_identity_lookup_rejects_ambiguous_exact_matches() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/_apis/identities")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "value": [ + { + "id": "first-id", + "properties": {"Mail": {"$value": "owner@example.com"}} + }, + { + "id": "second-id", + "properties": {"Mail": {"$value": "owner@example.com"}} + } + ] + }))) + .mount(&server) + .await; + + let id = lookup_reviewer_id( + &reqwest::Client::new(), + &server.uri(), + "owner@example.com", + "token", + None, + ) + .await; + assert!(id.is_none()); + } + + #[tokio::test] + async fn reviewer_identity_lookup_encodes_filter_as_one_query_parameter() { + use wiremock::matchers::{method, path, query_param}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + let reviewer = "owner+alerts&team=core@example.com"; + Mock::given(method("GET")) + .and(path("/_apis/identities")) + .and(query_param("searchFilter", "General")) + .and(query_param("filterValue", reviewer)) + .and(query_param("api-version", "7.1")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "value": [{ + "id": "exact-id", + "properties": {"Mail": {"$value": reviewer}} + }] + }))) + .expect(1) + .mount(&server) + .await; + + let id = lookup_reviewer_id( + &reqwest::Client::new(), + &server.uri(), + reviewer, + "token", + None, + ) + .await; + assert_eq!(id.as_deref(), Some("exact-id")); + } + + #[test] + fn test_config_deserializes_from_yaml() { + let yaml = r#" +allowed-operations: + - add-reviewers + - set-auto-complete +allowed-repositories: + - self +allowed-votes: + - approve + - reject +allowed-reviewers: + - owner@example.com +max-reviewers: 2 +"#; + let config: UpdatePrConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.allowed_operations.len(), 2); + assert!( + config + .allowed_operations + .contains(&"add-reviewers".to_string()) + ); + assert!( + config + .allowed_operations + .contains(&"set-auto-complete".to_string()) + ); + assert_eq!(config.allowed_repositories.len(), 1); + assert_eq!(config.allowed_votes.len(), 2); + assert_eq!(config.allowed_reviewers, ["owner@example.com"]); + assert_eq!(config.max_reviewers, 2); + } + + #[test] + fn test_valid_merge_strategies_are_expected_values() { + assert_eq!( + VALID_MERGE_STRATEGIES, + &["squash", "noFastForward", "rebase", "rebaseMerge"] + ); + } + + #[test] + fn test_config_deserializes_merge_strategy() { + let yaml = r#"merge-strategy: rebase"#; + let config: UpdatePrConfig = serde_yaml::from_str(yaml).unwrap(); + assert_eq!(config.merge_strategy, "rebase"); + } +} diff --git a/src/safe_outputs/result.rs b/src/safe_outputs/result.rs index f80c9d498..0821db457 100644 --- a/src/safe_outputs/result.rs +++ b/src/safe_outputs/result.rs @@ -284,6 +284,7 @@ pub struct ExecutionContext { pub resolved_work_items: Arc>>, /// Temporary pull-request IDs resolved by successful `create-pull-request` calls. pub resolved_pull_requests: Arc>>, + pub budget_groups: crate::compile::pr_migration::BudgetGroups, } impl ExecutionContext { @@ -313,6 +314,7 @@ impl ExecutionContext { if let Some(object) = value.as_object_mut() { object.remove("require-approval"); object.remove("staged"); + object.remove(crate::compile::pr_migration::LEGACY_PR_CONFIG); } serde_json::from_value(value).map_err(|error| { anyhow::anyhow!("failed to deserialize config for tool '{tool_name}': {error}") @@ -542,6 +544,7 @@ impl ExecutionContext { resolved_github_issues: Arc::new(Mutex::new(HashMap::new())), resolved_work_items: Arc::new(Mutex::new(HashMap::new())), resolved_pull_requests: Arc::new(Mutex::new(HashMap::new())), + budget_groups: Default::default(), } } } diff --git a/src/safe_outputs/set_pr_auto_complete.rs b/src/safe_outputs/set_pr_auto_complete.rs new file mode 100644 index 000000000..308cf2db9 --- /dev/null +++ b/src/safe_outputs/set_pr_auto_complete.rs @@ -0,0 +1,248 @@ +//! Enable Azure DevOps auto-complete, without immediately merging. + +use super::pr_common::{ + PullRequestReference, legacy_policy, resolve_pr_target, validate_reference, +}; +use super::pr_mutations::{UpdatePrContext, execute_set_auto_complete}; +use super::update_pr::UpdatePrConfig; +use super::{ExecutionContext, ExecutionResult, Executor, Validate}; +use crate::sanitize::{SanitizeContent, sanitize_config}; +use crate::tool_result; +use ado_aw_derive::SanitizeConfig; +use anyhow::{Context, ensure}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, JsonSchema)] +pub struct SetPrAutoCompleteParams { + pub pull_request_id: PullRequestReference, + #[serde(default)] + pub repository: Option, +} +impl Validate for SetPrAutoCompleteParams { + fn validate(&self) -> anyhow::Result<()> { + validate_reference(&self.pull_request_id)?; + if let Some(repository) = &self.repository { + crate::validate::reject_pipeline_injection(repository, "repository")?; + } + Ok(()) + } +} +tool_result! { + name = "set-pr-auto-complete", + write = true, + params = SetPrAutoCompleteParams, + pub struct SetPrAutoCompleteResult { + pull_request_id: PullRequestReference, + #[serde(default)] + repository: Option, + } +} +impl SanitizeContent for SetPrAutoCompleteResult { + fn sanitize_content_fields(&mut self) { + self.repository = self.repository.as_deref().map(sanitize_config); + } +} +fn default_true() -> bool { + true +} +fn default_merge_strategy() -> String { + "squash".into() +} + +#[derive(Debug, Clone, Serialize, Deserialize, SanitizeConfig)] +#[serde(deny_unknown_fields)] +pub struct SetPrAutoCompleteConfig { + #[serde(default, rename = "allowed-repositories")] + pub allowed_repositories: Vec, + #[serde(default = "default_true", rename = "delete-source-branch")] + pub delete_source_branch: bool, + #[serde(default = "default_merge_strategy", rename = "merge-strategy")] + pub merge_strategy: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[sanitize_config(skip)] + pub max: Option, +} +impl Default for SetPrAutoCompleteConfig { + fn default() -> Self { + Self { + allowed_repositories: Vec::new(), + delete_source_branch: true, + merge_strategy: default_merge_strategy(), + max: None, + } + } +} +pub(crate) fn validate_set_pr_auto_complete_config( + config: &SetPrAutoCompleteConfig, +) -> anyhow::Result<()> { + ensure!( + ["squash", "noFastForward", "rebase", "rebaseMerge"] + .contains(&config.merge_strategy.as_str()), + "Invalid merge-strategy '{}'", + config.merge_strategy + ); + for repository in &config.allowed_repositories { + ensure!( + !repository.trim().is_empty(), + "allowed-repositories entries must not be empty" + ); + crate::validate::reject_pipeline_injection(repository, "allowed-repositories")?; + } + Ok(()) +} + +#[async_trait::async_trait] +impl Executor for SetPrAutoCompleteResult { + fn dry_run_summary(&self) -> String { + format!("enable auto-complete on PR #{}", self.pull_request_id) + } + async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + if let Err(error) = (SetPrAutoCompleteParams { + pull_request_id: self.pull_request_id.clone(), + repository: self.repository.clone(), + }) + .validate() + { + return Ok(ExecutionResult::failure(error.to_string())); + } + ensure!( + ctx.tool_configs.contains_key("set-pr-auto-complete"), + "set-pr-auto-complete is not configured" + ); + let config: SetPrAutoCompleteConfig = ctx.get_tool_config("set-pr-auto-complete")?; + validate_set_pr_auto_complete_config(&config)?; + let policy = UpdatePrConfig { + allowed_repositories: config.allowed_repositories, + delete_source_branch: config.delete_source_branch, + merge_strategy: config.merge_strategy, + ..Default::default() + }; + let (pr_id, target) = match resolve_pr_target( + &self.pull_request_id, + self.repository.as_deref(), + &policy.allowed_repositories, + ctx, + )? { + Ok(target) => target, + Err(failure) => return Ok(failure), + }; + let legacy = legacy_policy(ctx, "set-pr-auto-complete", "set-auto-complete")?; + if let Some(legacy) = &legacy + && let Err(failure) = resolve_pr_target( + &self.pull_request_id, + self.repository.as_deref(), + &legacy.allowed_repositories, + ctx, + )? + { + return Ok(failure); + } + let client = reqwest::Client::new(); + execute_set_auto_complete( + &UpdatePrContext { + client: &client, + target, + pr_id, + token: ctx + .access_token + .as_deref() + .context("No access token available")?, + connection_type: ctx.write_connection_type, + }, + legacy.as_ref().unwrap_or(&policy), + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn typed_config_rejects_unknown_fields_and_invalid_allowlists() { + for value in [ + serde_json::json!({"merge-immediately": true}), + serde_json::json!({"delete-source-branch": "true"}), + serde_json::json!({"allowed-repositories": "self"}), + ] { + assert!(serde_json::from_value::(value).is_err()); + } + for repository in ["", " ", "##vso[task.setvariable variable=x]y"] { + let config = SetPrAutoCompleteConfig { + allowed_repositories: vec![repository.into()], + ..Default::default() + }; + assert!(validate_set_pr_auto_complete_config(&config).is_err()); + } + for strategy in ["squash", "noFastForward", "rebase", "rebaseMerge"] { + let config: SetPrAutoCompleteConfig = + serde_json::from_value(serde_json::json!({"merge-strategy": strategy, "max": 0})) + .unwrap(); + assert!(validate_set_pr_auto_complete_config(&config).is_ok()); + assert_eq!(config.max, Some(0)); + } + } + + #[test] + fn completion_defaults_and_validation_match_legacy() { + let config = SetPrAutoCompleteConfig::default(); + assert!(config.delete_source_branch); + assert_eq!(config.merge_strategy, "squash"); + assert!( + validate_set_pr_auto_complete_config(&SetPrAutoCompleteConfig { + merge_strategy: "merge-immediately".into(), + ..config + }) + .is_err() + ); + } + + #[tokio::test] + async fn migrated_completion_uses_target_actor_and_exact_legacy_options() { + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{body_json, header, method, path}, + }; + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/_apis/connectiondata")) + .and(header("authorization", "Bearer token")) + .respond_with( + ResponseTemplate::new(200).set_body_json( + serde_json::json!({"authenticatedUser": {"id": "target-actor"}}), + ), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PATCH")) + .and(path( + "/Other/_apis/git/repositories/repo-id/pullRequests/4294967296", + )) + .and(header("authorization", "Bearer token")) + .and(body_json(serde_json::json!({ + "autoCompleteSetBy": {"id": "target-actor"}, + "completionOptions": {"deleteSourceBranch": false, "mergeStrategy": "rebase"} + }))) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + let mut ctx = super::super::pr_common::tests::registered_context( + &server.uri(), + "set-pr-auto-complete", + serde_json::json!({ + "delete-source-branch": false, "merge-strategy": "rebase", + "legacy-update-pr": {"delete-source-branch": false, "merge-strategy": "rebase"} + }), + ); + ctx.write_connection_type = Some(crate::compile::types::WriteConnectionType::AzureDevOps); + let mut result: SetPrAutoCompleteResult = serde_json::from_value(serde_json::json!({ + "name": "set-pr-auto-complete", "pull_request_id": "#aw_pr123" + })) + .unwrap(); + assert!(result.execute_sanitized(&ctx).await.unwrap().success); + } +} diff --git a/src/safe_outputs/submit_pr_review.rs b/src/safe_outputs/submit_pr_review.rs index 86b6a2a56..4d1e33c62 100644 --- a/src/safe_outputs/submit_pr_review.rs +++ b/src/safe_outputs/submit_pr_review.rs @@ -6,7 +6,11 @@ use percent_encoding::utf8_percent_encode; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use super::{PATH_SEGMENT, resolve_repo_name}; +use super::pr_common::{ + PullRequestReference, legacy_policy, resolve_pr_target, validate_reference, +}; +use super::pr_mutations::UpdatePrContext; +use super::{PATH_SEGMENT, authenticate_ado_request}; use crate::safe_outputs::{ExecutionContext, ExecutionResult, Executor, Validate}; use crate::sanitize::{SanitizeContent, sanitize as sanitize_text, sanitize_config}; use crate::tool_result; @@ -19,6 +23,9 @@ const VALID_EVENTS: &[&str] = &[ "approve-with-suggestions", "request-changes", "comment", + "wait-for-author", + "reject", + "reset", ]; /// Map a review event string to its ADO vote numeric value @@ -26,8 +33,9 @@ fn event_to_vote(event: &str) -> Option { match event { "approve" => Some(10), "approve-with-suggestions" => Some(5), - "request-changes" => Some(-5), - "comment" => Some(0), + "request-changes" | "wait-for-author" => Some(-5), + "reject" => Some(-10), + "comment" | "reset" => Some(0), _ => None, } } @@ -35,10 +43,11 @@ fn event_to_vote(event: &str) -> Option { /// Parameters for submitting a pull request review #[derive(Deserialize, JsonSchema)] pub struct SubmitPrReviewParams { - /// The pull request ID to review (must be positive) - pub pull_request_id: i32, + /// Positive PR ID, or a same-run temporary ID when allow-temporary-ids is enabled. + pub pull_request_id: PullRequestReference, - /// Review decision: "approve", "approve-with-suggestions", "request-changes", or "comment" + /// Review decision: approve, approve-with-suggestions, request-changes, comment, + /// wait-for-author, reject, or reset. pub event: String, /// Review rationale in markdown. Required for "request-changes", optional otherwise. @@ -54,10 +63,7 @@ pub struct SubmitPrReviewParams { impl Validate for SubmitPrReviewParams { fn validate(&self) -> anyhow::Result<()> { - ensure!( - self.pull_request_id > 0, - "pull_request_id must be a positive integer" - ); + validate_reference(&self.pull_request_id)?; if let Some(repository) = &self.repository { reject_pipeline_injection(repository, "repository")?; } @@ -85,7 +91,7 @@ tool_result! { params = SubmitPrReviewParams, /// Result of submitting a pull request review pub struct SubmitPrReviewResult { - pull_request_id: i32, + pull_request_id: PullRequestReference, event: String, body: Option, repository: Option, @@ -114,6 +120,10 @@ impl SanitizeContent for SubmitPrReviewResult { /// ``` #[derive(Debug, Clone, Default, SanitizeConfig, Serialize, Deserialize)] pub struct SubmitPrReviewConfig { + /// Existing numeric-only configurations do not implicitly gain create-then-review authority. + #[serde(default, rename = "allow-temporary-ids")] + #[sanitize_config(skip)] + pub allow_temporary_ids: bool, /// Which events are permitted. REQUIRED — empty list rejects all. #[serde(default, rename = "allowed-events")] pub allowed_events: Vec, @@ -123,19 +133,37 @@ pub struct SubmitPrReviewConfig { pub allowed_repositories: Vec, } +pub(crate) fn validate_submit_pr_review_config( + config: &SubmitPrReviewConfig, +) -> anyhow::Result<()> { + for event in &config.allowed_events { + ensure!( + VALID_EVENTS.contains(&event.as_str()), + "unknown submit-pr-review event '{event}'" + ); + } + for repository in &config.allowed_repositories { + ensure!( + !repository.trim().is_empty(), + "allowed-repositories entries must not be empty" + ); + reject_pipeline_injection(repository, "allowed-repositories")?; + } + Ok(()) +} + /// Fetches the authenticated user's ID from the ADO connection data endpoint. /// Returns `Ok(Err(ExecutionResult::failure(...)))` on HTTP errors, `Ok(Ok(user_id))` on success. async fn fetch_authenticated_user_id( client: &reqwest::Client, org_url: &str, token: &str, + connection_type: Option, ) -> anyhow::Result> { let connection_url = format!("{}/_apis/connectiondata", org_url.trim_end_matches('/')); debug!("Connection data URL: {}", connection_url); - let response = client - .get(&connection_url) - .basic_auth("", Some(token)) + let response = authenticate_ado_request(client.get(&connection_url), token, connection_type) .send() .await .context("Failed to fetch connection data")?; @@ -169,13 +197,7 @@ async fn fetch_authenticated_user_id( } /// Shared transport/auth context for the vote-related helpers, reducing per-call argument count. -struct PrVoteCtx<'a> { - client: &'a reqwest::Client, - base_url: &'a str, - encoded_repo: &'a str, - pull_request_id: i32, - token: &'a str, -} +type PrVoteCtx<'a> = UpdatePrContext<'a>; /// Self-approval guard: returns `Some(failure)` when a positive vote targets a PR the /// authenticated user created; returns `None` when the vote is allowed to proceed. @@ -190,16 +212,15 @@ async fn check_self_approval( } let pr_url = format!( - "{}/{}/pullRequests/{}?api-version=7.1", - ctx.base_url, ctx.encoded_repo, ctx.pull_request_id + "{}/pullRequests/{}?api-version=7.1", + ctx.repository_api_base(), + ctx.pr_id ); - let pr_response = ctx - .client - .get(&pr_url) - .basic_auth("", Some(ctx.token)) - .send() - .await - .context("Failed to fetch PR for self-approval check")?; + let pr_response = + authenticate_ado_request(ctx.client.get(&pr_url), ctx.token, ctx.connection_type) + .send() + .await + .context("Failed to fetch PR for self-approval check")?; if !pr_response.status().is_success() { let status = pr_response.status(); @@ -209,7 +230,7 @@ async fn check_self_approval( .unwrap_or_else(|_| "Unknown error".to_string()); return Ok(Some(ExecutionResult::failure(format!( "Failed to fetch PR #{} for self-approval check (HTTP {}): {}", - ctx.pull_request_id, status, error_body + ctx.pr_id, status, error_body )))); } @@ -223,11 +244,16 @@ async fn check_self_approval( .and_then(|cb| cb.get("id")) .and_then(|id| id.as_str()); - if creator_id == Some(user_id) { + let Some(creator_id) = creator_id else { + return Ok(Some(ExecutionResult::failure( + "PR response missing createdBy.id for self-approval check", + ))); + }; + if creator_id.eq_ignore_ascii_case(user_id) { return Ok(Some(ExecutionResult::failure(format!( "Self-approval blocked: the authenticated identity created PR #{} \ and cannot cast a positive vote ('{}') on it", - ctx.pull_request_id, event + ctx.pr_id, event )))); } @@ -243,22 +269,19 @@ async fn submit_vote( vote_value: i32, ) -> anyhow::Result> { let vote_url = format!( - "{}/{}/pullRequests/{}/reviewers/{}?api-version=7.1", - ctx.base_url, ctx.encoded_repo, ctx.pull_request_id, encoded_user_id - ); - info!( - "Voting '{}' ({}) on PR #{}", - event, vote_value, ctx.pull_request_id + "{}/pullRequests/{}/reviewers/{}?api-version=7.1", + ctx.repository_api_base(), + ctx.pr_id, + encoded_user_id ); - let response = ctx - .client - .put(&vote_url) - .header("Content-Type", "application/json") - .basic_auth("", Some(ctx.token)) - .json(&serde_json::json!({ "vote": vote_value })) - .send() - .await - .context("Failed to submit vote")?; + info!("Voting '{}' ({}) on PR #{}", event, vote_value, ctx.pr_id); + let response = + authenticate_ado_request(ctx.client.put(&vote_url), ctx.token, ctx.connection_type) + .header("Content-Type", "application/json") + .json(&serde_json::json!({ "vote": vote_value })) + .send() + .await + .context("Failed to submit vote")?; if !response.status().is_success() { let status = response.status(); @@ -268,43 +291,40 @@ async fn submit_vote( .unwrap_or_else(|_| "Unknown error".to_string()); return Ok(Some(ExecutionResult::failure(format!( "Failed to submit vote on PR #{} (HTTP {}): {}", - ctx.pull_request_id, status, error_body + ctx.pr_id, status, error_body )))); } - info!("Vote '{}' submitted on PR #{}", event, ctx.pull_request_id); + info!("Vote '{}' submitted on PR #{}", event, ctx.pr_id); Ok(None) } /// POSTs an optional review comment thread. Returns the ADO thread ID on success, or a failure. async fn post_review_comment_thread( - client: &reqwest::Client, - base_url: &str, - encoded_repo: &str, - pull_request_id: i32, + ctx: &PrVoteCtx<'_>, body: &str, - token: &str, ) -> anyhow::Result> { + let pull_request_id = ctx.pr_id; let thread_url = format!( - "{}/{}/pullRequests/{}/threads?api-version=7.1", - base_url, encoded_repo, pull_request_id + "{}/pullRequests/{}/threads?api-version=7.1", + ctx.repository_api_base(), + pull_request_id ); info!( "Posting review comment on PR #{} ({} chars)", pull_request_id, body.len() ); - let response = client - .post(&thread_url) - .header("Content-Type", "application/json") - .basic_auth("", Some(token)) - .json(&serde_json::json!({ - "comments": [{"parentCommentId": 0, "content": body, "commentType": 1}], - "status": 1 - })) - .send() - .await - .context("Failed to post review comment thread")?; + let response = + authenticate_ado_request(ctx.client.post(&thread_url), ctx.token, ctx.connection_type) + .header("Content-Type", "application/json") + .json(&serde_json::json!({ + "comments": [{"parentCommentId": 0, "content": body, "commentType": 1}], + "status": 1 + })) + .send() + .await + .context("Failed to post review comment thread")?; if !response.status().is_success() { let status = response.status(); @@ -331,6 +351,30 @@ async fn post_review_comment_thread( Ok(Ok(thread_id)) } +/// Sole vote mutation implementation, also used by historical `update-pr` records. +pub(crate) async fn execute_review_vote( + ctx: &UpdatePrContext<'_>, + event: &str, + vote_value: i32, +) -> anyhow::Result> { + let user_id = match fetch_authenticated_user_id( + ctx.client, + &ctx.target.organization_url, + ctx.token, + ctx.connection_type, + ) + .await? + { + Ok(id) => id, + Err(failure) => return Ok(Some(failure)), + }; + if let Some(failure) = check_self_approval(ctx, &user_id, event, vote_value).await? { + return Ok(Some(failure)); + } + let encoded_id = utf8_percent_encode(&user_id, PATH_SEGMENT).to_string(); + submit_vote(ctx, &encoded_id, event, vote_value).await +} + #[async_trait::async_trait] impl Executor for SubmitPrReviewResult { fn dry_run_summary(&self) -> String { @@ -350,21 +394,42 @@ impl Executor for SubmitPrReviewResult { self.pull_request_id, self.event ); - let org_url = ctx - .ado_org_url - .as_ref() - .context("AZURE_DEVOPS_ORG_URL not set")?; - let project = ctx - .ado_project - .as_ref() - .context("SYSTEM_TEAMPROJECT not set")?; + if let Err(error) = (SubmitPrReviewParams { + pull_request_id: self.pull_request_id.clone(), + event: self.event.clone(), + body: self.body.clone(), + repository: self.repository.clone(), + }) + .validate() + { + return Ok(ExecutionResult::failure(error.to_string())); + } let token = ctx .access_token .as_ref() .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; - debug!("ADO org: {}, project: {}", org_url, project); - let config: SubmitPrReviewConfig = ctx.get_tool_config("submit-pr-review")?; + validate_submit_pr_review_config(&config)?; + if matches!(self.pull_request_id, PullRequestReference::Temporary(_)) + && !config.allow_temporary_ids + { + return Ok(ExecutionResult::failure( + "submit-pr-review temporary IDs require allow-temporary-ids: true", + )); + } + let legacy = legacy_policy(ctx, "submit-pr-review", "vote")?; + if let Some(legacy) = &legacy { + if self.body.is_some() { + return Ok(ExecutionResult::failure( + "legacy update-pr vote does not permit a rationale comment", + )); + } + if !legacy.allowed_votes.contains(&self.event) { + return Ok(ExecutionResult::failure( + "event is not in the legacy allowed-votes list", + )); + } + } debug!("Config: {:?}", config); // Validate event against allowed-events — REQUIRED. @@ -386,26 +451,26 @@ impl Executor for SubmitPrReviewResult { ))); } - // Validate repository against allowed-repositories config - let repo_alias = self.repository.as_deref().unwrap_or("self"); - if !config.allowed_repositories.is_empty() - && !config - .allowed_repositories - .contains(&repo_alias.to_string()) - { - return Ok(ExecutionResult::failure(format!( - "Repository '{}' is not in the allowed-repositories list: [{}]", - repo_alias, - config.allowed_repositories.join(", ") - ))); - } - - // Resolve repo name - let repo_name = match resolve_repo_name(self.repository.as_deref(), ctx) { - Ok(name) => name, + let (pr_id, target) = match resolve_pr_target( + &self.pull_request_id, + self.repository.as_deref(), + &config.allowed_repositories, + ctx, + )? { + Ok(target) => target, Err(failure) => return Ok(failure), }; - debug!("Resolved repository: {}", repo_name); + if let Some(legacy) = &legacy + && let Err(failure) = resolve_pr_target( + &self.pull_request_id, + self.repository.as_deref(), + &legacy.allowed_repositories, + ctx, + )? + { + return Ok(failure); + } + let repo_name = target.qualified_repository(); // Map event to vote value let vote_value = event_to_vote(&self.event).context(format!( @@ -415,55 +480,20 @@ impl Executor for SubmitPrReviewResult { ))?; let client = reqwest::Client::new(); - let encoded_project = utf8_percent_encode(project, PATH_SEGMENT).to_string(); - let encoded_repo = utf8_percent_encode(&repo_name, PATH_SEGMENT).to_string(); - let base_url = format!( - "{}/{}/_apis/git/repositories", - org_url.trim_end_matches('/'), - encoded_project, - ); - - // Resolve the current user identity via connection data. - // Use the org URL — supports vanity domains and national clouds. - let user_id = match fetch_authenticated_user_id(&client, org_url, token).await? { - Ok(id) => id, - Err(failure) => return Ok(failure), - }; - - // Self-approval guard: prevent the agent from approving PRs it created. let vote_ctx = PrVoteCtx { client: &client, - base_url: &base_url, - encoded_repo: &encoded_repo, - pull_request_id: self.pull_request_id, + target, + pr_id, token, + connection_type: ctx.write_connection_type, }; - if let Some(failure) = - check_self_approval(&vote_ctx, &user_id, &self.event, vote_value).await? - { - return Ok(failure); - } - - // PUT vote to reviewers endpoint - let encoded_user_id = utf8_percent_encode(&user_id, PATH_SEGMENT).to_string(); - if let Some(failure) = - submit_vote(&vote_ctx, &encoded_user_id, &self.event, vote_value).await? - { + if let Some(failure) = execute_review_vote(&vote_ctx, &self.event, vote_value).await? { return Ok(failure); } // If body is provided, also POST a comment thread with the review rationale if let Some(ref body) = self.body { - let thread_id = match post_review_comment_thread( - &client, - &base_url, - &encoded_repo, - self.pull_request_id, - body, - token, - ) - .await? - { + let thread_id = match post_review_comment_thread(&vote_ctx, body).await? { Ok(id) => id, Err(failure) => return Ok(failure), }; @@ -474,7 +504,7 @@ impl Executor for SubmitPrReviewResult { self.event, self.pull_request_id, thread_id ), serde_json::json!({ - "pull_request_id": self.pull_request_id, + "pull_request_id": pr_id, "event": self.event, "vote_value": vote_value, "thread_id": thread_id, @@ -489,7 +519,7 @@ impl Executor for SubmitPrReviewResult { self.event, self.pull_request_id ), serde_json::json!({ - "pull_request_id": self.pull_request_id, + "pull_request_id": pr_id, "event": self.event, "vote_value": vote_value, "repository": repo_name, @@ -512,7 +542,7 @@ mod tests { fn test_params_deserializes() { let json = r#"{"pull_request_id": 42, "event": "approve"}"#; let params: SubmitPrReviewParams = serde_json::from_str(json).unwrap(); - assert_eq!(params.pull_request_id, 42); + assert_eq!(params.pull_request_id, PullRequestReference::Number(42)); assert_eq!(params.event, "approve"); assert!(params.body.is_none()); assert!(params.repository.is_none()); @@ -521,21 +551,21 @@ mod tests { #[test] fn test_params_converts_to_result() { let params = SubmitPrReviewParams { - pull_request_id: 42, + pull_request_id: PullRequestReference::Number(42), event: "approve".to_string(), body: None, repository: Some("self".to_string()), }; let result: SubmitPrReviewResult = params.try_into().unwrap(); assert_eq!(result.name, "submit-pr-review"); - assert_eq!(result.pull_request_id, 42); + assert_eq!(result.pull_request_id, PullRequestReference::Number(42)); assert_eq!(result.event, "approve"); } #[test] fn test_validation_rejects_zero_pr_id() { let params = SubmitPrReviewParams { - pull_request_id: 0, + pull_request_id: PullRequestReference::Number(0), event: "approve".to_string(), body: None, repository: Some("self".to_string()), @@ -551,7 +581,7 @@ mod tests { #[test] fn test_validation_rejects_invalid_event() { let params = SubmitPrReviewParams { - pull_request_id: 1, + pull_request_id: PullRequestReference::Number(1), event: "merge".to_string(), body: None, repository: Some("self".to_string()), @@ -566,7 +596,7 @@ mod tests { #[test] fn test_validation_rejects_request_changes_without_body() { let params = SubmitPrReviewParams { - pull_request_id: 1, + pull_request_id: PullRequestReference::Number(1), event: "request-changes".to_string(), body: None, repository: Some("self".to_string()), @@ -582,7 +612,7 @@ mod tests { #[test] fn test_validation_rejects_repository_pipeline_command() { let params = SubmitPrReviewParams { - pull_request_id: 1, + pull_request_id: PullRequestReference::Number(1), event: "approve".to_string(), body: None, repository: Some("##vso[task.setvariable variable=x]y".to_string()), @@ -597,7 +627,7 @@ mod tests { #[test] fn test_result_serializes_correctly() { let params = SubmitPrReviewParams { - pull_request_id: 99, + pull_request_id: PullRequestReference::Number(99), event: "request-changes".to_string(), body: Some("This needs significant rework before merging.".to_string()), repository: Some("self".to_string()), @@ -615,6 +645,208 @@ mod tests { let config = SubmitPrReviewConfig::default(); assert!(config.allowed_events.is_empty()); assert!(config.allowed_repositories.is_empty()); + assert!(!config.allow_temporary_ids); + } + + #[test] + fn all_review_events_retain_exact_vote_values_and_rationale_rules() { + for (event, vote) in [ + ("approve", 10), + ("approve-with-suggestions", 5), + ("request-changes", -5), + ("comment", 0), + ("wait-for-author", -5), + ("reject", -10), + ("reset", 0), + ] { + assert_eq!(event_to_vote(event), Some(vote)); + let params = SubmitPrReviewParams { + pull_request_id: PullRequestReference::Number(u64::MAX), + event: event.into(), + body: None, + repository: None, + }; + assert_eq!(params.validate().is_ok(), event != "request-changes"); + } + } + + #[tokio::test] + async fn native_numeric_config_rejects_temporary_ids_before_network() { + let server = wiremock::MockServer::start().await; + let ctx = super::super::pr_common::tests::registered_context( + &server.uri(), + "submit-pr-review", + serde_json::json!({"allowed-events": ["reset"]}), + ); + let mut result: SubmitPrReviewResult = serde_json::from_value(serde_json::json!({ + "name": "submit-pr-review", "pull_request_id": "#aw_pr123", "event": "reset" + })) + .unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(!execution.success); + assert!(execution.message.contains("allow-temporary-ids")); + assert!(server.received_requests().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn native_comment_event_still_writes_zero_vote_and_optional_thread() { + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{body_json, method, path}, + }; + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/_apis/connectiondata")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"authenticatedUser": {"id": "actor"}})), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PUT")) + .and(path( + "/Other/_apis/git/repositories/repo-id/pullRequests/4294967296/reviewers/actor", + )) + .and(body_json(serde_json::json!({"vote": 0}))) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")).and(path("/Other/_apis/git/repositories/repo-id/pullRequests/4294967296/threads")) + .and(body_json(serde_json::json!({ + "comments": [{"parentCommentId": 0, "content": "Reviewed without objection.", "commentType": 1}], + "status": 1 + }))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"id": 12}))) + .expect(1).mount(&server).await; + let ctx = super::super::pr_common::tests::registered_context( + &server.uri(), + "submit-pr-review", + serde_json::json!({"allowed-events": ["comment"], "allow-temporary-ids": true}), + ); + let mut result: SubmitPrReviewResult = serde_json::from_value(serde_json::json!({ + "name": "submit-pr-review", "pull_request_id": "#aw_pr123", "event": "comment", + "body": "Reviewed without objection." + })) + .unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(execution.success, "{}", execution.message); + assert_eq!(execution.data.unwrap()["thread_id"], 12); + } + + #[tokio::test] + async fn migrated_review_uses_exact_target_and_does_not_require_wait_rationale() { + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{body_json, header, method, path}, + }; + for (event, vote) in [("wait-for-author", -5), ("reject", -10), ("reset", 0)] { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/_apis/connectiondata")) + .and(header("authorization", "Bearer token")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"authenticatedUser": {"id": "actor"}})), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PUT")) + .and(path( + "/Other/_apis/git/repositories/repo-id/pullRequests/4294967296/reviewers/actor", + )) + .and(header("authorization", "Bearer token")) + .and(body_json(serde_json::json!({"vote": vote}))) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + let mut ctx = super::super::pr_common::tests::registered_context( + &server.uri(), + "submit-pr-review", + serde_json::json!({ + "allowed-events": [event], "allow-temporary-ids": true, + "legacy-update-pr": {"allowed-votes": [event]} + }), + ); + ctx.write_connection_type = + Some(crate::compile::types::WriteConnectionType::AzureDevOps); + let mut result: SubmitPrReviewResult = serde_json::from_value(serde_json::json!({ + "name": "submit-pr-review", "pull_request_id": "#aw_pr123", "event": event + })) + .unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + assert!(execution.success, "{}", execution.message); + assert_eq!(execution.data.unwrap()["vote_value"], vote); + assert_eq!(server.received_requests().await.unwrap().len(), 2); + } + } + + #[tokio::test] + async fn migrated_review_rejects_comments_and_legacy_vote_exclusions() { + let server = wiremock::MockServer::start().await; + for (body, allowed_votes) in [ + (Some("Not permitted as a new comment"), vec!["reset"]), + (None, vec!["reject"]), + ] { + let ctx = super::super::pr_common::tests::registered_context( + &server.uri(), + "submit-pr-review", + serde_json::json!({ + "allowed-events": ["reset"], "allow-temporary-ids": true, + "legacy-update-pr": {"allowed-votes": allowed_votes} + }), + ); + let mut result: SubmitPrReviewResult = serde_json::from_value(serde_json::json!({ + "name": "submit-pr-review", "pull_request_id": "#aw_pr123", "event": "reset", "body": body + })).unwrap(); + assert!(!result.execute_sanitized(&ctx).await.unwrap().success); + } + assert!(server.received_requests().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn positive_review_rejects_self_approval_and_missing_creator() { + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{method, path}, + }; + for creator in [serde_json::json!("ACTOR"), serde_json::Value::Null] { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/_apis/connectiondata")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"authenticatedUser": {"id": "actor"}})), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path( + "/Other/_apis/git/repositories/repo-id/pullRequests/4294967296", + )) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(serde_json::json!({"createdBy": {"id": creator}})), + ) + .expect(1) + .mount(&server) + .await; + let ctx = super::super::pr_common::tests::registered_context( + &server.uri(), + "submit-pr-review", + serde_json::json!({"allowed-events": ["approve"], "allow-temporary-ids": true}), + ); + let mut result: SubmitPrReviewResult = serde_json::from_value(serde_json::json!({ + "name": "submit-pr-review", "pull_request_id": "#aw_pr123", "event": "approve" + })) + .unwrap(); + assert!(!result.execute_sanitized(&ctx).await.unwrap().success); + assert_eq!(server.received_requests().await.unwrap().len(), 2); + } } #[test] diff --git a/src/safe_outputs/update_pr.rs b/src/safe_outputs/update_pr.rs index 944099880..e41c3c9c0 100644 --- a/src/safe_outputs/update_pr.rs +++ b/src/safe_outputs/update_pr.rs @@ -1,2153 +1,5 @@ -//! Update pull request safe output tool +//! Historical `update-pr` proposal compatibility. New workflows use focused tools. -use ado_aw_derive::SanitizeConfig; -use log::{debug, info, warn}; -use percent_encoding::utf8_percent_encode; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use std::fmt; - -use super::result::AdoRepositoryTarget; -use super::{PATH_SEGMENT, canonical_repository_alias, resolve_repository_write_target}; -use crate::safe_outputs::{ExecutionContext, ExecutionResult, Executor, Validate}; -use crate::sanitize::{SanitizeContent, sanitize as sanitize_text, sanitize_config}; -use crate::secure::{Guid, PullRequestTemporaryId}; -use crate::tool_result; -use crate::validate::reject_pipeline_injection; -use anyhow::{Context, ensure}; - -/// Valid operation names for update-pr -const VALID_OPERATIONS: &[&str] = &[ - "add-reviewers", - "add-labels", - "set-auto-complete", - "vote", - "update-description", -]; - -/// Valid vote values -const VALID_VOTES: &[&str] = &[ - "approve", - "approve-with-suggestions", - "wait-for-author", - "reject", - "reset", -]; - -/// Valid merge strategy values accepted by ADO's completionOptions.mergeStrategy -const VALID_MERGE_STRATEGIES: &[&str] = &["squash", "noFastForward", "rebase", "rebaseMerge"]; -const DEFAULT_MAX_REVIEWERS: usize = 3; -const MAX_REVIEWER_LEN: usize = 256; - -/// Positive Azure DevOps pull-request ID or a same-run temporary ID. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] -#[serde(untagged)] -pub enum PullRequestReference { - Number(u64), - Temporary(PullRequestTemporaryId), -} - -impl fmt::Display for PullRequestReference { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Number(id) => write!(formatter, "{id}"), - Self::Temporary(temporary_id) => formatter.write_str(&temporary_id.canonical()), - } - } -} - -impl_temporary_reference_deserialize!( - PullRequestReference, - PullRequestTemporaryId, - expecting = "a positive pull-request ID or #aw_ temporary ID", - negative = "pull_request_id must be positive", - quoted_out_of_range = "quoted pull_request_id is outside the u64 range", -); - -/// Map a vote string to its ADO numeric value -fn vote_to_ado_value(vote: &str) -> Option { - match vote { - "approve" => Some(10), - "approve-with-suggestions" => Some(5), - "wait-for-author" => Some(-5), - "reject" => Some(-10), - "reset" => Some(0), - _ => None, - } -} - -/// Parameters for updating a pull request -#[derive(Deserialize, JsonSchema)] -pub struct UpdatePrParams { - /// Positive pull request ID or a temporary ID from create-pull-request. - pub pull_request_id: PullRequestReference, - - /// Repository alias: "self" for the pipeline repo, or an alias from the checkout list - #[serde(default)] - pub repository: Option, - - /// Operation to perform: "add-reviewers", "add-labels", "set-auto-complete", "vote", or "update-description" - pub operation: String, - - /// Reviewer emails (required for add-reviewers operation) - pub reviewers: Option>, - - /// Label names (required for add-labels operation) - pub labels: Option>, - - /// Vote value: "approve", "approve-with-suggestions", "wait-for-author", "reject", or "reset" - pub vote: Option, - - /// New PR description in markdown (required for update-description, must be >= 10 chars) - pub description: Option, -} - -impl Validate for UpdatePrParams { - fn validate(&self) -> anyhow::Result<()> { - if let PullRequestReference::Number(id) = self.pull_request_id { - ensure!(id > 0, "pull_request_id must be positive"); - } - if let Some(repository) = &self.repository { - reject_pipeline_injection(repository, "repository")?; - } - ensure!( - VALID_OPERATIONS.contains(&self.operation.as_str()), - "operation must be one of: {}", - VALID_OPERATIONS.join(", ") - ); - - match self.operation.as_str() { - "add-reviewers" => { - let reviewers = self - .reviewers - .as_ref() - .context("reviewers must be provided for add-reviewers operation")?; - ensure!( - !reviewers.is_empty(), - "reviewers list must not be empty for add-reviewers operation" - ); - ensure!( - reviewers.len() <= 100, - "reviewers list must contain at most 100 entries" - ); - for reviewer in reviewers { - let reviewer = reviewer.trim(); - ensure!(!reviewer.is_empty(), "reviewer must not be empty"); - ensure!( - reviewer.len() <= MAX_REVIEWER_LEN, - "reviewer must be {MAX_REVIEWER_LEN} characters or fewer" - ); - reject_pipeline_injection(reviewer, "update-pr.reviewer")?; - } - } - "add-labels" => { - let labels = self - .labels - .as_ref() - .context("labels must be provided for add-labels operation")?; - ensure!( - !labels.is_empty(), - "labels list must not be empty for add-labels operation" - ); - } - "vote" => { - let vote = self - .vote - .as_ref() - .context("vote must be provided for vote operation")?; - ensure!( - VALID_VOTES.contains(&vote.as_str()), - "vote must be one of: {}", - VALID_VOTES.join(", ") - ); - } - "update-description" => { - let desc = self - .description - .as_ref() - .context("description must be provided for update-description operation")?; - ensure!( - desc.len() >= 10, - "description must be at least 10 characters" - ); - } - _ => {} // set-auto-complete has no extra required fields - } - Ok(()) - } -} - -tool_result! { - name = "update-pr", - write = true, - params = UpdatePrParams, - /// Result of updating a pull request - pub struct UpdatePrResult { - pull_request_id: PullRequestReference, - repository: Option, - operation: String, - reviewers: Option>, - labels: Option>, - vote: Option, - description: Option, - } -} - -impl SanitizeContent for UpdatePrResult { - fn sanitize_content_fields(&mut self) { - self.repository = self.repository.as_deref().map(sanitize_config); - self.operation = sanitize_config(&self.operation); - self.reviewers = self - .reviewers - .as_ref() - .map(|rs| rs.iter().map(|r| sanitize_config(r)).collect()); - self.labels = self - .labels - .as_ref() - .map(|ls| ls.iter().map(|l| sanitize_config(l)).collect()); - self.vote = self.vote.as_deref().map(sanitize_config); - self.description = self.description.as_deref().map(sanitize_text); - } -} - -/// Configuration for the update-pr tool (specified in front matter) -/// -/// **Allow-list semantics note:** `allowed-operations` and `allowed-repositories` use -/// permissive defaults (empty = all allowed), while `allowed-votes` uses a secure default -/// (empty = all rejected). This asymmetry is intentional — vote operations can auto-approve -/// PRs, so they require explicit opt-in to prevent accidental privilege escalation. -/// -/// Example front matter: -/// ```yaml -/// safe-outputs: -/// update-pr: -/// allowed-operations: -/// - add-reviewers -/// - set-auto-complete -/// allowed-repositories: -/// - self -/// allowed-votes: -/// - approve -/// - reject -/// ``` -#[derive(Debug, Clone, SanitizeConfig, Serialize, Deserialize)] -pub struct UpdatePrConfig { - /// Which operations are permitted. Empty list means all operations are allowed. - #[serde(default, rename = "allowed-operations")] - pub allowed_operations: Vec, - - /// Which repositories the agent may target. Empty list means all allowed repos. - #[serde(default, rename = "allowed-repositories")] - pub allowed_repositories: Vec, - - /// Which vote values are permitted. REQUIRED for vote operation — - /// empty list rejects all votes to prevent accidental auto-approve. - #[serde(default, rename = "allowed-votes")] - pub allowed_votes: Vec, - - /// Case-insensitive exact allowlist for model-selected reviewers. - /// Empty or a literal "*" allows any valid reviewer. - #[serde(default, rename = "allowed-reviewers")] - pub allowed_reviewers: Vec, - - /// Maximum reviewers accepted by one add-reviewers operation. - #[serde(default = "default_max_reviewers", rename = "max-reviewers")] - #[sanitize_config(skip)] - pub max_reviewers: usize, - - /// Whether to delete the source branch after merge (for set-auto-complete, default: true) - #[serde(default = "default_true", rename = "delete-source-branch")] - pub delete_source_branch: bool, - - /// Merge strategy for auto-complete: "squash", "noFastForward", "rebase", "rebaseMerge" (default: "squash") - #[serde(default = "default_merge_strategy", rename = "merge-strategy")] - pub merge_strategy: String, -} - -fn default_true() -> bool { - true -} - -fn default_merge_strategy() -> String { - "squash".to_string() -} - -fn default_max_reviewers() -> usize { - DEFAULT_MAX_REVIEWERS -} - -impl Default for UpdatePrConfig { - fn default() -> Self { - Self { - allowed_operations: Vec::new(), - allowed_repositories: Vec::new(), - allowed_votes: Vec::new(), - allowed_reviewers: Vec::new(), - max_reviewers: default_max_reviewers(), - delete_source_branch: true, - merge_strategy: "squash".to_string(), - } - } -} - -struct UpdatePrContext<'a> { - client: &'a reqwest::Client, - target: AdoRepositoryTarget, - pr_id: u64, - token: &'a str, - connection_type: Option, -} - -impl UpdatePrContext<'_> { - fn repository_api_base(&self) -> String { - format!( - "{}/{}/_apis/git/repositories/{}", - self.target.organization_url, - utf8_percent_encode(&self.target.project, PATH_SEGMENT), - utf8_percent_encode(self.target.repository_locator(), PATH_SEGMENT), - ) - } -} - -fn repository_is_allowed(config: &UpdatePrConfig, alias: &str) -> bool { - config.allowed_repositories.is_empty() - || config - .allowed_repositories - .iter() - .any(|allowed| allowed.eq_ignore_ascii_case(alias)) -} - -fn resolve_update_pr_target( - reference: &PullRequestReference, - requested_repository: Option<&str>, - config: &UpdatePrConfig, - ctx: &ExecutionContext, -) -> anyhow::Result> { - match reference { - PullRequestReference::Number(id) => { - if *id == 0 { - return Ok(Err(ExecutionResult::failure( - "pull_request_id must be positive", - ))); - } - let selector = requested_repository.unwrap_or("self"); - let Some(alias) = canonical_repository_alias(selector, ctx) else { - return Ok(Err(ExecutionResult::failure(format!( - "Repository '{}' is not in the allowed repository list", - crate::sanitize::neutralize_pipeline_commands(selector) - )))); - }; - if !repository_is_allowed(config, &alias) { - return Ok(Err(ExecutionResult::failure(format!( - "Repository '{}' is not in the allowed-repositories list: [{}]", - alias, - config.allowed_repositories.join(", ") - )))); - } - let target = match resolve_repository_write_target(Some(&alias), ctx) { - Ok(target) => target, - Err(error) => return Ok(Err(error)), - }; - Ok(Ok((*id, target))) - } - PullRequestReference::Temporary(temporary_id) => { - let Some(resolved) = ctx.resolve_pull_request(temporary_id)? else { - return Ok(Err(ExecutionResult::failure(format!( - "temporary pull-request ID '{}' has not been resolved; \ - create-pull-request must succeed earlier in the same SafeOutputs job", - temporary_id.canonical() - )))); - }; - if let Some(selector) = requested_repository { - let Some(alias) = canonical_repository_alias(selector, ctx) else { - return Ok(Err(ExecutionResult::failure(format!( - "Repository '{}' is not in the allowed repository list", - crate::sanitize::neutralize_pipeline_commands(selector) - )))); - }; - if !alias.eq_ignore_ascii_case(&resolved.target.alias) { - return Ok(Err(ExecutionResult::failure(format!( - "temporary pull-request ID '{}' resolved to repository '{}', which does \ - not match requested repository '{}'", - temporary_id.canonical(), - resolved.target.alias, - crate::sanitize::neutralize_pipeline_commands(selector) - )))); - } - } - if !repository_is_allowed(config, &resolved.target.alias) { - return Ok(Err(ExecutionResult::failure(format!( - "Repository '{}' is not in the allowed-repositories list: [{}]", - resolved.target.alias, - config.allowed_repositories.join(", ") - )))); - } - Ok(Ok((resolved.id, resolved.target))) - } - } -} - -#[async_trait::async_trait] -impl Executor for UpdatePrResult { - fn dry_run_summary(&self) -> String { - format!("{} on PR #{}", self.operation, self.pull_request_id) - } - - async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { - info!( - "Updating PR #{} — operation: {}", - self.pull_request_id, self.operation - ); - debug!( - "update-pr: pr_id={}, operation='{}'", - self.pull_request_id, self.operation - ); - - let token = ctx - .access_token - .as_ref() - .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; - let config: UpdatePrConfig = ctx.get_tool_config("update-pr")?; - debug!("Config: {:?}", config); - - // Validate operation against allowed-operations - if !config.allowed_operations.is_empty() - && !config.allowed_operations.contains(&self.operation) - { - return Ok(ExecutionResult::failure(format!( - "Operation '{}' is not in the allowed-operations list: [{}]", - self.operation, - config.allowed_operations.join(", ") - ))); - } - - let (pr_id, target) = match resolve_update_pr_target( - &self.pull_request_id, - self.repository.as_deref(), - &config, - ctx, - )? { - Ok(target) => target, - Err(failure) => return Ok(failure), - }; - debug!("Resolved PR target: {} #{}", target.display_name(), pr_id); - - let client = reqwest::Client::new(); - let operation_ctx = UpdatePrContext { - client: &client, - target, - pr_id, - token, - connection_type: ctx.write_connection_type, - }; - - match self.operation.as_str() { - "set-auto-complete" => { - self.execute_set_auto_complete(&operation_ctx, &config) - .await - } - "vote" => self.execute_vote(&operation_ctx, &config).await, - "add-reviewers" => self.execute_add_reviewers(&operation_ctx, &config).await, - "add-labels" => self.execute_add_labels(&operation_ctx).await, - "update-description" => self.execute_update_description(&operation_ctx).await, - _ => Ok(ExecutionResult::failure(format!( - "Unknown operation: {}", - self.operation - ))), - } - } -} - -/// Outcome of a single reviewer resolution + add attempt. -enum ReviewerAddResult { - Added, - Failed(String), -} - -fn reviewer_execution_result( - pr_id: u64, - added: Vec, - failed: Vec, -) -> ExecutionResult { - let mut message = format!("Added {} reviewer(s) to PR #{}", added.len(), pr_id); - if !failed.is_empty() { - message.push_str(&format!( - " ({} failed: {})", - failed.len(), - failed.join(", ") - )); - } - let has_failures = !failed.is_empty(); - let data = serde_json::json!({ - "pull_request_id": pr_id, - "operation": "add-reviewers", - "added": added, - "failed": failed, - }); - if has_failures { - ExecutionResult::warning_with_data(message, data) - } else { - ExecutionResult::success_with_data(message, data) - } -} - -fn validate_and_normalize_reviewers( - reviewers: &[String], - config: &UpdatePrConfig, -) -> Result, ExecutionResult> { - if config.max_reviewers == 0 { - return Err(ExecutionResult::failure( - "update-pr.max-reviewers must be greater than zero", - )); - } - let allow_any = config.allowed_reviewers.is_empty() - || config - .allowed_reviewers - .iter() - .any(|allowed| allowed == "*"); - - let mut normalized = Vec::new(); - for reviewer in reviewers { - let reviewer = reviewer.trim(); - if !allow_any - && !config - .allowed_reviewers - .iter() - .any(|allowed| allowed.eq_ignore_ascii_case(reviewer)) - { - return Err(ExecutionResult::failure(format!( - "Reviewer '{}' is not in update-pr.allowed-reviewers", - crate::sanitize::neutralize_pipeline_commands(reviewer) - ))); - } - if !normalized - .iter() - .any(|existing: &String| existing.eq_ignore_ascii_case(reviewer)) - { - normalized.push(reviewer.to_string()); - } - } - if normalized.len() > config.max_reviewers { - return Err(ExecutionResult::failure(format!( - "add-reviewers requested {} unique reviewers, exceeding max-reviewers: {}", - normalized.len(), - config.max_reviewers - ))); - } - Ok(normalized) -} - -impl UpdatePrResult { - /// Set auto-complete on a pull request. - /// - /// Resolves the authenticated user identity via `_apis/connectiondata`, then - /// patches the PR with `autoCompleteSetBy` and default completion options. - /// Uses the agent's own identity (not the PR creator) for proper audit trail. - async fn execute_set_auto_complete( - &self, - operation_ctx: &UpdatePrContext<'_>, - config: &UpdatePrConfig, - ) -> anyhow::Result { - // Validate merge_strategy before any network I/O - if !VALID_MERGE_STRATEGIES.contains(&config.merge_strategy.as_str()) { - return Ok(ExecutionResult::failure(format!( - "Invalid merge-strategy '{}'. Must be one of: {}", - config.merge_strategy, - VALID_MERGE_STRATEGIES.join(", ") - ))); - } - - // Resolve the agent's identity via connection data - let connection_url = format!( - "{}/_apis/connectiondata", - operation_ctx.target.organization_url.trim_end_matches('/') - ); - let conn_response = crate::safe_outputs::authenticate_ado_request( - operation_ctx.client.get(&connection_url), - operation_ctx.token, - operation_ctx.connection_type, - ) - .send() - .await - .context("Failed to fetch connection data for auto-complete identity")?; - - if !conn_response.status().is_success() { - let status = conn_response.status(); - let error_body = conn_response - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - return Ok(ExecutionResult::failure(format!( - "Failed to fetch connection data (HTTP {}): {}", - status, error_body - ))); - } - - let conn_body: serde_json::Value = conn_response - .json() - .await - .context("Failed to parse connection data response")?; - - let agent_user_id = conn_body - .get("authenticatedUser") - .and_then(|au| au.get("id")) - .and_then(|id| id.as_str()) - .context("Connection data response missing authenticatedUser.id")?; - debug!("Agent user ID for auto-complete: {}", agent_user_id); - - // PATCH to set auto-complete using the agent's identity - let patch_url = format!( - "{}/pullRequests/{}?api-version=7.1", - operation_ctx.repository_api_base(), - operation_ctx.pr_id - ); - let patch_body = serde_json::json!({ - "autoCompleteSetBy": { - "id": agent_user_id - }, - "completionOptions": { - "deleteSourceBranch": config.delete_source_branch, - "mergeStrategy": config.merge_strategy - } - }); - - info!("Setting auto-complete on PR #{}", operation_ctx.pr_id); - let response = crate::safe_outputs::authenticate_ado_request( - operation_ctx.client.patch(&patch_url), - operation_ctx.token, - operation_ctx.connection_type, - ) - .header("Content-Type", "application/json") - .json(&patch_body) - .send() - .await - .context("Failed to set auto-complete on PR")?; - - if response.status().is_success() { - info!("Auto-complete set on PR #{}", operation_ctx.pr_id); - Ok(ExecutionResult::success_with_data( - format!("Auto-complete set on PR #{}", operation_ctx.pr_id), - serde_json::json!({ - "pull_request_id": operation_ctx.pr_id, - "operation": "set-auto-complete", - }), - )) - } else { - let status = response.status(); - let error_body = response - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - Ok(ExecutionResult::failure(format!( - "Failed to set auto-complete on PR #{} (HTTP {}): {}", - operation_ctx.pr_id, status, error_body - ))) - } - } - - /// Submit a vote on a pull request. - /// - /// Resolves the current user identity via `_apis/connectiondata`, then - /// PUTs the vote to the reviewers endpoint. - async fn execute_vote( - &self, - operation_ctx: &UpdatePrContext<'_>, - config: &UpdatePrConfig, - ) -> anyhow::Result { - let vote_str = self - .vote - .as_deref() - .context("vote value is required for vote operation")?; - - // Validate against allowed-votes — REQUIRED for vote operation. - // An empty allowed-votes list means the operator hasn't opted in, so reject. - if config.allowed_votes.is_empty() { - return Ok(ExecutionResult::failure( - "vote operation requires 'allowed-votes' to be configured in safe-outputs.update-pr. \ - This prevents agents from casting unrestricted votes (including approve). \ - Example:\n safe-outputs:\n update-pr:\n allowed-votes:\n - approve-with-suggestions\n - wait-for-author" - .to_string(), - )); - } - if !config.allowed_votes.contains(&vote_str.to_string()) { - return Ok(ExecutionResult::failure(format!( - "Vote '{}' is not in the allowed-votes list: [{}]", - vote_str, - config.allowed_votes.join(", ") - ))); - } - - let vote_value = vote_to_ado_value(vote_str).context(format!( - "Invalid vote value: '{}'. Must be one of: {}", - vote_str, - VALID_VOTES.join(", ") - ))?; - - // Resolve the current user identity. - // Use the org URL for connection data — supports vanity domains and national clouds. - let connection_url = format!( - "{}/_apis/connectiondata", - operation_ctx.target.organization_url.trim_end_matches('/') - ); - debug!("Connection data URL: {}", connection_url); - - let conn_response = crate::safe_outputs::authenticate_ado_request( - operation_ctx.client.get(&connection_url), - operation_ctx.token, - operation_ctx.connection_type, - ) - .send() - .await - .context("Failed to fetch connection data")?; - - if !conn_response.status().is_success() { - let status = conn_response.status(); - let error_body = conn_response - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - return Ok(ExecutionResult::failure(format!( - "Failed to fetch connection data (HTTP {}): {}", - status, error_body - ))); - } - - let conn_body: serde_json::Value = conn_response - .json() - .await - .context("Failed to parse connection data response")?; - - let user_id = conn_body - .get("authenticatedUser") - .and_then(|au| au.get("id")) - .and_then(|id| id.as_str()) - .context("Connection data response missing authenticatedUser.id")?; - debug!("Authenticated user ID: {}", user_id); - - // Self-approval guard: prevent the agent from approving PRs it created. - // Positive votes (approve=10, approve-with-suggestions=5) are blocked when - // the authenticated user is also the PR author. - if vote_value > 0 { - let pr_url = format!( - "{}/pullRequests/{}?api-version=7.1", - operation_ctx.repository_api_base(), - operation_ctx.pr_id - ); - let pr_response = crate::safe_outputs::authenticate_ado_request( - operation_ctx.client.get(&pr_url), - operation_ctx.token, - operation_ctx.connection_type, - ) - .send() - .await - .context("Failed to fetch PR for self-approval check")?; - - if pr_response.status().is_success() { - let pr_body: serde_json::Value = pr_response - .json() - .await - .context("Failed to parse PR response")?; - - let creator_id = pr_body - .get("createdBy") - .and_then(|cb| cb.get("id")) - .and_then(|id| id.as_str()); - - if creator_id == Some(user_id) { - return Ok(ExecutionResult::failure(format!( - "Self-approval blocked: the authenticated identity created PR #{} \ - and cannot cast a positive vote ('{}') on it", - operation_ctx.pr_id, vote_str - ))); - } - } else { - let status = pr_response.status(); - let error_body = pr_response - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - return Ok(ExecutionResult::failure(format!( - "Failed to fetch PR #{} for self-approval check (HTTP {}): {}", - operation_ctx.pr_id, status, error_body - ))); - } - } - - // PUT vote to reviewers endpoint - let encoded_user_id = utf8_percent_encode(user_id, PATH_SEGMENT).to_string(); - let vote_url = format!( - "{}/pullRequests/{}/reviewers/{}?api-version=7.1", - operation_ctx.repository_api_base(), - operation_ctx.pr_id, - encoded_user_id - ); - let vote_body = serde_json::json!({ - "vote": vote_value - }); - - info!( - "Voting '{}' ({}) on PR #{}", - vote_str, vote_value, operation_ctx.pr_id - ); - let response = crate::safe_outputs::authenticate_ado_request( - operation_ctx.client.put(&vote_url), - operation_ctx.token, - operation_ctx.connection_type, - ) - .header("Content-Type", "application/json") - .json(&vote_body) - .send() - .await - .context("Failed to submit vote")?; - - if response.status().is_success() { - info!( - "Vote '{}' submitted on PR #{}", - vote_str, operation_ctx.pr_id - ); - Ok(ExecutionResult::success_with_data( - format!( - "Vote '{}' submitted on PR #{}", - vote_str, operation_ctx.pr_id - ), - serde_json::json!({ - "pull_request_id": operation_ctx.pr_id, - "operation": "vote", - "vote": vote_str, - "vote_value": vote_value, - }), - )) - } else { - let status = response.status(); - let error_body = response - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - Ok(ExecutionResult::failure(format!( - "Failed to submit vote on PR #{} (HTTP {}): {}", - operation_ctx.pr_id, status, error_body - ))) - } - } - - /// Add reviewers to a pull request. - /// - /// Resolves and verifies each reviewer identity via VSSPS, then PUTs to the - /// reviewers endpoint with vote 0. - async fn execute_add_reviewers( - &self, - operation_ctx: &UpdatePrContext<'_>, - config: &UpdatePrConfig, - ) -> anyhow::Result { - let requested_reviewers = self - .reviewers - .as_ref() - .context("reviewers list is required for add-reviewers operation")?; - let reviewers = match validate_and_normalize_reviewers(requested_reviewers, config) { - Ok(reviewers) => reviewers, - Err(failure) => return Ok(failure), - }; - - let mut added = Vec::new(); - let mut failed = Vec::new(); - - // Derive VSSPS base URL once, before the loop. - let trimmed_org = operation_ctx.target.organization_url.trim_end_matches('/'); - let vssps_base = trimmed_org.replace("://dev.azure.com/", "://vssps.dev.azure.com/"); - if vssps_base == trimmed_org { - return Ok(ExecutionResult::failure(format!( - "Cannot derive VSSPS identity endpoint from org URL '{}'. \ - The add-reviewers operation requires dev.azure.com-style URLs \ - to resolve reviewer identities. Legacy *.visualstudio.com \ - organizations are not currently supported for this operation.", - trimmed_org - ))); - } - - for reviewer in &reviewers { - match resolve_and_add_reviewer( - operation_ctx.client, - &vssps_base, - &operation_ctx.repository_api_base(), - operation_ctx.pr_id, - reviewer, - operation_ctx.token, - operation_ctx.connection_type, - ) - .await - { - ReviewerAddResult::Added => added.push(reviewer.clone()), - ReviewerAddResult::Failed(reason) => { - failed.push(format!("{} ({})", reviewer, reason)); - } - } - } - - Ok(reviewer_execution_result( - operation_ctx.pr_id, - added, - failed, - )) - } - - /// Add labels to a pull request. - /// - /// For each label, POSTs to the labels endpoint. - async fn execute_add_labels( - &self, - operation_ctx: &UpdatePrContext<'_>, - ) -> anyhow::Result { - let labels = self - .labels - .as_ref() - .context("labels list is required for add-labels operation")?; - - let labels_url = format!( - "{}/pullRequests/{}/labels?api-version=7.1", - operation_ctx.repository_api_base(), - operation_ctx.pr_id - ); - - let mut added = Vec::new(); - let mut failed = Vec::new(); - - for label in labels { - let label_body = serde_json::json!({ - "name": label - }); - - debug!("Adding label '{}' to PR #{}", label, operation_ctx.pr_id); - let response = crate::safe_outputs::authenticate_ado_request( - operation_ctx.client.post(&labels_url), - operation_ctx.token, - operation_ctx.connection_type, - ) - .header("Content-Type", "application/json") - .json(&label_body) - .send() - .await; - - match response { - Ok(resp) if resp.status().is_success() => { - info!("Added label '{}' to PR #{}", label, operation_ctx.pr_id); - added.push(label.clone()); - } - Ok(resp) => { - let status = resp.status(); - let error_body = resp - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - warn!( - "Failed to add label '{}' to PR #{} (HTTP {}): {}", - label, operation_ctx.pr_id, status, error_body - ); - failed.push(format!("{} (HTTP {})", label, status)); - } - Err(e) => { - warn!( - "Request failed for label '{}' on PR #{}: {}", - label, operation_ctx.pr_id, e - ); - failed.push(format!("{} (request error)", label)); - } - } - } - - if added.is_empty() && !failed.is_empty() { - Ok(ExecutionResult::failure(format!( - "Failed to add any labels to PR #{}: {}", - operation_ctx.pr_id, - failed.join(", ") - ))) - } else { - let mut message = format!( - "Added {} label(s) to PR #{}", - added.len(), - operation_ctx.pr_id - ); - if !failed.is_empty() { - message.push_str(&format!( - " ({} failed: {})", - failed.len(), - failed.join(", ") - )); - } - Ok(ExecutionResult::success_with_data( - message, - serde_json::json!({ - "pull_request_id": operation_ctx.pr_id, - "operation": "add-labels", - "added": added, - "failed": failed, - }), - )) - } - } - - /// Update the description of a pull request. - async fn execute_update_description( - &self, - operation_ctx: &UpdatePrContext<'_>, - ) -> anyhow::Result { - let description = self - .description - .as_ref() - .context("description is required for update-description operation")?; - - let patch_url = format!( - "{}/pullRequests/{}?api-version=7.1", - operation_ctx.repository_api_base(), - operation_ctx.pr_id - ); - let patch_body = serde_json::json!({ - "description": description - }); - - info!( - "Updating description on PR #{} ({} chars)", - operation_ctx.pr_id, - description.len() - ); - let response = crate::safe_outputs::authenticate_ado_request( - operation_ctx.client.patch(&patch_url), - operation_ctx.token, - operation_ctx.connection_type, - ) - .header("Content-Type", "application/json") - .json(&patch_body) - .send() - .await - .context("Failed to update PR description")?; - - if response.status().is_success() { - info!("Description updated on PR #{}", operation_ctx.pr_id); - Ok(ExecutionResult::success_with_data( - format!("Description updated on PR #{}", operation_ctx.pr_id), - serde_json::json!({ - "pull_request_id": operation_ctx.pr_id, - "operation": "update-description", - }), - )) - } else { - let status = response.status(); - let error_body = response - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - Ok(ExecutionResult::failure(format!( - "Failed to update description on PR #{} (HTTP {}): {}", - operation_ctx.pr_id, status, error_body - ))) - } - } -} - -/// Look up the Azure DevOps identity GUID for `reviewer` via the VSSPS -/// identities API. Returns `Some(guid)` on success or `None` if the identity -/// cannot be resolved (warning is logged in that case). -async fn lookup_reviewer_id( - client: &reqwest::Client, - vssps_base: &str, - reviewer: &str, - token: &str, - connection_type: Option, -) -> Option { - if let Ok(reviewer_id) = Guid::parse(reviewer) { - let identity_url = format!("{}/_apis/identities", vssps_base); - debug!( - "Verifying reviewer identity GUID '{}': {}", - reviewer, identity_url - ); - return match crate::safe_outputs::authenticate_ado_request( - client.get(&identity_url).query(&[ - ("identityIds", reviewer_id.as_str()), - ("api-version", "7.1"), - ]), - token, - connection_type, - ) - .send() - .await - { - Ok(resp) if resp.status().is_success() => { - match resp.json::().await { - Ok(body) => { - let Some(identities) = - body.get("value").and_then(serde_json::Value::as_array) - else { - warn!( - "Identity lookup for GUID '{}' response missing 'value' array", - reviewer - ); - return None; - }; - if identities.len() != 1 { - warn!( - "Identity lookup for GUID '{}' returned {} identities", - reviewer, - identities.len() - ); - return None; - } - identities[0] - .get("id") - .and_then(serde_json::Value::as_str) - .filter(|id| id.eq_ignore_ascii_case(reviewer_id.as_str())) - .map(str::to_string) - } - Err(error) => { - warn!( - "Identity lookup for GUID '{}' returned invalid JSON: {}", - reviewer, error - ); - None - } - } - } - Ok(resp) => { - warn!( - "Identity lookup for GUID '{}' returned HTTP {}", - reviewer, - resp.status() - ); - None - } - Err(e) => { - warn!("Identity lookup for GUID '{}' failed: {}", reviewer, e); - None - } - }; - } - - let identity_url = format!("{}/_apis/identities", vssps_base); - debug!("Resolving identity for '{}': {}", reviewer, identity_url); - - match crate::safe_outputs::authenticate_ado_request( - client.get(&identity_url).query(&[ - ("searchFilter", "General"), - ("filterValue", reviewer), - ("api-version", "7.1"), - ]), - token, - connection_type, - ) - .send() - .await - { - Ok(resp) if resp.status().is_success() => { - let body: serde_json::Value = resp.json().await.unwrap_or_default(); - let matching_ids = body - .get("value") - .and_then(|v| v.as_array()) - .into_iter() - .flatten() - .filter(|identity| { - let direct_match = ["providerDisplayName", "customDisplayName", "displayName"] - .iter() - .filter_map(|field| identity.get(field).and_then(serde_json::Value::as_str)) - .any(|value| value.eq_ignore_ascii_case(reviewer)); - let property_match = ["Account", "Mail"] - .iter() - .filter_map(|field| { - identity - .get("properties") - .and_then(|properties| properties.get(field)) - .and_then(|property| property.get("$value")) - .and_then(serde_json::Value::as_str) - }) - .any(|value| value.eq_ignore_ascii_case(reviewer)); - direct_match || property_match - }) - .filter_map(|entry| entry.get("id").and_then(serde_json::Value::as_str)) - .collect::>(); - if matching_ids.len() == 1 { - matching_ids.into_iter().next().map(str::to_string) - } else { - if matching_ids.len() > 1 { - warn!( - "Identity lookup for '{}' returned multiple exact matches", - reviewer - ); - } - None - } - } - Ok(resp) => { - warn!( - "Identity lookup for '{}' returned HTTP {}", - reviewer, - resp.status() - ); - None - } - Err(e) => { - warn!("Identity lookup for '{}' failed: {}", reviewer, e); - None - } - } -} - -/// PUT `reviewer_id` as a reviewer onto `pr_id`. Returns -/// [`ReviewerAddResult::Added`] on success or [`ReviewerAddResult::Failed`] -/// with a short reason string on any HTTP or transport error. -async fn add_reviewer_to_pr( - client: &reqwest::Client, - repository_api_base: &str, - pr_id: u64, - reviewer_id: &str, - reviewer: &str, - token: &str, - connection_type: Option, -) -> ReviewerAddResult { - let reviewer_url = format!( - "{}/pullRequests/{}/reviewers/{}?api-version=7.1", - repository_api_base, pr_id, reviewer_id, - ); - let reviewer_body = serde_json::json!({ "vote": 0, "isRequired": false }); - - debug!("Adding reviewer '{}' to PR #{}", reviewer, pr_id); - let response = crate::safe_outputs::authenticate_ado_request( - client.put(&reviewer_url), - token, - connection_type, - ) - .header("Content-Type", "application/json") - .json(&reviewer_body) - .send() - .await; - - match response { - Ok(resp) if resp.status().is_success() => { - info!("Added reviewer '{}' to PR #{}", reviewer, pr_id); - ReviewerAddResult::Added - } - Ok(resp) => { - let status = resp.status(); - let error_body = resp - .text() - .await - .unwrap_or_else(|_| "Unknown error".to_string()); - warn!( - "Failed to add reviewer '{}' to PR #{} (HTTP {}): {}", - reviewer, pr_id, status, error_body - ); - ReviewerAddResult::Failed(format!("HTTP {}", status)) - } - Err(e) => { - warn!( - "Request failed for reviewer '{}' on PR #{}: {}", - reviewer, pr_id, e - ); - ReviewerAddResult::Failed("request error".to_string()) - } - } -} - -/// Resolve an ADO identity for `reviewer` via the VSSPS identities API, then -/// PUT the reviewer onto the PR. Returns [`ReviewerAddResult::Added`] on success -/// or [`ReviewerAddResult::Failed`] with a short reason string on any failure. -async fn resolve_and_add_reviewer( - client: &reqwest::Client, - vssps_base: &str, - repository_api_base: &str, - pr_id: u64, - reviewer: &str, - token: &str, - connection_type: Option, -) -> ReviewerAddResult { - let Some(reviewer_id) = - lookup_reviewer_id(client, vssps_base, reviewer, token, connection_type).await - else { - warn!("Could not resolve identity for '{}', skipping", reviewer); - return ReviewerAddResult::Failed("identity not found".to_string()); - }; - add_reviewer_to_pr( - client, - repository_api_base, - pr_id, - &reviewer_id, - reviewer, - token, - connection_type, - ) - .await -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::safe_outputs::ToolResult; - - #[test] - fn test_result_has_correct_name() { - assert_eq!(UpdatePrResult::NAME, "update-pr"); - } - - #[test] - fn test_params_deserializes() { - let json = r#"{ - "pull_request_id": 42, - "operation": "set-auto-complete" - }"#; - let params: UpdatePrParams = serde_json::from_str(json).unwrap(); - assert_eq!(params.pull_request_id, PullRequestReference::Number(42)); - assert_eq!(params.operation, "set-auto-complete"); - assert!(params.repository.is_none()); - } - - #[test] - fn pull_request_reference_accepts_quoted_numbers_and_temporary_ids() { - let quoted: PullRequestReference = serde_json::from_str("\"42\"").unwrap(); - let temporary: PullRequestReference = serde_json::from_str("\"#aw_pr123\"").unwrap(); - assert_eq!(quoted, PullRequestReference::Number(42)); - assert!(matches!(temporary, PullRequestReference::Temporary(_))); - assert!(serde_json::from_str::("\"not-an-id\"").is_err()); - } - - #[test] - fn test_params_converts_to_result() { - let params = UpdatePrParams { - pull_request_id: PullRequestReference::Number(42), - repository: Some("self".to_string()), - operation: "set-auto-complete".to_string(), - reviewers: None, - labels: None, - vote: None, - description: None, - }; - let result: UpdatePrResult = params.try_into().unwrap(); - assert_eq!(result.name, "update-pr"); - assert_eq!(result.pull_request_id, PullRequestReference::Number(42)); - assert_eq!(result.operation, "set-auto-complete"); - } - - #[test] - fn test_validation_rejects_zero_pr_id() { - let params = UpdatePrParams { - pull_request_id: PullRequestReference::Number(0), - repository: None, - operation: "set-auto-complete".to_string(), - reviewers: None, - labels: None, - vote: None, - description: None, - }; - let result: Result = params.try_into(); - assert!(result.is_err()); - } - - #[test] - fn test_validation_rejects_invalid_operation() { - let params = UpdatePrParams { - pull_request_id: PullRequestReference::Number(1), - repository: None, - operation: "delete-pr".to_string(), - reviewers: None, - labels: None, - vote: None, - description: None, - }; - let err: Result = params.try_into(); - let err = err.unwrap_err().to_string(); - assert!(err.contains("operation must be one of"), "got: {err}"); - } - - #[test] - fn test_validation_rejects_vote_without_value() { - let params = UpdatePrParams { - pull_request_id: PullRequestReference::Number(1), - repository: None, - operation: "vote".to_string(), - reviewers: None, - labels: None, - vote: None, - description: None, - }; - let result: Result = params.try_into(); - assert!(result.is_err()); - } - - #[test] - fn test_validation_rejects_reviewers_without_list() { - let params = UpdatePrParams { - pull_request_id: PullRequestReference::Number(1), - repository: None, - operation: "add-reviewers".to_string(), - reviewers: None, - labels: None, - vote: None, - description: None, - }; - let result: Result = params.try_into(); - assert!(result.is_err()); - } - - #[test] - fn test_validation_rejects_more_than_100_reviewers() { - let params = UpdatePrParams { - pull_request_id: PullRequestReference::Number(1), - repository: None, - operation: "add-reviewers".to_string(), - reviewers: Some(vec!["reviewer@example.com".to_string(); 101]), - labels: None, - vote: None, - description: None, - }; - - let error = params.validate().unwrap_err().to_string(); - assert!( - error.contains("reviewers list must contain at most 100 entries"), - "got: {error}" - ); - } - - #[test] - fn test_validation_rejects_overlong_reviewer() { - let params = UpdatePrParams { - pull_request_id: PullRequestReference::Number(1), - repository: None, - operation: "add-reviewers".to_string(), - reviewers: Some(vec!["a".repeat(MAX_REVIEWER_LEN + 1)]), - labels: None, - vote: None, - description: None, - }; - - let error = params.validate().unwrap_err().to_string(); - assert!( - error.contains("reviewer must be 256 characters or fewer"), - "got: {error}" - ); - } - - #[test] - fn test_validation_rejects_reviewer_pipeline_command() { - let params = UpdatePrParams { - pull_request_id: PullRequestReference::Number(1), - repository: None, - operation: "add-reviewers".to_string(), - reviewers: Some(vec![ - "##vso[task.setvariable variable=REVIEWER]attacker@example.com".to_string(), - ]), - labels: None, - vote: None, - description: None, - }; - - let error = params.validate().unwrap_err().to_string(); - assert!( - error.contains("update-pr.reviewer") && error.contains("ADO pipeline command"), - "got: {error}" - ); - } - - #[test] - fn test_validation_rejects_repository_pipeline_command() { - let params = UpdatePrParams { - pull_request_id: PullRequestReference::Number(1), - repository: Some("##vso[task.setvariable variable=x]y".to_string()), - operation: "set-auto-complete".to_string(), - reviewers: None, - labels: None, - vote: None, - description: None, - }; - let result: Result = params.try_into(); - assert!(result.is_err()); - } - - #[test] - fn test_result_serializes_correctly() { - let params = UpdatePrParams { - pull_request_id: PullRequestReference::Number(99), - repository: Some("self".to_string()), - operation: "vote".to_string(), - reviewers: None, - labels: None, - vote: Some("approve".to_string()), - description: None, - }; - let result: UpdatePrResult = params.try_into().unwrap(); - let json = serde_json::to_string(&result).unwrap(); - - assert!(json.contains(r#""name":"update-pr""#)); - assert!(json.contains(r#""pull_request_id":99"#)); - assert!(json.contains(r#""operation":"vote""#)); - } - - #[test] - fn test_config_defaults() { - let config = UpdatePrConfig::default(); - assert!(config.allowed_operations.is_empty()); - assert!(config.allowed_repositories.is_empty()); - assert!(config.allowed_votes.is_empty()); - assert!(config.allowed_reviewers.is_empty()); - assert_eq!(config.max_reviewers, DEFAULT_MAX_REVIEWERS); - assert_eq!(config.merge_strategy, "squash"); - } - - #[test] - fn reviewer_policy_allows_omitted_allowlist_and_explicit_wildcard() { - let reviewers = vec!["owner@example.com".to_string()]; - assert_eq!( - validate_and_normalize_reviewers(&reviewers, &UpdatePrConfig::default()).unwrap(), - reviewers - ); - - let config = UpdatePrConfig { - allowed_reviewers: vec!["*".to_string()], - ..Default::default() - }; - assert_eq!( - validate_and_normalize_reviewers(&reviewers, &config).unwrap(), - reviewers - ); - } - - #[test] - fn reviewer_policy_restricts_non_empty_allowlist() { - let result = validate_and_normalize_reviewers( - &["other@example.com".to_string()], - &UpdatePrConfig { - allowed_reviewers: vec!["owner@example.com".to_string()], - ..Default::default() - }, - ); - assert!(result.unwrap_err().message.contains("allowed-reviewers")); - } - - #[test] - fn reviewer_policy_deduplicates_and_enforces_limit() { - let config = UpdatePrConfig { - allowed_reviewers: vec![ - "Owner@example.com".to_string(), - "other@example.com".to_string(), - ], - max_reviewers: 2, - ..Default::default() - }; - let reviewers = validate_and_normalize_reviewers( - &[ - "owner@example.com".to_string(), - "OWNER@example.com".to_string(), - ], - &config, - ) - .unwrap(); - assert_eq!(reviewers, ["owner@example.com"]); - - let too_many = validate_and_normalize_reviewers( - &[ - "owner@example.com".to_string(), - "other@example.com".to_string(), - "third@example.com".to_string(), - ], - &UpdatePrConfig { - allowed_reviewers: vec!["*".to_string()], - max_reviewers: 2, - ..Default::default() - }, - ); - assert!(too_many.unwrap_err().message.contains("max-reviewers")); - } - - #[test] - fn reviewer_results_warn_for_partial_and_total_failures() { - let partial = reviewer_execution_result( - 42, - vec!["added@example.com".to_string()], - vec!["failed@example.com (HTTP 403)".to_string()], - ); - assert!(partial.success); - assert!(partial.is_warning()); - assert_eq!( - partial.data.as_ref().unwrap()["added"][0], - "added@example.com" - ); - - let total = reviewer_execution_result( - 42, - Vec::new(), - vec!["failed@example.com (identity not found)".to_string()], - ); - assert!(total.success); - assert!(total.is_warning()); - assert_eq!( - total.data.as_ref().unwrap()["failed"] - .as_array() - .unwrap() - .len(), - 1 - ); - - let success = - reviewer_execution_result(42, vec!["added@example.com".to_string()], Vec::new()); - assert!(success.success); - assert!(!success.is_warning()); - } - - #[test] - fn temporary_reference_resolves_exact_registered_target() { - let temporary_id = PullRequestTemporaryId::parse("#aw_pr123").unwrap(); - let ctx = ExecutionContext::default(); - let target = AdoRepositoryTarget { - alias: "tools".to_string(), - organization: "other-org".to_string(), - organization_url: "https://dev.azure.com/other-org".to_string(), - project: "Other Project".to_string(), - repository: "tools".to_string(), - repository_id: Some("repo-id".to_string()), - cross_organization: true, - }; - ctx.register_resolved_pull_request( - &temporary_id, - crate::safe_outputs::ResolvedPullRequest { - id: 42, - url: "https://example.test/pr/42".to_string(), - target: target.clone(), - }, - ) - .unwrap(); - - let resolved = resolve_update_pr_target( - &PullRequestReference::Temporary(temporary_id), - None, - &UpdatePrConfig::default(), - &ctx, - ) - .unwrap() - .unwrap(); - assert_eq!(resolved, (42, target)); - } - - #[test] - fn temporary_reference_rejects_unresolved_id() { - let temporary_id = PullRequestTemporaryId::parse("#aw_pr123").unwrap(); - let result = resolve_update_pr_target( - &PullRequestReference::Temporary(temporary_id), - None, - &UpdatePrConfig::default(), - &ExecutionContext::default(), - ) - .unwrap() - .unwrap_err(); - - assert!( - result - .message - .contains("temporary pull-request ID '#aw_pr123' has not been resolved"), - "got: {}", - result.message - ); - } - - #[test] - fn temporary_reference_rejects_requested_repository_mismatch() { - let temporary_id = PullRequestTemporaryId::parse("#aw_pr123").unwrap(); - let ctx = ExecutionContext::default(); - ctx.register_resolved_pull_request( - &temporary_id, - crate::safe_outputs::ResolvedPullRequest { - id: 42, - url: "https://example.test/pr/42".to_string(), - target: AdoRepositoryTarget { - alias: "tools".to_string(), - organization: "other-org".to_string(), - organization_url: "https://dev.azure.com/other-org".to_string(), - project: "Other Project".to_string(), - repository: "tools".to_string(), - repository_id: Some("repo-id".to_string()), - cross_organization: true, - }, - }, - ) - .unwrap(); - - let result = resolve_update_pr_target( - &PullRequestReference::Temporary(temporary_id), - Some("self"), - &UpdatePrConfig::default(), - &ctx, - ) - .unwrap() - .unwrap_err(); - - assert!( - result.message.contains( - "resolved to repository 'tools', which does not match requested repository 'self'" - ), - "got: {}", - result.message - ); - } - - #[test] - fn temporary_reference_rejects_allowed_repositories_exclusion() { - let temporary_id = PullRequestTemporaryId::parse("#aw_pr123").unwrap(); - let ctx = ExecutionContext::default(); - ctx.register_resolved_pull_request( - &temporary_id, - crate::safe_outputs::ResolvedPullRequest { - id: 42, - url: "https://example.test/pr/42".to_string(), - target: AdoRepositoryTarget { - alias: "tools".to_string(), - organization: "other-org".to_string(), - organization_url: "https://dev.azure.com/other-org".to_string(), - project: "Other Project".to_string(), - repository: "tools".to_string(), - repository_id: Some("repo-id".to_string()), - cross_organization: true, - }, - }, - ) - .unwrap(); - - let result = resolve_update_pr_target( - &PullRequestReference::Temporary(temporary_id), - None, - &UpdatePrConfig { - allowed_repositories: vec!["self".to_string()], - ..Default::default() - }, - &ctx, - ) - .unwrap() - .unwrap_err(); - - assert!( - result - .message - .contains("Repository 'tools' is not in the allowed-repositories list: [self]"), - "got: {}", - result.message - ); - } - - #[tokio::test] - async fn reviewer_identity_lookup_requires_exact_match() { - use wiremock::matchers::{method, path}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/_apis/identities")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "value": [ - { - "id": "wrong-id", - "providerDisplayName": "Similar Person", - "properties": {"Mail": {"$value": "similar@example.com"}} - }, - { - "id": "exact-id", - "providerDisplayName": "Exact Person", - "properties": {"Mail": {"$value": "owner@example.com"}} - } - ] - }))) - .mount(&server) - .await; - - let id = lookup_reviewer_id( - &reqwest::Client::new(), - &server.uri(), - "owner@example.com", - "token", - None, - ) - .await; - assert_eq!(id.as_deref(), Some("exact-id")); - - let missing = lookup_reviewer_id( - &reqwest::Client::new(), - &server.uri(), - "missing@example.com", - "token", - None, - ) - .await; - assert!(missing.is_none()); - } - - #[tokio::test] - async fn reviewer_guid_lookup_verifies_existing_identity() { - use wiremock::matchers::{method, path, query_param}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - let server = MockServer::start().await; - let reviewer = "12345678-1234-1234-1234-1234567890ab"; - Mock::given(method("GET")) - .and(path("/_apis/identities")) - .and(query_param("identityIds", reviewer)) - .and(query_param("api-version", "7.1")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "count": 1, - "value": [{ - "id": "12345678-1234-1234-1234-1234567890AB", - "displayName": "Exact Person" - }] - }))) - .expect(1) - .mount(&server) - .await; - - let id = lookup_reviewer_id( - &reqwest::Client::new(), - &server.uri(), - reviewer, - "token", - None, - ) - .await; - assert_eq!(id.as_deref(), Some("12345678-1234-1234-1234-1234567890AB")); - } - - #[tokio::test] - async fn reviewer_guid_lookup_rejects_missing_identity() { - use wiremock::matchers::{method, path, query_param}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - let server = MockServer::start().await; - let reviewer = "12345678-1234-1234-1234-1234567890ab"; - Mock::given(method("GET")) - .and(path("/_apis/identities")) - .and(query_param("identityIds", reviewer)) - .and(query_param("api-version", "7.1")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "count": 0, - "value": [] - }))) - .expect(1) - .mount(&server) - .await; - - let id = lookup_reviewer_id( - &reqwest::Client::new(), - &server.uri(), - reviewer, - "token", - None, - ) - .await; - assert!(id.is_none()); - } - - #[tokio::test] - async fn reviewer_guid_lookup_rejects_response_missing_value_array() { - use wiremock::matchers::{method, path, query_param}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - let server = MockServer::start().await; - let reviewer = "12345678-1234-1234-1234-1234567890ab"; - Mock::given(method("GET")) - .and(path("/_apis/identities")) - .and(query_param("identityIds", reviewer)) - .and(query_param("api-version", "7.1")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "count": 1 - }))) - .expect(1) - .mount(&server) - .await; - - let id = lookup_reviewer_id( - &reqwest::Client::new(), - &server.uri(), - reviewer, - "token", - None, - ) - .await; - assert!(id.is_none()); - } - - #[tokio::test] - async fn reviewer_guid_lookup_rejects_duplicate_identities() { - use wiremock::matchers::{method, path, query_param}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - let server = MockServer::start().await; - let reviewer = "12345678-1234-1234-1234-1234567890ab"; - Mock::given(method("GET")) - .and(path("/_apis/identities")) - .and(query_param("identityIds", reviewer)) - .and(query_param("api-version", "7.1")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "count": 2, - "value": [ - {"id": reviewer}, - {"id": reviewer} - ] - }))) - .expect(1) - .mount(&server) - .await; - - let id = lookup_reviewer_id( - &reqwest::Client::new(), - &server.uri(), - reviewer, - "token", - None, - ) - .await; - assert!(id.is_none()); - } - - #[tokio::test] - async fn reviewer_guid_lookup_rejects_mismatched_identity() { - use wiremock::matchers::{method, path, query_param}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - let server = MockServer::start().await; - let reviewer = "12345678-1234-1234-1234-1234567890ab"; - Mock::given(method("GET")) - .and(path("/_apis/identities")) - .and(query_param("identityIds", reviewer)) - .and(query_param("api-version", "7.1")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "count": 1, - "value": [{ - "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" - }] - }))) - .expect(1) - .mount(&server) - .await; - - let id = lookup_reviewer_id( - &reqwest::Client::new(), - &server.uri(), - reviewer, - "token", - None, - ) - .await; - assert!(id.is_none()); - } - - #[tokio::test] - async fn reviewer_guid_lookup_rejects_http_errors_and_invalid_json() { - use wiremock::matchers::{method, path, query_param}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - let reviewer = "12345678-1234-1234-1234-1234567890ab"; - let http_error_server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/_apis/identities")) - .and(query_param("identityIds", reviewer)) - .and(query_param("api-version", "7.1")) - .respond_with(ResponseTemplate::new(500)) - .expect(1) - .mount(&http_error_server) - .await; - - let http_error = lookup_reviewer_id( - &reqwest::Client::new(), - &http_error_server.uri(), - reviewer, - "token", - None, - ) - .await; - assert!(http_error.is_none()); - - let invalid_json_server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/_apis/identities")) - .and(query_param("identityIds", reviewer)) - .and(query_param("api-version", "7.1")) - .respond_with(ResponseTemplate::new(200).set_body_raw("{", "application/json")) - .expect(1) - .mount(&invalid_json_server) - .await; - - let invalid_json = lookup_reviewer_id( - &reqwest::Client::new(), - &invalid_json_server.uri(), - reviewer, - "token", - None, - ) - .await; - assert!(invalid_json.is_none()); - } - - #[tokio::test] - async fn reviewer_guid_lookup_preserves_bearer_auth_routing() { - use wiremock::matchers::{header, method, path, query_param}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - let server = MockServer::start().await; - let reviewer = "12345678-1234-1234-1234-1234567890ab"; - Mock::given(method("GET")) - .and(path("/_apis/identities")) - .and(query_param("identityIds", reviewer)) - .and(query_param("api-version", "7.1")) - .and(header("authorization", "Bearer entra-token")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "count": 1, - "value": [{"id": reviewer}] - }))) - .expect(1) - .mount(&server) - .await; - - let id = lookup_reviewer_id( - &reqwest::Client::new(), - &server.uri(), - reviewer, - "entra-token", - Some(crate::compile::types::WriteConnectionType::AzureDevOps), - ) - .await; - assert_eq!(id.as_deref(), Some(reviewer)); - } - - #[tokio::test] - async fn malformed_guid_like_reviewer_uses_exact_identity_lookup() { - use wiremock::matchers::{method, path, query_param}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - let server = MockServer::start().await; - let reviewer = "12345678-1234-1234-1234-1234567890ag"; - Mock::given(method("GET")) - .and(path("/_apis/identities")) - .and(query_param("searchFilter", "General")) - .and(query_param("filterValue", reviewer)) - .and(query_param("api-version", "7.1")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "value": [] - }))) - .expect(1) - .mount(&server) - .await; - - let id = lookup_reviewer_id( - &reqwest::Client::new(), - &server.uri(), - reviewer, - "token", - None, - ) - .await; - assert!(id.is_none()); - } - - #[tokio::test] - async fn reviewer_identity_lookup_rejects_ambiguous_exact_matches() { - use wiremock::matchers::{method, path}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - let server = MockServer::start().await; - Mock::given(method("GET")) - .and(path("/_apis/identities")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "value": [ - { - "id": "first-id", - "properties": {"Mail": {"$value": "owner@example.com"}} - }, - { - "id": "second-id", - "properties": {"Mail": {"$value": "owner@example.com"}} - } - ] - }))) - .mount(&server) - .await; - - let id = lookup_reviewer_id( - &reqwest::Client::new(), - &server.uri(), - "owner@example.com", - "token", - None, - ) - .await; - assert!(id.is_none()); - } - - #[tokio::test] - async fn reviewer_identity_lookup_encodes_filter_as_one_query_parameter() { - use wiremock::matchers::{method, path, query_param}; - use wiremock::{Mock, MockServer, ResponseTemplate}; - - let server = MockServer::start().await; - let reviewer = "owner+alerts&team=core@example.com"; - Mock::given(method("GET")) - .and(path("/_apis/identities")) - .and(query_param("searchFilter", "General")) - .and(query_param("filterValue", reviewer)) - .and(query_param("api-version", "7.1")) - .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ - "value": [{ - "id": "exact-id", - "properties": {"Mail": {"$value": reviewer}} - }] - }))) - .expect(1) - .mount(&server) - .await; - - let id = lookup_reviewer_id( - &reqwest::Client::new(), - &server.uri(), - reviewer, - "token", - None, - ) - .await; - assert_eq!(id.as_deref(), Some("exact-id")); - } - - #[test] - fn test_config_deserializes_from_yaml() { - let yaml = r#" -allowed-operations: - - add-reviewers - - set-auto-complete -allowed-repositories: - - self -allowed-votes: - - approve - - reject -allowed-reviewers: - - owner@example.com -max-reviewers: 2 -"#; - let config: UpdatePrConfig = serde_yaml::from_str(yaml).unwrap(); - assert_eq!(config.allowed_operations.len(), 2); - assert!( - config - .allowed_operations - .contains(&"add-reviewers".to_string()) - ); - assert!( - config - .allowed_operations - .contains(&"set-auto-complete".to_string()) - ); - assert_eq!(config.allowed_repositories.len(), 1); - assert_eq!(config.allowed_votes.len(), 2); - assert_eq!(config.allowed_reviewers, ["owner@example.com"]); - assert_eq!(config.max_reviewers, 2); - } - - #[test] - fn test_valid_merge_strategies_are_expected_values() { - assert_eq!( - VALID_MERGE_STRATEGIES, - &["squash", "noFastForward", "rebase", "rebaseMerge"] - ); - } - - #[test] - fn test_config_deserializes_merge_strategy() { - let yaml = r#"merge-strategy: rebase"#; - let config: UpdatePrConfig = serde_yaml::from_str(yaml).unwrap(); - assert_eq!(config.merge_strategy, "rebase"); - } -} +#[allow(unused_imports)] +pub use super::pr_common::PullRequestReference; +pub use super::pr_mutations::{UpdatePrConfig, UpdatePrParams, UpdatePrResult}; diff --git a/src/safe_outputs/update_pull_request.rs b/src/safe_outputs/update_pull_request.rs index d50fa7623..31c6baaa4 100644 --- a/src/safe_outputs/update_pull_request.rs +++ b/src/safe_outputs/update_pull_request.rs @@ -2,23 +2,26 @@ use anyhow::{Context, ensure}; use log::{debug, info}; -use percent_encoding::utf8_percent_encode; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; use ado_aw_derive::SanitizeConfig; -use super::{ - PATH_SEGMENT, authenticate_ado_request, canonical_repository_alias, resolve_repo_name, +use super::authenticate_ado_request; +use super::pr_common::{ + PullRequestReference, legacy_policy, repository_api_base, resolve_pr_target, + resolved_reference_id, validate_description, validate_reference, }; use crate::safe_outputs::{ExecutionContext, ExecutionResult, Executor, Validate}; -use crate::sanitize::{SanitizeContent, sanitize as sanitize_text, sanitize_config}; +use crate::sanitize::{ + SanitizeContent, sanitize as sanitize_text, sanitize_config, sanitize_markdown, +}; use crate::tool_result; use crate::validate::reject_pipeline_injection; const MAX_TITLE_CHARS: usize = 256; -const MAX_BODY_CHARS: usize = 65_536; +const MAX_BODY_CHARS: usize = 4_000; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "kebab-case")] @@ -29,28 +32,7 @@ pub enum AdoPullRequestBodyOperation { ReplaceIsland, } -#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -#[serde(untagged)] -pub enum AdoPullRequestId { - Number(i32), - String(String), -} - -impl AdoPullRequestId { - fn parse(&self, field: &str) -> anyhow::Result { - let id = match self { - Self::Number(id) => *id, - Self::String(value) => value - .trim() - .strip_prefix('#') - .unwrap_or_else(|| value.trim()) - .parse::() - .map_err(|_| anyhow::anyhow!("{field} must be a positive pull request ID"))?, - }; - ensure!(id > 0, "{field} must be positive"); - Ok(id) - } -} +pub type AdoPullRequestId = PullRequestReference; #[derive(Deserialize, JsonSchema)] pub struct UpdatePullRequestParams { @@ -66,8 +48,13 @@ pub struct UpdatePullRequestParams { /// Not supported for Azure DevOps PRs; accepted for gh-aw schema compatibility but must be false/omitted. #[serde(default, rename = "update_branch", alias = "updateBranch")] pub update_branch: Option, - /// Azure DevOps pull request ID. Required when front matter uses `target: "*"`. - #[serde(default, rename = "pull_request_id", alias = "pullRequestId")] + /// Positive Azure DevOps PR ID or same-run temporary ID. Required when target is "*". + #[serde( + default, + rename = "pull_request_id", + alias = "pullRequestId", + alias = "id" + )] pub pull_request_id: Option, /// gh-aw-compatible alias for pull_request_id. #[serde(default, rename = "pull_request_number", alias = "pullRequestNumber")] @@ -84,19 +71,20 @@ pub struct UpdatePullRequestParams { } impl UpdatePullRequestParams { - fn requested_id(&self) -> anyhow::Result> { + fn requested_id(&self) -> anyhow::Result> { let mut found = None; - for (field, value) in [ + for (_field, value) in [ ("pull_request_id", self.pull_request_id.as_ref()), ("pull_request_number", self.pull_request_number.as_ref()), ("pr_number", self.pr_number.as_ref()), ("pr", self.pr.as_ref()), ] { if let Some(value) = value { - let id = value.parse(field)?; - if let Some(existing) = found { + validate_reference(value)?; + let id = value.clone(); + if let Some(existing) = &found { ensure!( - existing == id, + existing == &id, "pull request ID aliases must all refer to the same PR" ); } @@ -118,8 +106,8 @@ impl Validate for UpdatePullRequestParams { } if let Some(body) = self.body.as_deref() { ensure!( - body.chars().count() <= MAX_BODY_CHARS, - "body must be {MAX_BODY_CHARS} characters or fewer" + body.encode_utf16().count() <= MAX_BODY_CHARS, + "body must be {MAX_BODY_CHARS} UTF-16 units or fewer" ); } else { ensure!( @@ -150,7 +138,7 @@ tool_result! { operation: Option, #[serde(default, rename = "update_branch")] update_branch: Option, - #[serde(default, rename = "pull_request_id")] + #[serde(default, rename = "pull_request_id", alias = "pullRequestId", alias = "id")] pull_request_id: Option, #[serde(default, rename = "pull_request_number")] pull_request_number: Option, @@ -165,8 +153,15 @@ tool_result! { impl SanitizeContent for UpdatePullRequestResult { fn sanitize_content_fields(&mut self) { - self.title = self.title.as_deref().map(sanitize_text); - self.body = self.body.as_deref().map(sanitize_text); + // Rendering policy is selected in Stage 3; proposals still receive transport sanitization. + self.title = self + .title + .as_deref() + .map(crate::sanitize::sanitize_custom_payload); + self.body = self + .body + .as_deref() + .map(crate::sanitize::sanitize_custom_payload); self.repository = self.repository.as_deref().map(sanitize_config); } } @@ -182,7 +177,7 @@ fn default_operation() -> AdoPullRequestBodyOperation { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(untagged)] pub enum UpdatePullRequestTarget { - Id(i32), + Id(u64), Named(String), } @@ -212,9 +207,9 @@ pub struct UpdatePullRequestConfig { #[sanitize_config(skip)] pub sync_stack: bool, /// Include agent stats in body updates. - #[serde(default = "default_true")] + #[serde(default = "default_true", rename = "include-stats", alias = "footer")] #[sanitize_config(skip)] - pub footer: bool, + pub include_stats: bool, /// Body update operation. Defaults to replace. #[serde(default = "default_operation")] #[sanitize_config(skip)] @@ -241,7 +236,7 @@ impl Default for UpdatePullRequestConfig { body: true, update_branch: false, sync_stack: true, - footer: true, + include_stats: true, operation: AdoPullRequestBodyOperation::Replace, target: UpdatePullRequestTarget::default(), allowed_repositories: Vec::new(), @@ -262,7 +257,8 @@ pub(crate) fn validate_update_pull_request_config( match &config.target { UpdatePullRequestTarget::Id(id) => ensure!(*id > 0, "target PR ID must be positive"), UpdatePullRequestTarget::Named(target) => ensure!( - matches!(target.as_str(), "triggering" | "*"), + matches!(target.as_str(), "triggering" | "*") + || target.parse::().is_ok_and(|id| id > 0), "target must be \"triggering\", \"*\", or a positive pull request ID" ), } @@ -299,7 +295,7 @@ pub(crate) fn validate_update_pull_request_config( #[derive(Debug, Deserialize)] struct RawPullRequest { #[serde(rename = "pullRequestId")] - pull_request_id: i32, + pull_request_id: u64, title: String, #[serde(default)] description: Option, @@ -340,7 +336,7 @@ fn replace_island( .match_indices(&end_marker) .map(|(index, _)| index) .collect(); - if starts.len() != 1 || ends.len() != 1 { + if starts.is_empty() && ends.is_empty() { let island = format!("{start_marker}\n{replacement}\n{end_marker}"); return Ok(if current.is_empty() { island @@ -348,6 +344,11 @@ fn replace_island( format!("{current}\n\n---\n\n{island}") }); } + if starts.len() != 1 || ends.len() != 1 { + return Err(ExecutionResult::failure( + "replace-island requires exactly one matching marker pair; duplicate or partial markers are not safe to replace", + )); + } let start = starts[0]; let end = ends[0]; if end <= start { @@ -400,21 +401,19 @@ fn build_updated_body( AdoPullRequestBodyOperation::Replace => section, AdoPullRequestBodyOperation::ReplaceIsland => replace_island(current, §ion, ctx)?, }; - if updated.chars().count() > MAX_BODY_CHARS { - return Err(ExecutionResult::failure(format!( - "updated body exceeds Azure DevOps' {MAX_BODY_CHARS}-character limit" - ))); + if let Err(error) = validate_description(&updated) { + return Err(ExecutionResult::failure(error.to_string())); } Ok(updated) } -fn ctx_pull_request_id(ctx: &ExecutionContext) -> Result { +fn ctx_pull_request_id(ctx: &ExecutionContext) -> Result { let raw = ctx.pull_request_id.as_deref().ok_or_else(|| { ExecutionResult::failure( "SYSTEM_PULLREQUEST_PULLREQUESTID is required for target \"triggering\"", ) })?; - raw.parse::().ok().filter(|id| *id > 0).ok_or_else(|| { + raw.parse::().ok().filter(|id| *id > 0).ok_or_else(|| { ExecutionResult::failure(format!( "SYSTEM_PULLREQUEST_PULLREQUESTID '{}' is not a positive pull request ID", crate::sanitize::neutralize_pipeline_commands(raw) @@ -423,7 +422,7 @@ fn ctx_pull_request_id(ctx: &ExecutionContext) -> Result { } impl UpdatePullRequestResult { - fn requested_id(&self) -> anyhow::Result> { + fn requested_id(&self) -> anyhow::Result> { UpdatePullRequestParams { title: self.title.clone(), body: self.body.clone(), @@ -442,10 +441,13 @@ impl UpdatePullRequestResult { &self, config: &UpdatePullRequestConfig, ctx: &ExecutionContext, - ) -> Result { + ) -> Result { let requested = self .requested_id() - .map_err(|error| ExecutionResult::failure(error.to_string()))?; + .map_err(|error| ExecutionResult::failure(error.to_string()))? + .as_ref() + .map(|reference| resolved_reference_id(reference, ctx)) + .transpose()?; match &config.target { UpdatePullRequestTarget::Id(id) => { if let Some(requested) = requested @@ -473,6 +475,13 @@ impl UpdatePullRequestResult { } Ok(triggering) } + UpdatePullRequestTarget::Named(target) if target.parse::().is_ok_and(|id| id > 0) => { + let id = target.parse::().map_err(|error| ExecutionResult::failure(error.to_string()))?; + if requested.is_some_and(|requested| requested != id) { + return Err(ExecutionResult::failure(format!("requested pull_request_id does not match configured target #{id}"))); + } + Ok(id) + } UpdatePullRequestTarget::Named(target) => Err(ExecutionResult::failure(format!( "unsupported update-pull-request target '{}'", crate::sanitize::neutralize_pipeline_commands(target) @@ -495,13 +504,11 @@ impl UpdatePullRequestResult { &self, client: &reqwest::Client, base_url: &str, - repo_name: &str, token: &str, - pr_id: i32, + pr_id: u64, ctx: &ExecutionContext, ) -> anyhow::Result> { - let encoded_repo = utf8_percent_encode(repo_name, PATH_SEGMENT).to_string(); - let url = format!("{base_url}/{encoded_repo}/pullRequests/{pr_id}?api-version=7.1"); + let url = format!("{base_url}/pullRequests/{pr_id}?api-version=7.1"); let response = authenticate_ado_request(client.get(&url), token, ctx.write_connection_type) .send() .await @@ -585,20 +592,24 @@ impl Executor for UpdatePullRequestResult { "update-pull-request is not configured for this workflow", )); } - let org_url = ctx - .ado_org_url - .as_ref() - .context("AZURE_DEVOPS_ORG_URL not set")?; - let project = ctx - .ado_project - .as_ref() - .context("SYSTEM_TEAMPROJECT not set")?; let token = ctx .access_token .as_ref() .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; let config: UpdatePullRequestConfig = ctx.get_tool_config("update-pull-request")?; validate_update_pull_request_config(&config)?; + let legacy = legacy_policy(ctx, "update-pull-request", "update-description")?; + if legacy.is_some() + && (self.title.is_some() + || self.body.as_deref().is_none_or(|body| body.len() < 10) + || self.operation.unwrap_or(config.operation) + != AdoPullRequestBodyOperation::Replace + || config.include_stats) + { + return Ok(ExecutionResult::failure( + "legacy update-description requires replacement body of at least 10 characters, no title and include-stats: false", + )); + } if self.title.is_some() && !config.title { return Ok(ExecutionResult::failure( "update-pull-request field 'title' is not enabled by configuration", @@ -623,41 +634,60 @@ impl Executor for UpdatePullRequestResult { Ok(pr_id) => pr_id, Err(result) => return Ok(result), }; - let repo_selector = self.repository.as_deref().unwrap_or("self"); - let Some(repo_alias) = canonical_repository_alias(repo_selector, ctx) else { - return Ok(ExecutionResult::failure(format!( - "Repository '{}' is not in the configured checkout list", - crate::sanitize::neutralize_pipeline_commands(repo_selector) - ))); + let reference = self + .requested_id()? + .unwrap_or(PullRequestReference::Number(pr_id)); + let (_, target) = match resolve_pr_target( + &reference, + self.repository.as_deref(), + &config.allowed_repositories, + ctx, + )? { + Ok(target) => target, + Err(failure) => return Ok(failure), }; - if !config.allowed_repositories.is_empty() - && !config - .allowed_repositories - .iter() - .filter_map(|allowed| canonical_repository_alias(allowed, ctx)) - .any(|allowed| allowed == repo_alias) + if let Some(legacy) = &legacy + && let Err(failure) = resolve_pr_target( + &reference, + self.repository.as_deref(), + &legacy.allowed_repositories, + ctx, + )? { - return Ok(ExecutionResult::failure(format!( - "Repository '{}' is not in the allowed-repositories list: [{}]", - crate::sanitize::neutralize_pipeline_commands(repo_selector), - config.allowed_repositories.join(", ") - ))); + return Ok(failure); } - let repo_name = match resolve_repo_name(Some(&repo_alias), ctx) { - Ok(name) => name, - Err(failure) => return Ok(failure), - }; let client = reqwest::Client::new(); - let encoded_project = utf8_percent_encode(project, PATH_SEGMENT).to_string(); - let base_url = format!( - "{}/{}/_apis/git/repositories", - org_url.trim_end_matches('/'), - encoded_project, - ); - let current = match self - .fetch_pr(&client, &base_url, &repo_name, token, pr_id, ctx) - .await? + let base_url = repository_api_base(&target); + let body = self.body.as_deref().map(|body| { + if legacy.is_some() { + sanitize_text(body) + } else { + sanitize_markdown(body) + } + }); + if legacy.is_some() + && config.required_labels.is_empty() + && config.required_title_prefix.is_none() { + let body = body.as_deref().context("legacy body must be provided")?; + if body.len() < 10 { + return Ok(ExecutionResult::failure( + "description must be at least 10 characters after sanitization", + )); + } + return super::pr_mutations::execute_update_description( + &super::pr_mutations::UpdatePrContext { + client: &client, + target, + pr_id, + token, + connection_type: ctx.write_connection_type, + }, + body, + ) + .await; + } + let current = match self.fetch_pr(&client, &base_url, token, pr_id, ctx).await? { Ok(pr) => pr, Err(result) => return Ok(result), }; @@ -666,14 +696,20 @@ impl Executor for UpdatePullRequestResult { } let mut patch = Map::new(); if let Some(title) = self.title.as_ref() { - patch.insert("title".to_string(), Value::String(title.clone())); + let title = sanitize_text(title); + if title.trim().is_empty() || title.chars().count() > MAX_TITLE_CHARS { + return Ok(ExecutionResult::failure( + "sanitized title must be nonempty and 256 characters or fewer", + )); + } + patch.insert("title".to_string(), Value::String(title)); } - if let Some(body) = self.body.as_deref() { + if let Some(body) = body.as_deref() { let description = match build_updated_body( current.description.as_deref().unwrap_or_default(), body, self.operation.unwrap_or(config.operation), - config.footer, + config.include_stats, ctx, ) { Ok(description) => description, @@ -681,8 +717,7 @@ impl Executor for UpdatePullRequestResult { }; patch.insert("description".to_string(), Value::String(description)); } - let encoded_repo = utf8_percent_encode(&repo_name, PATH_SEGMENT).to_string(); - let patch_url = format!("{base_url}/{encoded_repo}/pullRequests/{pr_id}?api-version=7.1"); + let patch_url = format!("{base_url}/pullRequests/{pr_id}?api-version=7.1"); debug!( "Updating Azure DevOps PR #{pr_id}: {}", self.requested_fields().join(", ") @@ -737,6 +772,7 @@ mod tests { tool_configs.insert("update-pull-request".to_string(), config); ExecutionContext { ado_org_url: Some(server.uri()), + ado_organization: Some("org".to_string()), ado_project: Some("project".to_string()), access_token: Some("token".to_string()), repository_name: Some("repo".to_string()), @@ -785,7 +821,7 @@ mod tests { assert!(config.body); assert!(!config.update_branch); assert!(config.sync_stack); - assert!(config.footer); + assert!(config.include_stats); assert_eq!(config.operation, AdoPullRequestBodyOperation::Replace); let unsupported: UpdatePullRequestConfig = serde_json::from_value(serde_json::json!({ @@ -805,10 +841,234 @@ mod tests { let mut aliases = params(); aliases.pull_request_id = Some(AdoPullRequestId::Number(1)); - aliases.pull_request_number = Some(AdoPullRequestId::String("#2".to_string())); + aliases.pull_request_number = Some(serde_json::from_str("\"#2\"").unwrap()); assert!(aliases.validate().is_err()); } + #[test] + fn include_stats_alias_rejects_duplicate_spellings() { + for config in [ + serde_json::json!({"include-stats": false}), + serde_json::json!({"footer": false}), + ] { + assert!( + !serde_json::from_value::(config) + .unwrap() + .include_stats + ); + } + for footer in [true, false] { + assert!( + serde_json::from_value::( + serde_json::json!({"include-stats": false, "footer": footer}) + ) + .is_err() + ); + } + } + + #[test] + fn island_rejects_partial_duplicate_and_reversed_markers() { + let ctx = ExecutionContext { + definition_id: Some(123), + ..Default::default() + }; + let (start, end) = island_markers(&ctx).unwrap(); + for existing in [ + start.clone(), + end.clone(), + format!("{end}{start}"), + format!("{start}{end}{start}{end}"), + format!("{start}{start}{end}"), + format!("{start}{end}{end}"), + ] { + assert!( + replace_island(&existing, "replacement", &ctx).is_err(), + "{existing}" + ); + } + let other = "\nother\n"; + let current = format!("before\n{other}\n{start}\nold\n{end}\nafter"); + assert_eq!( + replace_island(¤t, "new", &ctx).unwrap(), + format!("before\n{other}\n{start}\nnew\n{end}\nafter") + ); + } + + #[test] + fn final_description_bound_includes_existing_content_markers_and_stats() { + let ctx = ExecutionContext { + definition_id: Some(1), + ..Default::default() + }; + for operation in [ + AdoPullRequestBodyOperation::Append, + AdoPullRequestBodyOperation::Prepend, + ] { + assert!(build_updated_body(&"a".repeat(3990), "bbbb", operation, false, &ctx).is_err()); + } + assert!( + build_updated_body( + "", + &"a".repeat(3990), + AdoPullRequestBodyOperation::ReplaceIsland, + false, + &ctx + ) + .is_err() + ); + let mut with_stats = ctx; + with_stats.agent_stats = Some(crate::agent_stats::AgentStats { + agent_name: "agent".into(), + model: None, + input_tokens: 1, + output_tokens: 1, + ai_credits: None, + duration_seconds: 1.0, + tool_calls: 1, + turns: 1, + }); + assert!( + build_updated_body( + "", + &"a".repeat(4000), + AdoPullRequestBodyOperation::Replace, + true, + &with_stats + ) + .is_err() + ); + assert!( + build_updated_body( + "", + &"a".repeat(4000), + AdoPullRequestBodyOperation::Replace, + false, + &with_stats + ) + .is_ok() + ); + } + + #[tokio::test] + async fn migrated_description_uses_registered_target_plain_text_and_no_get_or_footer() { + let server = MockServer::start().await; + let text = "Code: `` and title."; + Mock::given(method("PATCH")) + .and(path( + "/Other/_apis/git/repositories/repo-id/pullRequests/4294967296", + )) + .and(body_json( + serde_json::json!({"description": sanitize_text(text)}), + )) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + let ctx = super::super::pr_common::tests::registered_context( + &server.uri(), + "update-pull-request", + serde_json::json!({ + "title": false, "body": true, "target": "*", "include-stats": false, + "legacy-update-pr": {"allowed-operations": ["update-description"], "allowed-repositories": ["other"]} + }), + ); + let mut result: UpdatePullRequestResult = serde_json::from_value(serde_json::json!({ + "name": "update-pull-request", "pull_request_id": "#aw_pr123", "body": text + })) + .unwrap(); + assert!(result.execute_sanitized(&ctx).await.unwrap().success); + assert_eq!(server.received_requests().await.unwrap().len(), 1); + } + + #[tokio::test] + async fn migration_rejects_title_append_footer_and_short_body_before_network() { + let server = MockServer::start().await; + for (request, include_stats) in [ + ( + serde_json::json!({"body": "long enough body", "title": "new title"}), + false, + ), + ( + serde_json::json!({"body": "long enough body", "operation": "append"}), + false, + ), + (serde_json::json!({"body": "short"}), false), + (serde_json::json!({"body": "long enough body"}), true), + ] { + let ctx = context( + &server, + serde_json::json!({ + "target": "*", "include-stats": include_stats, + "legacy-update-pr": {"allowed-operations": ["update-description"]} + }), + ); + let mut request = request; + request["name"] = serde_json::json!("update-pull-request"); + request["pull_request_id"] = serde_json::json!(7); + let mut result: UpdatePullRequestResult = serde_json::from_value(request).unwrap(); + assert!(!result.execute_sanitized(&ctx).await.unwrap().success); + } + assert!(server.received_requests().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn native_body_uses_markdown_and_rejects_assembled_overflow_before_patch() { + for (body, expected_success) in [("``".to_string(), true), ("a".repeat(4000), false)] + { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/project/_apis/git/repositories/repo/pullRequests/7")) + .respond_with(ResponseTemplate::new(200).set_body_json(pr(7))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PATCH")) + .and(path("/project/_apis/git/repositories/repo/pullRequests/7")) + .and(body_json( + serde_json::json!({"description": "Existing body\n\n---\n\n``"}), + )) + .respond_with(ResponseTemplate::new(200)) + .expect(if expected_success { 1 } else { 0 }) + .mount(&server) + .await; + let ctx = context( + &server, + serde_json::json!({"operation": "append", "include-stats": false}), + ); + let mut result: UpdatePullRequestResult = UpdatePullRequestParams { + title: None, + body: Some(body), + ..params() + } + .try_into() + .unwrap(); + assert_eq!( + result.execute_sanitized(&ctx).await.unwrap().success, + expected_success + ); + } + } + + #[tokio::test] + async fn temporary_reference_does_not_bypass_fixed_or_triggering_target() { + for target in [serde_json::json!(7), serde_json::json!("triggering")] { + let server = MockServer::start().await; + let mut ctx = super::super::pr_common::tests::registered_context( + &server.uri(), + "update-pull-request", + serde_json::json!({"target": target}), + ); + ctx.pull_request_id = Some("7".into()); + let mut result: UpdatePullRequestResult = serde_json::from_value(serde_json::json!({ + "name": "update-pull-request", "pull_request_id": "#aw_pr123", "title": "new title" + })) + .unwrap(); + assert!(!result.execute_sanitized(&ctx).await.unwrap().success); + assert!(server.received_requests().await.unwrap().is_empty()); + } + } + #[test] fn replace_island_appends_then_replaces_pipeline_scoped_section() { let ctx = ExecutionContext { diff --git a/src/safe_outputs/upload_build_attachment.rs b/src/safe_outputs/upload_build_attachment.rs index 44d0bb1f6..139b2c36f 100644 --- a/src/safe_outputs/upload_build_attachment.rs +++ b/src/safe_outputs/upload_build_attachment.rs @@ -1066,6 +1066,7 @@ attachment-type: "agent-artifact" resolved_pull_requests: std::sync::Arc::new(std::sync::Mutex::new( std::collections::HashMap::new(), )), + budget_groups: Default::default(), triggered_by_build_id: None, triggered_by_definition_name: None, triggered_by_build_number: None, diff --git a/tests/codemod_tests.rs b/tests/codemod_tests.rs index 94f30e579..5592452f9 100644 --- a/tests/codemod_tests.rs +++ b/tests/codemod_tests.rs @@ -83,6 +83,52 @@ fn copy_fixture(dir: &Path, fixture_name: &str) -> PathBuf { dest } +#[test] +fn compile_migrates_pr_tools_and_keeps_prompt_warning_until_fixed() { + let dir = fresh_git_temp_dir(); + let original = "---\r\nname: pr-migration\r\ndescription: d\r\nsafe-outputs:\r\n update-pr:\r\n allowed-operations: [add-reviewers, update-description]\r\n allowed-reviewers: [owner@example.test]\r\n max: 1\r\n---\r\nCall `update-pr` to add reviewers.\r\n"; + let source = write_source(dir.path(), original); + let first = run_compile(&source); + assert!( + first.status.success(), + "{}", + String::from_utf8_lossy(&first.stderr) + ); + let after = fs::read_to_string(&source).unwrap(); + assert!(after.ends_with("\r\nCall `update-pr` to add reviewers.\r\n")); + let fm: serde_yaml::Value = serde_yaml::from_str(after.split("---").nth(1).unwrap()).unwrap(); + assert!(fm["safe-outputs"]["update-pr"].is_null()); + assert_eq!(fm["safe-outputs"]["budget-groups"]["update-pr"]["max"], 1); + assert!(String::from_utf8_lossy(&first.stderr).contains("deprecated-tool-reference")); + assert!(String::from_utf8_lossy(&first.stderr).contains("add-pr-reviewers")); + let second = run_compile(&source); + assert!(second.status.success()); + assert_eq!(fs::read_to_string(&source).unwrap(), after); + assert!(String::from_utf8_lossy(&second.stderr).contains("deprecated-tool-reference")); + let lint = Command::new(ado_aw_binary()) + .args(["lint", source.to_str().unwrap(), "--json"]) + .output() + .unwrap(); + assert!( + lint.status.success(), + "{}", + String::from_utf8_lossy(&lint.stderr) + ); + assert!(String::from_utf8_lossy(&lint.stdout).contains("deprecated-tool-reference")); +} + +#[test] +fn conflicting_pr_migration_does_not_rewrite_source_or_lock() { + let dir = fresh_git_temp_dir(); + let original = "---\nname: conflict\ndescription: d\nsafe-outputs:\n update-pr:\n allowed-operations: [update-description]\n update-pull-request: {}\n---\nBody\n"; + let source = write_source(dir.path(), original); + let output = run_compile(&source); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("manual migration required")); + assert_eq!(fs::read_to_string(&source).unwrap(), original); + assert!(!source.with_extension("lock.yml").exists()); +} + // ─── Legacy directory marker migration (codemod 0004) ────────────────────── #[test] diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 179337dd7..04ad4f065 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -1929,11 +1929,24 @@ Vote on pull requests. let compiled = fs::read_to_string(&output_path).expect("Should read compiled YAML"); - // update-pr must be listed as an enabled tool for the agent assert!( - compiled_has_enabled_tool(&compiled, "update-pr"), - "Compiled output should contain --enabled-tools update-pr (case: {})", - case_desc + !compiled_has_enabled_tool(&compiled, "update-pr"), + "legacy catch-all must not be advertised" + ); + for tool in ["add-pr-reviewers", "set-pr-auto-complete"] { + assert!( + compiled_has_enabled_tool(&compiled, tool), + "{case_desc}: missing {tool}" + ); + } + if case_desc.contains("vote reachable") { + assert!(compiled_has_enabled_tool(&compiled, "submit-pr-review")); + assert!(compiled_has_enabled_tool(&compiled, "update-pull-request")); + } + let migrated_source = fs::read_to_string(&test_input).unwrap(); + assert!( + migrated_source.contains("budget-groups:"), + "legacy aggregate budget must persist" ); // Stage 3 must acquire a write token (permissions.write is set) assert!( diff --git a/tests/executor-e2e/README.md b/tests/executor-e2e/README.md index 6f7cf1cf4..b44908f90 100644 --- a/tests/executor-e2e/README.md +++ b/tests/executor-e2e/README.md @@ -43,6 +43,15 @@ gitignored, non-root path) and is **deliberately excluded** from the released ## Coverage +The PR family includes focused content updates (including the exact +4,000-character description boundary), abandonment with comment verification, +and create-then-follow-up reviewer/content scenarios. Reviewer handoffs use +`add-pr-reviewers`; content handoffs use `update-pull-request`. A legacy +`update-pr` scenario remains for historical-record and config-migration coverage. +The compiled `pr-tools-preview` smoke exercises focused MCP discovery and +staged proposal schemas without writing to an existing PR; live executor +scenarios are responsible for checking actual service mutations. + ### Offline PR payload contract The default TypeScript suite also passes proposals generated by every diff --git a/tests/safe-outputs/pr-tools-preview.md b/tests/safe-outputs/pr-tools-preview.md new file mode 100644 index 000000000..ab2d2d25f --- /dev/null +++ b/tests/safe-outputs/pr-tools-preview.md @@ -0,0 +1,42 @@ +--- +name: "PR safe-output preview contract" +description: "Exercise focused PR tool discovery and proposal schemas without mutating repositories" +target: standalone +pool: + name: AZS-1ES-L-Playground-ubuntu-22.04 +engine: + id: copilot + timeout-minutes: 10 +permissions: + write: agent-playground-write +safe-outputs: + staged: true + update-pull-request: + target: "*" + include-stats: false + abandon-pull-request: + target: "*" + include-stats: false + add-pr-labels: {} + add-pr-reviewers: + allowed-reviewers: ["preview@example.test"] + max-reviewers: 1 + set-pr-auto-complete: {} + submit-pr-review: + allowed-events: [reset] +--- + +## Preview-only PR tool contract + +All declared safe outputs are staged previews. Do not inspect or modify a real +pull request. Emit exactly one proposal for each of the six tools below using +the synthetic numeric pull_request_id `1` and repository `self`. + +1. `update-pull-request`: body "Preview-only content update.", operation "replace". +2. `add-pr-labels`: labels ["preview"]. +3. `add-pr-reviewers`: reviewers ["preview@example.test"]. +4. `submit-pr-review`: event "reset". +5. `set-pr-auto-complete`: no additional fields. +6. `abandon-pull-request`: body "Preview-only abandonment." + +Stop after the six proposals. No catch-all PR update tool should be needed. diff --git a/tests/smoke/cases.json b/tests/smoke/cases.json index 79cffd7fa..a2f985e63 100644 --- a/tests/smoke/cases.json +++ b/tests/smoke/cases.json @@ -73,6 +73,13 @@ "modes": ["candidate"], "source": "tests/smoke/multi-repo.md" }, + { + "id": "pr-tools-preview", + "lane": "agentic", + "kind": "compiled", + "modes": ["candidate"], + "source": "tests/safe-outputs/pr-tools-preview.md" + }, { "id": "janitor", "lane": "agentic", From bff5a88857e4413a5a471d8b6527fec9627ba117 Mon Sep 17 00:00:00 2001 From: James Devine Date: Thu, 24 Sep 2026 14:21:38 +0100 Subject: [PATCH 14/43] refactor(safe-outputs): use canonical pull-request tool names Rename all abbreviated public ADO PR tools, migrate old configuration keys and shared-budget members, and warn about stale prompt references. Remove historical Stage 3 name translation; existing pipelines retain their pinned compiler release. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: da8711de-7251-47cd-a006-6e4ece913b49 --- AGENTS.md | 4 +- README.md | 14 +- docs/codemods.md | 5 + docs/safe-output-permissions.md | 2 +- docs/safe-outputs.md | 82 ++++--- .../approval-summary/__tests__/index.test.ts | 4 +- .../approval-summary/__tests__/render.test.ts | 10 +- .../ado-script/src/approval-summary/render.ts | 24 +-- .../src/exec-context-pr-checks/index.ts | 2 +- .../create-pull-request-scenarios.test.ts | 10 +- .../__tests__/execute-cli.test.ts | 4 +- .../__tests__/github-issue.test.ts | 10 +- .../src/executor-e2e/__tests__/index.test.ts | 4 +- .../ado-script/src/executor-e2e/ado-rest.ts | 2 +- .../scenarios/create-pull-request.ts | 4 +- .../src/executor-e2e/scenarios/pr.ts | 69 ++---- src/compile/agentic_pipeline.rs | 2 +- .../codemods/0010_pull_request_tool_names.rs | 27 +++ src/compile/codemods/mod.rs | 3 + src/compile/common.rs | 76 ++++--- src/compile/pr_migration.rs | 203 +++++++++++++----- src/compile/types.rs | 21 +- src/execute.rs | 138 ++++++------ src/inspect/catalog.rs | 18 +- src/mcp.rs | 40 ++-- src/safe_outputs/add_pr_comment.rs | 18 +- src/safe_outputs/add_pr_labels.rs | 20 +- src/safe_outputs/add_pr_reviewers.rs | 18 +- src/safe_outputs/mod.rs | 3 +- src/safe_outputs/pr_common.rs | 14 +- src/safe_outputs/pr_mutations.rs | 190 ++-------------- src/safe_outputs/reply_to_pr_comment.rs | 17 +- src/safe_outputs/resolve_pr_thread.rs | 20 +- src/safe_outputs/set_pr_auto_complete.rs | 16 +- src/safe_outputs/submit_pr_review.rs | 52 ++--- src/safe_outputs/update_pr.rs | 6 +- tests/codemod_tests.rs | 59 ++++- tests/compiler_tests.rs | 49 +++-- tests/executor-e2e/README.md | 8 +- tests/fixtures/manual-review-agent.md | 2 +- tests/safe-outputs/pr-tools-preview.md | 16 +- 41 files changed, 667 insertions(+), 619 deletions(-) create mode 100644 src/compile/codemods/0010_pull_request_tool_names.rs diff --git a/AGENTS.md b/AGENTS.md index eba6e21ea..928367d42 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -239,7 +239,7 @@ fail-closed and only pauses when the agent actually proposed a reviewed output. │ │ ├── missing_tool.rs │ │ ├── noop.rs │ │ ├── pr_common.rs # Shared PR references and target/policy resolution -│ │ ├── pr_mutations.rs # Shared PR mutations and historical update-pr executor +│ │ ├── pr_mutations.rs # Shared PR mutations and legacy configuration validation │ │ ├── queue_build.rs │ │ ├── remove_github_issue_labels.rs │ │ ├── reply_to_pr_comment.rs @@ -252,7 +252,7 @@ fail-closed and only pauses when the agent actually proposed a reviewed output. │ │ ├── submit_pr_review.rs │ │ ├── unassign_github_issue_from_user.rs │ │ ├── update_github_issue.rs -│ │ ├── update_pr.rs # Historical Stage 3 compatibility exports (not an MCP tool) +│ │ ├── update_pr.rs # Legacy configuration types used by migration (not a tool) │ │ ├── update_pull_request.rs │ │ ├── update_wiki_page.rs │ │ ├── update_work_item.rs diff --git a/README.md b/README.md index 0937dc496..dab2e266f 100644 --- a/README.md +++ b/README.md @@ -600,14 +600,14 @@ actions, and the executor processes them after threat analysis. | `update-work-item` | Updates fields on an existing ADO work item | | `create-wiki-page` | Creates a new Azure DevOps wiki page | | `update-wiki-page` | Updates the content of an existing wiki page | -| `add-pr-comment` | Adds a comment thread on a pull request | -| `reply-to-pr-comment` | Replies to an existing PR review comment thread | -| `resolve-pr-thread` | Resolves or updates the status of a PR review thread | -| `submit-pr-review` | Submits a review vote on a pull request | +| `add-pull-request-comment` | Adds a comment thread on a pull request | +| `reply-to-pull-request-comment` | Replies to an existing PR review comment thread | +| `resolve-pull-request-thread` | Resolves or updates the status of a PR review thread | +| `submit-pull-request-review` | Submits a review vote on a pull request | | `update-pull-request` | Updates PR title or description, including managed sections | -| `add-pr-reviewers` | Adds policy-permitted PR reviewers | -| `add-pr-labels` | Adds PR labels without replacing existing labels | -| `set-pr-auto-complete` | Enables PR auto-complete with configured completion options | +| `add-pull-request-reviewers` | Adds policy-permitted PR reviewers | +| `add-pull-request-labels` | Adds PR labels without replacing existing labels | +| `set-pull-request-auto-complete` | Enables PR auto-complete with configured completion options | | `abandon-pull-request` | Abandons a PR without merging, optionally with a comment | | `link-work-items` | Links two ADO work items together | | `queue-build` | Queues a pipeline build by definition ID | diff --git a/docs/codemods.md b/docs/codemods.md index 2257a57e0..787f02de8 100644 --- a/docs/codemods.md +++ b/docs/codemods.md @@ -113,6 +113,11 @@ continues. ### PR tool decomposition +The subsequent `pull_request_tool_names` codemod expands abbreviated tool keys +such as `add-pr-comment` to `add-pull-request-comment`, preserving configuration +and updating shared-budget members. Old/new key collisions fail atomically. +Only source configuration is migrated; old tool spellings are not runtime aliases. + The `split_update_pr` codemod replaces the old operation-based `update-pr` declaration with focused PR tools. It preserves the original aggregate `max` in a persisted `budget-groups` declaration and retains operator-owned legacy diff --git a/docs/safe-output-permissions.md b/docs/safe-output-permissions.md index ba5309b20..d37cf1fc1 100644 --- a/docs/safe-output-permissions.md +++ b/docs/safe-output-permissions.md @@ -132,7 +132,7 @@ from group membership. | Safe-output tool | Permission required (bit) | |---|---| -| `add-pr-comment`, `submit-pr-review`, `reply-to-pr-comment`, `resolve-pr-thread`, `update-pull-request`, `add-pr-reviewers`, `add-pr-labels`, `set-pr-auto-complete`, `abandon-pull-request` | `PullRequestContribute` (16384) | +| `add-pull-request-comment`, `submit-pull-request-review`, `reply-to-pull-request-comment`, `resolve-pull-request-thread`, `update-pull-request`, `add-pull-request-reviewers`, `add-pull-request-labels`, `set-pull-request-auto-complete`, `abandon-pull-request` | `PullRequestContribute` (16384) | | `create-pull-request` | `PullRequestContribute` (16384) + `CreateBranch` (16) + `GenericContribute` (4) on the target repo | | `create-branch` | `CreateBranch` (16) + `GenericContribute` (4) | | `create-git-tag` | `CreateTag` (32) + `GenericContribute` (4) | diff --git a/docs/safe-outputs.md b/docs/safe-outputs.md index 4a79067bf..a7f527013 100644 --- a/docs/safe-outputs.md +++ b/docs/safe-outputs.md @@ -30,7 +30,7 @@ safe-outputs: - agent-created work-items: - 12345 - add-pr-reviewers: + add-pull-request-reviewers: allowed-reviewers: - "user@example.com" max-reviewers: 3 @@ -127,7 +127,7 @@ safe-outputs: require-approval: true # global default: every output below needs review create-pull-request: target-branch: main - add-pr-comment: + add-pull-request-comment: require-approval: false # …except low-impact comments, which auto-apply ``` @@ -192,7 +192,7 @@ apply one note to every tool. diagnostic outputs (`noop`, `report-incomplete`, `missing-tool`, `missing-data`) until after approval, since they share that one job. If you want diagnostics to apply without waiting on a human, leave at least one - low-impact tool (e.g. `add-pr-comment`) non-gated so the automatic split job + low-impact tool (e.g. `add-pull-request-comment`) non-gated so the automatic split job is created. The Detection job always runs first. When AI threat analysis is enabled, a @@ -292,7 +292,7 @@ that pins the representation returned by Azure DevOps. ### Executor authentication All write-bearing safe outputs (e.g. `create-pull-request`, -`create-work-item`, `add-pr-comment`, `upload-build-attachment`) run in the +`create-work-item`, `add-pull-request-comment`, `upload-build-attachment`) run in the Stage 3 `SafeOutputs` job and authenticate to Azure DevOps using `SYSTEM_ACCESSTOKEN`. By default this is `$(System.AccessToken)` — the pipeline's built-in OAuth token running as the *Project Collection Build @@ -1267,7 +1267,7 @@ Reports that a task could not be completed. - `reason` - Why the task could not be completed (required, at least 10 characters) - `context` - Optional additional context about what was attempted -### add-pr-comment +### add-pull-request-comment Adds a new comment thread to a pull request. **Agent parameters:** @@ -1282,7 +1282,7 @@ Adds a new comment thread to a pull request. **Configuration options (front matter):** ```yaml safe-outputs: - add-pr-comment: + add-pull-request-comment: comment-prefix: "[Agent Review] " # Optional — prepended to all comments allowed-repositories: [] # Optional — restrict which repos can be commented on allowed-statuses: [] # Optional — restrict which thread statuses the agent can set (empty = any) @@ -1290,7 +1290,7 @@ safe-outputs: include-stats: true # Append agent stats to comment (default: true) ``` -### reply-to-pr-comment +### reply-to-pull-request-comment Replies to an existing review comment thread on a pull request. **Agent parameters:** @@ -1302,13 +1302,13 @@ Replies to an existing review comment thread on a pull request. **Configuration options (front matter):** ```yaml safe-outputs: - reply-to-pr-comment: + reply-to-pull-request-comment: comment-prefix: "[Agent] " # Optional — prepended to all replies allowed-repositories: [] # Optional — restrict which repos can be replied on max: 1 # Maximum per run (default: 1) ``` -### resolve-pr-thread +### resolve-pull-request-thread Resolves or updates the status of a pull request review thread. **Agent parameters:** @@ -1320,13 +1320,13 @@ Resolves or updates the status of a pull request review thread. **Configuration options (front matter):** ```yaml safe-outputs: - resolve-pr-thread: + resolve-pull-request-thread: allowed-repositories: [] # Optional — restrict which repos can be operated on allowed-statuses: [] # REQUIRED — empty list rejects all status transitions max: 1 # Maximum per run (default: 1) ``` -### submit-pr-review +### submit-pull-request-review Submits a review vote on a pull request. **Agent parameters:** @@ -1338,7 +1338,7 @@ Submits a review vote on a pull request. **Configuration options (front matter):** ```yaml safe-outputs: - submit-pr-review: + submit-pull-request-review: allowed-events: [] # REQUIRED — empty list rejects all events allowed-repositories: [] # Optional — restrict which repos can be reviewed allow-temporary-ids: false # Opt in to same-run create/follow-up references @@ -1352,10 +1352,10 @@ Each PR intent has one agent-facing tool: | Intent | Tool | |---|---| | Title/description | `update-pull-request` | -| Add reviewers | `add-pr-reviewers` | -| Add labels | `add-pr-labels` | -| Review/vote | `submit-pr-review` | -| Enable auto-complete | `set-pr-auto-complete` | +| Add reviewers | `add-pull-request-reviewers` | +| Add labels | `add-pull-request-labels` | +| Review/vote | `submit-pull-request-review` | +| Enable auto-complete | `set-pull-request-auto-complete` | | Abandon | `abandon-pull-request` | Reviewer, label, auto-complete and review tools require `pull_request_id` and @@ -1366,15 +1366,15 @@ or perform an immediate merge. ```yaml safe-outputs: - add-pr-reviewers: + add-pull-request-reviewers: allowed-repositories: [self] allowed-reviewers: ["owner@example.com"] max-reviewers: 3 max: 1 - add-pr-labels: + add-pull-request-labels: allowed-repositories: [self] max: 1 - set-pr-auto-complete: + set-pull-request-auto-complete: allowed-repositories: [self] delete-source-branch: true merge-strategy: squash @@ -1404,7 +1404,7 @@ after live creation. The compiler rejects both split-process configurations. Section-level `safe-outputs.staged` defaults and per-tool `staged` overrides are resolved before this comparison. Each follow-up counts against its tool budget and any shared budget group. -Existing `submit-pr-review` configurations remain numeric-only unless +Existing `submit-pull-request-review` configurations remain numeric-only unless `allow-temporary-ids: true` is configured. Automatic migration enables this for legacy votes that already supported temporary references. @@ -1416,21 +1416,43 @@ Example agent call sequence: ``` The first line represents the `create-pull-request` call; use the actual -temporary ID returned by that call in the later `add-pr-reviewers` call. +temporary ID returned by that call in the later `add-pull-request-reviewers` call. -### Migrating update-pr +### Migrating PR tool names + +All public Azure DevOps safe-output tool names use `pull-request`, not `pr`. +Compilation migrates the following keys without changing their configuration: + +| Previous name | Canonical name | +|---|---| +| `add-pr-comment` | `add-pull-request-comment` | +| `reply-to-pr-comment` | `reply-to-pull-request-comment` | +| `resolve-pr-thread` | `resolve-pull-request-thread` | +| `submit-pr-review` | `submit-pull-request-review` | +| `add-pr-reviewers` | `add-pull-request-reviewers` | +| `add-pr-labels` | `add-pull-request-labels` | +| `set-pr-auto-complete` | `set-pull-request-auto-complete` | + +Shared-budget member names are migrated too. If both spellings are configured, +compilation reports a conflict rather than merging policies. Prompt references +to old tool names are highlighted for manual correction; prompt text is not +rewritten. These are source migrations, not runtime aliases: MCP and Stage 3 +accept only canonical names. Existing compiled pipelines use their pinned +compiler release. + +### Migrating the update-pr operation-based tool `compile` automatically migrates `safe-outputs.update-pr` to focused tools. -The catch-all is no longer advertised over MCP. Historical Stage 3 proposals -remain supported with trusted legacy configuration. +The catch-all is no longer exposed by MCP or executable by the new Stage 3 +executor. | Old operation | Replacement | |---|---| | `update-description` | `update-pull-request` (`body`) | -| `add-reviewers` | `add-pr-reviewers` | -| `add-labels` | `add-pr-labels` | -| `vote` | `submit-pr-review` (`event`) | -| `set-auto-complete` | `set-pr-auto-complete` | +| `add-reviewers` | `add-pull-request-reviewers` | +| `add-labels` | `add-pull-request-labels` | +| `vote` | `submit-pull-request-review` (`event`) | +| `set-auto-complete` | `set-pull-request-auto-complete` | Migration preserves enabled operations, reviewer/vote/repository policy, temporary references, approval/staged settings and completion options. @@ -1446,14 +1468,14 @@ Do not remove migration metadata without reviewing the authority change. Conflicting old/new tool declarations require manual migration; no config is silently merged or overwritten. Prompt bodies are preserved byte-for-byte. -Explicit `update-pr`/`update_pr` references produce located warnings with +Explicit references to `update-pr` or abbreviated PR tool names produce located warnings with replacement guidance, including on later compile/lint passes until corrected. Review these warnings: front-matter migration cannot rewrite agent intent. Review votes retain their exact ADO meanings: approve=10, approve-with-suggestions=5, wait-for-author/request-changes=-5, reject=-10, reset/comment=0. Existing request-changes requires a rationale; migrated -wait-for-author does not. A discussion-only comment uses `add-pr-comment`. +wait-for-author does not. A discussion-only comment uses `add-pull-request-comment`. ### abandon-pull-request Abandons an Azure DevOps pull request without merging it. diff --git a/scripts/ado-script/src/approval-summary/__tests__/index.test.ts b/scripts/ado-script/src/approval-summary/__tests__/index.test.ts index 7d8c9bc1d..56632c70e 100644 --- a/scripts/ado-script/src/approval-summary/__tests__/index.test.ts +++ b/scripts/ado-script/src/approval-summary/__tests__/index.test.ts @@ -18,8 +18,8 @@ afterEach(() => { describe("parseReviewed", () => { it("splits a newline-delimited list, trims, and drops empties", () => { - const set = parseReviewed(" create-pull-request \n \n add-pr-comment "); - expect([...set].sort()).toEqual(["add-pr-comment", "create-pull-request"]); + const set = parseReviewed(" create-pull-request \n \n add-pull-request-comment "); + expect([...set].sort()).toEqual(["add-pull-request-comment", "create-pull-request"]); }); describe("parseRepositoryPolicies", () => { diff --git a/scripts/ado-script/src/approval-summary/__tests__/render.test.ts b/scripts/ado-script/src/approval-summary/__tests__/render.test.ts index 6d4fb56cb..72416ff46 100644 --- a/scripts/ado-script/src/approval-summary/__tests__/render.test.ts +++ b/scripts/ado-script/src/approval-summary/__tests__/render.test.ts @@ -35,7 +35,7 @@ describe("focused PR tools", () => { it("links temporary targets only to earlier creates without inventing real IDs", () => { const summary = renderSummary(parseProposals(ndjson( { name: "create-pull-request", temporary_id: "#aw_created", repository: "tools" }, - { name: "add-pr-reviewers", pull_request_id: "#aw_created", reviewers: ["person@example.test"] }, + { name: "add-pull-request-reviewers", pull_request_id: "#aw_created", reviewers: ["person@example.test"] }, { name: "abandon-pull-request", pull_request_id: "#aw_missing", body: "reason" }, )), new Set()); expect(summary).toContain("real ID assigned at execution"); @@ -90,12 +90,12 @@ describe("parseProposals", () => { it("parses one proposal per non-blank line with a string name", () => { const text = ndjson( { name: "create-pull-request", title: "T" }, - { name: "add-pr-comment", content: "C" }, + { name: "add-pull-request-comment", content: "C" }, ); const out = parseProposals(text); expect(out.map((p) => p.name)).toEqual([ "create-pull-request", - "add-pr-comment", + "add-pull-request-comment", ]); expect(out.map((p) => p.index)).toEqual([0, 1]); }); @@ -176,7 +176,7 @@ describe("sanitizeBlock", () => { describe("renderSummary — grouping/ordering", () => { const proposals: Proposal[] = parseProposals( ndjson( - { name: "add-pr-comment", pull_request_id: 5, content: "auto comment" }, + { name: "add-pull-request-comment", pull_request_id: 5, content: "auto comment" }, { name: "create-pull-request", title: "Reviewed PR", source_branch: "feat/x" }, { name: "create-work-item", title: "Reviewed WI" }, ), @@ -194,7 +194,7 @@ describe("renderSummary — grouping/ordering", () => { const pendingBlock = md.slice(pendingIdx, autoIdx); expect(pendingBlock).toContain("create-pull-request"); expect(pendingBlock).toContain("create-work-item"); - expect(pendingBlock).not.toContain("add-pr-comment"); + expect(pendingBlock).not.toContain("add-pull-request-comment"); }); it("counts the pending and automatic groups", () => { diff --git a/scripts/ado-script/src/approval-summary/render.ts b/scripts/ado-script/src/approval-summary/render.ts index 94d691427..b20ea967b 100644 --- a/scripts/ado-script/src/approval-summary/render.ts +++ b/scripts/ado-script/src/approval-summary/render.ts @@ -97,7 +97,7 @@ const TOOL_SPECS: Record = { ], body: "body", }, - "add-pr-reviewers": { + "add-pull-request-reviewers": { title: "Add pull request reviewers", fields: [ { label: "PR", key: "pull_request_id" }, @@ -105,7 +105,7 @@ const TOOL_SPECS: Record = { { label: "Repository selector", key: "repository" }, ], }, - "add-pr-labels": { + "add-pull-request-labels": { title: "Add pull request labels", fields: [ { label: "PR", key: "pull_request_id" }, @@ -113,7 +113,7 @@ const TOOL_SPECS: Record = { { label: "Repository selector", key: "repository" }, ], }, - "set-pr-auto-complete": { + "set-pull-request-auto-complete": { title: "Enable pull request auto-complete", fields: [ { label: "PR", key: "pull_request_id" }, @@ -129,17 +129,7 @@ const TOOL_SPECS: Record = { ], body: "description", }, - "update-pr": { - title: "Update pull request", - fields: [ - { label: "PR", key: "pull_request_id" }, - { label: "Operation", key: "operation" }, - { label: "Repository", key: "repository" }, - { label: "Vote", key: "vote" }, - ], - body: "description", - }, - "add-pr-comment": { + "add-pull-request-comment": { title: "Comment on pull request", fields: [ { label: "PR", key: "pull_request_id" }, @@ -148,7 +138,7 @@ const TOOL_SPECS: Record = { ], body: "content", }, - "reply-to-pr-comment": { + "reply-to-pull-request-comment": { title: "Reply to PR comment", fields: [ { label: "PR", key: "pull_request_id" }, @@ -156,7 +146,7 @@ const TOOL_SPECS: Record = { ], body: "content", }, - "submit-pr-review": { + "submit-pull-request-review": { title: "Submit PR review", fields: [ { label: "PR", key: "pull_request_id" }, @@ -164,7 +154,7 @@ const TOOL_SPECS: Record = { ], body: "body", }, - "resolve-pr-thread": { + "resolve-pull-request-thread": { title: "Resolve PR thread", fields: [ { label: "PR", key: "pull_request_id" }, diff --git a/scripts/ado-script/src/exec-context-pr-checks/index.ts b/scripts/ado-script/src/exec-context-pr-checks/index.ts index 57b78158f..8ed2d274f 100644 --- a/scripts/ado-script/src/exec-context-pr-checks/index.ts +++ b/scripts/ado-script/src/exec-context-pr-checks/index.ts @@ -136,7 +136,7 @@ export function successFragment(args: { lines.push( "Use `build_get_build_by_id` + `build_get_log` with the ids in " + "`failing.json` to read the failure logs. If you propose a fix, " + - "use `update_pr` / `add_pr_comment` to surface it.", + "use `add-pull-request-comment` to surface it.", ); } else { lines.push(""); diff --git a/scripts/ado-script/src/executor-e2e/__tests__/create-pull-request-scenarios.test.ts b/scripts/ado-script/src/executor-e2e/__tests__/create-pull-request-scenarios.test.ts index c4902bece..1facb33ed 100644 --- a/scripts/ado-script/src/executor-e2e/__tests__/create-pull-request-scenarios.test.ts +++ b/scripts/ado-script/src/executor-e2e/__tests__/create-pull-request-scenarios.test.ts @@ -161,7 +161,7 @@ describe("create-pull-request add-reviewers handoff", () => { }, }; const updated: ExecutedRecord = { - name: "add_pr_reviewers", + name: "add_pull_request_reviewers", status: "succeeded", result: { pull_request_id: 42, @@ -264,7 +264,7 @@ describe("create-pull-request add-reviewers handoff", () => { result: created, }, { - name: "add_pr_reviewers", + name: "add_pull_request_reviewers", status: "succeeded", result: updated, }, @@ -294,7 +294,7 @@ describe("create-pull-request add-reviewers handoff", () => { }, }; const updated: ExecutedRecord = { - name: "add_pr_reviewers", + name: "add_pull_request_reviewers", status: "succeeded", result: { pull_request_id: 42, @@ -406,7 +406,7 @@ describe("Rust executor payload contract", () => { it.each([ { target: "producer", tool: "create-pull-request", index: 0 }, - { target: "consumer", tool: "add-pr-reviewers", index: 1 }, + { target: "consumer", tool: "add-pull-request-reviewers", index: 1 }, ] as const)( "rejects an overlong $target temporary ID through Rust deserialization", async ({ target, tool, index }) => { @@ -456,7 +456,7 @@ describe("Rust executor payload contract", () => { expect(result.records).toHaveLength(2); expect(result.records[0]?.status).toBe("succeeded"); expect(result.records[1]?.status).toBe("failed"); - expect(result.records[1]?.error).toContain("Failed to parse add-pr-reviewers:"); + expect(result.records[1]?.error).toContain("Failed to parse add-pull-request-reviewers:"); expect(result.records[1]?.error).toContain("expected a sequence"); }); }); diff --git a/scripts/ado-script/src/executor-e2e/__tests__/execute-cli.test.ts b/scripts/ado-script/src/executor-e2e/__tests__/execute-cli.test.ts index 2b76d964f..5948fb74b 100644 --- a/scripts/ado-script/src/executor-e2e/__tests__/execute-cli.test.ts +++ b/scripts/ado-script/src/executor-e2e/__tests__/execute-cli.test.ts @@ -23,8 +23,8 @@ describe("renderSourceMarkdown", () => { it("emits a repos block when adoRepo is provided", () => { const md = renderSourceMarkdown({ - tool: "add-pr-comment", - safeOutputs: { "add-pr-comment": { "allowed-repositories": ["agent-definitions"] } }, + tool: "add-pull-request-comment", + safeOutputs: { "add-pull-request-comment": { "allowed-repositories": ["agent-definitions"] } }, adoRepo: "agent-definitions", }); expect(md).toContain("repos:"); diff --git a/scripts/ado-script/src/executor-e2e/__tests__/github-issue.test.ts b/scripts/ado-script/src/executor-e2e/__tests__/github-issue.test.ts index af4c5e2db..da0d9aae2 100644 --- a/scripts/ado-script/src/executor-e2e/__tests__/github-issue.test.ts +++ b/scripts/ado-script/src/executor-e2e/__tests__/github-issue.test.ts @@ -16,10 +16,10 @@ function result(partial: Partial & { tool: string }): ScenarioRe describe("buildIssueTitle", () => { it("keys the title on the sorted failing tool set", () => { const title = buildIssueTitle([ - result({ tool: "update-pr", ok: false }), - result({ tool: "add-pr-comment", ok: false }), + result({ tool: "update-pull-request", ok: false }), + result({ tool: "add-pull-request-comment", ok: false }), ]); - expect(title).toBe(`${ISSUE_TITLE_PREFIX}add-pr-comment, update-pr`); + expect(title).toBe(`${ISSUE_TITLE_PREFIX}add-pull-request-comment, update-pull-request`); }); it("dedupes repeated tools", () => { @@ -35,7 +35,7 @@ describe("renderIssueBody", () => { it("includes a failure table, run stats, and skipped section", () => { const results: ScenarioResult[] = [ result({ tool: "create-work-item" }), - result({ tool: "add-pr-comment", ok: false, phase: "assert", message: "no thread" }), + result({ tool: "add-pull-request-comment", ok: false, phase: "assert", message: "no thread" }), result({ tool: "queue-build", ok: true, skipped: true, message: "no pipeline id" }), ]; const body = renderIssueBody(results, { @@ -45,7 +45,7 @@ describe("renderIssueBody", () => { buildId: "42", buildUrl: "https://example/build/42", }); - expect(body).toContain("| `add-pr-comment` | assert | no thread |"); + expect(body).toContain("| `add-pull-request-comment` | assert | no thread |"); expect(body).toContain("Passed: 1 | Failed: 1 | Skipped: 1"); expect(body).toContain("`queue-build`: no pipeline id"); expect(body).toContain("https://example/build/42"); diff --git a/scripts/ado-script/src/executor-e2e/__tests__/index.test.ts b/scripts/ado-script/src/executor-e2e/__tests__/index.test.ts index 8188c2704..057493c14 100644 --- a/scripts/ado-script/src/executor-e2e/__tests__/index.test.ts +++ b/scripts/ado-script/src/executor-e2e/__tests__/index.test.ts @@ -8,12 +8,12 @@ describe("summarise", () => { it("renders PASS/FAIL/SKIP lines and a total", () => { const results: ScenarioResult[] = [ { tool: "create-work-item", ok: true, durationMs: 5 }, - { tool: "add-pr-comment", ok: false, phase: "assert", message: "no thread", durationMs: 5 }, + { tool: "add-pull-request-comment", ok: false, phase: "assert", message: "no thread", durationMs: 5 }, { tool: "queue-build", ok: true, skipped: true, phase: "skipped", message: "no id", durationMs: 1 }, ]; const text = summarise(results); expect(text).toContain("[PASS] create-work-item"); - expect(text).toContain("[FAIL] add-pr-comment (assert: no thread)"); + expect(text).toContain("[FAIL] add-pull-request-comment (assert: no thread)"); expect(text).toContain("[SKIP] queue-build"); expect(text).toContain("Total: 3 | Passed: 1 | Failed: 1 | Skipped: 1"); }); diff --git a/scripts/ado-script/src/executor-e2e/ado-rest.ts b/scripts/ado-script/src/executor-e2e/ado-rest.ts index d6b210908..fd60738c2 100644 --- a/scripts/ado-script/src/executor-e2e/ado-rest.ts +++ b/scripts/ado-script/src/executor-e2e/ado-rest.ts @@ -129,7 +129,7 @@ export class AdoRest { } /** - * Resolve an identity using the same exact-match fields as update-pr's + * Resolve an identity using the same exact-match fields as add-pull-request-reviewers' * production add-reviewers implementation. Canonical GUIDs are verified * through the identityIds query; names and emails use exact field matching. */ diff --git a/scripts/ado-script/src/executor-e2e/scenarios/create-pull-request.ts b/scripts/ado-script/src/executor-e2e/scenarios/create-pull-request.ts index 3f4e13a55..38c3517a6 100644 --- a/scripts/ado-script/src/executor-e2e/scenarios/create-pull-request.ts +++ b/scripts/ado-script/src/executor-e2e/scenarios/create-pull-request.ts @@ -511,7 +511,7 @@ function createPullRequestAddReviewersScenario( return { id: options.id, - tool: "add-pr-reviewers", + tool: "add-pull-request-reviewers", targetsAdoRepo: true, setup: async (ctx) => { const reviewer = resolveExecutorE2eReviewer(); @@ -595,7 +595,7 @@ function createPullRequestAddReviewersScenario( ); } if (strResult(record, "operation") !== "add-reviewers") { - throw new Error("add-pr-reviewers reported an unexpected operation"); + throw new Error("add-pull-request-reviewers reported an unexpected operation"); } const failed = stringArrayResult(record, "failed"); if (failed.length !== 0) { diff --git a/scripts/ado-script/src/executor-e2e/scenarios/pr.ts b/scripts/ado-script/src/executor-e2e/scenarios/pr.ts index 2984deb1c..085ad959b 100644 --- a/scripts/ado-script/src/executor-e2e/scenarios/pr.ts +++ b/scripts/ado-script/src/executor-e2e/scenarios/pr.ts @@ -1,6 +1,6 @@ /** * Pull-request safe-output scenarios against the ADO `agent-definitions` repo: - * add-pr-comment, reply-to-pr-comment, resolve-pr-thread, submit-pr-review, + * add-pull-request-comment, reply-to-pull-request-comment, resolve-pull-request-thread, submit-pull-request-review, * focused PR content editing and abandonment. * * Each scenario deterministically creates a transient PR (with a real commit, @@ -84,17 +84,17 @@ async function teardownPr(ctx: ScenarioContext, state: PrState): Promise { } export const addPrComment: Scenario = { - tool: "add-pr-comment", + tool: "add-pull-request-comment", targetsAdoRepo: true, config: (ctx) => ({ "allowed-repositories": [ctx.adoRepo], max: 1, "include-stats": false, }), - setup: (ctx) => setupPr(ctx, "add-pr-comment", false), + setup: (ctx) => setupPr(ctx, "add-pull-request-comment", false), ndjson: async (ctx, state) => ({ pull_request_id: state.prId, - content: detBody(ctx, "add-pr-comment"), + content: detBody(ctx, "add-pull-request-comment"), repository: ctx.adoRepo, status: "active", }), @@ -109,21 +109,21 @@ export const addPrComment: Scenario = { }; export const replyToPrComment: Scenario = { - tool: "reply-to-pr-comment", + tool: "reply-to-pull-request-comment", targetsAdoRepo: true, config: (ctx) => ({ "allowed-repositories": [ctx.adoRepo], max: 1 }), - setup: (ctx) => setupPr(ctx, "reply-to-pr-comment", true), + setup: (ctx) => setupPr(ctx, "reply-to-pull-request-comment", true), ndjson: async (ctx, state) => { - if (state.threadId === undefined) throw new Error(`[reply-to-pr-comment] threadId not set by setup`); + if (state.threadId === undefined) throw new Error(`[reply-to-pull-request-comment] threadId not set by setup`); return { pull_request_id: state.prId, thread_id: state.threadId, - content: detBody(ctx, "reply-to-pr-comment"), + content: detBody(ctx, "reply-to-pull-request-comment"), repository: ctx.adoRepo, }; }, assert: async (ctx, state) => { - if (state.threadId === undefined) throw new Error(`[reply-to-pr-comment] threadId not set by setup`); + if (state.threadId === undefined) throw new Error(`[reply-to-pull-request-comment] threadId not set by setup`); const thread = await ctx.rest.getThread(state.repo, state.prId, state.threadId); const replied = (thread.comments ?? []).some((c) => (c.content ?? "").includes(`build ${ctx.buildId}`)); if (!replied) throw new Error(`reply not found on thread #${state.threadId}`); @@ -132,16 +132,16 @@ export const replyToPrComment: Scenario = { }; export const resolvePrThread: Scenario = { - tool: "resolve-pr-thread", + tool: "resolve-pull-request-thread", targetsAdoRepo: true, config: (ctx) => ({ "allowed-repositories": [ctx.adoRepo], "allowed-statuses": ["fixed"], max: 1, }), - setup: (ctx) => setupPr(ctx, "resolve-pr-thread", true), + setup: (ctx) => setupPr(ctx, "resolve-pull-request-thread", true), ndjson: async (ctx, state) => { - if (state.threadId === undefined) throw new Error(`[resolve-pr-thread] threadId not set by setup`); + if (state.threadId === undefined) throw new Error(`[resolve-pull-request-thread] threadId not set by setup`); return { pull_request_id: state.prId, thread_id: state.threadId, @@ -150,7 +150,7 @@ export const resolvePrThread: Scenario = { }; }, assert: async (ctx, state) => { - if (state.threadId === undefined) throw new Error(`[resolve-pr-thread] threadId not set by setup`); + if (state.threadId === undefined) throw new Error(`[resolve-pull-request-thread] threadId not set by setup`); const thread = await ctx.rest.getThread(state.repo, state.prId, state.threadId); // ADO returns thread status as either a numeric enum (2=fixed) or its // string name. We requested "fixed", so accept ONLY the "fixed" states — @@ -166,14 +166,14 @@ export const resolvePrThread: Scenario = { }; export const submitPrReview: Scenario = { - tool: "submit-pr-review", + tool: "submit-pull-request-review", targetsAdoRepo: true, config: (ctx) => ({ "allowed-events": ["request-changes"], "allowed-repositories": [ctx.adoRepo], max: 1, }), - setup: (ctx) => setupPr(ctx, "submit-pr-review", false), + setup: (ctx) => setupPr(ctx, "submit-pull-request-review", false), ndjson: async (ctx, state) => ({ pull_request_id: state.prId, // Use "request-changes" (vote=-5), not a positive vote: the executor's @@ -182,7 +182,7 @@ export const submitPrReview: Scenario = { // creates and reviews the PR with the SAME identity. A negative vote // exercises the same submit path without tripping the guard. event: "request-changes", - body: detBody(ctx, "submit-pr-review"), + body: detBody(ctx, "submit-pull-request-review"), repository: ctx.adoRepo, }), assert: async (ctx, state) => { @@ -195,30 +195,6 @@ export const submitPrReview: Scenario = { cleanup: teardownPr, }; -export const updatePr: Scenario = { - tool: "update-pr", - targetsAdoRepo: true, - config: (ctx) => ({ - "allowed-operations": ["update-description"], - "allowed-repositories": [ctx.adoRepo], - max: 1, - }), - setup: (ctx) => setupPr(ctx, "update-pr", false), - ndjson: async (ctx, state) => ({ - pull_request_id: state.prId, - repository: ctx.adoRepo, - operation: "update-description", - description: `${detBody(ctx, "update-pr")} (updated)`, - }), - assert: async (ctx, state) => { - const pr = await ctx.rest.getPullRequest(state.repo, state.prId); - if (!(pr.description ?? "").includes("(updated)")) { - throw new Error(`PR #${state.prId} description was not updated`); - } - }, - cleanup: teardownPr, -}; - export const updatePullRequest: Scenario = { tool: "update-pull-request", targetsAdoRepo: true, @@ -327,11 +303,11 @@ export const updatePullRequestOversized: Scenario = { }; export const addPrLabels: Scenario = { - tool: "add-pr-labels", + tool: "add-pull-request-labels", targetsAdoRepo: true, config: (ctx) => ({ "allowed-repositories": [ctx.adoRepo] }), setup: async (ctx) => { - const state = await setupPr(ctx, "add-pr-labels", false); + const state = await setupPr(ctx, "add-pull-request-labels", false); try { await ctx.rest.setPullRequestLabels(state.repo, state.prId, ["existing-label"]); } catch (error) { @@ -356,7 +332,7 @@ export const addPrLabels: Scenario = { interface AutoCompleteState extends PrState { targetBranch: string } export const setPrAutoComplete: Scenario = { - tool: "set-pr-auto-complete", + tool: "set-pull-request-auto-complete", targetsAdoRepo: true, config: (ctx) => ({ "allowed-repositories": [ctx.adoRepo], @@ -368,8 +344,8 @@ export const setPrAutoComplete: Scenario = { const base = await defaultBranchShortName(ctx, repo); const sha = await ctx.rest.getRefObjectId(repo, `heads/${base}`); if (!sha) throw new Error("Default branch has no tip"); - const targetBranch = `${ctx.prefix("set-pr-auto-complete")}-target`; - const branch = `${ctx.prefix("set-pr-auto-complete")}-src`; + const targetBranch = `${ctx.prefix("set-pull-request-auto-complete")}-target`; + const branch = `${ctx.prefix("set-pull-request-auto-complete")}-src`; await ctx.rest.pushAddFileBranch(repo, targetBranch, sha, `/ado-aw-det/${ctx.buildId}/autocomplete-target.md`, "isolated target", "prepare isolated completion target"); let sourceCreated = false; @@ -380,7 +356,7 @@ export const setPrAutoComplete: Scenario = { `/ado-aw-det/${ctx.buildId}/autocomplete-source.md`, "isolated source", "prepare completion source"); sourceCreated = true; const pr = await ctx.rest.createPullRequest(repo, branch, targetBranch, - ctx.prefix("set-pr-auto-complete"), "Completes only into a disposable test branch."); + ctx.prefix("set-pull-request-auto-complete"), "Completes only into a disposable test branch."); return { repo, prId: pr.pullRequestId, branch, targetBranch }; } catch (error) { const cleanup = new Teardown(); @@ -413,7 +389,6 @@ export const prScenarios: Scenario[] = [ replyToPrComment, resolvePrThread, submitPrReview, - updatePr, updatePullRequest, abandonPullRequest, updatePullRequestIsland, diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index 3d8e02033..bd445c00b 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -8341,7 +8341,7 @@ safe-outputs: "safe-outputs:\n", " require-approval: true\n", " create-pull-request: {}\n", - " add-pr-comment:\n", + " add-pull-request-comment:\n", " require-approval: false\n", ); let enabled = format!("---\n{common} threat-detection: true\n---\nbody\n"); diff --git a/src/compile/codemods/0010_pull_request_tool_names.rs b/src/compile/codemods/0010_pull_request_tool_names.rs new file mode 100644 index 000000000..32ce03139 --- /dev/null +++ b/src/compile/codemods/0010_pull_request_tool_names.rs @@ -0,0 +1,27 @@ +use anyhow::Result; +use serde_yaml::{Mapping, Value}; + +use super::{Codemod, CodemodContext}; + +pub static CODEMOD: Codemod = Codemod { + id: "pull_request_tool_names", + summary: "expand abbreviated PR tool names to pull-request, including shared budget references", + introduced_in: env!("CARGO_PKG_VERSION"), + apply, +}; + +fn apply(front_matter: &mut Mapping, _ctx: &CodemodContext) -> Result { + let key = Value::String("safe-outputs".to_string()); + let Some(raw) = front_matter.get(&key) else { + return Ok(false); + }; + let value = serde_json::to_value(raw)?; + let Some(mut outputs) = value.as_object().cloned() else { + return Ok(false); + }; + if !crate::compile::pr_migration::rename_pr_tools(&mut outputs)? { + return Ok(false); + } + front_matter.insert(key, serde_yaml::to_value(outputs)?); + Ok(true) +} diff --git a/src/compile/codemods/mod.rs b/src/compile/codemods/mod.rs index 9b3099820..aa71e1260 100644 --- a/src/compile/codemods/mod.rs +++ b/src/compile/codemods/mod.rs @@ -51,6 +51,8 @@ mod m0007_promote_debug_create_github_issue; mod m0008_explicit_mcp_pipeline_env; #[path = "0009_split_update_pr.rs"] mod m0009_split_update_pr; +#[path = "0010_pull_request_tool_names.rs"] +mod m0010_pull_request_tool_names; #[allow(unused_imports)] // Re-exported for future codemods; only `take_key` is in-tree use. pub use helpers::{ConflictPolicy, insert_no_overwrite, rename_key, take_key}; @@ -158,6 +160,7 @@ pub static CODEMODS: &[&Codemod] = &[ &m0007_promote_debug_create_github_issue::CODEMOD, &m0008_explicit_mcp_pipeline_env::CODEMOD, &m0009_split_update_pr::CODEMOD, + &m0010_pull_request_tool_names::CODEMOD, ]; /// Result of running the codemod registry on a single front-matter diff --git a/src/compile/common.rs b/src/compile/common.rs index 52cffe951..c277c2415 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -3022,12 +3022,12 @@ pub fn validate_update_work_item_target(front_matter: &FrontMatter) -> Result<() Ok(()) } -/// Validate that submit-pr-review has a required `allowed-events` field when configured. +/// Validate that submit-pull-request-review has a required `allowed-events` field when configured. /// /// An empty or missing `allowed-events` list would allow agents to cast any review vote, /// including auto-approvals. Operators must explicitly opt in to each allowed event. pub fn validate_submit_pr_review_events(front_matter: &FrontMatter) -> Result<()> { - if let Some(config_value) = front_matter.safe_outputs.get("submit-pr-review") { + if let Some(config_value) = front_matter.safe_outputs.get("submit-pull-request-review") { if let Some(obj) = config_value.as_object() { let allowed_events = obj.get("allowed-events"); let is_empty = match allowed_events { @@ -3041,18 +3041,18 @@ pub fn validate_submit_pr_review_events(front_matter: &FrontMatter) -> Result<() ); } anyhow::bail!( - "safe-outputs.submit-pr-review requires a non-empty 'allowed-events' list \ + "safe-outputs.submit-pull-request-review requires a non-empty 'allowed-events' list \ to prevent agents from casting unrestricted review votes. Example:\n\n \ - safe-outputs:\n submit-pr-review:\n allowed-events:\n \ + safe-outputs:\n submit-pull-request-review:\n allowed-events:\n \ - comment\n - approve-with-suggestions\n\n\ Valid events: approve, approve-with-suggestions, request-changes, wait-for-author, reject, reset, comment\n" ); } } else { anyhow::bail!( - "safe-outputs.submit-pr-review must be a configuration object with an \ + "safe-outputs.submit-pull-request-review must be a configuration object with an \ 'allowed-events' list. Example:\n\n \ - safe-outputs:\n submit-pr-review:\n allowed-events:\n - comment\n" + safe-outputs:\n submit-pull-request-review:\n allowed-events:\n - comment\n" ); } } @@ -3063,19 +3063,23 @@ pub fn validate_submit_pr_review_events(front_matter: &FrontMatter) -> Result<() pub fn validate_pull_request_outputs_config(front_matter: &FrontMatter) -> Result<()> { super::pr_migration::validate_budget_groups(front_matter)?; if let Some(config) = front_matter - .typed_safe_output_config::("add-pr-labels")? + .typed_safe_output_config::( + "add-pull-request-labels", + )? { crate::safe_outputs::validate_add_pr_labels_config(&config)?; } if let Some(config) = front_matter .typed_safe_output_config::( - "set-pr-auto-complete", + "set-pull-request-auto-complete", )? { crate::safe_outputs::validate_set_pr_auto_complete_config(&config)?; } if let Some(config) = front_matter - .typed_safe_output_config::("submit-pr-review")? + .typed_safe_output_config::( + "submit-pull-request-review", + )? { crate::safe_outputs::validate_submit_pr_review_config(&config)?; } @@ -3086,17 +3090,17 @@ pub fn validate_pull_request_outputs_config(front_matter: &FrontMatter) -> Resul crate::safe_outputs::validate_abandon_pull_request_config(&config)?; } for tool in [ - "add-pr-reviewers", - "add-pr-labels", - "set-pr-auto-complete", + "add-pull-request-reviewers", + "add-pull-request-labels", + "set-pull-request-auto-complete", "update-pull-request", "abandon-pull-request", - "submit-pr-review", + "submit-pull-request-review", ] { if !front_matter.safe_outputs.contains_key(tool) { continue; } - let temporary_capable = tool != "submit-pr-review" + let temporary_capable = tool != "submit-pull-request-review" || front_matter .safe_outputs .get(tool) @@ -3123,23 +3127,25 @@ pub fn validate_pull_request_outputs_config(front_matter: &FrontMatter) -> Resul if let Some(max_reviewers) = front_matter .safe_outputs - .get("add-pr-reviewers") + .get("add-pull-request-reviewers") .and_then(serde_json::Value::as_object) .and_then(|object| object.get("max-reviewers")) { let max_reviewers = serde_json::from_value::(max_reviewers.clone()).map_err(|_| { anyhow::anyhow!( - "safe-outputs.update-pr.max-reviewers must be a positive integer that fits in usize" + "safe-outputs.add-pull-request-reviewers.max-reviewers must be a positive integer that fits in usize" ) })?; anyhow::ensure!( max_reviewers > 0, - "safe-outputs.update-pr.max-reviewers must be a positive integer that fits in usize" + "safe-outputs.add-pull-request-reviewers.max-reviewers must be a positive integer that fits in usize" ); } if let Some(config) = front_matter - .typed_safe_output_config::("add-pr-reviewers")? + .typed_safe_output_config::( + "add-pull-request-reviewers", + )? { crate::safe_outputs::validate_add_pr_reviewers_config(&config)?; } @@ -3158,7 +3164,7 @@ pub fn validate_update_pr_votes(front_matter: &FrontMatter) -> Result<()> { if let Some(config_value) = front_matter.safe_outputs.get("update-pr").or_else(|| { front_matter .safe_outputs - .get("submit-pr-review") + .get("submit-pull-request-review") .and_then(|config| config.get(super::pr_migration::LEGACY_PR_CONFIG)) }) && let Some(obj) = config_value.as_object() { @@ -3196,13 +3202,13 @@ pub fn validate_update_pr_votes(front_matter: &FrontMatter) -> Result<()> { Ok(()) } -/// Validate that resolve-pr-thread has a required `allowed-statuses` field when configured. +/// Validate that resolve-pull-request-thread has a required `allowed-statuses` field when configured. /// /// An empty or missing `allowed-statuses` list would let agents set any thread status, /// including "fixed" or "wontFix" on security-critical review threads. Operators must /// explicitly opt in to each allowed status transition. pub fn validate_resolve_pr_thread_statuses(front_matter: &FrontMatter) -> Result<()> { - if let Some(config_value) = front_matter.safe_outputs.get("resolve-pr-thread") { + if let Some(config_value) = front_matter.safe_outputs.get("resolve-pull-request-thread") { if let Some(obj) = config_value.as_object() { let allowed_statuses = obj.get("allowed-statuses"); let is_empty = match allowed_statuses { @@ -3211,19 +3217,19 @@ pub fn validate_resolve_pr_thread_statuses(front_matter: &FrontMatter) -> Result }; if is_empty { anyhow::bail!( - "safe-outputs.resolve-pr-thread requires a non-empty \ + "safe-outputs.resolve-pull-request-thread requires a non-empty \ 'allowed-statuses' list to prevent agents from manipulating thread \ statuses without explicit operator consent. Example:\n\n \ - safe-outputs:\n resolve-pr-thread:\n allowed-statuses:\n\ + safe-outputs:\n resolve-pull-request-thread:\n allowed-statuses:\n\ \x20 - fixed\n\n\ Valid statuses: active, fixed, wont-fix, closed, by-design\n" ); } } else { anyhow::bail!( - "safe-outputs.resolve-pr-thread must be a configuration object \ + "safe-outputs.resolve-pull-request-thread must be a configuration object \ with an 'allowed-statuses' list. Example:\n\n \ - safe-outputs:\n resolve-pr-thread:\n allowed-statuses:\n\ + safe-outputs:\n resolve-pull-request-thread:\n allowed-statuses:\n\ \x20 - fixed\n" ); } @@ -5604,7 +5610,7 @@ mod tests { #[test] fn test_submit_pr_review_events_fails_when_allowed_events_missing() { let (fm, _) = parse_markdown( - "---\nname: test\ndescription: test\nsafe-outputs:\n submit-pr-review:\n allowed-repositories:\n - self\n---\n" + "---\nname: test\ndescription: test\nsafe-outputs:\n submit-pull-request-review:\n allowed-repositories:\n - self\n---\n" ).unwrap(); let result = validate_submit_pr_review_events(&fm); assert!(result.is_err()); @@ -5615,7 +5621,7 @@ mod tests { #[test] fn test_submit_pr_review_events_fails_when_allowed_events_empty() { let (fm, _) = parse_markdown( - "---\nname: test\ndescription: test\nsafe-outputs:\n submit-pr-review:\n allowed-events: []\n---\n" + "---\nname: test\ndescription: test\nsafe-outputs:\n submit-pull-request-review:\n allowed-events: []\n---\n" ).unwrap(); let result = validate_submit_pr_review_events(&fm); assert!(result.is_err()); @@ -5626,7 +5632,7 @@ mod tests { #[test] fn test_submit_pr_review_events_fails_when_value_is_scalar() { let (fm, _) = parse_markdown( - "---\nname: test\ndescription: test\nsafe-outputs:\n submit-pr-review: true\n---\n", + "---\nname: test\ndescription: test\nsafe-outputs:\n submit-pull-request-review: true\n---\n", ) .unwrap(); let result = validate_submit_pr_review_events(&fm); @@ -5636,7 +5642,7 @@ mod tests { #[test] fn test_submit_pr_review_events_passes_when_events_provided() { let (fm, _) = parse_markdown( - "---\nname: test\ndescription: test\nsafe-outputs:\n submit-pr-review:\n allowed-events:\n - comment\n - approve\n---\n" + "---\nname: test\ndescription: test\nsafe-outputs:\n submit-pull-request-review:\n allowed-events:\n - comment\n - approve\n---\n" ).unwrap(); assert!(validate_submit_pr_review_events(&fm).is_ok()); } @@ -5721,7 +5727,7 @@ mod tests { #[test] fn test_resolve_pr_thread_fails_when_allowed_statuses_missing() { let (fm, _) = parse_markdown( - "---\nname: test\ndescription: test\nsafe-outputs:\n resolve-pr-thread:\n allowed-repositories:\n - self\n---\n" + "---\nname: test\ndescription: test\nsafe-outputs:\n resolve-pull-request-thread:\n allowed-repositories:\n - self\n---\n" ).unwrap(); let result = validate_resolve_pr_thread_statuses(&fm); assert!(result.is_err()); @@ -5732,7 +5738,7 @@ mod tests { #[test] fn test_resolve_pr_thread_fails_when_allowed_statuses_empty() { let (fm, _) = parse_markdown( - "---\nname: test\ndescription: test\nsafe-outputs:\n resolve-pr-thread:\n allowed-statuses: []\n---\n" + "---\nname: test\ndescription: test\nsafe-outputs:\n resolve-pull-request-thread:\n allowed-statuses: []\n---\n" ).unwrap(); let result = validate_resolve_pr_thread_statuses(&fm); assert!(result.is_err()); @@ -5743,7 +5749,7 @@ mod tests { #[test] fn test_resolve_pr_thread_fails_when_value_is_scalar() { let (fm, _) = parse_markdown( - "---\nname: test\ndescription: test\nsafe-outputs:\n resolve-pr-thread: true\n---\n", + "---\nname: test\ndescription: test\nsafe-outputs:\n resolve-pull-request-thread: true\n---\n", ) .unwrap(); let result = validate_resolve_pr_thread_statuses(&fm); @@ -5753,7 +5759,7 @@ mod tests { #[test] fn test_resolve_pr_thread_passes_when_statuses_provided() { let (fm, _) = parse_markdown( - "---\nname: test\ndescription: test\nsafe-outputs:\n resolve-pr-thread:\n allowed-statuses:\n - fixed\n - wont-fix\n---\n" + "---\nname: test\ndescription: test\nsafe-outputs:\n resolve-pull-request-thread:\n allowed-statuses:\n - fixed\n - wont-fix\n---\n" ).unwrap(); assert!(validate_resolve_pr_thread_statuses(&fm).is_ok()); } @@ -6615,7 +6621,7 @@ safe-outputs: ] { let (mut fm, _) = parse_markdown(yaml).unwrap(); fm.safe_outputs - .get_mut("add-pr-reviewers") + .get_mut("add-pull-request-reviewers") .unwrap() .as_object_mut() .unwrap() @@ -6625,7 +6631,7 @@ safe-outputs: .to_string(); assert!( error.contains( - "safe-outputs.update-pr.max-reviewers must be a positive integer that fits in usize" + "safe-outputs.add-pull-request-reviewers.max-reviewers must be a positive integer that fits in usize" ), "value {value}: {error}" ); diff --git a/src/compile/pr_migration.rs b/src/compile/pr_migration.rs index a9d87dc53..56e46ea8f 100644 --- a/src/compile/pr_migration.rs +++ b/src/compile/pr_migration.rs @@ -7,11 +7,20 @@ use serde_json::{Map, Value, json}; use super::types::FrontMatter; pub const LEGACY_PR_CONFIG: &str = "legacy-update-pr"; +pub const PR_TOOL_RENAMES: &[(&str, &str)] = &[ + ("add-pr-comment", "add-pull-request-comment"), + ("reply-to-pr-comment", "reply-to-pull-request-comment"), + ("resolve-pr-thread", "resolve-pull-request-thread"), + ("submit-pr-review", "submit-pull-request-review"), + ("add-pr-reviewers", "add-pull-request-reviewers"), + ("add-pr-labels", "add-pull-request-labels"), + ("set-pr-auto-complete", "set-pull-request-auto-complete"), +]; pub const PR_OPERATIONS: &[(&str, &str)] = &[ - ("add-reviewers", "add-pr-reviewers"), - ("add-labels", "add-pr-labels"), - ("set-auto-complete", "set-pr-auto-complete"), - ("vote", "submit-pr-review"), + ("add-reviewers", "add-pull-request-reviewers"), + ("add-labels", "add-pull-request-labels"), + ("set-auto-complete", "set-pull-request-auto-complete"), + ("vote", "submit-pull-request-review"), ("update-description", "update-pull-request"), ]; @@ -30,7 +39,7 @@ pub fn focused_pr_tool(operation: &str) -> Option<&'static str> { .find_map(|(old, new)| (*old == operation).then_some(*new)) } -/// Pure, atomic normalization shared by the codemod and historical execution. +/// Split the legacy front-matter declaration without widening its policy. pub fn migrate_safe_outputs(outputs: &mut Map) -> Result { let Some(raw) = outputs.get("update-pr") else { return Ok(false); @@ -201,21 +210,8 @@ pub fn validate_budget_groups(front_matter: &FrontMatter) -> Result<()> { Ok(()) } -/// Keep historical proposals executable without advertising the old MCP tool. -pub fn normalize_execution_context(ctx: &mut crate::safe_outputs::ExecutionContext) -> Result<()> { - let mut outputs: Map = ctx.tool_configs.clone().into_iter().collect(); - if !ctx.budget_groups.is_empty() { - outputs.insert( - "budget-groups".to_string(), - serde_json::to_value(&ctx.budget_groups)?, - ); - } - migrate_safe_outputs(&mut outputs)?; - ctx.budget_groups = outputs - .remove("budget-groups") - .map(serde_json::from_value) - .transpose()? - .unwrap_or_default(); +pub fn validate_execution_budget_groups(ctx: &crate::safe_outputs::ExecutionContext) -> Result<()> { + let outputs = &ctx.tool_configs; let mut seen = HashSet::new(); for (name, group) in &ctx.budget_groups { ensure!( @@ -256,27 +252,51 @@ pub fn normalize_execution_context(ctx: &mut crate::safe_outputs::ExecutionConte ); } } - let legacy = outputs - .values() - .filter_map(|config| { - config - .get(LEGACY_PR_CONFIG) - .map(|original| (original, config)) - }) - .collect::>(); - if let Some((original, effective)) = legacy.first() { - ensure!( - legacy.iter().all(|(config, _)| config == original), - "conflicting legacy update-pr execution policies" - ); - let mut original = (*original).clone(); - if let (Some(object), Some(staged)) = (original.as_object_mut(), effective.get("staged")) { - object.insert("staged".to_string(), staged.clone()); + Ok(()) +} + +pub fn rename_pr_tools(outputs: &mut Map) -> Result { + let mut renamed = outputs.clone(); + let mut changed = false; + for (old, new) in PR_TOOL_RENAMES { + if let Some(config) = renamed.get(*old).cloned() { + ensure!( + !renamed.contains_key(*new), + "manual migration required: both {old} and {new} are configured" + ); + renamed.remove(*old); + renamed.insert((*new).to_string(), config); + changed = true; } - outputs.insert("update-pr".to_string(), original); } - ctx.tool_configs = outputs.into_iter().collect(); - Ok(()) + if let Some(raw_groups) = renamed.get("budget-groups") { + let mut groups: BudgetGroups = serde_json::from_value(raw_groups.clone()) + .context("safe-outputs.budget-groups has invalid configuration")?; + let mut groups_changed = false; + for group in groups.values_mut() { + for tool in &mut group.tools { + if let Some((_, new)) = PR_TOOL_RENAMES.iter().find(|(old, _)| *old == tool) { + *tool = (*new).to_string(); + groups_changed = true; + } + } + } + if groups_changed { + renamed.insert("budget-groups".to_string(), serde_json::to_value(groups)?); + changed = true; + } + } + if changed { + *outputs = renamed; + } + Ok(changed) +} + +pub fn is_deprecated_pr_tool(name: &str) -> bool { + matches!(name, "update-pr" | "update_pr") + || PR_TOOL_RENAMES + .iter() + .any(|(old, _)| name == *old || name == old.replace('-', "_")) } pub fn deprecated_pr_prompt_lines(body: &str) -> Vec { @@ -286,15 +306,17 @@ pub fn deprecated_pr_prompt_lines(body: &str) -> Vec { text.split(|c: char| { c.is_whitespace() || (!c.is_alphanumeric() && c != '-' && c != '_') }) - .any(|word| matches!(word, "update-pr" | "update_pr")) + .any(is_deprecated_pr_tool) .then_some(line + 1) }) .collect() } -pub const PR_PROMPT_GUIDANCE: &str = "update-pr is no longer an agent tool: use update-pull-request for content, \ - add-pr-reviewers for reviewers, add-pr-labels for labels, set-pr-auto-complete \ - for auto-complete, and submit-pr-review for votes. Update the prompt manually; \ +pub const PR_PROMPT_GUIDANCE: &str = "PR tools now use pull-request, not pr. Use \ + add-pull-request-comment, reply-to-pull-request-comment and resolve-pull-request-thread. \ + For former update-pr operations use update-pull-request for content, \ + add-pull-request-reviewers for reviewers, add-pull-request-labels for labels, set-pull-request-auto-complete \ + for auto-complete, and submit-pull-request-review for votes. Update the prompt manually; \ its text has not been rewritten."; pub fn warn_prompt_references(source: &std::path::Path, content: &str, body: &str) { @@ -315,6 +337,84 @@ pub fn warn_prompt_references(source: &std::path::Path, content: &str, body: &st mod tests { use super::*; + #[test] + fn renamed_tools_preserve_configuration_and_are_idempotent() { + for (old, new) in PR_TOOL_RENAMES { + let config = json!({ + "max": 2, "require-approval": {"approvers": ["reviewers"]}, + "staged": false, "allowed-repositories": ["self"] + }); + let mut outputs = Map::from_iter([((*old).to_string(), config.clone())]); + assert!(rename_pr_tools(&mut outputs).unwrap()); + assert_eq!(outputs.get(*new), Some(&config)); + assert!(!outputs.contains_key(*old)); + let snapshot = outputs.clone(); + assert!(!rename_pr_tools(&mut outputs).unwrap()); + assert_eq!(outputs, snapshot); + } + } + + #[test] + fn renamed_budget_members_keep_the_same_limit() { + let mut outputs = json!({ + "add-pr-reviewers": {"max": 2, "legacy-update-pr": {"allowed-operations":["add-reviewers"]}}, + "add-pr-labels": {"max": 2}, + "budget-groups": {"update-pr": {"max": 2, "tools": ["add-pr-reviewers", "add-pr-labels"]}} + }).as_object().unwrap().clone(); + assert!(rename_pr_tools(&mut outputs).unwrap()); + assert_eq!( + outputs["budget-groups"]["update-pr"], + json!({ + "max": 2, "tools": ["add-pull-request-reviewers", "add-pull-request-labels"] + }) + ); + assert_eq!( + outputs["add-pull-request-reviewers"]["legacy-update-pr"], + json!({ + "allowed-operations": ["add-reviewers"] + }) + ); + assert!(!rename_pr_tools(&mut outputs).unwrap()); + } + + #[test] + fn name_conflicts_do_not_partially_rename_other_tools() { + let mut outputs = json!({ + "add-pr-comment": {"max": 2}, + "add-pr-labels": {"max": 1}, + "add-pull-request-labels": {"max": 5} + }) + .as_object() + .unwrap() + .clone(); + let original = outputs.clone(); + let error = rename_pr_tools(&mut outputs).unwrap_err().to_string(); + assert!(error.contains("manual migration required")); + assert!(error.contains("add-pr-labels")); + assert!(error.contains("add-pull-request-labels")); + assert_eq!(outputs, original); + } + + #[test] + fn every_abbreviated_prompt_name_is_detected_but_canonical_names_are_not() { + for (old, new) in PR_TOOL_RENAMES { + assert_eq!( + deprecated_pr_prompt_lines(&format!("Call `{old}`.")), + vec![1] + ); + assert_eq!( + deprecated_pr_prompt_lines(&format!("Call `{}`.", old.replace('-', "_"))), + vec![1] + ); + assert!( + deprecated_pr_prompt_lines(&format!( + "Call `{new}`; not prefix_{old} or {old}-helper." + )) + .is_empty() + ); + } + } + #[test] fn migration_preserves_policy_and_shared_budget() { let mut outputs = json!({ @@ -332,7 +432,7 @@ mod tests { .clone(); assert!(migrate_safe_outputs(&mut outputs).unwrap()); assert!(!outputs.contains_key("update-pr")); - assert_eq!(outputs["add-pr-reviewers"]["max-reviewers"], 2); + assert_eq!(outputs["add-pull-request-reviewers"]["max-reviewers"], 2); assert_eq!(outputs["update-pull-request"]["title"], false); assert_eq!(outputs["update-pull-request"]["include-stats"], false); assert_eq!(outputs["update-pull-request"]["target"], "*"); @@ -353,7 +453,7 @@ mod tests { fn migration_conflict_is_atomic() { let mut outputs = json!({ "update-pr": {"allowed-operations": ["vote"], "allowed-votes": ["reset"]}, - "submit-pr-review": {"allowed-events": ["approve"]} + "submit-pull-request-review": {"allowed-events": ["approve"]} }) .as_object() .unwrap() @@ -387,7 +487,7 @@ mod tests { parsed.body_raw, "\r\nCall `update-pr` with #aw_created.\r\n" ); - let config = &parsed.front_matter.safe_outputs["submit-pr-review"]; + let config = &parsed.front_matter.safe_outputs["submit-pull-request-review"]; assert_eq!( config["allowed-events"], json!(["wait-for-author", "reject", "reset"]) @@ -405,7 +505,8 @@ mod tests { let raw = crate::compile::custom_tools::resolved_execution_config_json(&fm, &[]).unwrap(); let config: Value = serde_json::from_str(&raw).unwrap(); assert_eq!(config["budgetGroups"]["update-pr"]["max"], 0); - fm.safe_outputs.get_mut("add-pr-labels").unwrap()["require-approval"] = json!(true); + fm.safe_outputs.get_mut("add-pull-request-labels").unwrap()["require-approval"] = + json!(true); assert!( validate_budget_groups(&fm) .unwrap_err() @@ -418,22 +519,22 @@ mod tests { fn focused_config_validators_run_during_compilation() { for (tool, config, expected) in [ ( - "add-pr-reviewers", + "add-pull-request-reviewers", json!({"allowed-reviewers":[""]}), "allowed-reviewers", ), ( - "add-pr-labels", + "add-pull-request-labels", json!({"allowed-repositories":[""]}), "allowed-repositories", ), ( - "set-pr-auto-complete", + "set-pull-request-auto-complete", json!({"merge-strategy":"invalid"}), "merge-strategy", ), ( - "submit-pr-review", + "submit-pull-request-review", json!({"allowed-events":["invalid"]}), "event", ), diff --git a/src/compile/types.rs b/src/compile/types.rs index 9bef9753b..2810c267f 100644 --- a/src/compile/types.rs +++ b/src/compile/types.rs @@ -7157,7 +7157,7 @@ description: "Test" safe-outputs: require-approval: true create-pull-request: {} - add-pr-comment: {} + add-pull-request-comment: {} --- Body @@ -7169,10 +7169,16 @@ Body assert_eq!(tools.len(), 2); // Global default makes every tool require approval. assert!(fm.tool_requires_approval("create-pull-request").is_some()); - assert!(fm.tool_requires_approval("add-pr-comment").is_some()); + assert!( + fm.tool_requires_approval("add-pull-request-comment") + .is_some() + ); let (auto, reviewed) = fm.partition_safe_outputs_by_approval(); assert!(auto.is_empty()); - assert_eq!(reviewed, vec!["add-pr-comment", "create-pull-request"]); + assert_eq!( + reviewed, + vec!["add-pull-request-comment", "create-pull-request"] + ); } #[test] @@ -7184,7 +7190,7 @@ safe-outputs: require-approval: true create-pull-request: require-approval: false - add-pr-comment: {} + add-pull-request-comment: {} --- Body @@ -7192,10 +7198,13 @@ Body let (fm, _) = super::super::common::parse_markdown(content).unwrap(); // Per-tool false overrides the global true. assert!(fm.tool_requires_approval("create-pull-request").is_none()); - assert!(fm.tool_requires_approval("add-pr-comment").is_some()); + assert!( + fm.tool_requires_approval("add-pull-request-comment") + .is_some() + ); let (auto, reviewed) = fm.partition_safe_outputs_by_approval(); assert_eq!(auto, vec!["create-pull-request"]); - assert_eq!(reviewed, vec!["add-pr-comment"]); + assert_eq!(reviewed, vec!["add-pull-request-comment"]); } #[test] diff --git a/src/execute.rs b/src/execute.rs index d57001eca..382860cd2 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -25,9 +25,9 @@ use crate::safe_outputs::{ MissingToolResult, NoopResult, QueueBuildResult, RemoveGithubIssueLabelsResult, ReplyToPrCommentResult, ReportIncompleteResult, ResolvePrThreadResult, SetGithubIssueFieldResult, SetGithubIssueTypeResult, SubmitPrReviewResult, ToolResult, - UnassignGithubIssueFromUserResult, UpdateGithubIssueResult, UpdatePrResult, - UpdatePullRequestResult, UpdateWikiPageResult, UpdateWorkItemResult, - UploadBuildAttachmentResult, UploadPipelineArtifactResult, UploadWorkitemAttachmentResult, + UnassignGithubIssueFromUserResult, UpdateGithubIssueResult, UpdatePullRequestResult, + UpdateWikiPageResult, UpdateWorkItemResult, UploadBuildAttachmentResult, + UploadPipelineArtifactResult, UploadWorkitemAttachmentResult, }; use crate::safe_outputs::{AddPrLabelsResult, AddPrReviewersResult, SetPrAutoCompleteResult}; use crate::sanitize::neutralize_pipeline_commands; @@ -205,9 +205,7 @@ pub async fn execute_safe_outputs( ctx: &ExecutionContext, filter: &ToolFilter, ) -> Result> { - let mut effective_ctx = ctx.clone(); - crate::compile::pr_migration::normalize_execution_context(&mut effective_ctx)?; - let ctx = &effective_ctx; + crate::compile::pr_migration::validate_execution_budget_groups(ctx)?; let safe_output_path = safe_output_dir.join(SAFE_OUTPUT_FILENAME); log_execution_context(safe_output_dir, ctx); @@ -249,7 +247,6 @@ pub async fn execute_safe_outputs( CreateGitTagResult, AddBuildTagResult, CreateBranchResult, - UpdatePrResult, AddPrReviewersResult, AddPrLabelsResult, SetPrAutoCompleteResult, @@ -356,34 +353,7 @@ async fn process_one_entry( // Skip entries the active filter excludes (manual-review split: the // auto job excludes reviewed tools; the reviewed job runs only them). - let canonical = if proposal_tool_name == "update-pr" { - entry - .get("operation") - .and_then(Value::as_str) - .and_then(crate::compile::pr_migration::focused_pr_tool) - .unwrap_or(proposal_tool_name) - } else { - proposal_tool_name - }; - let matches_filter = |names: &[String]| { - names.iter().any(|name| { - name == canonical - || name == proposal_tool_name - || (name == "update-pr" - && ctx.tool_configs.get(canonical).is_some_and(|config| { - config - .get(crate::compile::pr_migration::LEGACY_PR_CONFIG) - .is_some() - })) - }) - }; - let allowed = if canonical == proposal_tool_name && !matches_filter(&["update-pr".to_string()]) - { - filter.allows(canonical) - } else { - (filter.only.is_empty() || matches_filter(&filter.only)) && !matches_filter(&filter.exclude) - }; - if !allowed { + if !filter.allows(proposal_tool_name) { debug!( "[{}/{}] Skipping entry for tool '{}' (filtered out)", i + 1, @@ -400,8 +370,7 @@ async fn process_one_entry( // Budget is consumed before execution so that failed attempts (target policy rejection, // network errors) still count — this prevents unbounded retries against a failing endpoint. let group_failure = ctx.budget_groups.iter().find_map(|(name, group)| { - if (group.tools.iter().any(|tool| tool == canonical) - || (proposal_tool_name == "update-pr" && name == "update-pr")) + if group.tools.iter().any(|tool| tool == proposal_tool_name) && group_counts.get(name).copied().unwrap_or(0) >= group.max { Some(ExecutionResult::budget_exhausted(format!( @@ -423,9 +392,7 @@ async fn process_one_entry( return Some(result); } for (name, group) in &ctx.budget_groups { - if group.tools.iter().any(|tool| tool == canonical) - || (proposal_tool_name == "update-pr" && name == "update-pr") - { + if group.tools.iter().any(|tool| tool == proposal_tool_name) { *group_counts.entry(name.clone()).or_default() += 1; } } @@ -710,10 +677,6 @@ pub async fn execute_safe_output( .get("name") .and_then(|n| n.as_str()) .ok_or_else(|| anyhow::anyhow!("Safe output missing 'name' field"))?; - anyhow::ensure!( - tool_name != "update-pr" || ctx.tool_configs.contains_key("update-pr"), - "historical update-pr proposal has no trusted legacy configuration" - ); debug!("Dispatching tool: {}", tool_name); @@ -800,16 +763,15 @@ async fn dispatch_pr_tools( ) -> Result> { dispatch_executor_tools!(tool_name, entry, ctx, { "create-pull-request" => CreatePrResult, - "add-pr-comment" => AddPrCommentResult, - "update-pr" => UpdatePrResult, - "add-pr-reviewers" => AddPrReviewersResult, - "add-pr-labels" => AddPrLabelsResult, - "set-pr-auto-complete" => SetPrAutoCompleteResult, + "add-pull-request-comment" => AddPrCommentResult, + "add-pull-request-reviewers" => AddPrReviewersResult, + "add-pull-request-labels" => AddPrLabelsResult, + "set-pull-request-auto-complete" => SetPrAutoCompleteResult, "abandon-pull-request" => AbandonPullRequestResult, "update-pull-request" => UpdatePullRequestResult, - "submit-pr-review" => SubmitPrReviewResult, - "reply-to-pr-comment" => ReplyToPrCommentResult, - "resolve-pr-thread" => ResolvePrThreadResult, + "submit-pull-request-review" => SubmitPrReviewResult, + "reply-to-pull-request-comment" => ReplyToPrCommentResult, + "resolve-pull-request-thread" => ResolvePrThreadResult, }) } @@ -976,12 +938,30 @@ mod tests { use std::path::PathBuf; #[tokio::test] - async fn migrated_pr_tools_share_original_budget_with_historical_records() { + async fn old_pull_request_names_have_no_stage_three_aliases() { + let ctx = ExecutionContext { + dry_run: true, + ..Default::default() + }; + for name in crate::compile::pr_migration::PR_TOOL_RENAMES + .iter() + .map(|(old, _)| *old) + .chain(["update-pr"]) + { + let error = execute_safe_output(&serde_json::json!({"name": name}), &ctx) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("Unknown tool type"), "{name}: {error}"); + } + } + #[tokio::test] + async fn migrated_pr_tools_share_original_budget() { for max in [0, 1, 2] { let dir = tempfile::tempdir().unwrap(); let entries = [ - serde_json::json!({"name":"add-pr-labels","pull_request_id":7,"labels":["first"]}), - serde_json::json!({"name":"update-pr","pull_request_id":7,"operation":"update-description","description":"legacy description"}), + serde_json::json!({"name":"add-pull-request-labels","pull_request_id":7,"labels":["first"]}), + serde_json::json!({"name":"update-pull-request","pull_request_id":7,"body":"migrated description"}), serde_json::json!({"name":"update-pull-request","pull_request_id":7,"body":"canonical description"}), ]; let text = entries @@ -992,14 +972,16 @@ mod tests { tokio::fs::write(dir.path().join(SAFE_OUTPUT_FILENAME), text) .await .unwrap(); + let source = format!( + "---\nname: migrated\ndescription: test\nsafe-outputs:\n update-pr:\n allowed-operations: [add-labels, update-description]\n max: {max}\n---\nbody\n" + ); + let fm = crate::compile::parse_markdown_detailed(&source) + .unwrap() + .front_matter; let ctx = ExecutionContext { dry_run: true, - tool_configs: HashMap::from([( - "update-pr".to_string(), - serde_json::json!({ - "allowed-operations":["add-labels","update-description"],"max":max - }), - )]), + budget_groups: crate::compile::pr_migration::budget_groups(&fm).unwrap(), + tool_configs: fm.safe_outputs, ..ExecutionContext::default() }; let results = execute_safe_outputs(dir.path(), &ctx, &ToolFilter::default()) @@ -1017,19 +999,18 @@ mod tests { } #[tokio::test] - async fn historical_pr_filters_use_canonical_review_lane() { + async fn migrated_pr_filters_use_canonical_review_lane() { let dir = tempfile::tempdir().unwrap(); tokio::fs::write(dir.path().join(SAFE_OUTPUT_FILENAME), - "{\"name\":\"update-pr\",\"pull_request_id\":7,\"operation\":\"update-description\",\"description\":\"legacy description\"}\n" + "{\"name\":\"update-pull-request\",\"pull_request_id\":7,\"body\":\"migrated description\"}\n" ).await.unwrap(); + let fm = crate::compile::parse_markdown_detailed( + "---\nname: migrated\ndescription: test\nsafe-outputs:\n update-pr:\n allowed-operations: [update-description]\n require-approval: true\n---\nbody\n" + ).unwrap().front_matter; let ctx = ExecutionContext { dry_run: true, - tool_configs: HashMap::from([( - "update-pr".to_string(), - serde_json::json!({ - "allowed-operations":["update-description"],"require-approval":true - }), - )]), + budget_groups: crate::compile::pr_migration::budget_groups(&fm).unwrap(), + tool_configs: fm.safe_outputs, ..ExecutionContext::default() }; let automatic = ToolFilter { @@ -1151,7 +1132,7 @@ mod tests { // Empty filter allows everything. let f = ToolFilter::default(); assert!(f.allows("create-pull-request")); - assert!(f.allows("add-pr-comment")); + assert!(f.allows("add-pull-request-comment")); // `only` restricts to the listed tools. let f = ToolFilter { @@ -1159,7 +1140,7 @@ mod tests { exclude: vec![], }; assert!(f.allows("create-pull-request")); - assert!(!f.allows("add-pr-comment")); + assert!(!f.allows("add-pull-request-comment")); // `exclude` removes the listed tools. let f = ToolFilter { @@ -1167,7 +1148,7 @@ mod tests { exclude: vec!["create-pull-request".into()], }; assert!(!f.allows("create-pull-request")); - assert!(f.allows("add-pr-comment")); + assert!(f.allows("add-pull-request-comment")); } async fn write_custom_tool_test_config(dir: &Path) -> PathBuf { @@ -1648,10 +1629,9 @@ mod tests { "patch_sha256": patch_sha256 }); let update = serde_json::json!({ - "name": "update-pr", + "name": "update-pull-request", "pull_request_id": "#aw_pr123", - "operation": "update-description", - "description": "Updated through the temporary reference." + "body": "Updated through the temporary reference." }); let ndjson = format!( "{}\n{}\n", @@ -1667,7 +1647,13 @@ mod tests { "create-pull-request".to_string(), serde_json::json!({"max": 1, "include-stats": false}), ); - tool_configs.insert("update-pr".to_string(), serde_json::json!({"max": 1})); + tool_configs.insert( + "update-pull-request".to_string(), + serde_json::json!({ + "max": 1, "target": "*", "title": false, "body": true, "include-stats": false, + "legacy-update-pr": {"allowed-operations": ["update-description"], "max": 1} + }), + ); let ctx = ExecutionContext { ado_org_url: Some(server.uri()), ado_organization: Some("target-org".to_string()), diff --git a/src/inspect/catalog.rs b/src/inspect/catalog.rs index 632d4b85d..bc6e0bd8c 100644 --- a/src/inspect/catalog.rs +++ b/src/inspect/catalog.rs @@ -273,7 +273,7 @@ fn safe_output_classification(name: &str) -> &'static str { fn safe_output_description(name: &str) -> &'static str { match name { "add-build-tag" => "Parameters for adding a tag to an Azure DevOps build", - "add-pr-comment" => "Parameters for adding a comment thread on a pull request", + "add-pull-request-comment" => "Parameters for adding a comment thread on a pull request", "assign-work-item" => "Assigns an Azure DevOps work item to an allowed identity", "comment-on-work-item" => "Parameters for commenting on a work item", "create-branch" => "Parameters for creating a branch", @@ -302,17 +302,21 @@ fn safe_output_description(name: &str) -> &'static str { "missing-tool" => "Parameters for reporting a missing tool", "noop" => "Parameters for describing a no operation. Use this if there is no work to do.", "queue-build" => "Parameters for queuing a build", - "reply-to-pr-comment" => { + "reply-to-pull-request-comment" => { "Parameters for replying to an existing review comment thread on a pull request" } "report-incomplete" => "Parameters for reporting that a task could not be completed", - "resolve-pr-thread" => "Parameters for resolving or reactivating a PR review thread", + "resolve-pull-request-thread" => { + "Parameters for resolving or reactivating a PR review thread" + } "set-github-issue-type" => "Sets or clears the native type on a GitHub issue", "set-github-issue-field" => "Sets a repository-defined field on a GitHub issue", - "submit-pr-review" => "Parameters for submitting a pull request review", - "add-pr-reviewers" => "Adds policy-permitted Azure DevOps PR reviewers", - "add-pr-labels" => "Adds labels without replacing existing Azure DevOps PR labels", - "set-pr-auto-complete" => { + "submit-pull-request-review" => "Parameters for submitting a pull request review", + "add-pull-request-reviewers" => "Adds policy-permitted Azure DevOps PR reviewers", + "add-pull-request-labels" => { + "Adds labels without replacing existing Azure DevOps PR labels" + } + "set-pull-request-auto-complete" => { "Enables Azure DevOps PR auto-complete without bypassing branch policies" } "update-pull-request" => "Updates an Azure DevOps pull request title or description", diff --git a/src/mcp.rs b/src/mcp.rs index a65e4d821..777392285 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -436,7 +436,7 @@ fn apply_tool_filter(tool_router: &mut ToolRouter, enabled_tools: O if let Some(enabled) = enabled_tools { for name in enabled { if !all_tools.iter().any(|t| t == name) { - if name == "update-pr" { + if crate::compile::pr_migration::is_deprecated_pr_tool(name) { warn!("{}", crate::compile::pr_migration::PR_PROMPT_GUIDANCE); } warn!( @@ -1294,7 +1294,7 @@ structured output that should be visible in the project wiki." } #[tool( - name = "add-pr-comment", + name = "add-pull-request-comment", description = "Add a comment thread to an Azure DevOps pull request. Supports both \ general comments and file-specific inline comments with optional line positioning. \ The comment will be posted during safe output processing." @@ -1304,7 +1304,7 @@ The comment will be posted during safe output processing." params: Parameters, ) -> Result { info!( - "Tool called: add-pr-comment - PR #{}", + "Tool called: add-pull-request-comment - PR #{}", params.0.pull_request_id ); debug!("Content length: {} chars", params.0.content.len()); @@ -1440,7 +1440,7 @@ pull request. The branch will be created during safe output processing." } #[tool( - name = "add-pr-reviewers", + name = "add-pull-request-reviewers", description = "Add policy-permitted reviewers to an Azure DevOps PR. Accepts a numeric or same-run temporary PR ID." )] async fn add_pr_reviewers( @@ -1452,7 +1452,7 @@ pull request. The branch will be created during safe output processing." } #[tool( - name = "add-pr-labels", + name = "add-pull-request-labels", description = "Add labels to an Azure DevOps PR without replacing existing labels. Accepts a numeric or same-run temporary PR ID." )] async fn add_pr_labels( @@ -1464,7 +1464,7 @@ pull request. The branch will be created during safe output processing." } #[tool( - name = "set-pr-auto-complete", + name = "set-pull-request-auto-complete", description = "Enable Azure DevOps PR auto-complete using configured completion options. Does not merge immediately or bypass branch policies." )] async fn set_pr_auto_complete( @@ -1768,7 +1768,7 @@ restrictions may apply per the workflow's safe-outputs config." } #[tool( - name = "submit-pr-review", + name = "submit-pull-request-review", description = "Submit a pull request review with a decision (approve, request-changes, \ or comment-only) and an optional body explaining the rationale. The review will be \ submitted during safe output processing. Requires 'allowed-events' to be configured." @@ -1778,7 +1778,7 @@ submitted during safe output processing. Requires 'allowed-events' to be configu params: Parameters, ) -> Result { info!( - "Tool called: submit-pr-review - PR #{} event '{}'", + "Tool called: submit-pull-request-review - PR #{} event '{}'", params.0.pull_request_id, params.0.event ); let mut sanitized = params.0; @@ -1794,7 +1794,7 @@ submitted during safe output processing. Requires 'allowed-events' to be configu } #[tool( - name = "reply-to-pr-comment", + name = "reply-to-pull-request-comment", description = "Reply to an existing review comment thread on an Azure DevOps pull request. \ Provide the PR ID, thread ID, and reply content. The reply will be posted during safe output processing." )] @@ -1803,7 +1803,7 @@ Provide the PR ID, thread ID, and reply content. The reply will be posted during params: Parameters, ) -> Result { info!( - "Tool called: reply-to-pr-comment - PR #{} thread #{}", + "Tool called: reply-to-pull-request-comment - PR #{} thread #{}", params.0.pull_request_id, params.0.thread_id ); let mut sanitized = params.0; @@ -1819,7 +1819,7 @@ Provide the PR ID, thread ID, and reply content. The reply will be posted during } #[tool( - name = "resolve-pr-thread", + name = "resolve-pull-request-thread", description = "Resolve or change the status of a review thread on an Azure DevOps pull request. \ Valid statuses: fixed, wont-fix, closed, by-design, active. \ The status change will be applied during safe output processing." @@ -1829,7 +1829,7 @@ The status change will be applied during safe output processing." params: Parameters, ) -> Result { info!( - "Tool called: resolve-pr-thread - PR #{} thread #{} → '{}'", + "Tool called: resolve-pull-request-thread - PR #{} thread #{} → '{}'", params.0.pull_request_id, params.0.thread_id, params.0.status ); let result: ResolvePrThreadResult = params.0.try_into()?; @@ -2687,6 +2687,22 @@ safe-outputs: } } + #[test] + fn public_pull_request_names_have_no_abbreviated_runtime_aliases() { + let tools = SafeOutputs::tool_router().list_all(); + for (old, new) in crate::compile::pr_migration::PR_TOOL_RENAMES { + assert!( + tools.iter().any(|tool| tool.name.as_ref() == *new), + "missing {new}" + ); + assert!( + !tools.iter().any(|tool| tool.name.as_ref() == *old), + "deprecated route {old}" + ); + } + assert!(!tools.iter().any(|tool| tool.name.as_ref() == "update-pr")); + } + #[tokio::test] async fn test_assign_work_item_schema_when_explicitly_enabled() { let temp_dir = tempfile::tempdir().unwrap(); diff --git a/src/safe_outputs/add_pr_comment.rs b/src/safe_outputs/add_pr_comment.rs index b3ee5dcb5..1bd241b6a 100644 --- a/src/safe_outputs/add_pr_comment.rs +++ b/src/safe_outputs/add_pr_comment.rs @@ -102,7 +102,7 @@ impl Validate for AddPrCommentParams { } tool_result! { - name = "add-pr-comment", + name = "add-pull-request-comment", write = true, params = AddPrCommentParams, /// Result of adding a comment thread on a pull request @@ -130,12 +130,12 @@ impl SanitizeContent for AddPrCommentResult { } } -/// Configuration for the add-pr-comment tool (specified in front matter) +/// Configuration for the add-pull-request-comment tool (specified in front matter) /// /// Example front matter: /// ```yaml /// safe-outputs: -/// add-pr-comment: +/// add-pull-request-comment: /// comment-prefix: "[Agent Review] " /// allowed-repositories: /// - self @@ -399,7 +399,7 @@ impl Executor for AddPrCommentResult { self.content.len() ); debug!( - "add-pr-comment: pr_id={}, content length={}", + "add-pull-request-comment: pr_id={}, content length={}", self.pull_request_id, self.content.len() ); @@ -418,7 +418,7 @@ impl Executor for AddPrCommentResult { .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; debug!("ADO org: {}, project: {}", org_url, project); - let config: AddPrCommentConfig = ctx.get_tool_config("add-pr-comment")?; + let config: AddPrCommentConfig = ctx.get_tool_config("add-pull-request-comment")?; debug!("Config: {:?}", config); let status_int = match self.validate_against_config(&config) { @@ -506,7 +506,7 @@ mod tests { #[test] fn test_result_has_correct_name() { - assert_eq!(AddPrCommentResult::NAME, "add-pr-comment"); + assert_eq!(AddPrCommentResult::NAME, "add-pull-request-comment"); } #[test] @@ -533,7 +533,7 @@ mod tests { status: "active".to_string(), }; let result: AddPrCommentResult = params.try_into().unwrap(); - assert_eq!(result.name, "add-pr-comment"); + assert_eq!(result.name, "add-pull-request-comment"); assert_eq!(result.pull_request_id, 42); assert!(result.content.contains("test comment")); } @@ -652,7 +652,7 @@ mod tests { let result: AddPrCommentResult = params.try_into().unwrap(); let json = serde_json::to_string(&result).unwrap(); - assert!(json.contains(r#""name":"add-pr-comment""#)); + assert!(json.contains(r#""name":"add-pull-request-comment""#)); assert!(json.contains(r#""pull_request_id":42"#)); } @@ -793,7 +793,7 @@ allowed-statuses: status: "active".to_string(), }; let mut result = AddPrCommentResult { - name: "add-pr-comment".to_string(), + name: "add-pull-request-comment".to_string(), pull_request_id: params.pull_request_id, content: params.content, repository: params.repository, diff --git a/src/safe_outputs/add_pr_labels.rs b/src/safe_outputs/add_pr_labels.rs index 45d70fcf2..e95285a21 100644 --- a/src/safe_outputs/add_pr_labels.rs +++ b/src/safe_outputs/add_pr_labels.rs @@ -32,7 +32,7 @@ impl Validate for AddPrLabelsParams { } tool_result! { - name = "add-pr-labels", + name = "add-pull-request-labels", write = true, params = AddPrLabelsParams, pub struct AddPrLabelsResult { @@ -91,10 +91,10 @@ impl Executor for AddPrLabelsResult { return Ok(ExecutionResult::failure(error.to_string())); } ensure!( - ctx.tool_configs.contains_key("add-pr-labels"), - "add-pr-labels is not configured" + ctx.tool_configs.contains_key("add-pull-request-labels"), + "add-pull-request-labels is not configured" ); - let config: AddPrLabelsConfig = ctx.get_tool_config("add-pr-labels")?; + let config: AddPrLabelsConfig = ctx.get_tool_config("add-pull-request-labels")?; validate_add_pr_labels_config(&config)?; let (pr_id, target) = match resolve_pr_target( &self.pull_request_id, @@ -105,7 +105,7 @@ impl Executor for AddPrLabelsResult { Ok(target) => target, Err(failure) => return Ok(failure), }; - if let Some(legacy) = legacy_policy(ctx, "add-pr-labels", "add-labels")? + if let Some(legacy) = legacy_policy(ctx, "add-pull-request-labels", "add-labels")? && let Err(failure) = resolve_pr_target( &self.pull_request_id, self.repository.as_deref(), @@ -185,9 +185,9 @@ mod tests { ctx.allowed_repositories .insert("other".into(), "Other/repo".into()); ctx.tool_configs - .insert("add-pr-labels".into(), serde_json::json!({})); + .insert("add-pull-request-labels".into(), serde_json::json!({})); let result: AddPrLabelsResult = serde_json::from_value(serde_json::json!({ - "name": "add-pr-labels", "pull_request_id": "4294967296", + "name": "add-pull-request-labels", "pull_request_id": "4294967296", "repository": "other", "labels": ["ready"] })) .unwrap(); @@ -208,16 +208,16 @@ mod tests { .await; let mut ctx = super::super::pr_common::tests::registered_context( &server.uri(), - "add-pr-labels", + "add-pull-request-labels", serde_json::json!({"legacy-update-pr": {"allowed-repositories": ["other"]}}), ); let mut result: AddPrLabelsResult = serde_json::from_value(serde_json::json!({ - "name": "add-pr-labels", "pull_request_id": "#aw_pr123", "labels": ["ready"] + "name": "add-pull-request-labels", "pull_request_id": "#aw_pr123", "labels": ["ready"] })) .unwrap(); assert!(result.execute_sanitized(&ctx).await.unwrap().success); ctx.tool_configs.insert( - "add-pr-labels".into(), + "add-pull-request-labels".into(), serde_json::json!({"legacy-update-pr": {"allowed-repositories": ["self"]}}), ); assert!(!result.execute_sanitized(&ctx).await.unwrap().success); diff --git a/src/safe_outputs/add_pr_reviewers.rs b/src/safe_outputs/add_pr_reviewers.rs index 5e504edf7..b15bb404f 100644 --- a/src/safe_outputs/add_pr_reviewers.rs +++ b/src/safe_outputs/add_pr_reviewers.rs @@ -39,7 +39,7 @@ impl Validate for AddPrReviewersParams { } tool_result! { - name = "add-pr-reviewers", + name = "add-pull-request-reviewers", write = true, params = AddPrReviewersParams, pub struct AddPrReviewersResult { @@ -93,7 +93,7 @@ pub(crate) fn validate_add_pr_reviewers_config( ) -> anyhow::Result<()> { ensure!( config.max_reviewers > 0, - "add-pr-reviewers.max-reviewers must be greater than zero" + "add-pull-request-reviewers.max-reviewers must be greater than zero" ); for reviewer in &config.allowed_reviewers { ensure!( @@ -132,10 +132,10 @@ impl Executor for AddPrReviewersResult { return Ok(ExecutionResult::failure(error.to_string())); } ensure!( - ctx.tool_configs.contains_key("add-pr-reviewers"), - "add-pr-reviewers is not configured" + ctx.tool_configs.contains_key("add-pull-request-reviewers"), + "add-pull-request-reviewers is not configured" ); - let config: AddPrReviewersConfig = ctx.get_tool_config("add-pr-reviewers")?; + let config: AddPrReviewersConfig = ctx.get_tool_config("add-pull-request-reviewers")?; validate_add_pr_reviewers_config(&config)?; let policy = UpdatePrConfig { allowed_repositories: config.allowed_repositories, @@ -155,7 +155,7 @@ impl Executor for AddPrReviewersResult { Ok(target) => target, Err(failure) => return Ok(failure), }; - let legacy = legacy_policy(ctx, "add-pr-reviewers", "add-reviewers")?; + let legacy = legacy_policy(ctx, "add-pull-request-reviewers", "add-reviewers")?; if let Some(legacy) = &legacy && let Err(failure) = resolve_pr_target( &self.pull_request_id, @@ -247,7 +247,7 @@ mod tests { async fn historical_metadata_cannot_widen_reviewer_allowlist() { let mut ctx = ExecutionContext::default(); ctx.tool_configs.insert( - "add-pr-reviewers".into(), + "add-pull-request-reviewers".into(), serde_json::json!({ "allowed-reviewers": ["permitted"], "legacy-update-pr": {"allowed-reviewers": ["*"]} }), @@ -271,11 +271,11 @@ mod tests { ] { let ctx = super::super::pr_common::tests::registered_context( &server.uri(), - "add-pr-reviewers", + "add-pull-request-reviewers", serde_json::json!({"allowed-reviewers": ["*"], "max-reviewers": 3, "legacy-update-pr": legacy}), ); let mut result: AddPrReviewersResult = serde_json::from_value(serde_json::json!({ - "name": "add-pr-reviewers", "pull_request_id": "#aw_pr123", + "name": "add-pull-request-reviewers", "pull_request_id": "#aw_pr123", "reviewers": ["forbidden", "second"] })) .unwrap(); diff --git a/src/safe_outputs/mod.rs b/src/safe_outputs/mod.rs index 78f641538..2daa889c0 100644 --- a/src/safe_outputs/mod.rs +++ b/src/safe_outputs/mod.rs @@ -568,7 +568,7 @@ pub(crate) fn resolve_repository_write_target( /// /// `repository` may be **either** a raw agent-supplied selector or an alias /// already canonicalized by [`canonical_repository_alias`]; both are supported -/// because that helper is idempotent. `add-pr-comment` passes the raw value +/// because that helper is idempotent. `add-pull-request-comment` passes the raw value /// straight from the agent, while `create-pull-request` canonicalizes first so /// it can reuse the alias for target-branch resolution. Callers must not build /// the path themselves — routing every selector through here is what keeps @@ -862,6 +862,7 @@ pub use set_pr_auto_complete::*; pub use submit_pr_review::*; pub use unassign_github_issue_from_user::*; pub use update_github_issue::*; +#[cfg(test)] pub use update_pr::*; pub use update_pull_request::*; pub use update_wiki_page::*; diff --git a/src/safe_outputs/pr_common.rs b/src/safe_outputs/pr_common.rs index 6dd7fb019..64a00ba14 100644 --- a/src/safe_outputs/pr_common.rs +++ b/src/safe_outputs/pr_common.rs @@ -303,7 +303,7 @@ pub(crate) mod tests { fn temporary_reference_checks_exact_target_not_only_alias() { let mut ctx = registered_context( "https://dev.azure.com/other-org", - "add-pr-labels", + "add-pull-request-labels", serde_json::json!({}), ); ctx.allowed_repositories @@ -329,23 +329,23 @@ pub(crate) mod tests { fn legacy_metadata_is_trusted_config_only_and_fail_closed() { let mut ctx = ExecutionContext::default(); ctx.tool_configs.insert( - "add-pr-labels".into(), + "add-pull-request-labels".into(), serde_json::json!({ "legacy-update-pr": {"allowed-operations": ["vote"]} }), ); - assert!(legacy_policy(&ctx, "add-pr-labels", "add-labels").is_err()); + assert!(legacy_policy(&ctx, "add-pull-request-labels", "add-labels").is_err()); ctx.tool_configs.insert( - "add-pr-labels".into(), + "add-pull-request-labels".into(), serde_json::json!({ "legacy-update-pr": null }), ); - assert!(legacy_policy(&ctx, "add-pr-labels", "add-labels").is_err()); + assert!(legacy_policy(&ctx, "add-pull-request-labels", "add-labels").is_err()); ctx.tool_configs - .insert("add-pr-labels".into(), serde_json::json!({})); + .insert("add-pull-request-labels".into(), serde_json::json!({})); assert!( - legacy_policy(&ctx, "add-pr-labels", "add-labels") + legacy_policy(&ctx, "add-pull-request-labels", "add-labels") .unwrap() .is_none() ); diff --git a/src/safe_outputs/pr_mutations.rs b/src/safe_outputs/pr_mutations.rs index 34e339fbe..488e3793f 100644 --- a/src/safe_outputs/pr_mutations.rs +++ b/src/safe_outputs/pr_mutations.rs @@ -1,13 +1,19 @@ -//! Shared Azure DevOps PR mutations and historical `update-pr` execution. +//! Shared Azure DevOps PR mutations and legacy configuration validation. pub use super::pr_common::PullRequestReference; -use super::pr_common::{repository_api_base, resolve_pr_target}; +use super::pr_common::repository_api_base; +#[cfg(test)] +use super::pr_common::resolve_pr_target; use super::result::AdoRepositoryTarget; -use crate::safe_outputs::{ExecutionContext, ExecutionResult, Executor, Validate}; +#[cfg(test)] +use crate::safe_outputs::ExecutionContext; +use crate::safe_outputs::{ExecutionResult, Validate}; +#[cfg(test)] use crate::sanitize::{SanitizeContent, sanitize as sanitize_text, sanitize_config}; use crate::secure::Guid; #[cfg(test)] use crate::secure::PullRequestTemporaryId; +#[cfg(test)] use crate::tool_result; use crate::validate::reject_pipeline_injection; use ado_aw_derive::SanitizeConfig; @@ -39,18 +45,6 @@ const VALID_MERGE_STRATEGIES: &[&str] = &["squash", "noFastForward", "rebase", " const DEFAULT_MAX_REVIEWERS: usize = 3; const MAX_REVIEWER_LEN: usize = 256; -/// Map a vote string to its ADO numeric value -fn vote_to_ado_value(vote: &str) -> Option { - match vote { - "approve" => Some(10), - "approve-with-suggestions" => Some(5), - "wait-for-author" => Some(-5), - "reject" => Some(-10), - "reset" => Some(0), - _ => None, - } -} - /// Parameters for updating a pull request #[derive(Deserialize, JsonSchema)] pub struct UpdatePrParams { @@ -152,6 +146,7 @@ impl Validate for UpdatePrParams { } } +#[cfg(test)] tool_result! { name = "update-pr", write = true, @@ -168,6 +163,7 @@ tool_result! { } } +#[cfg(test)] impl SanitizeContent for UpdatePrResult { fn sanitize_content_fields(&mut self) { self.repository = self.repository.as_deref().map(sanitize_config); @@ -279,6 +275,7 @@ impl UpdatePrContext<'_> { } } +#[cfg(test)] fn resolve_update_pr_target( reference: &PullRequestReference, requested_repository: Option<&str>, @@ -293,109 +290,6 @@ fn resolve_update_pr_target( ) } -#[async_trait::async_trait] -impl Executor for UpdatePrResult { - fn dry_run_summary(&self) -> String { - format!("{} on PR #{}", self.operation, self.pull_request_id) - } - - async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { - let params = UpdatePrParams { - pull_request_id: self.pull_request_id.clone(), - repository: self.repository.clone(), - operation: self.operation.clone(), - reviewers: self.reviewers.clone(), - labels: self.labels.clone(), - vote: self.vote.clone(), - description: self.description.clone(), - }; - if let Err(error) = params.validate() { - return Ok(ExecutionResult::failure(error.to_string())); - } - info!( - "Updating PR #{} — operation: {}", - self.pull_request_id, self.operation - ); - debug!( - "update-pr: pr_id={}, operation='{}'", - self.pull_request_id, self.operation - ); - - let token = ctx - .access_token - .as_ref() - .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; - let config: UpdatePrConfig = ctx.get_tool_config("update-pr")?; - debug!("Config: {:?}", config); - - // Validate operation against allowed-operations - if !config.allowed_operations.is_empty() - && !config.allowed_operations.contains(&self.operation) - { - return Ok(ExecutionResult::failure(format!( - "Operation '{}' is not in the allowed-operations list: [{}]", - self.operation, - config.allowed_operations.join(", ") - ))); - } - - let (pr_id, target) = match resolve_update_pr_target( - &self.pull_request_id, - self.repository.as_deref(), - &config, - ctx, - )? { - Ok(target) => target, - Err(failure) => return Ok(failure), - }; - debug!("Resolved PR target: {} #{}", target.display_name(), pr_id); - - let client = reqwest::Client::new(); - let operation_ctx = UpdatePrContext { - client: &client, - target, - pr_id, - token, - connection_type: ctx.write_connection_type, - }; - - match self.operation.as_str() { - "set-auto-complete" => execute_set_auto_complete(&operation_ctx, &config).await, - "vote" => self.execute_vote(&operation_ctx, &config).await, - "add-reviewers" => { - execute_add_reviewers( - &operation_ctx, - &config, - self.reviewers - .as_deref() - .context("reviewers are required")?, - ) - .await - } - "add-labels" => { - execute_add_labels( - &operation_ctx, - self.labels.as_deref().context("labels are required")?, - ) - .await - } - "update-description" => { - execute_update_description( - &operation_ctx, - self.description - .as_deref() - .context("description is required")?, - ) - .await - } - _ => Ok(ExecutionResult::failure(format!( - "Unknown operation: {}", - self.operation - ))), - } - } -} - /// Outcome of a single reviewer resolution + add attempt. enum ReviewerAddResult { Added, @@ -581,66 +475,6 @@ pub(crate) async fn execute_set_auto_complete( } } -impl UpdatePrResult { - /// Submit a vote on a pull request. - /// - /// Resolves the current user identity via `_apis/connectiondata`, then - /// PUTs the vote to the reviewers endpoint. - async fn execute_vote( - &self, - operation_ctx: &UpdatePrContext<'_>, - config: &UpdatePrConfig, - ) -> anyhow::Result { - let vote_str = self - .vote - .as_deref() - .context("vote value is required for vote operation")?; - - // Validate against allowed-votes — REQUIRED for vote operation. - // An empty allowed-votes list means the operator hasn't opted in, so reject. - if config.allowed_votes.is_empty() { - return Ok(ExecutionResult::failure( - "vote operation requires 'allowed-votes' to be configured in safe-outputs.update-pr. \ - This prevents agents from casting unrestricted votes (including approve). \ - Example:\n safe-outputs:\n update-pr:\n allowed-votes:\n - approve-with-suggestions\n - wait-for-author" - .to_string(), - )); - } - if !config.allowed_votes.contains(&vote_str.to_string()) { - return Ok(ExecutionResult::failure(format!( - "Vote '{}' is not in the allowed-votes list: [{}]", - vote_str, - config.allowed_votes.join(", ") - ))); - } - - let vote_value = vote_to_ado_value(vote_str).context(format!( - "Invalid vote value: '{}'. Must be one of: {}", - vote_str, - VALID_VOTES.join(", ") - ))?; - - if let Some(failure) = - super::submit_pr_review::execute_review_vote(operation_ctx, vote_str, vote_value) - .await? - { - return Ok(failure); - } - Ok(ExecutionResult::success_with_data( - format!( - "Vote '{}' submitted on PR #{}", - vote_str, operation_ctx.pr_id - ), - serde_json::json!({ - "pull_request_id": operation_ctx.pr_id, - "operation": "vote", - "vote": vote_str, - "vote_value": vote_value, - }), - )) - } -} - /// Add reviewers to a pull request. /// /// Resolves and verifies each reviewer identity via VSSPS, then PUTs to the diff --git a/src/safe_outputs/reply_to_pr_comment.rs b/src/safe_outputs/reply_to_pr_comment.rs index bf54af9cd..38c167a64 100644 --- a/src/safe_outputs/reply_to_pr_comment.rs +++ b/src/safe_outputs/reply_to_pr_comment.rs @@ -51,7 +51,7 @@ impl Validate for ReplyToPrCommentParams { } tool_result! { - name = "reply-to-pr-comment", + name = "reply-to-pull-request-comment", write = true, params = ReplyToPrCommentParams, /// Result of replying to a review comment thread on a pull request @@ -70,12 +70,12 @@ impl SanitizeContent for ReplyToPrCommentResult { } } -/// Configuration for the reply-to-pr-comment tool (specified in front matter) +/// Configuration for the reply-to-pull-request-comment tool (specified in front matter) /// /// Example front matter: /// ```yaml /// safe-outputs: -/// reply-to-pr-comment: +/// reply-to-pull-request-comment: /// comment-prefix: "[Agent] " /// allowed-repositories: /// - self @@ -110,7 +110,7 @@ impl Executor for ReplyToPrCommentResult { self.content.len() ); debug!( - "reply-to-pr-comment: pr_id={}, thread_id={}", + "reply-to-pull-request-comment: pr_id={}, thread_id={}", self.pull_request_id, self.thread_id ); @@ -128,7 +128,8 @@ impl Executor for ReplyToPrCommentResult { .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; debug!("ADO org: {}, project: {}", org_url, project); - let config: ReplyToPrCommentConfig = ctx.get_tool_config("reply-to-pr-comment")?; + let config: ReplyToPrCommentConfig = + ctx.get_tool_config("reply-to-pull-request-comment")?; debug!("Config: {:?}", config); let repository = self.repository.as_deref().unwrap_or("self"); @@ -270,7 +271,7 @@ mod tests { repository: Some("self".to_string()), }; let result: ReplyToPrCommentResult = params.try_into().unwrap(); - assert_eq!(result.name, "reply-to-pr-comment"); + assert_eq!(result.name, "reply-to-pull-request-comment"); assert_eq!(result.pull_request_id, 42); assert_eq!(result.thread_id, 7); assert_eq!( @@ -359,7 +360,7 @@ mod tests { let result: ReplyToPrCommentResult = params.try_into().unwrap(); let json = serde_json::to_string(&result).unwrap(); - assert!(json.contains(r#""name":"reply-to-pr-comment""#)); + assert!(json.contains(r#""name":"reply-to-pull-request-comment""#)); assert!(json.contains(r#""pull_request_id":42"#)); assert!(json.contains(r#""thread_id":7"#)); } @@ -387,7 +388,7 @@ allowed-repositories: #[test] fn test_sanitize_content_neutralizes_repository_pipeline_command() { let mut result = ReplyToPrCommentResult { - name: "reply-to-pr-comment".to_string(), + name: "reply-to-pull-request-comment".to_string(), pull_request_id: 42, thread_id: 7, content: "This is a valid reply body text.".to_string(), diff --git a/src/safe_outputs/resolve_pr_thread.rs b/src/safe_outputs/resolve_pr_thread.rs index 671d730f8..c5d5abec7 100644 --- a/src/safe_outputs/resolve_pr_thread.rs +++ b/src/safe_outputs/resolve_pr_thread.rs @@ -76,7 +76,7 @@ impl Validate for ResolvePrThreadParams { } tool_result! { - name = "resolve-pr-thread", + name = "resolve-pull-request-thread", write = true, params = ResolvePrThreadParams, /// Result of resolving or reactivating a PR review thread @@ -97,12 +97,12 @@ impl SanitizeContent for ResolvePrThreadResult { } } -/// Configuration for the resolve-pr-thread tool (specified in front matter) +/// Configuration for the resolve-pull-request-thread tool (specified in front matter) /// /// Example front matter: /// ```yaml /// safe-outputs: -/// resolve-pr-thread: +/// resolve-pull-request-thread: /// allowed-repositories: /// - self /// - other-repo @@ -138,7 +138,7 @@ impl Executor for ResolvePrThreadResult { self.thread_id, self.pull_request_id, self.status ); debug!( - "resolve-pr-thread: pr_id={}, thread_id={}, status='{}'", + "resolve-pull-request-thread: pr_id={}, thread_id={}, status='{}'", self.pull_request_id, self.thread_id, self.status ); @@ -156,7 +156,7 @@ impl Executor for ResolvePrThreadResult { .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; debug!("ADO org: {}, project: {}", org_url, project); - let config: ResolvePrThreadConfig = ctx.get_tool_config("resolve-pr-thread")?; + let config: ResolvePrThreadConfig = ctx.get_tool_config("resolve-pull-request-thread")?; debug!("Config: {:?}", config); // Validate status against allowed-statuses — REQUIRED. @@ -165,10 +165,10 @@ impl Executor for ResolvePrThreadResult { // concerns as "fixed") without explicit operator consent. if config.allowed_statuses.is_empty() { return Ok(ExecutionResult::failure( - "resolve-pr-thread requires 'allowed-statuses' to be configured in \ - safe-outputs.resolve-pr-thread. This prevents agents from \ + "resolve-pull-request-thread requires 'allowed-statuses' to be configured in \ + safe-outputs.resolve-pull-request-thread. This prevents agents from \ manipulating thread statuses without explicit operator consent. Example:\n \ - safe-outputs:\n resolve-pr-thread:\n allowed-statuses:\n \ + safe-outputs:\n resolve-pull-request-thread:\n allowed-statuses:\n \ - fixed\n\nValid statuses: active, fixed, wont-fix, closed, by-design" .to_string(), )); @@ -309,7 +309,7 @@ mod tests { repository: Some("self".to_string()), }; let result: ResolvePrThreadResult = params.try_into().unwrap(); - assert_eq!(result.name, "resolve-pr-thread"); + assert_eq!(result.name, "resolve-pull-request-thread"); assert_eq!(result.pull_request_id, 42); assert_eq!(result.thread_id, 7); assert_eq!(result.status, "fixed"); @@ -392,7 +392,7 @@ mod tests { let result: ResolvePrThreadResult = params.try_into().unwrap(); let json = serde_json::to_string(&result).unwrap(); - assert!(json.contains(r#""name":"resolve-pr-thread""#)); + assert!(json.contains(r#""name":"resolve-pull-request-thread""#)); assert!(json.contains(r#""pull_request_id":42"#)); assert!(json.contains(r#""thread_id":7"#)); } diff --git a/src/safe_outputs/set_pr_auto_complete.rs b/src/safe_outputs/set_pr_auto_complete.rs index 308cf2db9..8d9faf927 100644 --- a/src/safe_outputs/set_pr_auto_complete.rs +++ b/src/safe_outputs/set_pr_auto_complete.rs @@ -29,7 +29,7 @@ impl Validate for SetPrAutoCompleteParams { } } tool_result! { - name = "set-pr-auto-complete", + name = "set-pull-request-auto-complete", write = true, params = SetPrAutoCompleteParams, pub struct SetPrAutoCompleteResult { @@ -107,10 +107,12 @@ impl Executor for SetPrAutoCompleteResult { return Ok(ExecutionResult::failure(error.to_string())); } ensure!( - ctx.tool_configs.contains_key("set-pr-auto-complete"), - "set-pr-auto-complete is not configured" + ctx.tool_configs + .contains_key("set-pull-request-auto-complete"), + "set-pull-request-auto-complete is not configured" ); - let config: SetPrAutoCompleteConfig = ctx.get_tool_config("set-pr-auto-complete")?; + let config: SetPrAutoCompleteConfig = + ctx.get_tool_config("set-pull-request-auto-complete")?; validate_set_pr_auto_complete_config(&config)?; let policy = UpdatePrConfig { allowed_repositories: config.allowed_repositories, @@ -127,7 +129,7 @@ impl Executor for SetPrAutoCompleteResult { Ok(target) => target, Err(failure) => return Ok(failure), }; - let legacy = legacy_policy(ctx, "set-pr-auto-complete", "set-auto-complete")?; + let legacy = legacy_policy(ctx, "set-pull-request-auto-complete", "set-auto-complete")?; if let Some(legacy) = &legacy && let Err(failure) = resolve_pr_target( &self.pull_request_id, @@ -232,7 +234,7 @@ mod tests { .await; let mut ctx = super::super::pr_common::tests::registered_context( &server.uri(), - "set-pr-auto-complete", + "set-pull-request-auto-complete", serde_json::json!({ "delete-source-branch": false, "merge-strategy": "rebase", "legacy-update-pr": {"delete-source-branch": false, "merge-strategy": "rebase"} @@ -240,7 +242,7 @@ mod tests { ); ctx.write_connection_type = Some(crate::compile::types::WriteConnectionType::AzureDevOps); let mut result: SetPrAutoCompleteResult = serde_json::from_value(serde_json::json!({ - "name": "set-pr-auto-complete", "pull_request_id": "#aw_pr123" + "name": "set-pull-request-auto-complete", "pull_request_id": "#aw_pr123" })) .unwrap(); assert!(result.execute_sanitized(&ctx).await.unwrap().success); diff --git a/src/safe_outputs/submit_pr_review.rs b/src/safe_outputs/submit_pr_review.rs index 4d1e33c62..2450ad914 100644 --- a/src/safe_outputs/submit_pr_review.rs +++ b/src/safe_outputs/submit_pr_review.rs @@ -17,7 +17,7 @@ use crate::tool_result; use crate::validate::reject_pipeline_injection; use anyhow::{Context, ensure}; -/// Valid event values for submit-pr-review +/// Valid event values for submit-pull-request-review const VALID_EVENTS: &[&str] = &[ "approve", "approve-with-suggestions", @@ -86,7 +86,7 @@ impl Validate for SubmitPrReviewParams { } tool_result! { - name = "submit-pr-review", + name = "submit-pull-request-review", write = true, params = SubmitPrReviewParams, /// Result of submitting a pull request review @@ -106,12 +106,12 @@ impl SanitizeContent for SubmitPrReviewResult { } } -/// Configuration for the submit-pr-review tool (specified in front matter) +/// Configuration for the submit-pull-request-review tool (specified in front matter) /// /// Example front matter: /// ```yaml /// safe-outputs: -/// submit-pr-review: +/// submit-pull-request-review: /// allowed-events: /// - approve /// - comment @@ -139,7 +139,7 @@ pub(crate) fn validate_submit_pr_review_config( for event in &config.allowed_events { ensure!( VALID_EVENTS.contains(&event.as_str()), - "unknown submit-pr-review event '{event}'" + "unknown submit-pull-request-review event '{event}'" ); } for repository in &config.allowed_repositories { @@ -351,7 +351,7 @@ async fn post_review_comment_thread( Ok(Ok(thread_id)) } -/// Sole vote mutation implementation, also used by historical `update-pr` records. +/// Sole vote mutation implementation for pull-request reviews. pub(crate) async fn execute_review_vote( ctx: &UpdatePrContext<'_>, event: &str, @@ -390,7 +390,7 @@ impl Executor for SubmitPrReviewResult { self.pull_request_id, self.event ); debug!( - "submit-pr-review: pr_id={}, event='{}'", + "submit-pull-request-review: pr_id={}, event='{}'", self.pull_request_id, self.event ); @@ -408,16 +408,16 @@ impl Executor for SubmitPrReviewResult { .access_token .as_ref() .context("No access token available (SYSTEM_ACCESSTOKEN or AZURE_DEVOPS_EXT_PAT)")?; - let config: SubmitPrReviewConfig = ctx.get_tool_config("submit-pr-review")?; + let config: SubmitPrReviewConfig = ctx.get_tool_config("submit-pull-request-review")?; validate_submit_pr_review_config(&config)?; if matches!(self.pull_request_id, PullRequestReference::Temporary(_)) && !config.allow_temporary_ids { return Ok(ExecutionResult::failure( - "submit-pr-review temporary IDs require allow-temporary-ids: true", + "submit-pull-request-review temporary IDs require allow-temporary-ids: true", )); } - let legacy = legacy_policy(ctx, "submit-pr-review", "vote")?; + let legacy = legacy_policy(ctx, "submit-pull-request-review", "vote")?; if let Some(legacy) = &legacy { if self.body.is_some() { return Ok(ExecutionResult::failure( @@ -436,9 +436,9 @@ impl Executor for SubmitPrReviewResult { // An empty allowed-events list means the operator hasn't opted in, so reject. if config.allowed_events.is_empty() { return Ok(ExecutionResult::failure( - "submit-pr-review requires 'allowed-events' to be configured in \ - safe-outputs.submit-pr-review. This prevents agents from casting \ - unrestricted review votes. Example:\n safe-outputs:\n submit-pr-review:\n \ + "submit-pull-request-review requires 'allowed-events' to be configured in \ + safe-outputs.submit-pull-request-review. This prevents agents from casting \ + unrestricted review votes. Example:\n safe-outputs:\n submit-pull-request-review:\n \ allowed-events:\n - comment\n - approve-with-suggestions" .to_string(), )); @@ -535,7 +535,7 @@ mod tests { #[test] fn test_result_has_correct_name() { - assert_eq!(SubmitPrReviewResult::NAME, "submit-pr-review"); + assert_eq!(SubmitPrReviewResult::NAME, "submit-pull-request-review"); } #[test] @@ -557,7 +557,7 @@ mod tests { repository: Some("self".to_string()), }; let result: SubmitPrReviewResult = params.try_into().unwrap(); - assert_eq!(result.name, "submit-pr-review"); + assert_eq!(result.name, "submit-pull-request-review"); assert_eq!(result.pull_request_id, PullRequestReference::Number(42)); assert_eq!(result.event, "approve"); } @@ -635,7 +635,7 @@ mod tests { let result: SubmitPrReviewResult = params.try_into().unwrap(); let json = serde_json::to_string(&result).unwrap(); - assert!(json.contains(r#""name":"submit-pr-review""#)); + assert!(json.contains(r#""name":"submit-pull-request-review""#)); assert!(json.contains(r#""pull_request_id":99"#)); assert!(json.contains(r#""event":"request-changes""#)); } @@ -675,11 +675,11 @@ mod tests { let server = wiremock::MockServer::start().await; let ctx = super::super::pr_common::tests::registered_context( &server.uri(), - "submit-pr-review", + "submit-pull-request-review", serde_json::json!({"allowed-events": ["reset"]}), ); let mut result: SubmitPrReviewResult = serde_json::from_value(serde_json::json!({ - "name": "submit-pr-review", "pull_request_id": "#aw_pr123", "event": "reset" + "name": "submit-pull-request-review", "pull_request_id": "#aw_pr123", "event": "reset" })) .unwrap(); let execution = result.execute_sanitized(&ctx).await.unwrap(); @@ -722,11 +722,11 @@ mod tests { .expect(1).mount(&server).await; let ctx = super::super::pr_common::tests::registered_context( &server.uri(), - "submit-pr-review", + "submit-pull-request-review", serde_json::json!({"allowed-events": ["comment"], "allow-temporary-ids": true}), ); let mut result: SubmitPrReviewResult = serde_json::from_value(serde_json::json!({ - "name": "submit-pr-review", "pull_request_id": "#aw_pr123", "event": "comment", + "name": "submit-pull-request-review", "pull_request_id": "#aw_pr123", "event": "comment", "body": "Reviewed without objection." })) .unwrap(); @@ -765,7 +765,7 @@ mod tests { .await; let mut ctx = super::super::pr_common::tests::registered_context( &server.uri(), - "submit-pr-review", + "submit-pull-request-review", serde_json::json!({ "allowed-events": [event], "allow-temporary-ids": true, "legacy-update-pr": {"allowed-votes": [event]} @@ -774,7 +774,7 @@ mod tests { ctx.write_connection_type = Some(crate::compile::types::WriteConnectionType::AzureDevOps); let mut result: SubmitPrReviewResult = serde_json::from_value(serde_json::json!({ - "name": "submit-pr-review", "pull_request_id": "#aw_pr123", "event": event + "name": "submit-pull-request-review", "pull_request_id": "#aw_pr123", "event": event })) .unwrap(); let execution = result.execute_sanitized(&ctx).await.unwrap(); @@ -793,14 +793,14 @@ mod tests { ] { let ctx = super::super::pr_common::tests::registered_context( &server.uri(), - "submit-pr-review", + "submit-pull-request-review", serde_json::json!({ "allowed-events": ["reset"], "allow-temporary-ids": true, "legacy-update-pr": {"allowed-votes": allowed_votes} }), ); let mut result: SubmitPrReviewResult = serde_json::from_value(serde_json::json!({ - "name": "submit-pr-review", "pull_request_id": "#aw_pr123", "event": "reset", "body": body + "name": "submit-pull-request-review", "pull_request_id": "#aw_pr123", "event": "reset", "body": body })).unwrap(); assert!(!result.execute_sanitized(&ctx).await.unwrap().success); } @@ -837,11 +837,11 @@ mod tests { .await; let ctx = super::super::pr_common::tests::registered_context( &server.uri(), - "submit-pr-review", + "submit-pull-request-review", serde_json::json!({"allowed-events": ["approve"], "allow-temporary-ids": true}), ); let mut result: SubmitPrReviewResult = serde_json::from_value(serde_json::json!({ - "name": "submit-pr-review", "pull_request_id": "#aw_pr123", "event": "approve" + "name": "submit-pull-request-review", "pull_request_id": "#aw_pr123", "event": "approve" })) .unwrap(); assert!(!result.execute_sanitized(&ctx).await.unwrap().success); diff --git a/src/safe_outputs/update_pr.rs b/src/safe_outputs/update_pr.rs index e41c3c9c0..000af0a94 100644 --- a/src/safe_outputs/update_pr.rs +++ b/src/safe_outputs/update_pr.rs @@ -1,5 +1,7 @@ -//! Historical `update-pr` proposal compatibility. New workflows use focused tools. +//! Legacy configuration validation used by the source migration. #[allow(unused_imports)] pub use super::pr_common::PullRequestReference; -pub use super::pr_mutations::{UpdatePrConfig, UpdatePrParams, UpdatePrResult}; +#[cfg(test)] +pub use super::pr_mutations::UpdatePrResult; +pub use super::pr_mutations::{UpdatePrConfig, UpdatePrParams}; diff --git a/tests/codemod_tests.rs b/tests/codemod_tests.rs index 5592452f9..67f5120e3 100644 --- a/tests/codemod_tests.rs +++ b/tests/codemod_tests.rs @@ -100,7 +100,7 @@ fn compile_migrates_pr_tools_and_keeps_prompt_warning_until_fixed() { assert!(fm["safe-outputs"]["update-pr"].is_null()); assert_eq!(fm["safe-outputs"]["budget-groups"]["update-pr"]["max"], 1); assert!(String::from_utf8_lossy(&first.stderr).contains("deprecated-tool-reference")); - assert!(String::from_utf8_lossy(&first.stderr).contains("add-pr-reviewers")); + assert!(String::from_utf8_lossy(&first.stderr).contains("add-pull-request-reviewers")); let second = run_compile(&source); assert!(second.status.success()); assert_eq!(fs::read_to_string(&source).unwrap(), after); @@ -129,6 +129,63 @@ fn conflicting_pr_migration_does_not_rewrite_source_or_lock() { assert!(!source.with_extension("lock.yml").exists()); } +#[test] +fn compile_expands_all_pr_tool_names_and_budget_members_without_body_edits() { + let dir = fresh_git_temp_dir(); + let original = "---\nname: full-names\ndescription: d\nsafe-outputs:\n require-approval: true\n staged: true\n add-pr-comment: {max: 2}\n reply-to-pr-comment: {max: 2}\n resolve-pr-thread: {allowed-statuses: [fixed], max: 2}\n submit-pr-review: {allowed-events: [comment], max: 2}\n add-pr-reviewers: {allowed-reviewers: [owner@example.test], max-reviewers: 1, max: 2}\n add-pr-labels: {max: 2}\n set-pr-auto-complete: {max: 2}\n budget-groups:\n shared: {max: 1, tools: [add-pr-reviewers, add-pr-labels]}\n---\nCall `add-pr-comment` and `submit-pr-review`.\n"; + let source = write_source(dir.path(), original); + let output = run_compile(&source); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let rewritten = fs::read_to_string(&source).unwrap(); + assert!(rewritten.ends_with("\nCall `add-pr-comment` and `submit-pr-review`.\n")); + let fm: serde_yaml::Value = + serde_yaml::from_str(rewritten.split("---").nth(1).unwrap()).unwrap(); + for (old, new) in [ + ("add-pr-comment", "add-pull-request-comment"), + ("reply-to-pr-comment", "reply-to-pull-request-comment"), + ("resolve-pr-thread", "resolve-pull-request-thread"), + ("submit-pr-review", "submit-pull-request-review"), + ("add-pr-reviewers", "add-pull-request-reviewers"), + ("add-pr-labels", "add-pull-request-labels"), + ("set-pr-auto-complete", "set-pull-request-auto-complete"), + ] { + assert!(fm["safe-outputs"][old].is_null()); + assert_eq!(fm["safe-outputs"][new]["max"], 2); + } + assert_eq!(fm["safe-outputs"]["budget-groups"]["shared"]["max"], 1); + assert_eq!( + fm["safe-outputs"]["budget-groups"]["shared"]["tools"][0], + "add-pull-request-reviewers" + ); + assert_eq!( + fm["safe-outputs"]["budget-groups"]["shared"]["tools"][1], + "add-pull-request-labels" + ); + assert!(String::from_utf8_lossy(&output.stderr).contains("pull_request_tool_names")); + assert!(String::from_utf8_lossy(&output.stderr).contains("deprecated-tool-reference")); + assert!(run_compile(&source).status.success()); + assert_eq!(fs::read_to_string(&source).unwrap(), rewritten); +} + +#[test] +fn abbreviated_and_full_pr_keys_conflict_without_rewriting() { + let dir = fresh_git_temp_dir(); + let original = "---\nname: conflict\ndescription: d\nsafe-outputs:\n add-pr-comment: {max: 1}\n add-pull-request-comment: {max: 3}\n---\nbody\n"; + let source = write_source(dir.path(), original); + let output = run_compile(&source); + assert!(!output.status.success()); + let error = String::from_utf8_lossy(&output.stderr); + assert!(error.contains("manual migration required")); + assert!(error.contains("add-pr-comment")); + assert!(error.contains("add-pull-request-comment")); + assert_eq!(fs::read_to_string(&source).unwrap(), original); + assert!(!source.with_extension("lock.yml").exists()); +} + // ─── Legacy directory marker migration (codemod 0004) ────────────────────── #[test] diff --git a/tests/compiler_tests.rs b/tests/compiler_tests.rs index 04ad4f065..8370d6a61 100644 --- a/tests/compiler_tests.rs +++ b/tests/compiler_tests.rs @@ -1474,7 +1474,7 @@ fn test_compile_auto_discover_skips_missing_source() { let _ = fs::remove_dir_all(&temp_dir); } -/// Test that submit-pr-review fails compilation when allowed-events is missing +/// Test that submit-pull-request-review fails compilation when allowed-events is missing #[test] fn test_submit_pr_review_requires_allowed_events() { let temp_dir = std::env::temp_dir().join(format!( @@ -1490,7 +1490,7 @@ description: "Agent that submits PR reviews but has no allowed-events" permissions: write: my-write-sc safe-outputs: - submit-pr-review: + submit-pull-request-review: allowed-repositories: - self --- @@ -1515,7 +1515,7 @@ Submit PR reviews. assert!( !output.status.success(), - "Compiler should fail when submit-pr-review lacks allowed-events" + "Compiler should fail when submit-pull-request-review lacks allowed-events" ); let stderr = String::from_utf8_lossy(&output.stderr); @@ -1527,7 +1527,7 @@ Submit PR reviews. let _ = fs::remove_dir_all(&temp_dir); } -/// Test that submit-pr-review fails compilation when allowed-events is an empty list +/// Test that submit-pull-request-review fails compilation when allowed-events is an empty list #[test] fn test_submit_pr_review_requires_nonempty_allowed_events() { let temp_dir = @@ -1541,7 +1541,7 @@ description: "Agent that submits PR reviews but has empty allowed-events" permissions: write: my-write-sc safe-outputs: - submit-pr-review: + submit-pull-request-review: allowed-events: [] --- @@ -1565,7 +1565,7 @@ Submit PR reviews. assert!( !output.status.success(), - "Compiler should fail when submit-pr-review has empty allowed-events" + "Compiler should fail when submit-pull-request-review has empty allowed-events" ); let stderr = String::from_utf8_lossy(&output.stderr); @@ -1577,7 +1577,7 @@ Submit PR reviews. let _ = fs::remove_dir_all(&temp_dir); } -/// Test that submit-pr-review compiles successfully with proper config +/// Test that submit-pull-request-review compiles successfully with proper config #[test] fn test_submit_pr_review_compiles_with_allowed_events() { let temp_dir = @@ -1591,7 +1591,7 @@ description: "Agent that submits PR reviews with proper config" permissions: write: my-write-sc safe-outputs: - submit-pr-review: + submit-pull-request-review: allowed-events: - comment - approve-with-suggestions @@ -1617,16 +1617,16 @@ Submit PR reviews. assert!( output.status.success(), - "Compiler should succeed with proper submit-pr-review config: {}", + "Compiler should succeed with proper submit-pull-request-review config: {}", String::from_utf8_lossy(&output.stderr) ); let compiled = fs::read_to_string(&output_path).expect("Should read compiled YAML"); - // The submit-pr-review tool must be listed as an enabled tool for the agent + // The submit-pull-request-review tool must be listed as an enabled tool for the agent assert!( - compiled_has_enabled_tool(&compiled, "submit-pr-review"), - "Compiled output should contain --enabled-tools submit-pr-review" + compiled_has_enabled_tool(&compiled, "submit-pull-request-review"), + "Compiled output should contain --enabled-tools submit-pull-request-review" ); // Stage 3 write token must be acquired for the executor assert!( @@ -1933,14 +1933,20 @@ Vote on pull requests. !compiled_has_enabled_tool(&compiled, "update-pr"), "legacy catch-all must not be advertised" ); - for tool in ["add-pr-reviewers", "set-pr-auto-complete"] { + for tool in [ + "add-pull-request-reviewers", + "set-pull-request-auto-complete", + ] { assert!( compiled_has_enabled_tool(&compiled, tool), "{case_desc}: missing {tool}" ); } if case_desc.contains("vote reachable") { - assert!(compiled_has_enabled_tool(&compiled, "submit-pr-review")); + assert!(compiled_has_enabled_tool( + &compiled, + "submit-pull-request-review" + )); assert!(compiled_has_enabled_tool(&compiled, "update-pull-request")); } let migrated_source = fs::read_to_string(&test_input).unwrap(); @@ -8086,7 +8092,7 @@ safe-outputs: assert!(!ok, "{label} max-reviewers should not compile"); assert!( stderr.contains( - "safe-outputs.update-pr.max-reviewers must be a positive integer that fits in usize" + "safe-outputs.add-pull-request-reviewers.max-reviewers must be a positive integer that fits in usize" ), "{label}: {stderr}" ); @@ -8107,6 +8113,7 @@ safe-outputs: let (ok, _, stderr) = compile_inline_source("update-pr-max-reviewers-overflow", overflow); assert!(!ok, "overflow max-reviewers should not compile"); assert!( + // YAML overflows fail before the tool-name migration can run. stderr.contains("safe-outputs.update-pr.max-reviewers"), "overflow: {stderr}" ); @@ -9111,7 +9118,7 @@ safe-outputs: create-pull-request: target-branch: main require-approval: true - add-pr-comment: {} + add-pull-request-comment: {} --- ## Body @@ -9160,7 +9167,7 @@ safe-outputs: create-pull-request: target-branch: main require-approval: true - add-pr-comment: {} + add-pull-request-comment: {} --- ## Body @@ -9190,7 +9197,7 @@ safe-outputs: create-pull-request: target-branch: main require-approval: true - add-pr-comment: {} + add-pull-request-comment: {} teardown: - script: echo "cleanup" displayName: "Cleanup" @@ -9237,7 +9244,7 @@ safe-outputs: create-pull-request: target-branch: main require-approval: true - add-pr-comment: {} + add-pull-request-comment: {} --- ## Body @@ -9457,7 +9464,7 @@ safe-outputs: create-work-item: require-approval: instructions: "Check the work-item priority and area path." - add-pr-comment: + add-pull-request-comment: require-approval: true --- @@ -9467,7 +9474,7 @@ safe-outputs: assert!(ok, "multi-tool approval pipeline should compile: {stderr}"); // Every reviewed tool is enumerated in the gate message. assert!( - compiled.contains("add-pr-comment, create-pull-request, create-work-item"), + compiled.contains("add-pull-request-comment, create-pull-request, create-work-item"), "gate message must list every reviewed tool:\n{compiled}" ); // BOTH distinct author notes are present — not just the first. diff --git a/tests/executor-e2e/README.md b/tests/executor-e2e/README.md index b44908f90..b5a24565b 100644 --- a/tests/executor-e2e/README.md +++ b/tests/executor-e2e/README.md @@ -46,8 +46,8 @@ gitignored, non-root path) and is **deliberately excluded** from the released The PR family includes focused content updates (including the exact 4,000-character description boundary), abandonment with comment verification, and create-then-follow-up reviewer/content scenarios. Reviewer handoffs use -`add-pr-reviewers`; content handoffs use `update-pull-request`. A legacy -`update-pr` scenario remains for historical-record and config-migration coverage. +`add-pull-request-reviewers`; content handoffs use `update-pull-request`. Old +configuration names are covered by compiler migration tests, not runtime aliases. The compiled `pr-tools-preview` smoke exercises focused MCP discovery and staged proposal schemas without writing to an existing PR; live executor scenarios are responsible for checking actual service mutations. @@ -90,8 +90,8 @@ All deterministically-assertable ADO-write safe outputs plus the flagship two rendering-fidelity scenarios (see [Rendering fidelity](#rendering-fidelity) below) - **Wiki:** `create-wiki-page`, `update-wiki-page` -- **PR:** `add-pr-comment`, `reply-to-pr-comment`, `resolve-pr-thread`, - `submit-pr-review`, `update-pr` +- **PR:** `add-pull-request-comment`, `reply-to-pull-request-comment`, `resolve-pull-request-thread`, + `submit-pull-request-review`, `update-pull-request` - **Git:** `create-branch`, `create-git-tag` - **Cross-org Git (optional infrastructure):** `create-branch-cross-org`, `create-git-tag-cross-org`, and `create-pull-request-cross-org` diff --git a/tests/fixtures/manual-review-agent.md b/tests/fixtures/manual-review-agent.md index cbcd06755..cf209ad16 100644 --- a/tests/fixtures/manual-review-agent.md +++ b/tests/fixtures/manual-review-agent.md @@ -6,7 +6,7 @@ on: safe-outputs: require-approval: true create-pull-request: {} - add-pr-comment: + add-pull-request-comment: require-approval: false --- diff --git a/tests/safe-outputs/pr-tools-preview.md b/tests/safe-outputs/pr-tools-preview.md index ab2d2d25f..d324a3765 100644 --- a/tests/safe-outputs/pr-tools-preview.md +++ b/tests/safe-outputs/pr-tools-preview.md @@ -17,12 +17,12 @@ safe-outputs: abandon-pull-request: target: "*" include-stats: false - add-pr-labels: {} - add-pr-reviewers: + add-pull-request-labels: {} + add-pull-request-reviewers: allowed-reviewers: ["preview@example.test"] max-reviewers: 1 - set-pr-auto-complete: {} - submit-pr-review: + set-pull-request-auto-complete: {} + submit-pull-request-review: allowed-events: [reset] --- @@ -33,10 +33,10 @@ pull request. Emit exactly one proposal for each of the six tools below using the synthetic numeric pull_request_id `1` and repository `self`. 1. `update-pull-request`: body "Preview-only content update.", operation "replace". -2. `add-pr-labels`: labels ["preview"]. -3. `add-pr-reviewers`: reviewers ["preview@example.test"]. -4. `submit-pr-review`: event "reset". -5. `set-pr-auto-complete`: no additional fields. +2. `add-pull-request-labels`: labels ["preview"]. +3. `add-pull-request-reviewers`: reviewers ["preview@example.test"]. +4. `submit-pull-request-review`: event "reset". +5. `set-pull-request-auto-complete`: no additional fields. 6. `abandon-pull-request`: body "Preview-only abandonment." Stop after the six proposals. No catch-all PR update tool should be needed. From 68f75d72f2c23c741fc3ef3b361842451b5a83b8 Mon Sep 17 00:00:00 2001 From: James Devine Date: Thu, 24 Sep 2026 18:22:46 +0100 Subject: [PATCH 15/43] fix(safe-outputs): address PR identity and migration review findings Bind triggering PRs to trusted native or synthetic identity, normalize preview targets, preserve imported built-in policy precedence, and validate legacy vote/default migration. Verify expected-failure side effects and select the correct primary execution record. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: da8711de-7251-47cd-a006-6e4ece913b49 --- docs/ado-script.md | 19 +- docs/codemods.md | 6 + docs/execution-context.md | 14 + docs/imports.md | 35 + docs/safe-outputs.md | 28 +- .../__tests__/compiler-policy.test.ts | 67 ++ .../approval-summary/__tests__/index.test.ts | 49 +- .../approval-summary/__tests__/render.test.ts | 91 ++- .../ado-script/src/approval-summary/index.ts | 32 +- .../ado-script/src/approval-summary/render.ts | 137 +++- .../__tests__/harness.ts | 2 + .../__tests__/index.test.ts | 58 ++ .../src/exec-context-pr-synth/index.ts | 25 +- .../create-pull-request-scenarios.test.ts | 72 +- .../src/executor-e2e/__tests__/runner.test.ts | 168 ++++- .../src/executor-e2e/execute-cli.ts | 3 +- scripts/ado-script/src/executor-e2e/runner.ts | 30 +- .../ado-script/src/executor-e2e/scenario.ts | 7 + .../src/executor-e2e/scenarios/pr.ts | 5 +- .../src/shared/__tests__/ado-remote.test.ts | 15 + scripts/ado-script/src/shared/ado-remote.ts | 87 ++- src/compile/agentic_pipeline.rs | 485 +++++++++--- src/compile/codemods/0009_split_update_pr.rs | 24 +- .../codemods/0010_pull_request_tool_names.rs | 19 +- src/compile/common.rs | 84 ++- src/compile/extensions/ado_script.rs | 31 +- src/compile/imports/integration_tests.rs | 3 + src/compile/imports/merge.rs | 99 ++- src/compile/imports/mod.rs | 1 + src/compile/imports/pr_migration_tests.rs | 541 ++++++++++++++ src/compile/imports/pr_policy.rs | 196 +++++ src/compile/mod.rs | 115 +-- src/compile/pr_migration.rs | 138 +++- src/execute.rs | 25 + src/main.rs | 23 +- src/safe_outputs/abandon_pull_request.rs | 86 +-- src/safe_outputs/create_pull_request.rs | 1 + src/safe_outputs/pr_common.rs | 696 +++++++++++++++++- src/safe_outputs/pr_mutations.rs | 8 +- src/safe_outputs/result.rs | 3 + src/safe_outputs/update_pull_request.rs | 120 +-- src/safe_outputs/upload_build_attachment.rs | 1 + tests/codemod_tests.rs | 68 ++ tests/executor-e2e/README.md | 8 + 44 files changed, 3189 insertions(+), 536 deletions(-) create mode 100644 scripts/ado-script/src/approval-summary/__tests__/compiler-policy.test.ts create mode 100644 src/compile/imports/pr_migration_tests.rs create mode 100644 src/compile/imports/pr_policy.rs diff --git a/docs/ado-script.md b/docs/ado-script.md index 950846e1c..29a84e7c6 100644 --- a/docs/ado-script.md +++ b/docs/ado-script.md @@ -383,12 +383,19 @@ GitHub-typed repos return before any SDK load. ## Bundle env contract -The approval-summary bundle also receives `AW_PR_POLICIES`, a non-secret JSON -map of configured PR target/default-operation selectors. It uses the existing -`SYSTEM_PULLREQUEST_PULLREQUESTID` environment variable for triggering targets. -Missing target context is displayed as unresolved; same-run references identify -an earlier create proposal without inventing a real PR ID. This preview is not -an authorization decision. +The approval-summary bundle receives `AW_PR_POLICIES`, a non-secret map of +normalized target policies. Targets have an explicit kind (`fixed`, `explicit`, +or `triggering`); fixed IDs are decimal strings, preserving precision across +the Rust/Node boundary. Raw quoted numbers are not reinterpreted by the renderer. + +Native PR identity is captured from trusted build metadata separately from the +compiler's `self` checkout. Synthetic Setup resolution supplies +`ADO_AW_TRIGGERING_PR_IDENTITY`, carrying the selected PR's collection, +project, repository name/ID and PR ID. Typed job outputs make that same identity +available to the preview and both SafeOutputs variants. Missing or inconsistent +identity is unresolved in the preview and rejected by triggering-target +executors. Same-run references identify an earlier create proposal without +inventing a real PR ID. The preview is not an authorization decision. Every compiler-emitted step that runs an ado-script bundle has an implicit environment contract — which `process.env` keys the bundle reads. That contract diff --git a/docs/codemods.md b/docs/codemods.md index 787f02de8..c1f570e5e 100644 --- a/docs/codemods.md +++ b/docs/codemods.md @@ -129,6 +129,12 @@ tool references are highlighted with source locations and replacement guidance; the markdown body is never automatically rewritten. The warning persists on subsequent compile/lint passes until the author updates the instructions. +The original declaration shape is significant: bare/null/true `update-pr` +declarations migrate without voting, whereas vote-enabled mappings must carry +explicit legacy `allowed-votes`. The legacy vocabulary is validated before +translation; review-only values are not silently accepted as old votes. +Invalid policy and migration conflicts leave the source untouched. + You need a codemod whenever you introduce a breaking change to the front-matter grammar: diff --git a/docs/execution-context.md b/docs/execution-context.md index 25105c140..d28a178e5 100644 --- a/docs/execution-context.md +++ b/docs/execution-context.md @@ -64,6 +64,20 @@ locally and `git` is added to its bash allow-list automatically. ## Front-matter surface +### Triggering PR identity for safe outputs + +Safe outputs configured with `target: triggering` require the actual triggering +collection/organization, repository, and PR ID as one trusted identity. Native +PR validation uses build metadata; synthetic PR runs use the PR selected by the +trusted Setup resolver. The compiler carries that identity to the approval +preview and both automatic/reviewed SafeOutputs jobs. + +This identity is separate from the compiler-owned `self` repository and from a +fork's source URI. A matching numeric ID alone does not authorize a different +repository's PR. Missing or malformed identity fails closed when a +triggering-target output is executed; fixed targets do not require a trigger. +Normal repository allowlists and write permissions still apply. + ```yaml execution-context: enabled: true # master switch; defaults to true diff --git a/docs/imports.md b/docs/imports.md index 9762473b9..d97aa5b94 100644 --- a/docs/imports.md +++ b/docs/imports.md @@ -209,6 +209,7 @@ Only these imported fields are applied: | `permissions-required` | Boolean OR of abstract `read` / `write` requirements | | `safe-outputs.jobs` | Custom job names are unique across consumer and imports; duplicates fail | | Built-in `safe-outputs` keys | Duplicates across imports fail; consumer configuration replaces imported built-in configuration | +| `safe-outputs.budget-groups` | Merge by group name; duplicate names across imports fail, and a consumer replaces only the same-named group | | `runtimes` | Consumer fields override imported fields; earlier imports fill remaining fields | | `env` | Duplicate keys across imports fail; consumer overrides | | `repos` | Consumer entries first, then imported entries, deduplicated by alias/name. An import can therefore add a `resources.repositories` entry, including one that references an `endpoint:` service connection — review the compiled `.lock.yml` diff | @@ -243,6 +244,40 @@ concrete `permissions`, `variable-groups`, `parameters`, `setup`, `teardown`, `execution-context`, `supply-chain`, `ado-aw-debug`, and `inlined-imports`. Other unsupported imported fields are also warned and ignored. +### Deprecated PR built-ins + +PR tool migrations run in memory after `import-schema` input substitution. +Old and canonical spellings identify the same built-in during merging: a +consumer `add-pull-request-comment` with `max: 1` replaces an imported +`add-pr-comment` with `max: 5`, and vice versa. Two imports declaring those +spellings still conflict. Declaring both spellings in one manifest is also an +error, even if a consumer would replace that declaration. + +A consumer `update-pr` replaces the **entire** imported legacy declaration, +not only its overlapping operations. Its migrated children retain +`legacy-update-pr` metadata and the `update-pr` budget group, so recompiling +the automatically rewritten consumer does not restore excluded imported +operations. Current child restrictions remain authoritative; the compiler +does not rebuild children from stale metadata. Inconsistent family metadata +or missing/mismatched budget members require an explicit manual correction. +Unrelated imported budget groups retain their own limits. + +Only the winning legacy policy is migrated and validated. An overridden +component default does not cause an invalid-vote error; an invalid effective +policy does. Custom job ownership is determined across all manifests first: +neither names under `safe-outputs.jobs` nor their top-level policy keys are +renamed or split. A custom name colliding with a canonical built-in remains +an error. + +Imported local files and SHA-cached manifests are never rewritten by these +migrations. Only locally authored root declarations are rewritten, after a +successful compilation, with the root's markdown body preserved byte for +byte. Imported prompt text is preserved too; stale PR-tool references report +the component origin and body line for manual correction. `compile`, `check`, +`inspect`, and lint tools use the same effective policy. Read-only commands +never apply source rewrites; `check` still requires `compile` when the root +itself has a pending migration. + ## `permissions-required` A component can declare abstract ADO capability requirements without naming a diff --git a/docs/safe-outputs.md b/docs/safe-outputs.md index a7f527013..00996545a 100644 --- a/docs/safe-outputs.md +++ b/docs/safe-outputs.md @@ -882,6 +882,18 @@ safe-outputs: - `repository` *(optional)* - Target repository alias, constrained by `allowed-repositories`. +`target: triggering` binds the **collection/organization, repository, and PR +ID together**. It is not an ID default that can be redirected to another +repository. Native PR builds and trusted synthetic-PR resolution both provide +this identity; an incomplete or mismatched identity fails before mutation. +When omitted, the repository is the trusted triggering repository, which may +differ from the pipeline's `self` checkout. This does not grant write access: +repository allowlists and write-scope authorization still apply. + +For another PR, use an explicit fixed target or `target: "*"`, subject to the +same repository permissions. Numeric and quoted numeric fixed targets have the +same meaning, including in the human-review preview. + Numeric IDs, quoted numeric IDs and same-run temporary PR references are accepted. Repository destinations resolve their configured organization and project; cross-organization writes require the normal explicit write policy. @@ -1446,6 +1458,18 @@ compiler release. The catch-all is no longer exposed by MCP or executable by the new Stage 3 executor. +Legacy `allowed-votes` accepts exactly `approve`, `approve-with-suggestions`, +`wait-for-author`, `reject`, and `reset`. Unsupported values, including the +review-only events `comment` and `request-changes`, stop migration with an +actionable error rather than being reinterpreted. The same restriction applies +to retained legacy policy metadata; native review configuration still supports +its normal review events. + +Bare `update-pr:`, `update-pr: null`, and `update-pr: true` migrate to the four +non-voting operations with their original shared limit. They do not generate +an empty review configuration or acquire voting permission. An object-form +configuration that enables voting but omits `allowed-votes` remains invalid. + | Old operation | Replacement | |---|---| | `update-description` | `update-pull-request` (`body`) | @@ -1498,8 +1522,8 @@ safe-outputs: max: 1 # Maximum per run (default: 1) ``` -When `target` is `"triggering"`, Stage 3 uses -`SYSTEM_PULLREQUEST_PULLREQUESTID`. When `target` is a number, that configured +When `target` is `"triggering"`, Stage 3 requires the complete trusted native or +synthetic PR identity, not just a matching numeric ID. When `target` is a number, that configured ADO PR ID is used. The tool fetches the PR first, applies the optional title/label filters, patches the PR status to `abandoned`, then optionally posts `body` as a PR thread comment. diff --git a/scripts/ado-script/src/approval-summary/__tests__/compiler-policy.test.ts b/scripts/ado-script/src/approval-summary/__tests__/compiler-policy.test.ts new file mode 100644 index 000000000..5675bd6b2 --- /dev/null +++ b/scripts/ado-script/src/approval-summary/__tests__/compiler-policy.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { parse } from "yaml"; +import { parsePrPolicies } from "../index.js"; +import { parseProposals, renderSummary } from "../render.js"; + +// Match the executor-E2E offline contract: build the local compiler first. +const binary = process.env.ADO_AW_BIN + ?? resolve(process.cwd(), "..", "..", "target", "debug", process.platform === "win32" ? "ado-aw.exe" : "ado-aw"); +const directories: string[] = []; +afterEach(() => { + for (const directory of directories.splice(0)) rmSync(directory, { recursive: true, force: true }); +}); + +function findSummaryEnv(value: unknown): Record | undefined { + if (!value || typeof value !== "object") return undefined; + if (!Array.isArray(value)) { + const object = value as Record; + if (object.displayName === "Render safe-outputs summary") return object.env as Record; + } + for (const child of Object.values(value)) { + const found = findSummaryEnv(child); + if (found) return found; + } + return undefined; +} + +describe.skipIf(!existsSync(binary))("compiler-to-preview target policy contract", () => { + it("normalizes quoted/numeric fixed IDs and full-u64 targets before rendering", () => { + const directory = mkdtempSync(join(process.cwd(), ".approval-policy-contract-")); + directories.push(directory); + execFileSync("git", ["init", "--quiet", directory]); + execFileSync("git", ["-C", directory, "remote", "add", "origin", "https://dev.azure.com/org/Project/_git/policy"]); + for (const id of ["42", "18446744073709551615"]) { + const rendered: string[] = []; + for (const target of [id, `"${id}"`]) { + const source = join(directory, "policy.md"); + const output = join(directory, "policy.lock.yml"); + writeFileSync(source, [ + "---", "name: preview-contract", "description: Test", "target: standalone", + "safe-outputs:", " update-pull-request:", ` target: ${target}`, + " abandon-pull-request:", ` target: ${target}`, + " add-pull-request-labels:", "---", "Review the pull request.", "", + ].join("\n")); + execFileSync(binary, ["compile", source, "--output", output, "--force"], { + cwd: directory, env: { ...process.env, CI: "true" }, stdio: "pipe", + }); + const env = findSummaryEnv(parse(readFileSync(output, "utf8"))); + expect(env).toBeDefined(); + const policies = parsePrPolicies(env!.AW_PR_POLICIES); + expect(policies.get("update-pull-request")?.target).toEqual({kind:"fixed",id}); + expect(policies.get("abandon-pull-request")?.target).toEqual({kind:"fixed",id}); + expect(policies.get("add-pull-request-labels")?.target).toEqual({kind:"explicit"}); + const summary = renderSummary(parseProposals('{"name":"update-pull-request","title":"New"}'), new Set(), { + policies:new Map(),prPolicies:policies, + triggeringPr:{collection_uri:"https://dev.azure.com/org/",project:"Project",repository_name:"policy", + repository_id:"11111111-1111-1111-1111-111111111111",id:"7"}, + }); + expect(summary).toContain(`| PR | ${id} |`); + rendered.push(summary); + } + expect(rendered[0]).toBe(rendered[1]); + } + }, 60_000); +}); diff --git a/scripts/ado-script/src/approval-summary/__tests__/index.test.ts b/scripts/ado-script/src/approval-summary/__tests__/index.test.ts index 56632c70e..4a40c5387 100644 --- a/scripts/ado-script/src/approval-summary/__tests__/index.test.ts +++ b/scripts/ado-script/src/approval-summary/__tests__/index.test.ts @@ -1,13 +1,12 @@ import { describe, it, expect, afterEach } from "vitest"; import { mkdtempSync, readFileSync, writeFileSync, rmSync, existsSync } from "node:fs"; -import { tmpdir } from "node:os"; import { join } from "node:path"; -import { main, parseRepositoryPolicies, parseReviewed } from "../index.js"; +import { main, parsePrPolicies, parseRepositoryPolicies, parseReviewed } from "../index.js"; const dirs: string[] = []; function freshDir(): string { - const d = mkdtempSync(join(tmpdir(), "approval-summary-")); + const d = mkdtempSync(join(process.cwd(), ".approval-summary-test-")); dirs.push(d); return d; } @@ -17,6 +16,17 @@ afterEach(() => { }); describe("parseReviewed", () => { + it("only accepts compiler-normalized PR policies with lossless decimal fixed IDs", () => { + const policies = parsePrPolicies(JSON.stringify({ + "update-pull-request": {target:{kind:"fixed",id:"18446744073709551615"}}, + "abandon-pull-request": {target:{kind:"triggering"}}, + "add-pull-request-labels": {target:{kind:"explicit"}}, + "bad-raw-target": {target:"42"}, + "bad-rounded-target": {target:{kind:"fixed",id:18446744073709552000}}, + })); + expect(policies.size).toBe(3); + expect(policies.get("update-pull-request")?.target).toEqual({kind:"fixed",id:"18446744073709551615"}); + }); it("splits a newline-delimited list, trims, and drops empties", () => { const set = parseReviewed(" create-pull-request \n \n add-pull-request-comment "); expect([...set].sort()).toEqual(["add-pull-request-comment", "create-pull-request"]); @@ -57,6 +67,39 @@ describe("parseReviewed", () => { }); describe("main", () => { + it("previews native and synthetic triggering destinations without using self or fork metadata", () => { + const directory = freshDir(); + const input = join(directory, "proposals.ndjson"); + const output = join(directory, "summary.md"); + writeFileSync(input, '{"name":"update-pull-request","title":"New title"}'); + const identity = { + collection_uri:"https://dev.azure.com/org/", project:"Other", repository_name:"target", + repository_id:"11111111-1111-1111-1111-111111111111", id:"42", + }; + const common = { + AW_SAFE_OUTPUTS_NDJSON:input,AW_APPROVAL_SUMMARY_OUT:output, + AW_PR_POLICIES:JSON.stringify({"update-pull-request":{target:{kind:"triggering"}}}), + ADO_AW_SELF_REPOSITORY_NAME:"templates", + SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI:"https://dev.azure.com/fork/Elsewhere/_git/source", + }; + for (const env of [ + {...common,ADO_AW_TRIGGERING_PR_IDENTITY:JSON.stringify(identity)}, + {...common,ADO_AW_TRIGGERING_PR_CAPTURED:"true",ADO_AW_TRIGGER_COLLECTION_URI:identity.collection_uri, + ADO_AW_TRIGGER_REPOSITORY_URI:"https://dev.azure.com/org/Other/_git/target", + ADO_AW_TRIGGER_REPOSITORY_ID:identity.repository_id,ADO_AW_TRIGGER_REPOSITORY_PROVIDER:"TfsGit", + ADO_AW_TRIGGER_BUILD_REASON:"PullRequest",ADO_AW_TRIGGER_PR_ID:"42"}, + ]) { + expect(main(env)).toBe(0); + const summary = readFileSync(output,"utf8"); + expect(summary).toContain("| PR | 42 |"); + expect(summary).toContain("https://dev.azure.com/org/Other/target"); + expect(summary).not.toContain("templates"); + expect(summary).not.toContain("/fork/"); + } + main({...common,ADO_AW_TRIGGERING_PR_IDENTITY:"",SYSTEM_PULLREQUEST_PULLREQUESTID:"42"}); + expect(readFileSync(output,"utf8")).toContain("complete triggering PR identity unavailable"); + }); + it("writes a summary and returns 0 when proposals exist", () => { const dir = freshDir(); const ndjsonPath = join(dir, "safe_outputs.ndjson"); diff --git a/scripts/ado-script/src/approval-summary/__tests__/render.test.ts b/scripts/ado-script/src/approval-summary/__tests__/render.test.ts index 72416ff46..659e9dd40 100644 --- a/scripts/ado-script/src/approval-summary/__tests__/render.test.ts +++ b/scripts/ado-script/src/approval-summary/__tests__/render.test.ts @@ -22,7 +22,7 @@ describe("focused PR tools", () => { new Set(["update-pull-request"]), { policies: new Map(), - prPolicies: new Map([["update-pull-request", { target: 42, operation: "append", "target-repo": "tools" }]]), + prPolicies: new Map([["update-pull-request", { target: {kind: "fixed", id: "42"}, operation: "append", "target-repo": "tools" }]]), }, ); expect(summary).toContain("| PR | 42 |"); @@ -37,11 +37,96 @@ describe("focused PR tools", () => { { name: "create-pull-request", temporary_id: "#aw_created", repository: "tools" }, { name: "add-pull-request-reviewers", pull_request_id: "#aw_created", reviewers: ["person@example.test"] }, { name: "abandon-pull-request", pull_request_id: "#aw_missing", body: "reason" }, - )), new Set()); - expect(summary).toContain("real ID assigned at execution"); + )), new Set(), { + policies: new Map(), + prPolicies: new Map([ + ["add-pull-request-reviewers", {target: {kind: "explicit"}}], + ["abandon-pull-request", {target: {kind: "explicit"}}], + ]), + }); + expect(summary).toContain("real ID unknown until successful execution"); expect(summary).toContain("no earlier create proposal"); expect(summary).toContain("person@example.test"); }); + + const trigger = { + collection_uri: "https://dev.azure.com/org/", project: "Other", repository_name: "trigger-repo", + repository_id: "11111111-1111-1111-1111-111111111111", id: "7", + }; + + it("shows fixed string targets instead of the triggering number and retains full u64", () => { + for (const [id, proposal] of [ + ["42", '{"name":"update-pull-request","body":"new body"}'], + ["18446744073709551615", '{"name":"update-pull-request","pull_request_id":18446744073709551615}'], + ]) { + const summary = renderSummary(parseProposals(proposal!), new Set(), { + policies: new Map(), triggeringPr: trigger, + prPolicies: new Map([["update-pull-request", {target: {kind: "fixed", id: id!}}]]), + }); + expect(summary).toContain(`| PR | ${id} |`); + expect(summary).not.toContain("| PR | 7 |"); + expect(summary).not.toContain("18446744073709552000"); + } + }); + + it("distinguishes required explicit IDs from complete triggering identities", () => { + const policies: TrustedRepositoryContext = { + policies: new Map(), triggeringPr: trigger, prPolicies: new Map([ + ["update-pull-request", {target: {kind: "triggering"}}], + ["add-pull-request-labels", {target: {kind: "explicit"}}], + ]), + }; + const text = ndjson({name:"update-pull-request", title:"new"}, {name:"add-pull-request-labels", labels:["ready"]}); + const summary = renderSummary(parseProposals(text), new Set(), policies); + expect(summary).toContain("| PR | 7 |"); + expect(summary).toContain(sanitizeInline("https://dev.azure.com/org/Other/trigger-repo")); + expect(summary).toContain("explicit PR ID required"); + const unresolved = renderSummary(parseProposals(text), new Set(), {...policies, triggeringPr:undefined}); + expect(unresolved).toContain("complete triggering PR identity unavailable"); + expect(unresolved).not.toContain("| PR | 7 |"); + }); + + it("preserves explicit empty or mismatched repository selectors for temporary references", () => { + for (const [selector, expected] of [["", "invalid explicit repository selector"], ["self", "possible conflict"]]) { + const summary = renderSummary(parseProposals(ndjson( + {name:"create-pull-request", temporary_id:"#aw_new", repository:"other"}, + {name:"add-pull-request-labels", pull_request_id:"#aw_new", repository:selector, labels:["ready"]}, + )), new Set(), {policies:new Map(), prPolicies:new Map([ + ["add-pull-request-labels", {target:{kind:"explicit"}}], + ])}); + expect(summary).toContain(expected!); + expect(summary).not.toContain("producer's proposed selector"); + } + }); + + it("reports fixed and triggering ID conflicts and never resolves a same-run unknown number", () => { + for (const target of [{kind:"fixed" as const,id:"42"}, {kind:"triggering" as const}]) { + const context: TrustedRepositoryContext = {policies:new Map(),triggeringPr:trigger, + prPolicies:new Map([["update-pull-request", {target}]])}; + const conflict = renderSummary(parseProposals(ndjson({ + name:"update-pull-request",pull_request_id:99,title:"new", + })),new Set(),context); + expect(conflict).toContain("conflict: configured target is PR"); + const temporary = renderSummary(parseProposals(ndjson( + {name:"create-pull-request",temporary_id:"#aw_new",repository:"other"}, + {name:"update-pull-request",pull_request_id:"#aw_new",title:"new"}, + )),new Set(),context); + expect(temporary).toContain("real ID unknown"); + expect(temporary).toContain("must equal configured PR"); + } + }); + + it("does not accept unsupported aliases or fractional numbers as explicit PR IDs", () => { + const summary = renderSummary(parseProposals([ + '{"name":"add-pull-request-labels","pr":42,"labels":["ready"]}', + '{"name":"add-pull-request-labels","pull_request_id":42.0,"labels":["ready"]}', + ].join("\n")),new Set(),{policies:new Map(),prPolicies:new Map([ + ["add-pull-request-labels",{target:{kind:"explicit"}}], + ])}); + expect(summary).toContain("explicit PR ID required"); + expect(summary).toContain("invalid PR reference"); + expect(summary).not.toContain("| PR | 42 |"); + }); }); function repositoryContext( diff --git a/scripts/ado-script/src/approval-summary/index.ts b/scripts/ado-script/src/approval-summary/index.ts index 1387e9c4c..23b34090a 100644 --- a/scripts/ado-script/src/approval-summary/index.ts +++ b/scripts/ado-script/src/approval-summary/index.ts @@ -27,6 +27,14 @@ * - AW_CURRENT_REPOSITORY / AW_CURRENT_REPOSITORY_PROVIDER trusted ADO build * metadata used only for GitHub-source fallback * - AW_GITHUB_API_URL operator-resolved GitHub API URL + * - AW_PR_POLICIES compiler-normalized target policies; fixed IDs + * are decimal strings, never JavaScript numbers + * - ADO_AW_TRIGGERING_PR_IDENTITY trusted Setup JSON for synthetic mode + * - ADO_AW_TRIGGERING_PR_CAPTURED + ADO_AW_TRIGGER_* job-level native + * Build.Repository/collection/PR captures + * + * The triggering tuple is independent of compiler-owned self and fork-source + * URIs. Incomplete identity remains unresolved; preview never grants permission. * * Failure policy: best-effort. Any error is logged as a warning and the * program exits 0 — rendering the summary must never fail the build or block @@ -36,11 +44,13 @@ import { readFileSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { logWarning, uploadSummary } from "../shared/vso-logger.js"; +import { positivePrId, readTriggeringPrIdentity } from "../shared/ado-remote.js"; import { parseProposals, renderSummary, type GithubRepositoryPolicy, type TrustedRepositoryContext, + type PrPolicy, } from "./render.js"; /** @@ -97,7 +107,7 @@ export function parseRepositoryPolicies( return policies; } -export function parsePrPolicies(value: string | undefined): Map>> { +export function parsePrPolicies(value: string | undefined): Map { if (!value) return new Map(); let parsed: unknown; try { @@ -110,10 +120,24 @@ export function parsePrPolicies(value: string | undefined): Map>>(); + const policies = new Map(); for (const [tool, policy] of Object.entries(parsed)) { if (policy !== null && typeof policy === "object" && !Array.isArray(policy)) { - policies.set(tool, policy); + const candidate = policy as Record; + const target = candidate.target as Record | undefined; + if (!target || typeof target !== "object" || Array.isArray(target) + || !["triggering", "explicit", "fixed"].includes(String(target.kind)) + || (target.kind === "fixed" && (typeof target.id !== "string" || !positivePrId(target.id)))) { + logWarning(`approval-summary: invalid normalized PR target policy for ${tool}`); + continue; + } + policies.set(tool, { + target: target.kind === "fixed" + ? { kind: "fixed", id: positivePrId(target.id)! } + : { kind: target.kind as "triggering" | "explicit" }, + operation: typeof candidate.operation === "string" ? candidate.operation : undefined, + "target-repo": typeof candidate["target-repo"] === "string" ? candidate["target-repo"] : undefined, + }); } else { logWarning(`approval-summary: invalid trusted policy for ${tool}`); } @@ -156,7 +180,7 @@ export function main(env: NodeJS.ProcessEnv = process.env): number { currentProvider: env.AW_CURRENT_REPOSITORY_PROVIDER, githubApiUrl: env.AW_GITHUB_API_URL, prPolicies: parsePrPolicies(env.AW_PR_POLICIES), - triggeringPr: env.SYSTEM_PULLREQUEST_PULLREQUESTID, + triggeringPr: readTriggeringPrIdentity(env), }; const markdown = renderSummary(proposals, reviewed, repositoryContext); if (markdown.length === 0) { diff --git a/scripts/ado-script/src/approval-summary/render.ts b/scripts/ado-script/src/approval-summary/render.ts index b20ea967b..075d70709 100644 --- a/scripts/ado-script/src/approval-summary/render.ts +++ b/scripts/ado-script/src/approval-summary/render.ts @@ -13,6 +13,7 @@ * (markdown-escaped, single line) or `sanitizeBlock` (fenced, neutralised) * before they reach the output. */ +import { positivePrId, type TriggeringPullRequest } from "../shared/ado-remote.js"; /** A parsed safe-output proposal record (one NDJSON line). */ export interface Proposal { @@ -53,8 +54,19 @@ export interface TrustedRepositoryContext { currentRepository?: string; currentProvider?: string; githubApiUrl?: string; - prPolicies?: ReadonlyMap>>; - triggeringPr?: string; + prPolicies?: ReadonlyMap; + triggeringPr?: TriggeringPullRequest; +} + +export type PrTargetPolicy = + | { kind: "triggering" } + | { kind: "explicit" } + | { kind: "fixed"; id: string }; + +export interface PrPolicy { + target: PrTargetPolicy; + operation?: string; + "target-repo"?: string; } interface RepositoryResolution { @@ -151,6 +163,7 @@ const TOOL_SPECS: Record = { fields: [ { label: "PR", key: "pull_request_id" }, { label: "Event", key: "event" }, + { label: "Repository selector", key: "repository" }, ], body: "body", }, @@ -974,23 +987,7 @@ export function renderSummary( producers.set(record.temporary_id.replace(/^#/, ""), proposal); } const policy = repositoryContext?.prPolicies?.get(proposal.name); - if (policy) { - record.operation ??= policy.operation; - record.repository ??= policy["target-repo"] ?? "self"; - record.pull_request_id ??= record.pull_request_number ?? record.pr_number ?? record.pr - ?? (policy.target === "*" ? "" - : typeof policy.target === "number" ? policy.target - : repositoryContext?.triggeringPr || ""); - } - if (typeof record.pull_request_id === "string" && /^#?aw_/.test(record.pull_request_id)) { - const producer = producers.get(record.pull_request_id.replace(/^#/, "")); - record.pull_request_id = producer - ? `${record.pull_request_id} (from earlier create proposal ${producer.index + 1}; real ID assigned at execution)` - : `${record.pull_request_id} (unresolved: no earlier create proposal)`; - if (producer && !proposal.record.repository) { - record.repository = `${String(producer.record.repository ?? "self")} (producer's proposed selector; validated at execution)`; - } - } + if (policy) renderPrTarget(proposal.name, record, policy, repositoryContext?.triggeringPr, producers); return { ...proposal, record }; }); @@ -1035,6 +1032,106 @@ export function renderSummary( return lines.join("\n").replace(/\n{3,}/g, "\n\n").trimEnd() + "\n"; } +function renderPrTarget( + tool: string, + record: Record, + policy: PrPolicy, + triggering: TriggeringPullRequest | undefined, + producers: ReadonlyMap, + ): void { + record.operation ??= policy.operation; + const keys = tool === "update-pull-request" + ? ["pull_request_id", "pullRequestId", "pull_request_number", "pullRequestNumber", "pr_number", "prNumber", "pr", "id"] + : tool === "abandon-pull-request" ? ["pull_request_id", "pull_request_number"] : ["pull_request_id"]; + const refs = keys + .map((key) => record[key]).filter((value) => value !== undefined && value !== null); + const normalized = refs.map((value) => { + if (typeof value === "string") { + const text = value.trim(); + if (/^#?aw_[A-Za-z0-9_-]+$/.test(text)) return `#${text.replace(/^#/, "")}`; + return positivePrId(text.replace(/^#/, "")) ?? ""; + } + return positivePrId(value) ?? ""; + }); + const reference = normalized[0]; + const selector = record.repository ?? policy["target-repo"]; + const explicitSelector = selector !== undefined && selector !== null; + record.repository = explicitSelector ? selector : "self"; + if (normalized.some((value) => value !== reference)) { + record.pull_request_id = ""; + return; + } + if (reference === "") { + record.pull_request_id = reference; + return; + } + if (explicitSelector && (typeof selector !== "string" || selector.trim().length === 0)) { + record.pull_request_id = `${reference ?? ""} (unresolved: invalid explicit repository selector)`; + return; + } + const configured = policy.target.kind === "fixed" ? policy.target.id + : policy.target.kind === "triggering" ? triggering?.id : undefined; + if (policy.target.kind === "triggering") { + if (!triggering) { + record.pull_request_id = `${reference ?? ""} (unresolved: complete triggering PR identity unavailable)`; + record.repository = explicitSelector ? selector : ""; + return; + } + const destination = `${triggering.collection_uri.replace(/\/$/, "")}/${triggering.project}/${triggering.repository_name} (repository ID ${triggering.repository_id})`; + record.repository = explicitSelector + ? `${destination}; explicit selector '${String(selector)}' must identify this destination (verified at execution)` + : `${destination}; checkout/write authorization verified at execution`; + } + if (reference?.startsWith("#aw_")) { + const producer = producers.get(reference.slice(1)); + record.pull_request_id = producer + ? `${reference} (from earlier create proposal ${producer.index + 1}; real ID unknown until successful execution)` + : `${reference} (unresolved: no earlier create proposal)`; + if (configured) record.pull_request_id += `; must equal configured PR ${configured}`; + if (policy.target.kind !== "triggering" && producer) { + const producerSelector = producer.record.repository ?? "self"; + if (!explicitSelector) { + record.repository = `${String(producerSelector)} (producer's proposed selector; validated at execution)`; + } else if (selector !== producerSelector) { + record.repository = `${String(selector)} (possible conflict: producer requested '${String(producerSelector)}'; verified at execution)`; + } + } + return; + } + if (reference && configured && reference !== configured) { + record.pull_request_id = `${reference} (conflict: configured target is PR ${configured})`; + return; + } + record.pull_request_id = reference ?? configured ?? ""; + } + + /** Preserve full-u64 integer tokens before JSON.parse can round proposal identifiers. */ + function preserveLargeIntegers(json: string): string { + let out = ""; + let index = 0; + while (index < json.length) { + if (json[index] === '"') { + const start = index++; + while (index < json.length) { + const char = json[index++]; + if (char === "\\") index++; + else if (char === '"') break; + } + out += json.slice(start, index); + } else { + const number = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(json.slice(index)); + if (number) { + const raw = number[0]; + out += !/^-?\d+$/.test(raw) || !Number.isSafeInteger(Number(raw)) ? JSON.stringify(raw) : raw; + index += raw.length; + } else { + out += json[index++]; + } + } + } + return out; + } + /** * Parse NDJSON text into proposals, skipping blank lines and records that * fail to parse or lack a string `name`. Index is the proposal position so @@ -1048,7 +1145,7 @@ export function parseProposals(ndjson: string): Proposal[] { if (line.length === 0) continue; let parsed: unknown; try { - parsed = JSON.parse(line); + parsed = JSON.parse(preserveLargeIntegers(line)); } catch { continue; } diff --git a/scripts/ado-script/src/exec-context-pr-synth/__tests__/harness.ts b/scripts/ado-script/src/exec-context-pr-synth/__tests__/harness.ts index aae98ee9c..933dc9757 100644 --- a/scripts/ado-script/src/exec-context-pr-synth/__tests__/harness.ts +++ b/scripts/ado-script/src/exec-context-pr-synth/__tests__/harness.ts @@ -43,6 +43,8 @@ export function makeEnv(overrides: Record): NodeJS.ProcessEnv { BUILD_SOURCEBRANCH: "refs/heads/feature/x", SYSTEM_TEAMPROJECT: "MyProject", BUILD_REPOSITORY_ID: "00000000-0000-0000-0000-000000000000", + SYSTEM_COLLECTIONURI: "https://dev.azure.com/org/", + BUILD_REPOSITORY_URI: "https://dev.azure.com/org/MyProject/_git/target", ...overrides, }; } diff --git a/scripts/ado-script/src/exec-context-pr-synth/__tests__/index.test.ts b/scripts/ado-script/src/exec-context-pr-synth/__tests__/index.test.ts index ec618187c..2dfd740d8 100644 --- a/scripts/ado-script/src/exec-context-pr-synth/__tests__/index.test.ts +++ b/scripts/ado-script/src/exec-context-pr-synth/__tests__/index.test.ts @@ -23,6 +23,64 @@ describe("exec-context-pr-synth main", () => { }); afterEach(() => vi.restoreAllMocks()); + it("emits the complete native identity from Build.Repository rather than self or fork source", async () => { + const {output} = await runMain(makeEnv({ + BUILD_REASON: "PullRequest", SYSTEM_PULLREQUEST_PULLREQUESTID: "42", + ADO_AW_SELF_REPOSITORY_NAME: "templates", + SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI: "https://dev.azure.com/fork/Elsewhere/_git/source", + })); + expect(output).toContain('AW_PR_TRIGGERING_IDENTITY;isOutput=true]{"collection_uri":"https://dev.azure.com/org/","project":"MyProject","repository_name":"target"'); + expect(output).toContain('"repository_id":"00000000-0000-0000-0000-000000000000","id":"42"'); + expect(mocked.listActivePullRequestsBySourceRef).not.toHaveBeenCalled(); + }); + + it("emits the exact synthetic selection identity without another API lookup", async () => { + mocked.listActivePullRequestsBySourceRef.mockResolvedValue([ + {pullRequestId:42,sourceRefName:"refs/heads/feature/x",targetRefName:"refs/heads/main", + repository:{id:"00000000-0000-0000-0000-000000000000"}}, + ]); + const {output} = await runMain(makeEnv({PR_SYNTH_SPEC:build_pr_synth_spec()})); + expect(output).toContain('AW_PR_TRIGGERING_IDENTITY;isOutput=true]{"collection_uri":"https://dev.azure.com/org/"'); + expect(output).toContain('"id":"42"'); + expect(mocked.listActivePullRequestsBySourceRef).toHaveBeenCalledExactlyOnceWith("MyProject","00000000-0000-0000-0000-000000000000","refs/heads/feature/x"); + expect(mocked.getPullRequestIterations).not.toHaveBeenCalled(); + }); + + it("does not emit authority for missing, foreign, non-ADO or mismatched identities", async () => { + const invalid: Record[] = [ + {BUILD_REPOSITORY_URI:""}, + {BUILD_REPOSITORY_ID:""}, + {BUILD_REPOSITORY_PROVIDER:"GitHub"}, + {SYSTEM_COLLECTIONURI:"https://dev.azure.com/foreign/"}, + ]; + for (const override of invalid) { + const {output} = await runMain(makeEnv({ + BUILD_REASON:"PullRequest",SYSTEM_PULLREQUEST_PULLREQUESTID:"42",...override, + })); + expect(output).toContain("AW_PR_TRIGGERING_IDENTITY;isOutput=true]\n"); + expect(output).not.toContain('AW_PR_TRIGGERING_IDENTITY;isOutput=true]{'); + } + mocked.listActivePullRequestsBySourceRef.mockResolvedValue([ + {pullRequestId:42,sourceRefName:"refs/heads/feature/x",targetRefName:"refs/heads/main",repository:{id:"foreign"}}, + ]); + const {output} = await runMain(makeEnv({PR_SYNTH_SPEC:build_pr_synth_spec()})); + expect(output).toContain("AW_PR_TRIGGERING_IDENTITY;isOutput=true]\n"); + }); + + it("looks up synthetic PRs in the trusted triggering repository project, not pipeline self project", async () => { + mocked.listActivePullRequestsBySourceRef.mockResolvedValue([ + {pullRequestId:42,sourceRefName:"refs/heads/feature/x",targetRefName:"refs/heads/main", + repository:{id:"00000000-0000-0000-0000-000000000000",name:"target",project:{name:"Other"}}}, + ]); + const {output} = await runMain(makeEnv({ + SYSTEM_TEAMPROJECT:"PipelineProject", + BUILD_REPOSITORY_URI:"https://dev.azure.com/org/Other/_git/target", + PR_SYNTH_SPEC:build_pr_synth_spec(), + })); + expect(mocked.listActivePullRequestsBySourceRef).toHaveBeenCalledExactlyOnceWith("Other","00000000-0000-0000-0000-000000000000","refs/heads/feature/x"); + expect(output).toContain('"project":"Other"'); + }); + // ── Real-PR path ───────────────────────────────────────────────── // // On a real PR build, ADO populates `SYSTEM_PULLREQUEST_*` env vars diff --git a/scripts/ado-script/src/exec-context-pr-synth/index.ts b/scripts/ado-script/src/exec-context-pr-synth/index.ts index ba8806c05..df83996ea 100644 --- a/scripts/ado-script/src/exec-context-pr-synth/index.ts +++ b/scripts/ado-script/src/exec-context-pr-synth/index.ts @@ -36,6 +36,12 @@ * - `AW_PR_TARGETBRANCH` — resolved target ref (`refs/heads/`) * - `AW_PR_SOURCEBRANCH` — resolved source ref * - `AW_PR_IS_DRAFT` — "true"/"false"/"" (only meaningful on synth path) + * - `AW_PR_TRIGGERING_IDENTITY` — JSON {collection_uri, project, + * repository_name, repository_id, id}; empty when + * complete trusted Azure Repos identity is unavailable. + * The ID is a decimal string. Consumed directly from + * Setup by preview and both SafeOutputs job variants, + * never relayed through agent-authored files. * - `AW_SYNTHETIC_PR` — "true" iff this build was synth-promoted * (i.e. CI build + matched open PR). Empty * on real PR builds and on non-promoted CI. @@ -65,6 +71,7 @@ import { listActivePullRequestsBySourceRef, } from "../shared/ado-client.js"; import { logError, logInfo, setOutput, setVar } from "../shared/vso-logger.js"; +import { isCurrentAdoOrganization, nativeTriggeringPrIdentity, parseAdoRepoUrl, positivePrId, type TriggeringPullRequest } from "../shared/ado-remote.js"; import { matchesIncludeExclude, normalisePath, pathMatchesIncludeExclude } from "./match.js"; import { decodeSpec, type PrSynthSpec } from "./spec.js"; @@ -111,6 +118,7 @@ function emitPrIdentifiers( targetBranch: string, sourceBranch: string, isDraft: string, + identity?: TriggeringPullRequest, ): void { const emitBoth = (name: string, value: string): void => { setOutput(name, value); @@ -120,6 +128,7 @@ function emitPrIdentifiers( emitBoth("AW_PR_TARGETBRANCH", targetBranch); emitBoth("AW_PR_SOURCEBRANCH", sourceBranch); emitBoth("AW_PR_IS_DRAFT", isDraft); + emitBoth("AW_PR_TRIGGERING_IDENTITY", identity ? JSON.stringify(identity) : ""); } function emitSkip(reason: string): void { @@ -146,6 +155,7 @@ export async function main(env: NodeJS.ProcessEnv = process.env): Promise { expect(adoAwBin, "Cargo must report the freshly built ado-aw executable").toBeTruthy(); }, cargoTimeoutMs); + function previewPolicyEnv(value: unknown): string | undefined { + if (Array.isArray(value)) { + for (const child of value) { + const result = previewPolicyEnv(child); + if (result !== undefined) return result; + } + } else if (value !== null && typeof value === "object") { + const object = value as Record; + if (object.env !== null && typeof object.env === "object" && !Array.isArray(object.env)) { + const env = object.env as Record; + if (typeof env.AW_PR_POLICIES === "string") return env.AW_PR_POLICIES; + } + for (const child of Object.values(object)) { + const result = previewPolicyEnv(child); + if (result !== undefined) return result; + } + } + return undefined; + } + + it.each([42, "42", "9007199254740993"])( + "renders the Rust compiler's fixed target %s without falling back to trigger 7", + async (target) => { + const dir = await mkdtemp(join(tmpdir(), "ado-aw-preview-contract-")); + try { + const source = join(dir, "workflow.md"); + await writeFile(source, "---\n" + JSON.stringify({ + name: "preview-contract", description: "Compiler to preview target contract", + "safe-outputs": { + "update-pull-request": { target, "include-stats": false }, + "abandon-pull-request": { target, "include-stats": false }, + }, + }) + "\n---\nReview fixture.\n"); + const compiled = spawnSync(adoAwBin, ["compile", source], { + cwd: dir, encoding: "utf8", timeout: 30000, + env: { ...process.env, ADO_AW_LOG_DIR: join(dir, "logs"), + ADO_AW_COMPILE_REMOTE_URL: "https://dev.azure.com/org/P/_git/repo" }, + }); + if (compiled.error) throw compiled.error; + expect(compiled.status, compiled.stderr).toBe(0); + const pipeline: unknown = parseYaml(await readFile(join(dir, "workflow.lock.yml"), "utf8")); + const policies = previewPolicyEnv(pipeline); + expect(policies, "compiler must emit preview policy").toBeDefined(); + const proposals = join(dir, "safe_outputs.ndjson"); + const summary = join(dir, "ado-aw-safe-outputs.md"); + await writeFile(proposals, [ + { name: "update-pull-request", body: "Update report." }, + { name: "abandon-pull-request", body: "Abandonment reason." }, + ].map((record) => JSON.stringify(record)).join("\n")); + expect(renderApprovalSummary({ + AW_SAFE_OUTPUTS_NDJSON: proposals, AW_APPROVAL_SUMMARY_OUT: summary, + AW_PR_POLICIES: policies, SYSTEM_PULLREQUEST_PULLREQUESTID: "7", + BUILD_REASON: "PullRequest", BUILD_REPOSITORY_PROVIDER: "TfsGit", + BUILD_REPOSITORY_ID: "11111111-2222-3333-4444-555555555555", + BUILD_REPOSITORY_URI: "https://dev.azure.com/org/P/_git/repo", + SYSTEM_COLLECTIONURI: "https://dev.azure.com/org/", + SYSTEM_TEAMPROJECT: "P", + })).toBe(0); + const markdown = await readFile(summary, "utf8"); + expect(markdown.split("| PR | " + String(target) + " |")).toHaveLength(3); + expect(markdown).not.toContain("| PR | 7 |"); + expect(markdown).not.toContain("9007199254740992"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }, + ); + async function parseScenario( scenario: Scenario, mutate?: (prior: PriorEntry[], entry: Record) => void, diff --git a/scripts/ado-script/src/executor-e2e/__tests__/runner.test.ts b/scripts/ado-script/src/executor-e2e/__tests__/runner.test.ts index 08fcbcbef..4de70c9a1 100644 --- a/scripts/ado-script/src/executor-e2e/__tests__/runner.test.ts +++ b/scripts/ado-script/src/executor-e2e/__tests__/runner.test.ts @@ -1,10 +1,13 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { runScenario } from "../runner.js"; +import { updatePullRequestOversized } from "../scenarios/pr.js"; +import { AdoRest } from "../ado-rest.js"; import { SkipError } from "../scenario.js"; import type { ExecutedRecord, Scenario, ScenarioContext } from "../scenario.js"; @@ -83,6 +86,103 @@ describe("runScenario precondition handling", () => { }); describe("runScenario expected executor failures", () => { + it("the oversized PR scenario checks unchanged state through assertFailure", async () => { + const base = fakeCtx(); + const rest = new AdoRest({ orgUrl: base.orgUrl, project: base.project, token: "" }); + const ctx: ScenarioContext = { ...base, rest }; + const state = { repo: "repo", prId: 42, branch: "test" }; + const records: ExecutedRecord[] = [{ + name: "update_pull_request", status: "failed", error: "4000-unit limit", + }]; + const normal = "preserved original body"; + const body = await import("../scenarios/common.js"); + const expected = body.detBody(ctx, "update-pull-request-oversized"); + const getPr = vi.spyOn(rest, "getPullRequest").mockResolvedValue({ + pullRequestId: 42, status: "active", title: "title", description: expected, + }); + expect(updatePullRequestOversized.assertFailure).toBeDefined(); + await updatePullRequestOversized.assertFailure!(ctx, state, records[0]!, records); + getPr.mockResolvedValue({ + pullRequestId: 42, status: "active", title: "title", description: normal, + }); + await expect(updatePullRequestOversized.assertFailure!(ctx, state, records[0]!, records)) + .rejects.toThrow("changed the live description"); + }); + + async function outcomeBinary( + dir: string, + status: string | null, + error: string, + mutate = false, + ): Promise { + const bin = join(dir, "outcome.js"); + const records = status === null ? [] : [{ name: "update_pull_request", status, error }]; + await writeFile(bin, ` +const fs = require("node:fs"); +const path = require("node:path"); +const out = process.argv[process.argv.indexOf("--safe-output-dir") + 1]; +if (${mutate}) fs.writeFileSync(path.join(out, "unexpected-write"), "changed"); +fs.writeFileSync(path.join(out, "safe-outputs-executed.ndjson"), ${JSON.stringify(records.map((record) => JSON.stringify(record)).join("\n") + "\n")}); +`, "utf8"); + return bin; + } + + it.each([false, true])("checks postconditions after expected failure (mutated=%s)", async (mutated) => { + const dir = await mkdtemp(join(tmpdir(), "ado-aw-negative-assert-")); + try { + const bin = await outcomeBinary(dir, "failed", "body too long", mutated); + const flags = { failure: false, success: false, post: false, cleanup: false }; + const scenario: Scenario = { + id: "negative-case", tool: "update-pull-request", + config: () => ({}), setup: async () => ({}), ndjson: async () => ({}), + expectedFailure: { error: /too long/ }, + assertFailure: async (_ctx, _state, record, records) => { + flags.failure = true; + expect(record.status).toBe("failed"); + expect(records).toHaveLength(1); + if (existsSync(join(dir, "negative-case", "out", "unexpected-write"))) { + throw new Error("unexpected mutation"); + } + }, + assert: async () => { flags.success = true; }, + postExecute: async () => { flags.post = true; }, + cleanup: async () => { flags.cleanup = true; }, + }; + const result = await runScenario({ ...fakeCtx(), adoAwBin: bin, workDir: dir }, scenario); + expect(result.ok).toBe(!mutated); + if (mutated) expect(result).toMatchObject({ phase: "assert", message: "unexpected mutation" }); + expect(flags).toEqual({ failure: true, success: false, post: false, cleanup: true }); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it.each([ + { status: "succeeded", error: "" }, + { status: "failed", error: "different error" }, + { status: "warning", error: "body too long" }, + { status: null, error: "" }, + ])("rejects an unmatched outcome $status/$error", async ({ status, error }) => { + const dir = await mkdtemp(join(tmpdir(), "ado-aw-negative-outcome-")); + try { + const bin = await outcomeBinary(dir, status, error); + const flags = { asserted: false, cleaned: false }; + const scenario: Scenario = { + tool: "update-pull-request", config: () => ({}), + setup: async () => ({}), ndjson: async () => ({}), + expectedFailure: { error: /too long/ }, + assertFailure: async () => { flags.asserted = true; }, + assert: async () => { flags.asserted = true; }, + cleanup: async () => { flags.cleaned = true; }, + }; + const result = await runScenario({ ...fakeCtx(), adoAwBin: bin, workDir: dir }, scenario); + expect(result).toMatchObject({ ok: false, phase: "execute" }); + expect(flags).toEqual({ asserted: false, cleaned: true }); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + it("passes an expected executor rejection without running assertions", async () => { const dir = await mkdtemp(join(tmpdir(), "ado-aw-runner-test-")); try { @@ -141,7 +241,11 @@ describe("runScenario prior entries", () => { * Fake `ado-aw` that turns every staged input line into an executed record, * preserving order. `statuses` overrides the status for a given tool. */ - async function writeEchoBin(dir: string, statuses: Record = {}): Promise { + async function writeEchoBin( + dir: string, + statuses: Record = {}, + statusesByIndex: Record = {}, + ): Promise { const bin = join(dir, "echo-ado-aw.js"); await writeFile( bin, @@ -150,14 +254,15 @@ const fs = require("node:fs"); const path = require("node:path"); const out = process.argv[process.argv.indexOf("--safe-output-dir") + 1]; const statuses = ${JSON.stringify(statuses)}; +const statusesByIndex = ${JSON.stringify(statusesByIndex)}; const lines = fs.readFileSync(path.join(out, "safe_outputs.ndjson"), "utf8") .split(/\\r?\\n/).filter((l) => l.trim()); const records = lines.map((l, i) => { const parsed = JSON.parse(l); return { name: parsed.name.replaceAll("-", "_"), - status: statuses[parsed.name] ?? "succeeded", - error: statuses[parsed.name] ? "synthetic prior failure" : null, + status: statusesByIndex[i] ?? statuses[parsed.name] ?? "succeeded", + error: statusesByIndex[i] || statuses[parsed.name] ? "synthetic prior failure" : null, result: { order: i, tool: parsed.name }, }; }); @@ -171,6 +276,61 @@ fs.writeFileSync( return bin; } + it.each(["succeeded", "failed"])("selects the second same-tool entry when its status is %s", async (status) => { + const dir = await mkdtemp(join(tmpdir(), "ado-aw-same-tool-")); + try { + const bin = await writeEchoBin(dir, {}, { 1: status }); + let asserted = false; + let cleaned = false; + const scenario: Scenario = { + tool: "update-pull-request", config: () => ({ max: 2 }), + setup: async () => ({}), ndjson: async () => ({ body: "second" }), + priorEntries: async () => [{ tool: "update-pull-request", config: { max: 2 }, entry: { body: "first" } }], + assert: async (_ctx, _state, record) => { + asserted = true; + expect(record.result?.order).toBe(1); + }, + cleanup: async () => { cleaned = true; }, + }; + const result = await runScenario({ ...fakeCtx(), adoAwBin: bin, workDir: dir }, scenario); + expect(result.ok).toBe(status === "succeeded"); + expect(asserted).toBe(status === "succeeded"); + expect(cleaned).toBe(true); + if (status === "failed") expect(result.phase).toBe("execute"); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("does not substitute a successful prior record for a missing same-tool primary", async () => { + const dir = await mkdtemp(join(tmpdir(), "ado-aw-missing-primary-")); + try { + const bin = join(dir, "only-prior.js"); + await writeFile(bin, ` +const fs = require("node:fs"); +const path = require("node:path"); +const out = process.argv[process.argv.indexOf("--safe-output-dir") + 1]; +fs.writeFileSync(path.join(out, "safe-outputs-executed.ndjson"), JSON.stringify({ + name: "update_pull_request", status: "succeeded", result: {order: 0} +}) + "\\n"); +`, "utf8"); + let cleaned = false; + const scenario: Scenario = { + tool: "update-pull-request", config: () => ({ max: 2 }), + setup: async () => ({}), ndjson: async () => ({ body: "second" }), + priorEntries: async () => [{ tool: "update-pull-request", config: { max: 2 }, entry: { body: "first" } }], + assert: async () => { throw new Error("primary record is absent"); }, + cleanup: async () => { cleaned = true; }, + }; + const result = await runScenario({ ...fakeCtx(), adoAwBin: bin, workDir: dir }, scenario); + expect(result).toMatchObject({ ok: false, phase: "execute" }); + expect(result.message).toContain("no executed record"); + expect(cleaned).toBe(true); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + function handoffScenario( onAssert: (records: ExecutedRecord[]) => void, onCleanup: (records: ExecutedRecord[] | undefined) => void = () => {}, diff --git a/scripts/ado-script/src/executor-e2e/execute-cli.ts b/scripts/ado-script/src/executor-e2e/execute-cli.ts index c483a58e5..bb382b19f 100644 --- a/scripts/ado-script/src/executor-e2e/execute-cli.ts +++ b/scripts/ado-script/src/executor-e2e/execute-cli.ts @@ -241,7 +241,8 @@ export async function runExecute(opts: RunExecuteOptions): Promise r.name === snake); + const priorOccurrences = priorEntries.filter((prior) => prior.tool === opts.tool).length; + const record = records.filter((r) => r.name === snake)[priorOccurrences]; return { exitCode, stdout, stderr, records, record, safeOutputDir }; } diff --git a/scripts/ado-script/src/executor-e2e/runner.ts b/scripts/ado-script/src/executor-e2e/runner.ts index 12ee5e477..b3f677b27 100644 --- a/scripts/ado-script/src/executor-e2e/runner.ts +++ b/scripts/ado-script/src/executor-e2e/runner.ts @@ -143,17 +143,29 @@ export async function runScenario( message: `no executed record for '${tool}' (exit ${result.exitCode}); stderr: ${result.stderr.trim().slice(0, 500)}`, }); } - if (result.record.status !== "succeeded") { - const expected = scenario.expectedFailure; + const expected = scenario.expectedFailure; + if (expected) { const error = result.record.error ?? ""; - if ( - expected && - (expected.status === undefined || result.record.status === expected.status) && - expected.error.test(error) - ) { - ctx.log(`[${scenarioId}] expected failure: ${error}`); - return finish({ ok: true }); + if (result.record.status === "succeeded" + || result.record.status !== (expected.status ?? "failed") + || !expected.error.test(error)) { + return finish({ + ok: false, + phase: "execute", + message: `expected rejection was not observed: status='${result.record.status}', error='${error}'`, + }); } + if (scenario.assertFailure) { + try { + await scenario.assertFailure(ctx, state, result.record, result.records); + } catch (err) { + return finish({ ok: false, phase: "assert", message: errMessage(err) }); + } + } + ctx.log(`[${scenarioId}] expected failure verified: ${error}`); + return finish({ ok: true }); + } + if (result.record.status !== "succeeded") { return finish({ ok: false, phase: "execute", diff --git a/scripts/ado-script/src/executor-e2e/scenario.ts b/scripts/ado-script/src/executor-e2e/scenario.ts index 0717e1963..dda1b3ee7 100644 --- a/scripts/ado-script/src/executor-e2e/scenario.ts +++ b/scripts/ado-script/src/executor-e2e/scenario.ts @@ -191,6 +191,13 @@ export interface Scenario { readonly status?: string; readonly error: RegExp; }; + /** Verify postconditions after a matching expected failure, such as no mutation. */ + assertFailure?( + ctx: ScenarioContext, + state: State, + record: ExecutedRecord, + records: ExecutedRecord[], + ): Promise; /** * Assert the ADO side-effect actually happened. Throw on failure. * diff --git a/scripts/ado-script/src/executor-e2e/scenarios/pr.ts b/scripts/ado-script/src/executor-e2e/scenarios/pr.ts index 085ad959b..3848ca8b9 100644 --- a/scripts/ado-script/src/executor-e2e/scenarios/pr.ts +++ b/scripts/ado-script/src/executor-e2e/scenarios/pr.ts @@ -293,12 +293,15 @@ export const updatePullRequestOversized: Scenario = { pull_request_id: state.prId, repository: ctx.adoRepo, body: "x".repeat(4001), }), expectedFailure: { error: /4000|4,000/ }, - assert: async (ctx, state) => { + assertFailure: async (ctx, state) => { const pr = await ctx.rest.getPullRequest(state.repo, state.prId); if (pr.description !== detBody(ctx, "update-pull-request-oversized")) { throw new Error("Rejected oversized body changed the live description"); } }, + assert: async () => { + throw new Error("Oversized PR description unexpectedly succeeded"); + }, cleanup: teardownPr, }; diff --git a/scripts/ado-script/src/shared/__tests__/ado-remote.test.ts b/scripts/ado-script/src/shared/__tests__/ado-remote.test.ts index 4fc3f1afa..400fc85c0 100644 --- a/scripts/ado-script/src/shared/__tests__/ado-remote.test.ts +++ b/scripts/ado-script/src/shared/__tests__/ado-remote.test.ts @@ -4,6 +4,7 @@ import { adoOrganizationFromCollectionUri, isCurrentAdoOrganization, parseAdoRepoUrl, + nativeTriggeringPrIdentity, } from "../ado-remote.js"; describe("parseAdoRepoUrl", () => { @@ -52,6 +53,20 @@ describe("parseAdoRepoUrl", () => { }); describe("ADO collection matching", () => { + it("captures equivalent native URL spellings but rejects malformed collection paths", () => { + const env = { + BUILD_REASON:"PullRequest", BUILD_REPOSITORY_PROVIDER:"TfsGit", + BUILD_REPOSITORY_URI:"https://DEV.AZURE.COM/org/Other/_git/target/", + BUILD_REPOSITORY_ID:"11111111-1111-1111-1111-111111111111", + SYSTEM_PULLREQUEST_PULLREQUESTID:"18446744073709551615", + SYSTEM_COLLECTIONURI:"https://org.visualstudio.com/DefaultCollection/", + }; + expect(nativeTriggeringPrIdentity(env)?.id).toBe("18446744073709551615"); + for (const uri of ["https://dev.azure.com/org/extra","https://org.visualstudio.com/OtherCollection/"]) { + expect(nativeTriggeringPrIdentity({...env,SYSTEM_COLLECTIONURI:uri})).toBeUndefined(); + } + expect(nativeTriggeringPrIdentity({...env,BUILD_REPOSITORY_URI:"https://dev.azure.com/org//Other/_git/target"})).toBeUndefined(); + }); it("extracts organizations from both service URL forms", () => { expect(adoOrganizationFromCollectionUri("https://dev.azure.com/MyOrg/")).toBe( "myorg", diff --git a/scripts/ado-script/src/shared/ado-remote.ts b/scripts/ado-script/src/shared/ado-remote.ts index 9dea0bf81..3f54f70b6 100644 --- a/scripts/ado-script/src/shared/ado-remote.ts +++ b/scripts/ado-script/src/shared/ado-remote.ts @@ -8,7 +8,7 @@ export interface AdoRepoIdentity { function decodeSegment(value: string): string | null { try { const decoded = decodeURIComponent(value); - return decoded.length > 0 ? decoded : null; + return decoded.length > 0 && !/[\/\\\u0000-\u001f\u007f]/.test(decoded) ? decoded : null; } catch { return null; } @@ -26,17 +26,18 @@ export function parseAdoRepoUrl(raw: string): AdoRepoIdentity | null { } catch { return null; } - if (url.protocol !== "https:") return null; + if (url.protocol !== "https:" || url.port || url.password || url.search || url.hash) return null; const host = url.hostname.toLowerCase(); - const parts = url.pathname.split("/").filter((part) => part.length > 0); + const parts = url.pathname.replace(/\/$/, "").split("/").slice(1); + if (parts.some((part) => !part)) return null; let organization: string; let projectPart: string; let repoPart: string; let collectionUri: string; if (host === "dev.azure.com") { - if (parts.length !== 4 || parts[2]?.toLowerCase() !== "_git") return null; + if (parts.length !== 4 || parts[2] !== "_git") return null; const orgPart = decodeSegment(parts[0] ?? ""); if (!orgPart) return null; organization = orgPart.toLowerCase(); @@ -47,12 +48,12 @@ export function parseAdoRepoUrl(raw: string): AdoRepoIdentity | null { const hasDefaultCollection = parts.length === 4 && parts[0]?.toLowerCase() === "defaultcollection" && - parts[2]?.toLowerCase() === "_git"; + parts[2] === "_git"; const directProject = - parts.length === 3 && parts[1]?.toLowerCase() === "_git"; + parts.length === 3 && parts[1] === "_git"; if (!hasDefaultCollection && !directProject) return null; organization = host.slice(0, -".visualstudio.com".length); - if (organization.length === 0) return null; + if (organization.length === 0 || organization.includes(".")) return null; projectPart = parts[hasDefaultCollection ? 1 : 0] ?? ""; repoPart = parts[hasDefaultCollection ? 3 : 2] ?? ""; collectionUri = hasDefaultCollection @@ -76,18 +77,86 @@ export function adoOrganizationFromCollectionUri(raw: string | undefined): strin } catch { return null; } + if (url.protocol !== "https:" || url.port || url.username || url.password || url.search || url.hash) return null; const host = url.hostname.toLowerCase(); if (host === "dev.azure.com") { - const org = url.pathname.split("/").find((part) => part.length > 0); + const parts = url.pathname.split("/").filter((part) => part.length > 0); + if (parts.length !== 1) return null; + const org = parts[0]; return org ? decodeSegment(org)?.toLowerCase() ?? null : null; } if (host.endsWith(".visualstudio.com")) { const org = host.slice(0, -".visualstudio.com".length); - return org.length > 0 ? org : null; + const parts = url.pathname.split("/").filter(Boolean); + return org.length > 0 && !org.includes(".") + && (parts.length === 0 || (parts.length === 1 && parts[0]?.toLowerCase() === "defaultcollection")) + ? org : null; } return null; } +export interface TriggeringPullRequest { + collection_uri: string; + project: string; + repository_name: string; + repository_id: string; + id: string; +} + +export function positivePrId(value: unknown): string | null { + if (typeof value === "number") { + return Number.isSafeInteger(value) && value > 0 ? String(value) : null; + } + if (typeof value !== "string" || !/^\d+$/.test(value)) return null; + const id = BigInt(value); + return id > 0n && id <= 18446744073709551615n ? id.toString() : null; +} + +export function parseTriggeringPrIdentity(value: unknown): TriggeringPullRequest | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const raw = value as Record; + if (typeof raw.collection_uri !== "string" || !adoOrganizationFromCollectionUri(raw.collection_uri) + || typeof raw.project !== "string" || !raw.project.trim() + || typeof raw.repository_name !== "string" || !raw.repository_name.trim() + || /[\/\\\u0000-\u001f\u007f]/.test(raw.project) + || /[\/\\\u0000-\u001f\u007f]/.test(raw.repository_name) + || /\$\(|\$\[|\$\{\{|##vso\[|##\[|\{\{/.test(raw.project) + || /\$\(|\$\[|\$\{\{|##vso\[|##\[|\{\{/.test(raw.repository_name) + || typeof raw.repository_id !== "string" + || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw.repository_id) + || !positivePrId(raw.id)) return undefined; + return { + collection_uri: raw.collection_uri, project: raw.project, + repository_name: raw.repository_name, repository_id: raw.repository_id, + id: positivePrId(raw.id)!, + }; +} + +/** Capture the target Build.Repository metadata, never the fork's SourceRepositoryURI. */ +export function nativeTriggeringPrIdentity(env: NodeJS.ProcessEnv): TriggeringPullRequest | undefined { + const read = (projected: string, native: string): string | undefined => + env.ADO_AW_TRIGGERING_PR_CAPTURED !== undefined ? env[projected] : env[native]; + if (read("ADO_AW_TRIGGER_BUILD_REASON", "BUILD_REASON") !== "PullRequest" + || read("ADO_AW_TRIGGER_REPOSITORY_PROVIDER", "BUILD_REPOSITORY_PROVIDER") !== "TfsGit") return undefined; + const remote = parseAdoRepoUrl(read("ADO_AW_TRIGGER_REPOSITORY_URI", "BUILD_REPOSITORY_URI") ?? ""); + const collection = read("ADO_AW_TRIGGER_COLLECTION_URI", "SYSTEM_COLLECTIONURI") + ?? (env.ADO_AW_TRIGGERING_PR_CAPTURED === undefined ? env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI : undefined); + if (!remote || !collection || adoOrganizationFromCollectionUri(collection) !== remote.organization) return undefined; + return parseTriggeringPrIdentity({ + collection_uri: collection, project: remote.project, repository_name: remote.repository, + repository_id: read("ADO_AW_TRIGGER_REPOSITORY_ID", "BUILD_REPOSITORY_ID"), + id: read("ADO_AW_TRIGGER_PR_ID", "SYSTEM_PULLREQUEST_PULLREQUESTID"), + }); +} + +export function readTriggeringPrIdentity(env: NodeJS.ProcessEnv): TriggeringPullRequest | undefined { + if (env.ADO_AW_TRIGGERING_PR_IDENTITY !== undefined) { + try { return parseTriggeringPrIdentity(JSON.parse(env.ADO_AW_TRIGGERING_PR_IDENTITY)); } + catch { return undefined; } + } + return nativeTriggeringPrIdentity(env); +} + export function isCurrentAdoOrganization( identity: AdoRepoIdentity, env: NodeJS.ProcessEnv, diff --git a/src/compile/agentic_pipeline.rs b/src/compile/agentic_pipeline.rs index bd445c00b..2c602d1ae 100644 --- a/src/compile/agentic_pipeline.rs +++ b/src/compile/agentic_pipeline.rs @@ -72,9 +72,7 @@ use super::common::{ HEADER_MARKER, MCPG_CONTAINER_NAME, MCPG_DOMAIN, MCPG_IMAGE, MCPG_PORT, MCPG_VERSION, image_ref, }; -use super::container_invocation::{ - DockerMount, DockerRun, DockerTmpfs, ShellWord, -}; +use super::container_invocation::{DockerMount, DockerRun, DockerTmpfs, ShellWord}; use super::custom_tools::{CustomToolDefinition, collect_custom_tool_definitions}; use super::extensions::ado_script as paths; use super::extensions::{CompileContext, CompilerExtension, Declarations, Extension, McpgConfig}; @@ -1519,7 +1517,7 @@ fn agent_job_variables_hoist( use crate::compile::ir::output::OutputRef; if !front_matter.is_synthetic_pr() { - return Ok(Vec::new()); + return triggering_pr_variables(front_matter); } let synth = StepId::new("synthPr")?; let mut out: Vec = Vec::new(); @@ -1540,6 +1538,52 @@ fn agent_job_variables_hoist( Ok(out) } +const NATIVE_TRIGGER_VARIABLES: &[(&str, &str)] = &[ + ("ADO_AW_TRIGGER_COLLECTION_URI", "System.CollectionUri"), + ("ADO_AW_TRIGGER_REPOSITORY_URI", "Build.Repository.Uri"), + ("ADO_AW_TRIGGER_REPOSITORY_ID", "Build.Repository.ID"), + ( + "ADO_AW_TRIGGER_REPOSITORY_PROVIDER", + "Build.Repository.Provider", + ), + ("ADO_AW_TRIGGER_BUILD_REASON", "Build.Reason"), + ("ADO_AW_TRIGGER_PR_ID", "System.PullRequest.PullRequestId"), +]; + +fn triggering_pr_variables(front_matter: &FrontMatter) -> Result> { + if front_matter.is_synthetic_pr() { + return Ok(vec![JobVariable { + name: "AW_PR_TRIGGERING_IDENTITY".into(), + value: EnvValue::coalesce(vec![EnvValue::step_output(OutputRef::new( + StepId::new("synthPr")?, + "AW_PR_TRIGGERING_IDENTITY", + ))]), + }]); + } + Ok(NATIVE_TRIGGER_VARIABLES + .iter() + .map(|(name, native)| JobVariable { + name: (*name).into(), + value: EnvValue::coalesce(vec![EnvValue::pipeline_var(*native)]), + }) + .collect()) +} + +fn project_triggering_pr_env(mut step: BashStep, front_matter: &FrontMatter) -> BashStep { + if front_matter.is_synthetic_pr() { + step = step.with_env( + "ADO_AW_TRIGGERING_PR_IDENTITY", + EnvValue::pipeline_var("AW_PR_TRIGGERING_IDENTITY"), + ); + } else { + step = step.with_env("ADO_AW_TRIGGERING_PR_CAPTURED", EnvValue::literal("true")); + for (name, _) in NATIVE_TRIGGER_VARIABLES { + step = step.with_env(*name, EnvValue::pipeline_var(*name)); + } + } + step +} + /// The Agent-job condition fold lives inline in [`build_agent_job`]. /// Per-extension contributions arrive via /// [`crate::compile::extensions::Declarations::agent_conditions`] @@ -2329,9 +2373,7 @@ fn prepare_custom_agent_output_step(config_path: &str, output_path: &str) -> Bas fn agent_temp_filename(path: &str) -> String { let prefix = "$(Agent.TempDirectory)/"; path.strip_prefix(prefix) - .unwrap_or_else(|| panic!( - "custom-tools config path {path:?} must start with {prefix:?}" - )) + .unwrap_or_else(|| panic!("custom-tools config path {path:?} must start with {prefix:?}")) .to_string() } @@ -2655,14 +2697,17 @@ fn build_safeoutputs_job( resolved_config_path, )?)); // Execute safe outputs (Stage 3) — typed BashStep with typed env block - steps.push(Step::Bash(execute_safe_outputs_step( - &layout.source_path, - resolved_config_path, - &layout.self_repository_directory, - &cfg.self_repository_name, - &executor_ado_env, - &variant.filter_args, - )?)); + steps.push(Step::Bash(project_triggering_pr_env( + execute_safe_outputs_step( + &layout.source_path, + resolved_config_path, + &layout.self_repository_directory, + &cfg.self_repository_name, + &executor_ado_env, + &variant.filter_args, + )?, + front_matter, + ))); if let Some(app) = github_app && !app.skip_token_revocation { @@ -2689,6 +2734,7 @@ fn build_safeoutputs_job( cfg.pools.safe_outputs.clone() }; let mut job = Job::new(prefix.id(variant.base)?, variant.display, safeoutputs_pool); + job.variables = triggering_pr_variables(front_matter)?; job.steps = steps; // **Marquee**: condition uses typed Expr::StepOutput on Detection's // threatAnalysis.SafeToProcess output. Lowering picks the cross-job @@ -3234,20 +3280,15 @@ fn build_conclusion_job( // defaults (type: Task, no area/iteration path). The global // report-failure-as-work-item toggle controls whether it files at all. for tool_key in &["noop", "missing-tool", "missing-data"] { - conclusion_step = - apply_conclusion_tool_config_env(conclusion_step, front_matter, tool_key); + conclusion_step = apply_conclusion_tool_config_env(conclusion_step, front_matter, tool_key); } // Pass upstream job results via job-level variables hoist. // ADO only evaluates $[...] runtime expressions inside `variables:` and // `condition:` — NOT in step env blocks. We hoist to job variables and // reference them as $(name) macros in the step env. - let (conclusion_variables, conclusion_step) = hoist_conclusion_job_results( - conclusion_step, - prefix, - custom_defs, - has_reviewed_job, - )?; + let (conclusion_variables, conclusion_step) = + hoist_conclusion_job_results(conclusion_step, prefix, custom_defs, has_reviewed_job)?; steps.push(Step::Bash(conclusion_step)); @@ -3406,6 +3447,17 @@ fn wire_explicit_dependencies( } j.depends_on = deps; } + if has_setup + && (j.id == safeoutputs_id || j.id == reviewed_id) + && j.variables + .iter() + .any(|variable| variable.name == "AW_PR_TRIGGERING_IDENTITY") + && !j.depends_on.contains(&setup_id) + { + // ADO's dependencies context exposes only direct dependencies. + // These jobs consume Setup's trusted identity, not an Agent relay. + j.depends_on.push(setup_id.clone()); + } } Ok(()) } @@ -3964,8 +4016,7 @@ fn prepare_mcpg_config_step( {mcpg_sentinel}" ); let custom_tools_fragment = if let Some(custom_tools_json) = custom_tools_json { - let sentinel = - super::common::heredoc_sentinel("CUSTOM_TOOLS_JSON_EOF", custom_tools_json)?; + let sentinel = super::common::heredoc_sentinel("CUSTOM_TOOLS_JSON_EOF", custom_tools_json)?; format!( "# Write compiler-generated dynamic SafeOutputs tool definitions\n\ cat > \"$AGENT_TEMP/staging/custom-tools.json\" << '{sentinel}'\n\ @@ -4743,10 +4794,7 @@ fn execute_safe_outputs_step( // no part of it needs separate lowering. EnvValue::literal(self_repository_directory), ); - script = script.with_env( - "ADO_AW_SELF_REPOSITORY_NAME", - self_repository_name.clone(), - ); + script = script.with_env("ADO_AW_SELF_REPOSITORY_NAME", self_repository_name.clone()); Ok(script) } @@ -4836,61 +4884,79 @@ fn safe_outputs_summary_step(front_matter: &FrontMatter, reviewed: &[String]) -> use super::ir::env::EnvValue; let approval_summary_path = super::extensions::ado_script::APPROVAL_SUMMARY_PATH; let repository_policies = approval_summary_repository_policies(front_matter)?; - let pr_policies: serde_json::Map = front_matter - .safe_outputs - .iter() - .filter(|(tool, _)| { - super::pr_migration::PR_OPERATIONS - .iter() - .any(|(_, focused)| *focused == tool.as_str()) - || tool.as_str() == "abandon-pull-request" - }) - .map(|(tool, config)| { - let policy = ["target", "target-repo", "operation"] - .into_iter() - .filter_map(|key| { - config - .get(key) - .map(|value| (key.to_string(), value.clone())) - }) - .collect::>(); - (tool.clone(), serde_json::Value::Object(policy)) - }) - .collect(); + let pr_policies = approval_summary_pr_policies(front_matter)?; let github_api_url = front_matter .github_safe_outputs_auth()? .map(|auth| auth.api_url().to_string()) .unwrap_or_default(); - Ok(ShellScript::new(&SAFE_OUTPUTS_SUMMARY) - .bind_text("APPROVAL_SUMMARY_PATH", approval_summary_path) - .into_step("Render safe-outputs summary") - .with_env( - "AW_SAFE_OUTPUTS_NDJSON", - EnvValue::literal("$(Agent.TempDirectory)/staging/safe_outputs.ndjson"), - ) - .with_env( - "AW_APPROVAL_SUMMARY_OUT", - EnvValue::literal("$(Agent.TempDirectory)/ado-aw-safe-outputs.md"), - ) - .with_env("AW_REVIEWED_TOOLS", EnvValue::literal(reviewed.join("\n"))) - .with_env( - "AW_PR_POLICIES", - EnvValue::literal(serde_json::to_string(&pr_policies)?), - ) - .with_env( - "AW_GITHUB_REPOSITORY_POLICIES", - EnvValue::literal(repository_policies), - ) - .with_env( - "AW_CURRENT_REPOSITORY", - EnvValue::ado_macro("Build.Repository.Name")?, - ) - .with_env( - "AW_CURRENT_REPOSITORY_PROVIDER", - EnvValue::ado_macro("Build.Repository.Provider")?, - ) - .with_env("AW_GITHUB_API_URL", EnvValue::literal(github_api_url)) - .with_condition(Condition::Always)) + Ok(project_triggering_pr_env( + ShellScript::new(&SAFE_OUTPUTS_SUMMARY) + .bind_text("APPROVAL_SUMMARY_PATH", approval_summary_path) + .into_step("Render safe-outputs summary") + .with_env( + "AW_SAFE_OUTPUTS_NDJSON", + EnvValue::literal("$(Agent.TempDirectory)/staging/safe_outputs.ndjson"), + ) + .with_env( + "AW_APPROVAL_SUMMARY_OUT", + EnvValue::literal("$(Agent.TempDirectory)/ado-aw-safe-outputs.md"), + ) + .with_env("AW_REVIEWED_TOOLS", EnvValue::literal(reviewed.join("\n"))) + .with_env( + "AW_PR_POLICIES", + EnvValue::literal(serde_json::to_string(&pr_policies)?), + ) + .with_env( + "AW_GITHUB_REPOSITORY_POLICIES", + EnvValue::literal(repository_policies), + ) + .with_env( + "AW_CURRENT_REPOSITORY", + EnvValue::ado_macro("Build.Repository.Name")?, + ) + .with_env( + "AW_CURRENT_REPOSITORY_PROVIDER", + EnvValue::ado_macro("Build.Repository.Provider")?, + ) + .with_env("AW_GITHUB_API_URL", EnvValue::literal(github_api_url)) + .with_condition(Condition::Always), + front_matter, + )) +} + +fn approval_summary_pr_policies( + front_matter: &FrontMatter, +) -> Result> { + use crate::safe_outputs::pr_common::PrTargetPolicy; + let mut policies = serde_json::Map::new(); + for tool in front_matter.safe_outputs.keys() { + let policy = match tool.as_str() { + "update-pull-request" => { + let config = front_matter + .update_pull_request_config()? + .unwrap_or_default(); + serde_json::json!({"target": config.target_policy()?, "operation": config.operation}) + } + "abandon-pull-request" => { + let config = front_matter + .abandon_pull_request_config()? + .unwrap_or_default(); + serde_json::json!({"target": config.target_policy()?, "target-repo": config.target_repo}) + } + "add-pull-request-reviewers" + | "add-pull-request-labels" + | "set-pull-request-auto-complete" + | "submit-pull-request-review" + | "add-pull-request-comment" + | "reply-to-pull-request-comment" + | "resolve-pull-request-thread" => { + serde_json::json!({"target": PrTargetPolicy::Explicit}) + } + _ => continue, + }; + policies.insert(tool.clone(), policy); + } + Ok(policies) } shell_script! { @@ -5208,10 +5274,7 @@ fn start_azure_wif_refresh_steps(front_matter: &FrontMatter) -> Result .bind_text("REFRESH_BUNDLE", paths::AZURE_WIF_REFRESH_PATH) .bind_text("CLIENT_VARIABLE", client_variable.as_str()) .bind_text("TENANT_VARIABLE", tenant_variable.as_str()) - .fragment( - "run_container", - azure_wif_refresh_container_invocation()?, - ) + .fragment("run_container", azure_wif_refresh_container_invocation()?) .render(); let task = AzureCliV3::new( AzureCliV3Connection::AzureRm(auth.service_connection.as_str().to_string()), @@ -5346,23 +5409,14 @@ fn start_ado_proxy_step(front_matter: &FrontMatter) -> BashStep { Binding::text(ado_proxy_container_entrypoint_flattened()), ) .fragment("resolve_org", common::resolve_ado_organization_bash()) - .fragment( - "setup_workdir", - phase_body(&START_ADO_PROXY_SETUP_WORKDIR), - ) + .fragment("setup_workdir", phase_body(&START_ADO_PROXY_SETUP_WORKDIR)) .fragment("write_policy", phase_body(&START_ADO_PROXY_WRITE_POLICY)) - .fragment( - "mint_material", - phase_body(&START_ADO_PROXY_MINT_MATERIAL), - ) + .fragment("mint_material", phase_body(&START_ADO_PROXY_MINT_MATERIAL)) .fragment( "build_material", phase_body(&START_ADO_PROXY_BUILD_MATERIAL), ) - .fragment( - "run_container", - ado_proxy_run_container_phase(), - ) + .fragment("run_container", ado_proxy_run_container_phase()) .fragment( "handover_material", phase_body(&START_ADO_PROXY_HANDOVER_MATERIAL), @@ -5404,8 +5458,7 @@ fn ado_proxy_run_container_phase() -> String { fn ado_proxy_container_invocation() -> DockerRun { DockerRun::new( - ShellWord::variable("PROXY_IMAGE") - .expect("compiler-owned shell variable must be valid"), + ShellWord::variable("PROXY_IMAGE").expect("compiler-owned shell variable must be valid"), ) .detached() .name( @@ -5413,8 +5466,7 @@ fn ado_proxy_container_invocation() -> DockerRun { .expect("compiler-owned shell variable must be valid"), ) .network( - ShellWord::variable("PROXY_NETWORK") - .expect("compiler-owned shell variable must be valid"), + ShellWord::variable("PROXY_NETWORK").expect("compiler-owned shell variable must be valid"), ) .entrypoint(ShellWord::literal("sh").expect("static entrypoint must be valid")) .mount( @@ -5445,8 +5497,7 @@ fn ado_proxy_container_invocation() -> DockerRun { ) .mount( DockerMount::read_write( - ShellWord::literal("/tmp/gh-aw/ado-proxy-logs") - .expect("static log path must be valid"), + ShellWord::literal("/tmp/gh-aw/ado-proxy-logs").expect("static log path must be valid"), "/var/log/ado-proxy", ) .expect("static ado-proxy log mount must be valid"), @@ -6706,7 +6757,10 @@ fn verify_mcp_backends_step() -> BashStep { ShellScript::new(&VERIFY_MCP_BACKENDS) .bind("MCPG_PORT", Binding::number(MCPG_PORT.into())) .into_step("Verify MCP backends") - .with_env("MCPG_API_KEY", EnvValue::pipeline_var("MCP_GATEWAY_API_KEY")) + .with_env( + "MCPG_API_KEY", + EnvValue::pipeline_var("MCP_GATEWAY_API_KEY"), + ) } // ───────────────────────────────────────────────────────────────────── @@ -6994,6 +7048,185 @@ mod tests { serde_yaml::from_str(yaml).expect("front matter should parse") } + #[test] + fn preview_target_policy_normalizes_fixed_numeric_and_quoted_u64() { + for tool in ["update-pull-request", "abandon-pull-request"] { + for id in ["42", "18446744073709551615"] { + let policies: Vec<_> = [id.to_string(), format!("\"{id}\"")].into_iter().map(|target| { + let fm = test_front_matter(&format!( + "name: trigger-contract\ndescription: Test\nsafe-outputs:\n {tool}:\n target: {target}\n" + )); + approval_summary_pr_policies(&fm).unwrap() + }).collect(); + assert_eq!(policies[0], policies[1]); + assert_eq!( + policies[0][tool]["target"], + serde_json::json!({"kind":"fixed","id":id}) + ); + } + } + let fm = test_front_matter( + "name: trigger-contract\ndescription: Test\nsafe-outputs:\n update-pull-request:\n add-pull-request-labels:\n", + ); + let policies = approval_summary_pr_policies(&fm).unwrap(); + assert_eq!( + policies["update-pull-request"]["target"]["kind"], + "triggering" + ); + assert_eq!( + policies["add-pull-request-labels"]["target"]["kind"], + "explicit" + ); + } + + #[test] + fn trigger_identity_reaches_preview_and_both_executor_variants_for_all_targets() { + use crate::compile::extensions::{CompileContext, collect_extensions}; + fn jobs(value: &serde_yaml::Value, out: &mut Vec) { + match value { + serde_yaml::Value::Mapping(map) => { + if map.get("job").and_then(serde_yaml::Value::as_str).is_some() + && (map.contains_key("steps") || map.contains_key("templateContext")) + { + out.push(value.clone()); + } + for value in map.values() { + jobs(value, out); + } + } + serde_yaml::Value::Sequence(values) => { + for value in values { + jobs(value, out); + } + } + _ => {} + } + } + fn has_setup_dependency(value: &serde_yaml::Value) -> bool { + match value { + serde_yaml::Value::Mapping(map) => { + map.get("dependsOn").is_some_and(|depends| { + serde_yaml::to_string(depends).unwrap().contains("Setup") + }) || map.values().any(has_setup_dependency) + } + serde_yaml::Value::Sequence(values) => values.iter().any(has_setup_dependency), + _ => false, + } + } + for target in ["standalone", "1es", "job", "stage"] { + for mode in ["policy", "synthetic"] { + let fm = test_front_matter(&format!( + "name: trigger-contract\ndescription: Test\ntarget: {target}\non:\n pr:\n mode: {mode}\nsafe-outputs:\n update-pull-request:\n abandon-pull-request:\n require-approval: true\n" + )); + let extensions = collect_extensions(&fm); + let ctx = CompileContext::for_test(&fm); + let input = Path::new("trigger-contract.md"); + let output = Path::new("trigger-contract.lock.yml"); + let pipeline = match target { + "standalone" => super::super::standalone_ir::build_standalone_pipeline( + &fm, + &extensions, + &ctx, + input, + output, + "Review the PR", + true, + false, + ), + "1es" => super::super::onees_ir::build_onees_pipeline( + &fm, + &extensions, + &ctx, + input, + output, + "Review the PR", + true, + false, + ), + "job" => super::super::job_ir::build_job_pipeline( + &fm, + &extensions, + &ctx, + input, + output, + "Review the PR", + true, + false, + ), + _ => super::super::stage_ir::build_stage_pipeline( + &fm, + &extensions, + &ctx, + input, + output, + "Review the PR", + true, + false, + ), + } + .unwrap(); + let emitted = super::super::ir::emit::emit(&pipeline).unwrap(); + let yaml: serde_yaml::Value = serde_yaml::from_str(&emitted).unwrap(); + let mut found = Vec::new(); + jobs(&yaml, &mut found); + for suffix in ["Agent", "SafeOutputs", "SafeOutputs_Reviewed"] { + let job = found + .iter() + .find(|job| job["job"].as_str().unwrap().ends_with(suffix)) + .unwrap(); + let display = if suffix == "Agent" { + "Render safe-outputs summary" + } else { + "Execute safe outputs (Stage 3)" + }; + let steps = job["steps"] + .as_sequence() + .or_else(|| job["templateContext"]["steps"].as_sequence()) + .unwrap(); + let step = steps + .iter() + .find(|step| step["displayName"].as_str() == Some(display)) + .unwrap(); + if mode == "synthetic" { + assert_eq!( + step["env"]["ADO_AW_TRIGGERING_PR_IDENTITY"].as_str(), + Some("$(AW_PR_TRIGGERING_IDENTITY)"), + "{target} {suffix}" + ); + let variables = serde_yaml::to_string(&job["variables"]).unwrap(); + assert!( + variables.contains( + "dependencies.Setup.outputs['synthPr.AW_PR_TRIGGERING_IDENTITY']" + ), + "{target} {suffix}: {variables}" + ); + assert!( + has_setup_dependency(job), + "{target} {suffix} missing direct Setup dependency" + ); + } else { + assert_eq!( + step["env"]["ADO_AW_TRIGGERING_PR_CAPTURED"].as_str(), + Some("true") + ); + for (name, native) in NATIVE_TRIGGER_VARIABLES { + assert_eq!( + step["env"][*name].as_str(), + Some(format!("$({name})").as_str()) + ); + let variables = serde_yaml::to_string(&job["variables"]).unwrap(); + assert!( + variables.contains(native), + "{target} {mode} {suffix} {native}: {variables}" + ); + } + assert!(!emitted.contains("synthPr")); + } + } + } + } + } + fn test_ctx() -> StandaloneCtx { let test_pool = Pool::VmImage("ubuntu-latest".to_string()); StandaloneCtx { @@ -7780,7 +8013,11 @@ safe-outputs: assert_eq!(keys, vec![client.as_str(), tenant.as_str()]); } let plain = test_front_matter("name: t\ndescription: d\n"); - assert!(awf_exclude_keys(&plain, true, &plain.engine).unwrap().is_empty()); + assert!( + awf_exclude_keys(&plain, true, &plain.engine) + .unwrap() + .is_empty() + ); } #[test] @@ -7792,12 +8029,20 @@ safe-outputs: let keys = awf_exclude_keys(&fm, true, &provider.engine).unwrap(); assert_eq!(keys.len(), 3); assert!(keys.contains(&"COPILOT_PROVIDER_API_KEY".to_string())); - assert!(keys.contains( - &super::super::mcpg::azure_auth_client_variable("kusto").unwrap().into_inner() - )); - assert!(keys.contains( - &super::super::mcpg::azure_auth_tenant_variable("kusto").unwrap().into_inner() - )); + assert!( + keys.contains( + &super::super::mcpg::azure_auth_client_variable("kusto") + .unwrap() + .into_inner() + ) + ); + assert!( + keys.contains( + &super::super::mcpg::azure_auth_tenant_variable("kusto") + .unwrap() + .into_inner() + ) + ); } #[test] @@ -7921,9 +8166,9 @@ safe-outputs: step.script ); assert!( - step.script.contains( - "printf '%s' \"$PROXY_MATERIAL\" | docker exec -i \"$PROXY_CONTAINER\"" - ) && step.script.contains("cat > /tmp/ado-proxy-material"), + step.script + .contains("printf '%s' \"$PROXY_MATERIAL\" | docker exec -i \"$PROXY_CONTAINER\"") + && step.script.contains("cat > /tmp/ado-proxy-material"), "material must stream through the container-private FIFO: {}", step.script ); @@ -7982,8 +8227,7 @@ safe-outputs: let copy = copy_logs_step("/tmp/copilot", false); assert!(copy.script.contains("/tmp/gh-aw/ado-proxy-logs")); assert!( - copy.script - .contains("AGENT_TEMP='$(Agent.TempDirectory)'") + copy.script.contains("AGENT_TEMP='$(Agent.TempDirectory)'") && copy .script .contains(r#""$AGENT_TEMP/staging/logs/ado-proxy""#), @@ -8020,9 +8264,8 @@ safe-outputs: ); assert!( script.contains(&format!("CA_HOST_PATH='{ADO_PROXY_PUBLIC_CA_HOST_PATH}'")) - && script.contains( - "##vso[task.setvariable variable=ADO_PROXY_CA_FILE]$CA_HOST_PATH" - ), + && script + .contains("##vso[task.setvariable variable=ADO_PROXY_CA_FILE]$CA_HOST_PATH"), "clients need the published certificate's path: {script}" ); assert!( @@ -8075,10 +8318,8 @@ safe-outputs: "docker run must reuse the bound $PROXY_IMAGE: {script}" ); assert!( - script.contains(&format!( - "PROXY_SCRIPT_PATH='{}'", - paths::ADO_PROXY_PATH - )) && script.contains("\"${PROXY_SCRIPT_PATH}:/app/ado-proxy.js:ro\""), + script.contains(&format!("PROXY_SCRIPT_PATH='{}'", paths::ADO_PROXY_PATH)) + && script.contains("\"${PROXY_SCRIPT_PATH}:/app/ado-proxy.js:ro\""), "docker run must mount the bound ado-proxy bundle: {script}" ); } diff --git a/src/compile/codemods/0009_split_update_pr.rs b/src/compile/codemods/0009_split_update_pr.rs index 722b088da..9db43e627 100644 --- a/src/compile/codemods/0009_split_update_pr.rs +++ b/src/compile/codemods/0009_split_update_pr.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use serde_yaml::{Mapping, Value}; +use serde_yaml::Mapping; use super::{Codemod, CodemodContext}; @@ -11,17 +11,17 @@ pub static CODEMOD: Codemod = Codemod { }; fn apply(front_matter: &mut Mapping, _ctx: &CodemodContext) -> Result { - let key = Value::String("safe-outputs".to_string()); - let Some(raw) = front_matter.get(&key) else { - return Ok(false); - }; - let value = serde_json::to_value(raw)?; - let Some(mut outputs) = value.as_object().cloned() else { - return Ok(false); - }; - if !crate::compile::pr_migration::migrate_safe_outputs(&mut outputs)? { + if front_matter + .get("safe-outputs") + .and_then(|outputs| outputs.get("update-pr")) + .is_none() + { return Ok(false); } - front_matter.insert(key, serde_yaml::to_value(outputs)?); - Ok(true) + let custom_jobs = crate::compile::imports::pr_policy::local_custom_job_names(front_matter)?; + crate::compile::imports::pr_policy::transform_builtins( + front_matter, + &custom_jobs, + crate::compile::pr_migration::migrate_safe_outputs, + ) } diff --git a/src/compile/codemods/0010_pull_request_tool_names.rs b/src/compile/codemods/0010_pull_request_tool_names.rs index 32ce03139..1966bf26d 100644 --- a/src/compile/codemods/0010_pull_request_tool_names.rs +++ b/src/compile/codemods/0010_pull_request_tool_names.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use serde_yaml::{Mapping, Value}; +use serde_yaml::Mapping; use super::{Codemod, CodemodContext}; @@ -11,17 +11,16 @@ pub static CODEMOD: Codemod = Codemod { }; fn apply(front_matter: &mut Mapping, _ctx: &CodemodContext) -> Result { - let key = Value::String("safe-outputs".to_string()); - let Some(raw) = front_matter.get(&key) else { + let Some(outputs) = front_matter.get("safe-outputs") else { return Ok(false); }; - let value = serde_json::to_value(raw)?; - let Some(mut outputs) = value.as_object().cloned() else { - return Ok(false); - }; - if !crate::compile::pr_migration::rename_pr_tools(&mut outputs)? { + if !crate::compile::pr_migration::PR_TOOL_RENAMES + .iter() + .any(|(old, _)| outputs.get(*old).is_some()) + && outputs.get("budget-groups").is_none() + { return Ok(false); } - front_matter.insert(key, serde_yaml::to_value(outputs)?); - Ok(true) + let custom_jobs = crate::compile::imports::pr_policy::local_custom_job_names(front_matter)?; + crate::compile::imports::pr_policy::rename_declarations(front_matter, &custom_jobs) } diff --git a/src/compile/common.rs b/src/compile/common.rs index c277c2415..c4f41c7b6 100644 --- a/src/compile/common.rs +++ b/src/compile/common.rs @@ -179,8 +179,8 @@ fn atomic_write_blocking(path: &Path, contents: &str) -> Result<()> { /// See [`parse_markdown_detailed`]. #[derive(Debug)] pub struct ParsedSource { - /// Typed front matter, after codemods have been applied to the - /// underlying mapping. + /// Typed root front matter. PR migrations are deferred when imports exist; + /// callers needing effective policy must use the import preparation path. pub front_matter: FrontMatter, /// Body for compilation, with leading/trailing whitespace trimmed /// (matches the legacy `parse_markdown` second tuple element). @@ -268,6 +268,8 @@ pub(crate) fn split_markdown_front_matter( /// Use this from callers that may rewrite the source (the `compile` /// command). Callers that only want the typed view of the front matter /// should use the backward-compatible [`parse_markdown`] wrapper. +/// Neither parser resolves imports; use `prepare_source_front_matter` for +/// effective execution policy, including import-aware PR migrations. pub fn parse_markdown_detailed(content: &str) -> Result { parse_markdown_detailed_with_registry(content, super::codemods::CODEMODS, None) } @@ -326,10 +328,22 @@ pub(crate) fn parse_markdown_detailed_with_registry( } }; - // Stage 2: run the codemod registry against the untyped mapping. - let report = - super::codemods::apply_codemods_with(&mut mapping, registry, source_compiler_version) - .context("Failed to apply codemods")?; + // PR identity/ownership must survive until import substitution and merging. + let has_imports = mapping + .get("imports") + .and_then(serde_yaml::Value::as_sequence) + .is_some_and(|imports| !imports.is_empty()); + let initial_registry: Vec<_> = registry + .iter() + .copied() + .filter(|codemod| !has_imports || !is_import_deferred_codemod(codemod.id)) + .collect(); + let report = super::codemods::apply_codemods_with( + &mut mapping, + &initial_registry, + source_compiler_version, + ) + .context("Failed to apply codemods")?; // Stage 3: deserialize the (possibly modified) mapping into the // typed FrontMatter. Errors here mean either the user wrote an @@ -360,6 +374,41 @@ pub(crate) fn parse_markdown_detailed_with_registry( }) } +fn is_import_deferred_codemod(id: &str) -> bool { + matches!(id, "split_update_pr" | "pull_request_tool_names") +} + +/// Finish root-only source migration after imported custom-job ownership is known. +pub(crate) fn finish_import_codemods( + parsed: &mut ParsedSource, + registry: &[&'static super::codemods::Codemod], +) -> Result<()> { + let deferred: Vec<_> = registry + .iter() + .copied() + .filter(|codemod| is_import_deferred_codemod(codemod.id)) + .collect(); + let mut mapping = parsed.front_matter_mapping.clone(); + let mut custom = Vec::new(); + if let Some(serde_yaml::Value::Mapping(outputs)) = mapping.get_mut("safe-outputs") { + for name in parsed.front_matter.custom_safe_output_tool_names() { + let key = serde_yaml::Value::String(name); + if let Some(value) = outputs.remove(&key) { + custom.push((key, value)); + } + } + } + let report = super::codemods::apply_codemods_with(&mut mapping, &deferred, None)?; + if report.changed() { + if let Some(serde_yaml::Value::Mapping(outputs)) = mapping.get_mut("safe-outputs") { + outputs.extend(custom); + } + parsed.front_matter_mapping = mapping; + parsed.codemods.applied.extend(report.applied); + } + Ok(()) +} + /// Reconstruct full source content from codemod outputs. /// /// Takes the individual fragments rather than the full @@ -3061,6 +3110,7 @@ pub fn validate_submit_pr_review_events(front_matter: &FrontMatter) -> Result<() /// Validate the PR tool family, including temporary-reference lanes and shared budgets. pub fn validate_pull_request_outputs_config(front_matter: &FrontMatter) -> Result<()> { + super::pr_migration::validate_legacy_metadata(front_matter)?; super::pr_migration::validate_budget_groups(front_matter)?; if let Some(config) = front_matter .typed_safe_output_config::( @@ -3161,12 +3211,22 @@ pub fn validate_pull_request_outputs_config(front_matter: &FrontMatter) -> Resul /// runtime error. Catching this at compile time is consistent with how /// `validate_submit_pr_review_events` handles the analogous case. pub fn validate_update_pr_votes(front_matter: &FrontMatter) -> Result<()> { - if let Some(config_value) = front_matter.safe_outputs.get("update-pr").or_else(|| { - front_matter - .safe_outputs - .get("submit-pull-request-review") - .and_then(|config| config.get(super::pr_migration::LEGACY_PR_CONFIG)) - }) && let Some(obj) = config_value.as_object() + if let Some(config_value) = front_matter + .safe_outputs + .get("update-pr") + .filter(|_| { + !front_matter + .custom_safe_output_tool_names() + .iter() + .any(|name| name == "update-pr") + }) + .or_else(|| { + front_matter + .safe_outputs + .get("submit-pull-request-review") + .and_then(|config| config.get(super::pr_migration::LEGACY_PR_CONFIG)) + }) + && let Some(obj) = config_value.as_object() { // Determine whether the vote operation is reachable: // - allowed-operations absent or empty → all operations allowed (includes vote) diff --git a/src/compile/extensions/ado_script.rs b/src/compile/extensions/ado_script.rs index 26bd43c07..bda0e9aa6 100644 --- a/src/compile/extensions/ado_script.rs +++ b/src/compile/extensions/ado_script.rs @@ -998,13 +998,7 @@ pub fn synthetic_pr_step_typed(spec_b64: &str) -> Result { let script = ShellScript::new(&RESOLVE_SYNTHETIC_PR) .bind_text("BUNDLE", EXEC_CONTEXT_PR_SYNTH_PATH) .render(); - let condition = Condition::And(vec![ - Condition::Succeeded, - Condition::Ne( - Expr::Variable("Build.Reason".to_string()), - Expr::Literal("PullRequest".to_string()), - ), - ]); + let condition = Condition::Succeeded; let mut step = BashStep::new("Resolve synthetic PR context", script) .with_id(StepId::new("synthPr")?) .with_condition(condition); @@ -1045,6 +1039,7 @@ pub const SYNTH_PR_OUTPUT_NAMES: &[&str] = &[ "AW_PR_TARGETBRANCH", "AW_PR_SOURCEBRANCH", "AW_PR_IS_DRAFT", + "AW_PR_TRIGGERING_IDENTITY", // Always-emitted control flags. "AW_SYNTHETIC_PR", "AW_SYNTHETIC_PR_SKIP", @@ -1068,6 +1063,7 @@ pub const SYNTH_PR_AGENT_HOIST_NAMES: &[&str] = &[ "AW_PR_TARGETBRANCH", "AW_PR_SOURCEBRANCH", "AW_PR_IS_DRAFT", + "AW_PR_TRIGGERING_IDENTITY", "AW_SYNTHETIC_PR", ]; @@ -2862,25 +2858,16 @@ mod tests { "AW_PR_TARGETBRANCH", "AW_PR_SOURCEBRANCH", "AW_PR_IS_DRAFT", + "AW_PR_TRIGGERING_IDENTITY", "AW_SYNTHETIC_PR", "AW_SYNTHETIC_PR_SKIP", ] ); - // Condition is a typed And(Succeeded, Ne(BuildReason, "PullRequest")). - match b.condition.as_ref().expect("condition required") { - crate::compile::ir::condition::Condition::And(parts) => { - assert_eq!(parts.len(), 2); - assert!(matches!( - parts[0], - crate::compile::ir::condition::Condition::Succeeded - )); - assert!(matches!( - parts[1], - crate::compile::ir::condition::Condition::Ne(_, _) - )); - } - other => panic!("expected Condition::And, got {other:?}"), - } + assert_eq!( + b.condition, + Some(Condition::Succeeded), + "native PR runs also need the unified trusted identity output" + ); } other => panic!("expected Bash(synthPr) with id, got {other:?}"), } diff --git a/src/compile/imports/integration_tests.rs b/src/compile/imports/integration_tests.rs index bdbcb2eba..9bf170d0d 100644 --- a/src/compile/imports/integration_tests.rs +++ b/src/compile/imports/integration_tests.rs @@ -13,6 +13,9 @@ use crate::compile::imports::merge::merge_resolved; use crate::compile::types::{ImportEntry, ParsedImportSpec}; use crate::secure::CommitSha; +#[path = "pr_migration_tests.rs"] +mod pr_migration_tests; + const SHA: &str = "0123456789abcdef0123456789abcdef01234567"; const SHA2: &str = "89abcdef0123456789abcdef0123456789abcdef"; diff --git a/src/compile/imports/merge.rs b/src/compile/imports/merge.rs index d439a03aa..83f824e09 100644 --- a/src/compile/imports/merge.rs +++ b/src/compile/imports/merge.rs @@ -1,11 +1,12 @@ //! Field-specific merge policy for compile-time reusable imports. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::Path; use anyhow::{Context, Result}; use serde_yaml::{Mapping, Value}; +use super::pr_policy; use super::schema::apply_import_inputs; use super::{ManifestFetcher, ResolvedImport, resolve_imports_with_repo_root}; use crate::compile::custom_tools::COMPONENT_PROVENANCE_KEYS; @@ -69,7 +70,7 @@ pub fn merge_resolved_imported_body( consumer_fm: &mut Mapping, resolved: &[ResolvedImport], ) -> Result { - let mut state = MergeState::default(); + let mut components = Vec::new(); let mut body_parts = Vec::new(); for import in resolved { @@ -82,8 +83,15 @@ pub fn merge_resolved_imported_body( ) })?; stamp_component_provenance(&mut front_matter, import); + for line in crate::compile::pr_migration::deprecated_pr_prompt_lines(&body) { + eprintln!( + "warning: imported component `{}` (body line {line}): {}", + crate::sanitize::neutralize_pipeline_commands(&import.provenance.source), + crate::compile::pr_migration::PR_PROMPT_GUIDANCE, + ); + } if let Value::Mapping(mapping) = front_matter { - state.merge_import(&mapping, &import.provenance.source)?; + components.push((mapping, &import.provenance.source)); } let body = body.trim(); if !body.is_empty() { @@ -91,7 +99,33 @@ pub fn merge_resolved_imported_body( } } - state.overlay_consumer(consumer_fm)?; + let custom_jobs = pr_policy::custom_job_names( + std::iter::once(&*consumer_fm).chain(components.iter().map(|(mapping, _)| mapping)), + )?; + let mut consumer = consumer_fm.clone(); + pr_policy::rename_declarations(&mut consumer, &custom_jobs) + .context("failed to normalize consumer safe-output declarations")?; + let mut state = MergeState { + custom_jobs, + ..Default::default() + }; + for (mut component, source) in components { + pr_policy::rename_declarations(&mut component, &state.custom_jobs) + .with_context(|| format!("failed to normalize imported component `{source}`"))?; + state.merge_import(&component, source)?; + } + state.overlay_consumer(&consumer)?; + pr_policy::transform_builtins( + &mut state.merged, + &state.custom_jobs, + crate::compile::pr_migration::migrate_safe_outputs, + ) + .with_context(|| { + format!( + "failed to migrate effective safe-outputs from `{}` after resolving imports", + state.legacy_family_origin.as_deref().unwrap_or("consumer") + ) + })?; dedupe_repos(&mut state.merged)?; state.merged.remove(Value::String("imports".to_string())); *consumer_fm = state.merged; @@ -104,10 +138,24 @@ struct MergeState { env_origins: HashMap, mcp_origins: HashMap, safe_output_origins: HashMap, + custom_jobs: HashSet, + legacy_family_origin: Option, } impl MergeState { fn merge_import(&mut self, component: &Mapping, source: &str) -> Result<()> { + if let Some(Value::Mapping(outputs)) = component.get("safe-outputs") + && pr_policy::legacy_family(outputs, &self.custom_jobs) + .with_context(|| format!("invalid legacy PR declaration in `{source}`"))? + .is_some() + { + if let Some(previous) = &self.legacy_family_origin { + anyhow::bail!( + "import conflict: `safe-outputs.update-pr` legacy family is defined by both `{previous}` and `{source}`" + ); + } + self.legacy_family_origin = Some(source.to_string()); + } for (key, value) in component { let Some(key) = key.as_str() else { continue; @@ -144,6 +192,11 @@ impl MergeState { } fn overlay_consumer(&mut self, consumer: &Mapping) -> Result<()> { + if let Some(Value::Mapping(outputs)) = consumer.get("safe-outputs") + && pr_policy::legacy_family(outputs, &self.custom_jobs)?.is_some() + { + self.legacy_family_origin = Some("consumer".to_string()); + } for (key, value) in consumer { let Some(key) = key.as_str() else { continue; @@ -161,7 +214,9 @@ impl MergeState { // imported requirement. merge_permissions_required(&mut self.merged, value)?; } - "safe-outputs" => overlay_consumer_safe_outputs(&mut self.merged, value)?, + "safe-outputs" => { + overlay_consumer_safe_outputs(&mut self.merged, value, &self.custom_jobs)? + } "repos" => overlay_consumer_repos(&mut self.merged, value)?, "steps" => append_sequence(&mut self.merged, key, value)?, "post-steps" => prepend_sequence(&mut self.merged, key, value)?, @@ -461,6 +516,23 @@ fn merge_import_safe_outputs( "jobs" => { merge_import_custom_jobs(safe_outputs, value, source, origins)?; } + "budget-groups" => { + let groups = value + .as_mapping() + .context("imported safe-outputs.budget-groups must be a mapping")?; + let target_groups = ensure_mapping_field(safe_outputs, "budget-groups")?; + for (key, group) in groups { + let name = key.as_str().context("budget group names must be strings")?; + let origin_key = format!("safe-outputs.budget-groups.{name}"); + if let Some(previous) = origins.get(&origin_key) { + anyhow::bail!( + "import conflict: `{origin_key}` is defined by both `{previous}` and `{source}`" + ); + } + origins.insert(origin_key, source.to_string()); + target_groups.insert(key.clone(), group.clone()); + } + } _ => { let origin_key = format!("safe-outputs.{name}"); if let Some(previous) = origins.get(&origin_key) { @@ -507,14 +579,24 @@ fn merge_import_custom_jobs( Ok(()) } -fn overlay_consumer_safe_outputs(target: &mut Mapping, incoming: &Value) -> Result<()> { +fn overlay_consumer_safe_outputs( + target: &mut Mapping, + incoming: &Value, + custom_jobs: &HashSet, +) -> Result<()> { let incoming = incoming .as_mapping() .context("consumer `safe-outputs` must be a mapping")?; let safe_outputs = ensure_mapping_field(target, "safe-outputs")?; + pr_policy::replace_legacy_family(safe_outputs, incoming, custom_jobs)?; for (key, value) in incoming { if key.as_str() == Some("jobs") { overlay_consumer_custom_jobs(safe_outputs, value)?; + } else if key.as_str() == Some("budget-groups") { + let groups = value + .as_mapping() + .context("consumer safe-outputs.budget-groups must be a mapping")?; + ensure_mapping_field(safe_outputs, "budget-groups")?.extend(groups.clone()); } else { // Built-in safe-output configuration is consumer-owned. safe_outputs.insert(key.clone(), value.clone()); @@ -987,7 +1069,10 @@ mod tests { merge_resolved( &mut consumer, "", - &[local("safe-outputs:\n create-github-issue:\n max: 1", "")], + &[local( + "safe-outputs:\n create-github-issue:\n max: 1", + "", + )], ) .unwrap(); assert_eq!(consumer["safe-outputs"]["create-github-issue"]["max"], 9); diff --git a/src/compile/imports/mod.rs b/src/compile/imports/mod.rs index fe4cd2f4e..30f6dbddd 100644 --- a/src/compile/imports/mod.rs +++ b/src/compile/imports/mod.rs @@ -7,6 +7,7 @@ #[cfg(test)] mod integration_tests; pub mod merge; +pub(crate) mod pr_policy; pub mod schema; use std::collections::{BTreeMap, HashMap, VecDeque}; diff --git a/src/compile/imports/pr_migration_tests.rs b/src/compile/imports/pr_migration_tests.rs new file mode 100644 index 000000000..9b94043d9 --- /dev/null +++ b/src/compile/imports/pr_migration_tests.rs @@ -0,0 +1,541 @@ +use super::*; +use crate::compile::{self, common, types::FrontMatter}; +use serde_json::json; +use serde_yaml::{Mapping, Value}; + +fn workflow(imports: &str, outputs: &str) -> String { + format!( + "---\nname: imported-pr\ndescription: Imported PR policy\n{imports}safe-outputs:\n{outputs}---\n\nConsumer body.\n Unchanged bytes. \n" + ) +} + +fn local_workflow(repo: &Path, component: &str, outputs: &str) -> PathBuf { + write(&repo.join("component.md"), component); + let source = repo.join("agent.md"); + write( + &source, + &workflow("imports:\n - ./component.md\n", outputs), + ); + source +} + +async fn compile_source(path: &Path) -> Result { + compile::compile_pipeline_with_registry( + &path.to_string_lossy(), + None, + false, + false, + compile::codemods::CODEMODS, + ) + .await +} + +#[tokio::test] +async fn imported_old_comment_and_update_pr_match_root_capabilities_without_rewrites() { + let repo = temp_repo(); + let outputs = " add-pr-comment:\n max: 2\n update-pr:\n max: 3\n allowed-operations: [add-labels, add-reviewers]\n"; + let component = format!("---\nsafe-outputs:\n{outputs}---\nImported old update-pr prompt.\n"); + let source = local_workflow(repo.path(), &component, " noop:\n"); + let before = fs::read(&source).unwrap(); + let (imported, _) = compile::build_pipeline_ir(&source).await.unwrap(); + let root = common::parse_markdown_detailed(&workflow("", &format!("{outputs} noop:\n"))) + .unwrap() + .front_matter; + assert_eq!(imported.safe_outputs, root.safe_outputs); + assert!(!compile_source(&source).await.unwrap()); + compile::check_pipeline(&source.with_extension("lock.yml").to_string_lossy()) + .await + .unwrap(); + assert_eq!(fs::read(&source).unwrap(), before); + assert_eq!( + fs::read_to_string(repo.path().join("component.md")).unwrap(), + component + ); +} + +#[tokio::test] +async fn aliases_obey_consumer_precedence_in_both_directions() { + for (imported, consumer) in [ + ("add-pr-comment", "add-pull-request-comment"), + ("add-pull-request-comment", "add-pr-comment"), + ] { + let repo = temp_repo(); + let component = format!("---\nsafe-outputs:\n {imported}:\n max: 5\n---\nComponent"); + let source = local_workflow( + repo.path(), + &component, + &format!(" {consumer}:\n max: 1\n"), + ); + let (fm, _) = compile::build_pipeline_ir(&source).await.unwrap(); + assert_eq!(fm.safe_outputs["add-pull-request-comment"]["max"], 1); + assert!(!fm.safe_outputs.contains_key("add-pr-comment")); + compile_source(&source).await.unwrap(); + let (again, _) = compile::build_pipeline_ir(&source).await.unwrap(); + assert_eq!(again.safe_outputs, fm.safe_outputs); + assert_eq!( + fs::read_to_string(repo.path().join("component.md")).unwrap(), + component + ); + } +} + +#[tokio::test] +async fn nested_local_schema_policy_uses_shared_read_only_preparation() { + let repo = temp_repo(); + let child = "---\nimport-schema:\n operation:\n type: string\n required: true\nsafe-outputs:\n update-pr:\n allowed-operations: ['{{ inputs.operation }}']\n---\nChild {{ inputs.operation }}.\n"; + write(&repo.path().join("nested").join("child.md"), child); + let parent = "---\nimports:\n - uses: ./nested/child.md\n with:\n operation: add-reviewers\n---\nParent.\n"; + let source = local_workflow(repo.path(), parent, " noop:\n"); + let before = fs::read(&source).unwrap(); + let effective = compile::prepare_source_front_matter(&source).await.unwrap(); + let (inspected, _) = compile::build_pipeline_ir(&source).await.unwrap(); + assert_eq!(effective.safe_outputs, inspected.safe_outputs); + assert!( + effective + .safe_outputs + .contains_key("add-pull-request-reviewers") + ); + assert!( + !effective + .safe_outputs + .contains_key("add-pull-request-labels") + ); + crate::inspect::build_lint(&source).await.unwrap(); + assert!(!source.with_extension("lock.yml").exists()); + assert_eq!(fs::read(&source).unwrap(), before); + assert_eq!( + fs::read_to_string(repo.path().join("nested").join("child.md")).unwrap(), + child + ); + assert_eq!( + fs::read_to_string(repo.path().join("component.md")).unwrap(), + parent + ); +} + +#[tokio::test] +async fn duplicate_alias_imports_fail_even_with_consumer_override() { + let repo = temp_repo(); + write( + &repo.path().join("a.md"), + "---\nsafe-outputs:\n add-pr-comment:\n---\nA", + ); + write( + &repo.path().join("b.md"), + "---\nsafe-outputs:\n add-pull-request-comment:\n---\nB", + ); + let source = repo.path().join("agent.md"); + let content = workflow( + "imports:\n - ./a.md\n - ./b.md\n", + " add-pr-comment:\n max: 1\n", + ); + write(&source, &content); + let error = format!("{:#}", compile_source(&source).await.unwrap_err()); + assert!(error.contains("import conflict"), "{error}"); + assert!( + error.contains("add-pull-request-comment") + && error.contains("a.md") + && error.contains("b.md"), + "{error}" + ); + assert_eq!(fs::read_to_string(source).unwrap(), content); +} + +#[tokio::test] +async fn same_source_alias_conflicts_fail_atomically_for_root_and_import() { + let conflict = " add-pr-comment:\n add-pull-request-comment:\n"; + for root_conflict in [false, true] { + let repo = temp_repo(); + let component = format!( + "---\nsafe-outputs:\n{}---\nComponent", + if root_conflict { " noop:\n" } else { conflict } + ); + let source = local_workflow( + repo.path(), + &component, + if root_conflict { conflict } else { " noop:\n" }, + ); + let original = fs::read(&source).unwrap(); + let error = format!("{:#}", compile_source(&source).await.unwrap_err()); + assert!( + error.contains("both add-pr-comment and add-pull-request-comment"), + "{error}" + ); + assert_eq!(fs::read(&source).unwrap(), original); + assert_eq!( + fs::read_to_string(repo.path().join("component.md")).unwrap(), + component + ); + assert!(!source.with_extension("lock.yml").exists()); + } +} + +#[tokio::test] +async fn invalid_winning_import_policy_reports_origin_without_rewriting_files() { + let repo = temp_repo(); + let component = "---\nsafe-outputs:\n update-pr:\n allowed-operations: [vote]\n allowed-votes: [comment]\n---\nImported prompt"; + let source = local_workflow(repo.path(), component, " add-pr-comment:\n"); + let original = fs::read(&source).unwrap(); + let error = format!("{:#}", compile_source(&source).await.unwrap_err()); + assert!( + error.contains("component.md") && error.contains("allowed-votes"), + "{error}" + ); + assert_eq!(fs::read(source).unwrap(), original); + assert_eq!( + fs::read_to_string(repo.path().join("component.md")).unwrap(), + component + ); +} + +#[tokio::test] +async fn whole_family_override_survives_root_rewrite_and_second_compile() { + let repo = temp_repo(); + // The invalid vote default is irrelevant: the consumer replaces this whole declaration. + let component = "---\nsafe-outputs:\n update-pr:\n allowed-operations: [add-reviewers, add-labels, vote]\n allowed-votes: [comment]\n max: 5\n---\nImported prompt.\n"; + let source = local_workflow( + repo.path(), + component, + " update-pr:\n max: 1\n allowed-operations: [add-reviewers]\n allowed-reviewers: [alice, bob]\n", + ); + let original = fs::read_to_string(&source).unwrap(); + let body = common::split_markdown_front_matter(&original, true) + .unwrap() + .body_raw; + let (before, _) = compile::build_pipeline_ir(&source).await.unwrap(); + assert!( + before + .safe_outputs + .contains_key("add-pull-request-reviewers") + ); + assert!(!before.safe_outputs.contains_key("add-pull-request-labels")); + assert!( + !before + .safe_outputs + .contains_key("submit-pull-request-review") + ); + assert_eq!( + before.safe_outputs["budget-groups"]["update-pr"], + json!({"max": 1, "tools": ["add-pull-request-reviewers"]}) + ); + + assert!(compile_source(&source).await.unwrap()); + let rewritten = fs::read_to_string(&source).unwrap(); + assert_eq!( + common::split_markdown_front_matter(&rewritten, true) + .unwrap() + .body_raw, + body + ); + assert!(!rewritten.contains("allowed-votes")); + let (after, _) = compile::build_pipeline_ir(&source).await.unwrap(); + assert_eq!(before.safe_outputs, after.safe_outputs); + assert!(!compile_source(&source).await.unwrap()); + compile::check_pipeline(&source.with_extension("lock.yml").to_string_lossy()) + .await + .unwrap(); + assert_eq!(fs::read_to_string(&source).unwrap(), rewritten); + assert_eq!( + fs::read_to_string(repo.path().join("component.md")).unwrap(), + component + ); + + // Narrowing a migrated child must not be overwritten by its retained legacy metadata. + let mut parsed = common::parse_markdown_detailed(&rewritten).unwrap(); + parsed.front_matter_mapping["safe-outputs"]["add-pull-request-reviewers"]["max"] = + Value::from(0); + let narrowed = common::reconstruct_source( + &parsed.leading_whitespace, + &parsed.front_matter_mapping, + &parsed.body_raw, + ) + .unwrap(); + write(&source, &narrowed); + let effective = compile::prepare_source_front_matter(&source).await.unwrap(); + assert_eq!( + effective.safe_outputs["add-pull-request-reviewers"]["max"], + 0 + ); + assert_eq!( + effective.safe_outputs["add-pull-request-reviewers"]["legacy-update-pr"]["max"], + 1 + ); + assert!( + !effective + .safe_outputs + .contains_key("add-pull-request-labels") + ); +} + +#[tokio::test] +async fn migrated_import_family_replacement_retains_independent_caps() { + let repo = temp_repo(); + let imported = common::parse_markdown_detailed( + &workflow("", " update-pr:\n max: 5\n allowed-operations: [add-reviewers, add-labels]\n set-pull-request-auto-complete:\n max: 8\n budget-groups:\n independent:\n max: 2\n tools: [set-pull-request-auto-complete]\n"), + ).unwrap(); + let component = + common::reconstruct_source("", &imported.front_matter_mapping, &imported.body_raw).unwrap(); + let source = local_workflow( + repo.path(), + &component, + " update-pr:\n max: 1\n allowed-operations: [add-reviewers]\n", + ); + let (first, _) = compile::build_pipeline_ir(&source).await.unwrap(); + assert!(!first.safe_outputs.contains_key("add-pull-request-labels")); + assert_eq!( + first.safe_outputs["budget-groups"]["independent"], + json!({"max": 2, "tools": ["set-pull-request-auto-complete"]}) + ); + assert_eq!( + first.safe_outputs["budget-groups"]["update-pr"], + json!({"max": 1, "tools": ["add-pull-request-reviewers"]}) + ); + assert!(compile_source(&source).await.unwrap()); + let (second, _) = compile::build_pipeline_ir(&source).await.unwrap(); + assert_eq!(first.safe_outputs, second.safe_outputs); + assert!(!compile_source(&source).await.unwrap()); + assert_eq!( + fs::read_to_string(repo.path().join("component.md")).unwrap(), + component + ); +} + +#[tokio::test] +async fn unrelated_imported_and_root_budget_groups_are_preserved() { + let repo = temp_repo(); + write( + &repo.path().join("a.md"), + "---\nsafe-outputs:\n add-pr-labels:\n max: 4\n budget-groups:\n labels:\n max: 2\n tools: [add-pr-labels]\n---\nA", + ); + write( + &repo.path().join("b.md"), + "---\nsafe-outputs:\n set-pr-auto-complete:\n max: 4\n budget-groups:\n auto:\n max: 3\n tools: [set-pr-auto-complete]\n---\nB", + ); + let source = repo.path().join("agent.md"); + write( + &source, + &workflow( + "imports:\n - ./a.md\n - ./b.md\n", + " update-pr:\n allowed-operations: [add-reviewers]\n max: 1\n", + ), + ); + let (first, _) = compile::build_pipeline_ir(&source).await.unwrap(); + let groups = &first.safe_outputs["budget-groups"]; + assert_eq!(groups.as_object().unwrap().len(), 3); + assert_eq!( + groups["labels"], + json!({"max": 2, "tools": ["add-pull-request-labels"]}) + ); + assert_eq!( + groups["auto"], + json!({"max": 3, "tools": ["set-pull-request-auto-complete"]}) + ); + assert_eq!( + groups["update-pr"], + json!({"max": 1, "tools": ["add-pull-request-reviewers"]}) + ); + compile_source(&source).await.unwrap(); + let (second, _) = compile::build_pipeline_ir(&source).await.unwrap(); + assert_eq!(first.safe_outputs, second.safe_outputs); +} + +#[tokio::test] +async fn duplicate_legacy_families_conflict_across_raw_and_migrated_imports() { + let repo = temp_repo(); + let raw = "---\nname: x\ndescription: x\nsafe-outputs:\n update-pr:\n allowed-operations: [add-reviewers]\n---\nBody"; + let parsed = common::parse_markdown_detailed(raw).unwrap(); + write(&repo.path().join("a.md"), raw); + let migrated = + common::reconstruct_source("", &parsed.front_matter_mapping, &parsed.body_raw).unwrap(); + write(&repo.path().join("b.md"), &migrated); + let source = repo.path().join("agent.md"); + write( + &source, + &workflow("imports:\n - ./a.md\n - ./b.md\n", " noop:\n"), + ); + let error = format!("{:#}", compile_source(&source).await.unwrap_err()); + assert!( + error.contains("import conflict") && error.contains("legacy family"), + "{error}" + ); +} + +#[tokio::test] +async fn duplicate_budget_group_names_report_both_component_origins() { + let repo = temp_repo(); + write( + &repo.path().join("a.md"), + "---\nsafe-outputs:\n add-pr-labels:\n budget-groups:\n shared:\n max: 1\n tools: [add-pr-labels]\n---\nA", + ); + write( + &repo.path().join("b.md"), + "---\nsafe-outputs:\n add-pr-reviewers:\n budget-groups:\n shared:\n max: 2\n tools: [add-pr-reviewers]\n---\nB", + ); + let source = repo.path().join("agent.md"); + write( + &source, + &workflow("imports:\n - ./a.md\n - ./b.md\n", " noop:\n"), + ); + let error = format!("{:#}", compile_source(&source).await.unwrap_err()); + assert!( + error.contains("safe-outputs.budget-groups.shared"), + "{error}" + ); + assert!(error.contains("a.md") && error.contains("b.md"), "{error}"); +} + +#[tokio::test] +async fn ambiguous_migrated_family_metadata_is_rejected_without_rewrite() { + for tools in ["[add-pull-request-labels]", "[]"] { + let repo = temp_repo(); + let source = local_workflow( + repo.path(), + "---\n{}\n---\nComponent", + &format!( + " add-pull-request-reviewers:\n legacy-update-pr: {{allowed-operations: [add-reviewers]}}\n budget-groups:\n update-pr:\n max: 1\n tools: {tools}\n" + ), + ); + let original = fs::read(&source).unwrap(); + let error = format!("{:#}", compile_source(&source).await.unwrap_err()); + assert!( + error.contains("ambiguous legacy update-pr family"), + "{error}" + ); + assert_eq!(fs::read(source).unwrap(), original); + } +} + +#[tokio::test] +async fn custom_legacy_job_names_and_consumer_policies_are_not_migrated() { + for name in ["update-pr", "add-pr-comment"] { + let repo = temp_repo(); + let component = format!( + "---\nsafe-outputs:\n jobs:\n {name}:\n description: Custom operation\n steps:\n - bash: echo custom\n {name}:\n max: 5\n---\nComponent\n" + ); + let source = local_workflow(repo.path(), &component, &format!(" {name}:\n max: 1\n")); + let original = fs::read(&source).unwrap(); + let (fm, _) = compile::build_pipeline_ir(&source).await.unwrap(); + assert_eq!(fm.custom_safe_output_tool_names(), vec![name.to_string()]); + assert_eq!(fm.safe_outputs[name], json!({"max": 1})); + assert!(fm.safe_outputs["jobs"].get(name).is_some()); + assert!(!fm.safe_outputs.contains_key("budget-groups")); + assert!(!compile_source(&source).await.unwrap()); + assert_eq!(fs::read(source).unwrap(), original); + assert_eq!( + fs::read_to_string(repo.path().join("component.md")).unwrap(), + component + ); + } +} + +#[tokio::test] +async fn mixed_custom_ownership_survives_unrelated_builtin_root_rewrite() { + let repo = temp_repo(); + let component = "---\nsafe-outputs:\n jobs:\n update-pr:\n description: Custom update\n steps:\n - bash: echo update-pr\n---\nImported custom job"; + let source = local_workflow( + repo.path(), + component, + " jobs:\n add-pr-labels:\n description: Custom labels\n steps:\n - bash: echo add-pr-labels\n add-pr-labels:\n max: 3\n update-pr:\n max: 2\n add-pr-comment:\n max: 1\n", + ); + let (before, _) = compile::build_pipeline_ir(&source).await.unwrap(); + assert!(compile_source(&source).await.unwrap()); + let (after, _) = compile::build_pipeline_ir(&source).await.unwrap(); + assert_eq!(before.safe_outputs, after.safe_outputs); + assert_eq!(after.safe_outputs["update-pr"], json!({"max": 2})); + assert_eq!(after.safe_outputs["add-pr-labels"], json!({"max": 3})); + assert_eq!( + after.safe_outputs["add-pull-request-comment"], + json!({"max": 1}) + ); + let rewritten = fs::read_to_string(&source).unwrap(); + assert!(rewritten.contains("echo add-pr-labels")); + assert!(!rewritten.contains("add-pull-request-labels")); + assert!(!compile_source(&source).await.unwrap()); +} + +#[tokio::test] +async fn import_free_custom_legacy_names_keep_their_definitions_and_policies() { + let repo = temp_repo(); + let source = repo.path().join("agent.md"); + let content = workflow( + "", + " jobs:\n update-pr:\n description: Custom update\n steps:\n - bash: echo custom\n add-pr-comment:\n description: Custom comment\n steps:\n - bash: echo comment\n update-pr:\n max: 2\n add-pr-comment:\n max: 1\n", + ); + write(&source, &content); + assert!(!compile_source(&source).await.unwrap()); + assert_eq!(fs::read_to_string(&source).unwrap(), content); + let fm = compile::prepare_source_front_matter(&source).await.unwrap(); + assert_eq!(fm.safe_outputs["update-pr"], json!({"max": 2})); + assert_eq!(fm.safe_outputs["add-pr-comment"], json!({"max": 1})); +} + +#[tokio::test] +async fn canonical_custom_job_collisions_remain_explicit_errors() { + let repo = temp_repo(); + let component = "---\nsafe-outputs:\n jobs:\n add-pull-request-comment:\n steps:\n - bash: echo custom\n---\nComponent"; + let source = local_workflow(repo.path(), component, " add-pr-comment:\n"); + let original = fs::read(&source).unwrap(); + let error = format!("{:#}", compile_source(&source).await.unwrap_err()); + assert!( + error.contains("collides") && error.contains("add-pull-request-comment"), + "{error}" + ); + assert_eq!(fs::read(source).unwrap(), original); +} + +#[tokio::test] +async fn nested_schema_inputs_migrate_after_substitution_with_unchanged_offline_cache() { + let repo = temp_repo(); + let parent = "---\nimports:\n - uses: ./child.md\n with:\n limit: 3\n operation: add-labels\n---\nParent prompt.\n"; + let child = "---\nimport-schema:\n limit:\n type: number\n required: true\n operation:\n type: string\n required: true\nsafe-outputs:\n add-pr-comment:\n max: 3\n update-pr:\n max: 3\n allowed-operations: ['{{ inputs.operation }}']\n---\nChild {{ inputs.operation }} prompt, limit {{ inputs.limit }}.\n"; + let fetcher = FakeFetcher::default() + .with_manifest("components/parent.md", parent) + .with_manifest("components/child.md", child); + let entries = vec![remote_entry( + &format!("components/parent.md@{SHA}"), + "project/shared", + )]; + let first = resolve_imports_with_repo_root(&entries, repo.path(), repo.path(), &fetcher) + .await + .unwrap(); + let snapshot = cache_snapshot(repo.path()); + let mut consumer: Mapping = serde_yaml::from_str("name: test\ndescription: test").unwrap(); + let body = merge_resolved(&mut consumer, "Consumer prompt.", &first).unwrap(); + let fm: FrontMatter = serde_yaml::from_value(Value::Mapping(consumer.clone())).unwrap(); + common::validate_safe_outputs_keys(&fm).unwrap(); + common::validate_pull_request_outputs_config(&fm).unwrap(); + assert_eq!(fm.safe_outputs["add-pull-request-comment"]["max"], 3); + assert_eq!(fm.safe_outputs["add-pull-request-labels"]["max"], 3); + assert_eq!( + body, + "Parent prompt.\n\nChild add-labels prompt, limit 3.\n\nConsumer prompt." + ); + assert_eq!(fetcher.fetch_calls.load(Ordering::SeqCst), 2); + let cached = + resolve_imports_with_repo_root(&entries, repo.path(), repo.path(), &OfflineFetcher) + .await + .unwrap(); + let mut again: Mapping = serde_yaml::from_str("name: test\ndescription: test").unwrap(); + assert_eq!( + merge_resolved(&mut again, "Consumer prompt.", &cached).unwrap(), + body + ); + assert_eq!(again, consumer); + assert_eq!(cache_snapshot(repo.path()), snapshot); +} + +fn cache_snapshot(root: &Path) -> std::collections::BTreeMap> { + fn visit(path: &Path, out: &mut std::collections::BTreeMap>) { + for entry in fs::read_dir(path).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + visit(&path, out); + } else { + out.insert(path.clone(), fs::read(path).unwrap()); + } + } + } + let mut snapshot = Default::default(); + visit(&root.join(".ado-aw"), &mut snapshot); + snapshot +} diff --git a/src/compile/imports/pr_policy.rs b/src/compile/imports/pr_policy.rs new file mode 100644 index 000000000..7cbe4d349 --- /dev/null +++ b/src/compile/imports/pr_policy.rs @@ -0,0 +1,196 @@ +//! PR migration identities used before import precedence is resolved. + +use std::collections::HashSet; + +use anyhow::{Context, Result, ensure}; +use serde_yaml::{Mapping, Value}; + +use crate::compile::pr_migration::{LEGACY_PR_CONFIG, PR_OPERATIONS, PR_TOOL_RENAMES}; + +pub(super) fn custom_job_names<'a>( + manifests: impl IntoIterator, +) -> Result> { + let mut names = HashSet::new(); + for manifest in manifests { + if let Some(jobs) = manifest + .get("safe-outputs") + .and_then(|outputs| outputs.get("jobs")) + { + for name in jobs + .as_mapping() + .context("safe-outputs.jobs must be a mapping")? + .keys() + { + names.insert( + name.as_str() + .context("custom safe-output job names must be strings")? + .to_string(), + ); + } + } + } + Ok(names) +} + +/// Canonicalize identities, not policies: overridden defaults need not be valid. +pub(crate) fn rename_declarations( + manifest: &mut Mapping, + custom_jobs: &HashSet, +) -> Result { + let Some(Value::Mapping(outputs)) = manifest.get("safe-outputs") else { + return Ok(false); + }; + let mut renamed = outputs.clone(); + for (old, new) in PR_TOOL_RENAMES { + if custom_jobs.contains(*old) { + continue; + } + if let Some(value) = renamed.remove(*old) { + ensure!( + !renamed.contains_key(*new), + "manual migration required: both {old} and {new} are configured" + ); + renamed.insert(Value::String((*new).to_string()), value); + } + } + if let Some(Value::Mapping(groups)) = renamed.get_mut("budget-groups") { + for group in groups.values_mut() { + if let Some(Value::Sequence(tools)) = group.get_mut("tools") { + for tool in tools { + if let Some(name) = tool.as_str() + && !custom_jobs.contains(name) + && let Some((_, new)) = PR_TOOL_RENAMES.iter().find(|(old, _)| *old == name) + { + *tool = Value::String((*new).to_string()); + } + } + } + } + } + let changed = *outputs != renamed; + if changed { + manifest.insert( + Value::String("safe-outputs".to_string()), + Value::Mapping(renamed), + ); + } + Ok(changed) +} + +/// Apply a pure built-in transformation without touching custom-job policies. +pub(crate) fn transform_builtins( + manifest: &mut Mapping, + custom_jobs: &HashSet, + transform: impl FnOnce(&mut serde_json::Map) -> Result, +) -> Result { + let Some(raw) = manifest.get("safe-outputs") else { + return Ok(false); + }; + let value = serde_json::to_value(raw)?; + let Some(mut outputs) = value.as_object().cloned() else { + return Ok(false); + }; + let custom = custom_jobs + .iter() + .filter_map(|name| outputs.remove(name).map(|value| (name.clone(), value))) + .collect::>(); + let changed = transform(&mut outputs)?; + if changed { + outputs.extend(custom); + manifest.insert( + Value::String("safe-outputs".to_string()), + serde_yaml::to_value(outputs)?, + ); + } + Ok(changed) +} + +pub(crate) fn local_custom_job_names(manifest: &Mapping) -> Result> { + custom_job_names(std::iter::once(manifest)) +} + +/// A migrated family is identified by its children and shared budget, never by +/// reconstructing configuration from the retained legacy constraints. +pub(super) fn legacy_family( + outputs: &Mapping, + custom_jobs: &HashSet, +) -> Result>> { + let raw = outputs.contains_key("update-pr") && !custom_jobs.contains("update-pr"); + let mut children = HashSet::new(); + let mut original = None; + for (tool, config) in outputs { + let Some(tool) = tool.as_str() else { + continue; + }; + if custom_jobs.contains(tool) { + continue; + } + let Some(metadata) = config.get(LEGACY_PR_CONFIG) else { + continue; + }; + ensure!( + PR_OPERATIONS.iter().any(|(_, name)| *name == tool) && metadata.is_mapping(), + "ambiguous legacy update-pr family: {tool}.{LEGACY_PR_CONFIG} must be an object on a focused PR tool" + ); + if let Some(previous) = original { + ensure!( + previous == metadata, + "ambiguous legacy update-pr family: children retain different legacy policies" + ); + } else { + original = Some(metadata); + } + children.insert(tool.to_string()); + } + ensure!( + !raw || children.is_empty(), + "manual migration required: update-pr and migrated legacy update-pr children coexist" + ); + if raw { + return Ok(Some(HashSet::from(["update-pr".to_string()]))); + } + if children.is_empty() { + return Ok(None); + } + let tools = outputs + .get("budget-groups") + .and_then(|groups| groups.get("update-pr")) + .and_then(|group| group.get("tools")) + .and_then(Value::as_sequence) + .context("ambiguous legacy update-pr family: missing update-pr budget group")?; + let members = tools + .iter() + .map(|tool| { + tool.as_str() + .context("legacy update-pr budget members must be tool names") + }) + .collect::>>()?; + ensure!( + members.len() == tools.len() + && members.len() == children.len() + && children + .iter() + .all(|child| members.contains(child.as_str())), + "ambiguous legacy update-pr family: budget members must exactly match its migrated children" + ); + Ok(Some(children)) +} + +pub(super) fn replace_legacy_family( + existing: &mut Mapping, + incoming: &Mapping, + custom_jobs: &HashSet, +) -> Result<()> { + if legacy_family(incoming, custom_jobs)?.is_some() + && let Some(children) = legacy_family(existing, custom_jobs)? + { + let migrated = !children.contains("update-pr"); + for child in children { + existing.remove(child); + } + if migrated && let Some(Value::Mapping(groups)) = existing.get_mut("budget-groups") { + groups.remove("update-pr"); + } + } + Ok(()) +} diff --git a/src/compile/mod.rs b/src/compile/mod.rs index 2380a2c87..246d9601e 100644 --- a/src/compile/mod.rs +++ b/src/compile/mod.rs @@ -6,15 +6,15 @@ //! - **Standalone**: Self-contained pipeline with AWF network isolation //! - **1ES**: Integration with 1ES Pipeline Templates for SDL compliance -mod common; pub mod az_wrapper; +mod common; pub(crate) use common::resolve_repos; pub(crate) mod ado_bundle; pub(crate) mod agentic_pipeline; -pub(crate) mod container_invocation; #[cfg(test)] mod codemod_integration_test; pub(crate) mod codemods; +pub(crate) mod container_invocation; pub mod custom_tools; pub mod extensions; pub(crate) mod filter_ir; @@ -48,17 +48,17 @@ pub use common::ADO_MCP_HOST_NODE_MODULES; pub use common::ADO_MCP_IMAGE; pub use common::ADO_MCP_NODE_MODULES; pub use common::ADO_MCP_SERVER_NAME; -pub use common::ADO_MCP_VERSION; pub use common::ADO_MCP_TOKEN_SENTINEL; +pub use common::ADO_MCP_VERSION; pub use common::ADO_PROXY_NETWORK_NAME; pub use common::ADO_PROXY_PUBLIC_CA_HOST_PATH; pub use common::AWF_VERSION; pub use common::HEADER_MARKER; pub use common::MCPG_VERSION; pub use common::normalize_source_path; -pub use common::resolve_ado_organization_bash; #[allow(unused_imports)] pub use common::parse_markdown; +pub use common::resolve_ado_organization_bash; #[allow(unused_imports)] pub use common::{ ParsedSource, atomic_write, parse_markdown_detailed, parse_markdown_detailed_for_source, @@ -173,49 +173,22 @@ async fn compile_pipeline_inner( let yaml_output_path = resolve_output_path(input_path, output_path)?; let existing_version = read_existing_pipeline_version(&yaml_output_path).await; - let parsed = common::parse_markdown_detailed_with_registry( + let mut parsed = common::parse_markdown_detailed_with_registry( &content, registry, existing_version.as_deref(), )?; pr_migration::warn_prompt_references(input_path, &content, &parsed.body_raw); + let (imported_prompt_body, merged_body) = + prepare_parsed_source(&mut parsed, input_path, registry).await?; let mut front_matter = parsed.front_matter; - let mut markdown_body = parsed.markdown_body; + let markdown_body = merged_body; let codemod_report = parsed.codemods; let front_matter_mapping = parsed.front_matter_mapping; let leading_whitespace = parsed.leading_whitespace; let body_raw = parsed.body_raw; let source_sha256 = parsed.source_sha256; - // Resolve and merge cross-repository / local `imports:` (D8/D9). Runs only - // when the workflow declares imports, so import-free workflows are - // unaffected. The merge is applied to a CLONE of the front-matter mapping — - // the original `front_matter_mapping` is preserved untouched so that any - // codemod source-rewrite keeps the author's `imports:` in their file rather - // than writing the expanded/merged form back to disk. - // - // `imported_prompt_body` is the substituted, joined bodies of any imported - // components, inlined into the agent prompt at compile time (they cannot be - // delivered by the default runtime-import path, which reads the consumer's - // own source). Empty when the workflow declares no imports. - let (imported_prompt_body, merged_body) = resolve_and_merge_imports( - &mut front_matter, - &front_matter_mapping, - &markdown_body, - input_path, - ) - .await?; - if !imported_prompt_body.is_empty() { - for line in pr_migration::deprecated_pr_prompt_lines(&imported_prompt_body) { - eprintln!( - "warning: imported prompt for {} (combined import body line {line}): {}", - crate::sanitize::neutralize_pipeline_commands(&input_path.display().to_string()), - pr_migration::PR_PROMPT_GUIDANCE - ); - } - } - markdown_body = merged_body; - // Sanitize all front matter text fields before any further processing. // This neutralizes pipeline command injection (##vso[), strips control // characters, and enforces content limits across all config values. @@ -662,11 +635,13 @@ pub async fn check_pipeline(pipeline_path: &str) -> Result<()> { ) })?; - let parsed = parse_markdown_detailed_for_source( + let mut parsed = parse_markdown_detailed_for_source( &content, Some(header_meta.version.as_str()).filter(|v| !v.is_empty()), )?; pr_migration::warn_prompt_references(&source_path, &content, &parsed.body_raw); + let (imported_prompt_body, markdown_body) = + prepare_parsed_source(&mut parsed, &source_path, codemods::CODEMODS).await?; // Pending-migration enforcement: `check` MUST NOT silently let // a stale source pass. The runtime integrity check inside @@ -687,18 +662,6 @@ pub async fn check_pipeline(pipeline_path: &str) -> Result<()> { let mut front_matter = parsed.front_matter; - // Resolve + merge `imports:` so `check` validates the same fully-merged - // pipeline that `compile` produces. Reads the committed `.ado-aw/imports` - // cache (SHA-keyed), so this is offline when the cache is vendored. Uses the - // absolute `source_path` so the repo root (holding the cache) resolves. - let (imported_prompt_body, markdown_body) = resolve_and_merge_imports( - &mut front_matter, - &parsed.front_matter_mapping, - &parsed.markdown_body, - &source_path, - ) - .await?; - use crate::sanitize::SanitizeConfig; front_matter.sanitize_config_fields(); @@ -1046,6 +1009,48 @@ async fn resolve_and_merge_imports( Ok((imported, combined)) } +/// Resolve effective policy on a clone, then finish only root-authored codemods. +/// The rewrite mapping retains `imports:` and never acquires imported content. +async fn prepare_parsed_source( + parsed: &mut ParsedSource, + source_path: &Path, + registry: &[&'static codemods::Codemod], +) -> Result<(String, String)> { + let has_imports = !parsed.front_matter.imports.is_empty(); + let bodies = resolve_and_merge_imports( + &mut parsed.front_matter, + &parsed.front_matter_mapping, + &parsed.markdown_body, + source_path, + ) + .await?; + if has_imports { + common::finish_import_codemods(parsed, registry)?; + } + Ok(bodies) +} + +/// Read-only effective source policy, including in-memory import migrations. +/// +/// Execution callers apply their usual sanitization, repository resolution and +/// policy validation to this result; this function does not build or write IR. +pub async fn prepare_source_front_matter(input_path: &Path) -> Result { + let content = tokio::fs::read_to_string(input_path) + .await + .with_context(|| format!("Failed to read source file: {}", input_path.display()))?; + let version = read_existing_pipeline_version(&input_path.with_extension("lock.yml")).await; + let mut parsed = common::parse_markdown_detailed_for_source(&content, version.as_deref())?; + prepare_parsed_source(&mut parsed, input_path, codemods::CODEMODS).await?; + if parsed.codemods.changed() { + log::warn!( + "front matter at {} contains deprecated shapes; running with in-memory codemod fixes applied. Run `ado-aw compile {}` to update the source.", + input_path.display(), + input_path.display(), + ); + } + Ok(parsed.front_matter) +} + /// Public, read-only entry point that returns the typed [`ir::Pipeline`] /// for an agent source file **without** writing any YAML. /// @@ -1071,20 +1076,16 @@ pub async fn build_pipeline_ir(input_path: &Path) -> Result<(FrontMatter, ir::Pi // reading the IR without it would diverge from what `compile` produces. let existing_version = read_existing_pipeline_version(&input_path.with_extension("lock.yml")).await; - let parsed = common::parse_markdown_detailed_for_source(&content, existing_version.as_deref())?; - let mut front_matter = parsed.front_matter; + let mut parsed = + common::parse_markdown_detailed_for_source(&content, existing_version.as_deref())?; // Resolve + merge `imports:` so `inspect`/`graph`/`whatif`/`lint`/`trace` // reason about the same fully-merged pipeline `compile` and `check` produce // (imported tools, safe-outputs, custom jobs, and inlined bodies). Reads the // vendored SHA-keyed cache, so it stays offline when the cache is present. - let (imported_prompt_body, markdown_body) = resolve_and_merge_imports( - &mut front_matter, - &parsed.front_matter_mapping, - &parsed.markdown_body, - input_path, - ) - .await?; + let (imported_prompt_body, markdown_body) = + prepare_parsed_source(&mut parsed, input_path, codemods::CODEMODS).await?; + let mut front_matter = parsed.front_matter; use crate::sanitize::SanitizeConfig; front_matter.sanitize_config_fields(); diff --git a/src/compile/pr_migration.rs b/src/compile/pr_migration.rs index 56e46ea8f..f819339d8 100644 --- a/src/compile/pr_migration.rs +++ b/src/compile/pr_migration.rs @@ -7,6 +7,42 @@ use serde_json::{Map, Value, json}; use super::types::FrontMatter; pub const LEGACY_PR_CONFIG: &str = "legacy-update-pr"; +pub(crate) const LEGACY_PR_VOTES: &[&str] = &[ + "approve", + "approve-with-suggestions", + "wait-for-author", + "reject", + "reset", +]; + +pub fn validate_legacy_votes(votes: &[String]) -> Result<()> { + for vote in votes { + ensure!( + LEGACY_PR_VOTES.contains(&vote.as_str()), + "update-pr.allowed-votes contains unsupported legacy vote '{}'; supported values: {}", + crate::sanitize::neutralize_pipeline_commands(vote), + LEGACY_PR_VOTES.join(", ") + ); + } + Ok(()) +} + +pub fn validate_legacy_metadata(front_matter: &FrontMatter) -> Result<()> { + for (_, tool) in PR_OPERATIONS { + let Some(metadata) = front_matter.safe_outputs.get(*tool) + .and_then(|config| config.get(LEGACY_PR_CONFIG)) else { + continue; + }; + ensure!(metadata.is_object(), "safe-outputs.{tool}.{LEGACY_PR_CONFIG} must be an object"); + let votes: Vec = metadata.get("allowed-votes").cloned() + .map(serde_json::from_value).transpose() + .with_context(|| format!("safe-outputs.{tool}.{LEGACY_PR_CONFIG}.allowed-votes must be a list"))? + .unwrap_or_default(); + validate_legacy_votes(&votes) + .with_context(|| format!("safe-outputs.{tool}.{LEGACY_PR_CONFIG} has invalid vote policy"))?; + } + Ok(()) +} pub const PR_TOOL_RENAMES: &[(&str, &str)] = &[ ("add-pr-comment", "add-pull-request-comment"), ("reply-to-pr-comment", "reply-to-pull-request-comment"), @@ -44,6 +80,7 @@ pub fn migrate_safe_outputs(outputs: &mut Map) -> Result { let Some(raw) = outputs.get("update-pr") else { return Ok(false); }; + let bare = raw.is_null() || raw == &Value::Bool(true); let original = match raw { Value::Null | Value::Bool(true) => Map::new(), Value::Object(config) => config.clone(), @@ -65,7 +102,8 @@ pub fn migrate_safe_outputs(outputs: &mut Map) -> Result { let selected: Vec<_> = PR_OPERATIONS .iter() .filter(|(operation, _)| { - operations.is_empty() || operations.iter().any(|allowed| allowed == operation) + (!bare || *operation != "vote") + && (operations.is_empty() || operations.iter().any(|allowed| allowed == operation)) }) .copied() .collect(); @@ -83,6 +121,7 @@ pub fn migrate_safe_outputs(outputs: &mut Map) -> Result { .transpose() .context("update-pr.allowed-votes must be a list")? .unwrap_or_default(); + validate_legacy_votes(&votes)?; let mut migrated = outputs.clone(); let mut groups: BudgetGroups = outputs @@ -255,39 +294,17 @@ pub fn validate_execution_budget_groups(ctx: &crate::safe_outputs::ExecutionCont Ok(()) } -pub fn rename_pr_tools(outputs: &mut Map) -> Result { - let mut renamed = outputs.clone(); - let mut changed = false; - for (old, new) in PR_TOOL_RENAMES { - if let Some(config) = renamed.get(*old).cloned() { - ensure!( - !renamed.contains_key(*new), - "manual migration required: both {old} and {new} are configured" - ); - renamed.remove(*old); - renamed.insert((*new).to_string(), config); - changed = true; - } - } - if let Some(raw_groups) = renamed.get("budget-groups") { - let mut groups: BudgetGroups = serde_json::from_value(raw_groups.clone()) - .context("safe-outputs.budget-groups has invalid configuration")?; - let mut groups_changed = false; - for group in groups.values_mut() { - for tool in &mut group.tools { - if let Some((_, new)) = PR_TOOL_RENAMES.iter().find(|(old, _)| *old == tool) { - *tool = (*new).to_string(); - groups_changed = true; - } - } - } - if groups_changed { - renamed.insert("budget-groups".to_string(), serde_json::to_value(groups)?); - changed = true; - } - } +#[cfg(test)] +fn rename_pr_tools(outputs: &mut Map) -> Result { + let mut manifest = serde_yaml::Mapping::new(); + manifest.insert( + serde_yaml::Value::String("safe-outputs".into()), + serde_yaml::to_value(&*outputs)?, + ); + let custom = super::imports::pr_policy::local_custom_job_names(&manifest)?; + let changed = super::imports::pr_policy::rename_declarations(&mut manifest, &custom)?; if changed { - *outputs = renamed; + *outputs = serde_json::from_value(serde_json::to_value(&manifest["safe-outputs"])?)?; } Ok(changed) } @@ -337,6 +354,61 @@ pub fn warn_prompt_references(source: &std::path::Path, content: &str, body: &st mod tests { use super::*; + #[test] + fn legacy_votes_are_validated_before_translating_and_without_mutation() { + for vote in ["comment", "request-changes", "Approve", "unknown"] { + let mut outputs = json!({ + "update-pr": {"allowed-operations": ["vote"], "allowed-votes": [vote]} + }).as_object().unwrap().clone(); + let original = outputs.clone(); + let error = migrate_safe_outputs(&mut outputs).unwrap_err().to_string(); + assert!(error.contains("unsupported legacy vote"), "{error}"); + assert!(error.contains(vote), "{error}"); + assert_eq!(outputs, original); + } + for vote in LEGACY_PR_VOTES { + let mut outputs = json!({ + "update-pr": {"allowed-operations": ["vote"], "allowed-votes": [vote]} + }).as_object().unwrap().clone(); + migrate_safe_outputs(&mut outputs).unwrap(); + assert_eq!(outputs["submit-pull-request-review"]["allowed-events"], json!([vote])); + assert_eq!(outputs["submit-pull-request-review"][LEGACY_PR_CONFIG]["allowed-votes"], json!([vote])); + } + } + + #[test] + fn bare_declarations_keep_non_voting_capabilities_and_one_shared_budget() { + for config in [Value::Null, Value::Bool(true)] { + let mut outputs = Map::from_iter([("update-pr".to_string(), config)]); + migrate_safe_outputs(&mut outputs).unwrap(); + assert!(!outputs.contains_key("submit-pull-request-review")); + for tool in ["add-pull-request-reviewers", "add-pull-request-labels", + "set-pull-request-auto-complete", "update-pull-request"] { + assert!(outputs.contains_key(tool), "missing {tool}"); + } + assert_eq!(outputs["budget-groups"]["update-pr"]["max"], 1); + assert_eq!(outputs["budget-groups"]["update-pr"]["tools"].as_array().unwrap().len(), 4); + let snapshot = outputs.clone(); + assert!(!migrate_safe_outputs(&mut outputs).unwrap()); + assert_eq!(outputs, snapshot); + } + } + + #[test] + fn invalid_persisted_legacy_votes_are_rejected_but_native_review_events_are_not() { + for event in ["comment", "request-changes"] { + let source = format!("---\nname: test\ndescription: d\nsafe-outputs:\n submit-pull-request-review:\n allowed-events: [{event}]\n legacy-update-pr:\n allowed-operations: [vote]\n allowed-votes: [{event}]\n---\nbody\n"); + let parsed = crate::compile::parse_markdown_detailed(&source).unwrap(); + let error = validate_legacy_metadata(&parsed.front_matter).unwrap_err(); + assert!(format!("{error:#}").contains("unsupported legacy vote")); + let mut native = parsed.front_matter; + native.safe_outputs.get_mut("submit-pull-request-review").unwrap() + .as_object_mut().unwrap().remove(LEGACY_PR_CONFIG); + validate_legacy_metadata(&native).unwrap(); + crate::compile::common::validate_pull_request_outputs_config(&native).unwrap(); + } + } + #[test] fn renamed_tools_preserve_configuration_and_are_idempotent() { for (old, new) in PR_TOOL_RENAMES { diff --git a/src/execute.rs b/src/execute.rs index 382860cd2..e6ebd518e 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -937,6 +937,31 @@ mod tests { use std::collections::HashMap; use std::path::PathBuf; + #[tokio::test] + async fn invalid_legacy_review_metadata_cannot_reset_a_vote() { + let server = wiremock::MockServer::start().await; + let ctx = ExecutionContext { + ado_org_url: Some(server.uri()), + ado_organization: Some("org".into()), + ado_project: Some("project".into()), + repository_name: Some("repo".into()), + access_token: Some("test-token".into()), + tool_configs: HashMap::from([("submit-pull-request-review".into(), serde_json::json!({ + "allowed-events": ["comment"], + "legacy-update-pr": {"allowed-operations": ["vote"], "allowed-votes": ["comment"]} + }))]), + ..Default::default() + }; + let error = execute_safe_output(&serde_json::json!({ + "name": "submit-pull-request-review", + "pull_request_id": 42, + "event": "comment", + "legacy-update-pr": {"allowed-votes": ["reset"]} + }), &ctx).await.expect_err("trusted invalid legacy policy must fail before requests"); + assert!(format!("{error:#}").contains("unsupported legacy vote"), "{error:#}"); + assert!(server.received_requests().await.unwrap().is_empty()); + } + #[tokio::test] async fn old_pull_request_names_have_no_stage_three_aliases() { let ctx = ExecutionContext { diff --git a/src/main.rs b/src/main.rs index 0b1566e26..3c87554fb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -987,27 +987,10 @@ async fn run_execute(options: RunExecuteOptions) -> Result<()> { } let source = source.context("--source or --resolved-config is required for execution")?; - // Read and parse source markdown to get tool configs. - // Use parse_markdown_detailed so Stage 3 benefits from in-memory - // codemod fixes when a source has deprecated shapes. Stage 3 must - // NOT rewrite the source file (the executor's working tree is not - // the source-of-truth tree), so we just emit a log warning. - let content = tokio::fs::read_to_string(&source) + // Match compile's effective imported policy without rewriting source/cache files. + let mut front_matter = compile::prepare_source_front_matter(&source) .await - .with_context(|| format!("Failed to read source file: {}", source.display()))?; - - let parsed = compile::parse_markdown_detailed(&content) - .with_context(|| format!("Failed to parse source file: {}", source.display()))?; - - if parsed.codemods.changed() { - log::warn!( - "front matter at {} contains deprecated shapes; running with in-memory codemod fixes applied. Run `ado-aw compile {}` to update the source.", - source.display(), - source.display(), - ); - } - - let mut front_matter = parsed.front_matter; + .with_context(|| format!("Failed to prepare source file: {}", source.display()))?; // Sanitize before lowering repos, mirroring compile_pipeline_inner // and check_pipeline so unsanitized fields never flow into the diff --git a/src/safe_outputs/abandon_pull_request.rs b/src/safe_outputs/abandon_pull_request.rs index 1a036d602..73f541788 100644 --- a/src/safe_outputs/abandon_pull_request.rs +++ b/src/safe_outputs/abandon_pull_request.rs @@ -6,7 +6,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use super::pr_common::{ - PullRequestReference, repository_api_base, resolve_pr_target, resolved_reference_id, + PrTargetPolicy, PullRequestReference, repository_api_base, resolve_pr_policy_target, validate_reference, }; use crate::safe_outputs::{ExecutionContext, ExecutionResult, Executor, Validate}; @@ -227,10 +227,14 @@ pub(crate) fn validate_abandon_pull_request_config( Ok(()) } -fn parse_positive(value: Option<&str>) -> Option { - value - .and_then(|value| value.parse::().ok()) - .filter(|id| *id > 0) +impl AbandonPullRequestConfig { + pub(crate) fn target_policy(&self) -> anyhow::Result { + match self.target { + AbandonPullRequestTarget::Id(id) => PrTargetPolicy::fixed(id), + AbandonPullRequestTarget::Triggering => Ok(PrTargetPolicy::Triggering), + AbandonPullRequestTarget::Any => Ok(PrTargetPolicy::Explicit), + } + } } fn pr_labels(pr: &serde_json::Value) -> Vec { @@ -244,50 +248,6 @@ fn pr_labels(pr: &serde_json::Value) -> Vec { } impl AbandonPullRequestResult { - fn resolve_target_id( - &self, - config: &AbandonPullRequestConfig, - ctx: &ExecutionContext, - ) -> Result { - let requested = self - .pull_request_id - .as_ref() - .map(|reference| resolved_reference_id(reference, ctx)) - .transpose()?; - match config.target { - AbandonPullRequestTarget::Id(id) => { - if let Some(requested) = requested - && requested != id - { - return Err(ExecutionResult::failure(format!( - "requested pull_request_id #{requested} does not match configured target #{id}" - ))); - } - Ok(id) - } - AbandonPullRequestTarget::Any => requested.ok_or_else(|| { - ExecutionResult::failure( - "pull_request_id is required when safe-outputs.abandon-pull-request.target is '*'", - ) - }), - AbandonPullRequestTarget::Triggering => { - let triggering = parse_positive(ctx.pull_request_id.as_deref()).ok_or_else(|| { - ExecutionResult::failure( - "safe-outputs.abandon-pull-request.target is 'triggering' but no Azure DevOps pull request context is available; use target: '*' and pass pull_request_id, or configure a numeric target", - ) - })?; - if let Some(requested) = requested - && requested != triggering - { - return Err(ExecutionResult::failure(format!( - "requested pull_request_id #{requested} does not match triggering pull request #{triggering}" - ))); - } - Ok(triggering) - } - } - } - fn repository_selector<'a>(&'a self, config: &'a AbandonPullRequestConfig) -> Option<&'a str> { self.repository.as_deref().or(config.target_repo.as_deref()) } @@ -472,16 +432,9 @@ impl Executor for AbandonPullRequestResult { return Ok(ExecutionResult::failure(error.to_string())); } - let pull_request_id = match self.resolve_target_id(&config, ctx) { - Ok(id) => id, - Err(result) => return Ok(result), - }; - let reference = self - .pull_request_id - .clone() - .unwrap_or(PullRequestReference::Number(pull_request_id)); - let (_, target) = match resolve_pr_target( - &reference, + let (pull_request_id, target) = match resolve_pr_policy_target( + &config.target_policy()?, + self.pull_request_id.as_ref(), self.repository_selector(&config), &config.allowed_repositories, ctx, @@ -1030,14 +983,18 @@ mod tests { async fn triggering_target_uses_context_pull_request_id() { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/proj/_apis/git/repositories/repo/pullRequests/9")) + .and(path( + "/proj/_apis/git/repositories/11111111-1111-1111-1111-111111111111/pullRequests/9", + )) .and(query_param("api-version", "7.1")) .respond_with(ResponseTemplate::new(200).set_body_json(pr("active"))) .expect(1) .mount(&server) .await; Mock::given(method("PATCH")) - .and(path("/proj/_apis/git/repositories/repo/pullRequests/9")) + .and(path( + "/proj/_apis/git/repositories/11111111-1111-1111-1111-111111111111/pullRequests/9", + )) .and(query_param("api-version", "7.1")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({}))) .expect(1) @@ -1045,6 +1002,13 @@ mod tests { .await; let mut ctx = context(&server, serde_json::json!({})); ctx.pull_request_id = Some("9".to_string()); + ctx.triggering_pr = Some(super::super::pr_common::TriggeringPullRequest { + collection_uri: server.uri(), + project: "proj".into(), + repository_name: "repo".into(), + repository_id: "11111111-1111-1111-1111-111111111111".into(), + id: "9".into(), + }); let mut result: AbandonPullRequestResult = AbandonPullRequestParams { pull_request_id: None, body: None, diff --git a/src/safe_outputs/create_pull_request.rs b/src/safe_outputs/create_pull_request.rs index e7b9b8876..d350d813d 100644 --- a/src/safe_outputs/create_pull_request.rs +++ b/src/safe_outputs/create_pull_request.rs @@ -3566,6 +3566,7 @@ index 0000000..abcdefg std::collections::HashMap::new(), )), budget_groups: Default::default(), + triggering_pr: Default::default(), triggered_by_build_id: None, triggered_by_definition_name: None, triggered_by_build_number: None, diff --git a/src/safe_outputs/pr_common.rs b/src/safe_outputs/pr_common.rs index 64a00ba14..edc125763 100644 --- a/src/safe_outputs/pr_common.rs +++ b/src/safe_outputs/pr_common.rs @@ -16,6 +16,317 @@ use crate::secure::PullRequestTemporaryId; pub(crate) const MAX_DESCRIPTION_UTF16: usize = 4_000; +/// Normalized executor/preview target contract. Decimal strings avoid JS precision loss. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub(crate) enum PrTargetPolicy { + Triggering, + Explicit, + Fixed { id: String }, +} + +impl PrTargetPolicy { + pub(crate) fn named(value: &str) -> anyhow::Result { + match value { + "triggering" => Ok(Self::Triggering), + "*" => Ok(Self::Explicit), + value => Self::fixed( + value + .parse::() + .context("target must be triggering, *, or a positive PR ID")?, + ), + } + } + + pub(crate) fn fixed(id: u64) -> anyhow::Result { + ensure!(id > 0, "target PR ID must be positive"); + Ok(Self::Fixed { id: id.to_string() }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TriggeringPullRequest { + pub collection_uri: String, + pub project: String, + pub repository_name: String, + pub repository_id: String, + /// Kept as a validated decimal string across the compiler/Node/Rust boundary. + pub id: String, +} + +fn env_value(env: &impl Fn(&str) -> Option, key: &str) -> Option { + env(key).filter(|value| !value.is_empty() && !value.contains("$(") && !value.contains("$[")) +} + +/// Exact collection identity; known Services URL spellings normalize to one canonical URL. +fn collection_identity(raw: &str) -> Option { + let url = url::Url::parse(raw).ok()?; + if !matches!(url.scheme(), "https" | "http") + || !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + return None; + } + let host = url.host_str()?.to_ascii_lowercase(); + let parts: Vec<_> = url + .path_segments()? + .filter(|part| !part.is_empty()) + .collect(); + if url.scheme() == "https" && url.port().is_none() { + if host == "dev.azure.com" && parts.len() == 1 { + return Some(format!( + "https://dev.azure.com/{}", + parts[0].to_ascii_lowercase() + )); + } + if let Some(org) = host.strip_suffix(".visualstudio.com") + && !org.is_empty() + && !org.contains('.') + && (parts.is_empty() + || (parts.len() == 1 && parts[0].eq_ignore_ascii_case("DefaultCollection"))) + { + return Some(format!("https://dev.azure.com/{org}")); + } + if host == "dev.azure.com" || host.ends_with(".visualstudio.com") { + return None; + } + } + // Exact non-Services collection identities are useful for already-trusted + // contexts; native capture below only accepts supported Azure Repos remotes. + Some(url.as_str().trim_end_matches('/').to_ascii_lowercase()) +} + +impl TriggeringPullRequest { + fn validate(&self) -> anyhow::Result { + ensure!( + collection_identity(&self.collection_uri).is_some(), + "invalid triggering collection URI" + ); + crate::secure::Guid::parse(&self.repository_id)?; + ensure!( + !self.project.trim().is_empty() && !self.repository_name.trim().is_empty(), + "incomplete triggering repository identity" + ); + ensure!( + !self.project.contains(['/', '\\']) + && !self.repository_name.contains(['/', '\\']) + && !self.project.chars().any(char::is_control) + && !self.repository_name.chars().any(char::is_control), + "invalid triggering repository path segment" + ); + crate::validate::reject_pipeline_injection(&self.project, "triggering project")?; + crate::validate::reject_pipeline_injection(&self.repository_name, "triggering repository")?; + ensure!( + !self.id.is_empty() && self.id.bytes().all(|byte| byte.is_ascii_digit()), + "invalid triggering PR ID" + ); + let id = self.id.parse::()?; + ensure!(id > 0, "invalid triggering PR ID"); + Ok(id) + } + + pub(crate) fn from_env(env: &impl Fn(&str) -> Option) -> Option { + // A projected synthetic tuple is authoritative. An empty/malformed tuple + // must never fall back to unrelated native/self metadata. + if let Some(raw) = env("ADO_AW_TRIGGERING_PR_IDENTITY") { + let identity: Self = serde_json::from_str(&raw).ok()?; + identity.validate().ok()?; + if !collection_identity(&identity.collection_uri)?.starts_with("https://dev.azure.com/") + { + return None; + } + return Some(identity); + } + let get = |projected: &str, native: &str| { + if env("ADO_AW_TRIGGERING_PR_CAPTURED").is_some() { + env_value(env, projected) + } else { + env_value(env, native) + } + }; + if get("ADO_AW_TRIGGER_BUILD_REASON", "BUILD_REASON")? != "PullRequest" + || get( + "ADO_AW_TRIGGER_REPOSITORY_PROVIDER", + "BUILD_REPOSITORY_PROVIDER", + )? != "TfsGit" + { + return None; + } + let repository_uri = get("ADO_AW_TRIGGER_REPOSITORY_URI", "BUILD_REPOSITORY_URI")?; + let remote = url::Url::parse(&repository_uri).ok()?; + if remote.scheme() != "https" + || remote.port().is_some() + || remote.query().is_some() + || remote.fragment().is_some() + || remote.password().is_some() + { + return None; + } + let host = remote.host_str()?; + if host != "dev.azure.com" + && !host + .strip_suffix(".visualstudio.com") + .is_some_and(|org| !org.is_empty() && !org.contains('.')) + { + return None; + } + let mut segments: Vec<_> = remote.path_segments()?.collect(); + if segments.last() == Some(&"") { + segments.pop(); + } + if segments.iter().any(|part| part.is_empty()) { + return None; + } + let expected_len = if host == "dev.azure.com" + || segments + .first() + .is_some_and(|part| part.eq_ignore_ascii_case("DefaultCollection")) + { + 4 + } else { + 3 + }; + if segments.len() != expected_len || segments[expected_len - 2] != "_git" { + return None; + } + let parsed = crate::ado::parse_ado_remote(remote.as_str()).ok()?; + let decode = |part: &str| { + percent_encoding::percent_decode_str(part) + .decode_utf8() + .ok() + .map(|part| part.into_owned()) + }; + let collection_uri = + get("ADO_AW_TRIGGER_COLLECTION_URI", "SYSTEM_COLLECTIONURI").or_else(|| { + env("ADO_AW_TRIGGERING_PR_CAPTURED") + .is_none() + .then(|| env_value(env, "SYSTEM_TEAMFOUNDATIONCOLLECTIONURI")) + .flatten() + })?; + if collection_identity(&collection_uri)? != collection_identity(&parsed.org_url)? { + return None; + } + let identity = Self { + collection_uri, + project: decode(&parsed.project)?, + // Do not use parse_ado_remote's git-suffix stripping for repository names. + repository_name: decode(segments[expected_len - 1])?, + repository_id: get("ADO_AW_TRIGGER_REPOSITORY_ID", "BUILD_REPOSITORY_ID")?, + id: get("ADO_AW_TRIGGER_PR_ID", "SYSTEM_PULLREQUEST_PULLREQUESTID")?, + }; + identity.validate().ok()?; + Some(identity) + } + + fn matches(&self, target: &AdoRepositoryTarget) -> bool { + collection_identity(&self.collection_uri) == collection_identity(&target.organization_url) + && self.project.eq_ignore_ascii_case(&target.project) + && match &target.repository_id { + Some(id) => id.eq_ignore_ascii_case(&self.repository_id), + None => { + target + .repository + .eq_ignore_ascii_case(&self.repository_name) + || target.repository.eq_ignore_ascii_case(&self.repository_id) + } + } + } +} + +pub(crate) fn resolve_pr_policy_target( + policy: &PrTargetPolicy, + reference: Option<&PullRequestReference>, + repository: Option<&str>, + allowed: &[String], + ctx: &ExecutionContext, +) -> anyhow::Result> { + let failure = |message: String| Ok(Err(ExecutionResult::failure(message))); + let (expected, triggering) = match policy { + PrTargetPolicy::Explicit => (None, None), + PrTargetPolicy::Fixed { id } => (Some(id.parse::()?), None), + PrTargetPolicy::Triggering => { + let Some(identity) = &ctx.triggering_pr else { + return failure("target 'triggering' requires a complete trusted Azure DevOps triggering PR identity".into()); + }; + let id = match identity.validate() { + Ok(id) => id, + Err(error) => return failure(error.to_string()), + }; + (Some(id), Some(identity)) + } + }; + let default_reference; + let reference = match reference { + Some(reference) => reference, + None => { + let Some(id) = expected else { + return failure("pull_request_id is required when target is '*'".into()); + }; + default_reference = PullRequestReference::Number(id); + &default_reference + } + }; + let mapped_alias; + let repository = if let Some(identity) = triggering + && repository.is_none() + && matches!(reference, PullRequestReference::Number(_)) + { + let mut aliases: Vec<_> = ctx + .allowed_repositories + .keys() + .chain(ctx.repository_targets.keys()) + .cloned() + .collect(); + aliases.push("self".into()); + aliases.sort(); + aliases.dedup(); + mapped_alias = aliases.into_iter().find(|alias| { + repository_is_allowed(allowed, alias, ctx) + && resolve_repository_write_target(Some(alias), ctx) + .is_ok_and(|target| identity.matches(&target)) + }); + let Some(alias) = mapped_alias.as_deref() else { + return failure( + "triggering repository cannot be mapped to a permitted checkout/write target within allowed-repositories" + .into(), + ); + }; + Some(alias) + } else { + repository + }; + let (id, mut target) = match resolve_pr_target(reference, repository, allowed, ctx)? { + Ok(target) => target, + Err(failure) => return Ok(Err(failure)), + }; + if let Some(expected) = expected + && id != expected + { + return failure(format!( + "requested pull_request_id #{id} does not match configured target #{expected}" + )); + } + if let Some(identity) = triggering { + if !identity.matches(&target) { + return failure("requested PR destination does not match the trusted triggering collection/project/repository".into()); + } + // A registry reference must still map to a permitted checkout in this context. + let permitted = resolve_repository_write_target(Some(&target.alias), ctx) + .is_ok_and(|permitted| identity.matches(&permitted)); + if !permitted { + return failure( + "triggering repository cannot be mapped to a permitted checkout/write target within allowed-repositories" + .into(), + ); + } + target.repository_id = Some(identity.repository_id.clone()); + } + Ok(Ok((id, target))) +} + /// Positive Azure DevOps pull-request ID or a same-run temporary ID. #[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] #[serde(untagged)] @@ -98,6 +409,11 @@ pub(crate) fn resolve_pr_target( allowed_repositories: &[String], ctx: &ExecutionContext, ) -> anyhow::Result> { + if requested_repository.is_some_and(|selector| selector.trim().is_empty()) { + return Ok(Err(ExecutionResult::failure( + "explicit repository selector must not be empty", + ))); + } if let Err(error) = validate_reference(reference) { return Ok(Err(ExecutionResult::failure(error.to_string()))); } @@ -175,27 +491,6 @@ pub(crate) fn resolve_pr_target( Ok(Ok((id, target))) } -pub(crate) fn resolved_reference_id( - reference: &PullRequestReference, - ctx: &ExecutionContext, -) -> Result { - match reference { - PullRequestReference::Number(id) if *id > 0 => Ok(*id), - PullRequestReference::Number(_) => Err(ExecutionResult::failure( - "pull_request_id must be positive", - )), - PullRequestReference::Temporary(id) => ctx - .resolve_pull_request(id) - .map_err(|error| ExecutionResult::failure(error.to_string()))? - .filter(|resolved| resolved.id > 0) - .map(|resolved| resolved.id) - .ok_or_else(|| ExecutionResult::failure(format!( - "temporary pull-request ID '{}' has not been resolved; create-pull-request must succeed earlier in the same SafeOutputs job", - id.canonical() - ))), - } -} - /// Read compatibility policy only from trusted execution configuration, never proposal JSON. pub(crate) fn legacy_policy( ctx: &ExecutionContext, @@ -215,6 +510,7 @@ pub(crate) fn legacy_policy( ); let mut config: UpdatePrConfig = serde_json::from_value(value.clone()).context("invalid legacy-update-pr policy")?; + crate::compile::pr_migration::validate_legacy_votes(&config.allowed_votes)?; config.sanitize_config_fields(); ensure!( config.allowed_operations.is_empty() @@ -238,6 +534,332 @@ pub(crate) fn validate_description(body: &str) -> anyhow::Result<()> { #[cfg(test)] pub(crate) mod tests { use super::*; + use crate::safe_outputs::{AbandonPullRequestResult, Executor, UpdatePullRequestResult}; + + const TRIGGER_REPO_ID: &str = "11111111-1111-1111-1111-111111111111"; + + fn native_env() -> std::collections::HashMap { + [ + ("BUILD_REASON", "PullRequest"), + ("BUILD_REPOSITORY_PROVIDER", "TfsGit"), + ("SYSTEM_COLLECTIONURI", "https://dev.azure.com/org/"), + ( + "BUILD_REPOSITORY_URI", + "https://dev.azure.com/org/Other/_git/target", + ), + ("BUILD_REPOSITORY_ID", TRIGGER_REPO_ID), + ("SYSTEM_PULLREQUEST_PULLREQUESTID", "42"), + ("ADO_AW_SELF_REPOSITORY_NAME", "templates"), + ("SYSTEM_TEAMPROJECT", "Current"), + ( + "SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI", + "https://dev.azure.com/fork/Other/_git/target", + ), + ] + .into_iter() + .map(|(key, value)| (key.into(), value.into())) + .collect() + } + + #[test] + fn native_capture_is_independent_of_self_cli_and_fork_source() { + let mut env = native_env(); + env.insert( + "AZURE_DEVOPS_ORG_URL".into(), + "https://dev.azure.com/overridden".into(), + ); + let mut ctx = ExecutionContext::from_env_lookup(|key| env.get(key).cloned()); + let identity = ctx.triggering_pr.clone().unwrap(); + assert_eq!(identity.collection_uri, "https://dev.azure.com/org/"); + assert_eq!(identity.project, "Other"); + assert_eq!(identity.repository_name, "target"); + assert_eq!(identity.repository_id, TRIGGER_REPO_ID); + assert_eq!(ctx.repository_name.as_deref(), Some("templates")); + ctx.ado_org_url = Some("https://dev.azure.com/cli".into()); + ctx.ado_project = Some("cli-project".into()); + assert_eq!(ctx.triggering_pr, Some(identity)); + } + + #[test] + fn native_capture_normalizes_supported_url_spellings_without_relaxing_paths() { + let mut env = native_env(); + env.insert( + "BUILD_REPOSITORY_URI".into(), + "https://DEV.AZURE.COM/org/Other/_git/target/".into(), + ); + env.insert( + "SYSTEM_COLLECTIONURI".into(), + "https://org.visualstudio.com/DefaultCollection/".into(), + ); + env.insert( + "SYSTEM_PULLREQUEST_PULLREQUESTID".into(), + u64::MAX.to_string(), + ); + assert_eq!( + TriggeringPullRequest::from_env(&|key| env.get(key).cloned()) + .unwrap() + .id, + u64::MAX.to_string() + ); + for uri in [ + "https://dev.azure.com/org/extra", + "https://org.visualstudio.com/OtherCollection/", + ] { + env.insert("SYSTEM_COLLECTIONURI".into(), uri.into()); + assert!(TriggeringPullRequest::from_env(&|key| env.get(key).cloned()).is_none()); + } + } + + #[test] + fn native_capture_rejects_missing_malformed_non_ado_and_unresolved_metadata() { + for (key, value) in [ + ("BUILD_REASON", "Manual"), + ("BUILD_REPOSITORY_PROVIDER", "GitHub"), + ("BUILD_REPOSITORY_URI", ""), + ("BUILD_REPOSITORY_ID", ""), + ("SYSTEM_COLLECTIONURI", "https://dev.azure.com/wrong"), + ( + "BUILD_REPOSITORY_URI", + "https://dev.azure.com.evil/org/Other/_git/target", + ), + ( + "SYSTEM_PULLREQUEST_PULLREQUESTID", + "$(System.PullRequest.PullRequestId)", + ), + ("SYSTEM_PULLREQUEST_PULLREQUESTID", "18446744073709551616"), + ( + "BUILD_REPOSITORY_URI", + "https://dev.azure.com/org/Other/_git/target/extra", + ), + ] { + let mut env = native_env(); + env.insert(key.into(), value.into()); + assert!( + ExecutionContext::from_env_lookup(|key| env.get(key).cloned()) + .triggering_pr + .is_none(), + "{key}" + ); + } + let mut env = native_env(); + env.insert("ADO_AW_TRIGGERING_PR_IDENTITY".into(), "".into()); + assert!( + ExecutionContext::from_env_lookup(|key| env.get(key).cloned()) + .triggering_pr + .is_none() + ); + env.remove("ADO_AW_TRIGGERING_PR_IDENTITY"); + env.insert("ADO_AW_TRIGGERING_PR_CAPTURED".into(), "true".into()); + assert!( + ExecutionContext::from_env_lookup(|key| env.get(key).cloned()) + .triggering_pr + .is_none() + ); + } + + async fn run_content_tool( + tool: &str, + ctx: &ExecutionContext, + fields: serde_json::Value, + ) -> ExecutionResult { + let mut value = fields; + value["name"] = serde_json::json!(tool); + if tool == "update-pull-request" { + value["title"] = serde_json::json!("Updated title"); + let mut proposal: UpdatePullRequestResult = serde_json::from_value(value).unwrap(); + proposal.execute_sanitized(ctx).await.unwrap() + } else { + let mut proposal: AbandonPullRequestResult = serde_json::from_value(value).unwrap(); + proposal.execute_sanitized(ctx).await.unwrap() + } + } + + fn triggering_context( + server: &wiremock::MockServer, + tool: &str, + synthetic: bool, + ) -> ExecutionContext { + let mut env = native_env(); + if synthetic { + let identity = TriggeringPullRequest::from_env(&|key| env.get(key).cloned()).unwrap(); + env.insert( + "ADO_AW_TRIGGERING_PR_IDENTITY".into(), + serde_json::to_string(&identity).unwrap(), + ); + env.insert("BUILD_REASON".into(), "IndividualCI".into()); + env.remove("SYSTEM_PULLREQUEST_PULLREQUESTID"); + } + let mut ctx = ExecutionContext::from_env_lookup(|key| env.get(key).cloned()); + // Replace transport origin only; the captured project/repository/PR tuple remains unchanged. + ctx.ado_org_url = Some(server.uri()); + ctx.ado_organization = Some("org".into()); + ctx.triggering_pr.as_mut().unwrap().collection_uri = server.uri(); + ctx.access_token = Some("token".into()); + ctx.allowed_repositories + .insert("trigger".into(), "Other/target".into()); + ctx.tool_configs.insert(tool.into(), serde_json::json!({})); + ctx + } + + #[tokio::test] + async fn both_triggering_tools_use_permitted_native_and_synthetic_trigger_not_self() { + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{method, path}, + }; + for tool in ["update-pull-request", "abandon-pull-request"] { + for synthetic in [false, true] { + let server = MockServer::start().await; + let route = + format!("/Other/_apis/git/repositories/{TRIGGER_REPO_ID}/pullRequests/42"); + Mock::given(method("GET")) + .and(path(&route)) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "pullRequestId": 42, "title": "Old title", "status": "active" + }))) + .expect(4) + .mount(&server) + .await; + Mock::given(method("PATCH")) + .and(path(&route)) + .respond_with(ResponseTemplate::new(200)) + .expect(4) + .mount(&server) + .await; + let ctx = triggering_context(&server, tool, synthetic); + ctx.register_resolved_pull_request( + &PullRequestTemporaryId::parse("#aw_known").unwrap(), + crate::safe_outputs::ResolvedPullRequest { + id: 42, + url: "unused".into(), + target: resolve_repository_write_target(Some("trigger"), &ctx).unwrap(), + }, + ) + .unwrap(); + for fields in [ + serde_json::json!({}), + serde_json::json!({"pull_request_id":42}), + serde_json::json!({"pull_request_id":"42"}), + serde_json::json!({"pull_request_id":"#aw_known"}), + ] { + let result = run_content_tool(tool, &ctx, fields).await; + assert!( + result.success, + "{tool} synthetic={synthetic}: {}", + result.message + ); + } + } + } + } + + #[tokio::test] + async fn same_id_foreign_org_project_repo_and_temp_refs_never_mutate() { + use wiremock::MockServer; + for tool in ["update-pull-request", "abandon-pull-request"] { + for mismatch in ["org", "project", "repo", "guid"] { + let server = MockServer::start().await; + let ctx = triggering_context(&server, tool, false); + let mut target = resolve_repository_write_target(Some("trigger"), &ctx).unwrap(); + match mismatch { + "org" => target.organization_url = "https://dev.azure.com/foreign".into(), + "project" => target.project = "Foreign".into(), + "repo" => target.repository = "foreign".into(), + _ => target.repository_id = Some("22222222-2222-2222-2222-222222222222".into()), + } + ctx.register_resolved_pull_request( + &PullRequestTemporaryId::parse("#aw_foreign").unwrap(), + crate::safe_outputs::ResolvedPullRequest { + id: 42, + url: "unused".into(), + target, + }, + ) + .unwrap(); + let result = run_content_tool( + tool, + &ctx, + serde_json::json!({"pull_request_id":"#aw_foreign"}), + ) + .await; + assert!(!result.success, "{tool} {mismatch}"); + assert!(server.received_requests().await.unwrap().is_empty()); + } + let server = MockServer::start().await; + let mut ctx = triggering_context(&server, tool, false); + for fields in [ + serde_json::json!({"pull_request_id":42, "repository":"self"}), + serde_json::json!({"pull_request_id":"42", "repository":""}), + serde_json::json!({"pull_request_id":"#aw_unknown"}), + ] { + assert!(!run_content_tool(tool, &ctx, fields).await.success); + } + ctx.triggering_pr = None; + assert!( + !run_content_tool(tool, &ctx, serde_json::json!({"pull_request_id":42})) + .await + .success + ); + assert!(server.received_requests().await.unwrap().is_empty()); + } + } + + #[test] + fn triggering_context_cannot_grant_checkout_or_cross_org_write_authority() { + let mut env = native_env(); + let mut ctx = ExecutionContext::from_env_lookup(|key| env.get(key).cloned()); + assert!( + resolve_pr_policy_target(&PrTargetPolicy::Triggering, None, None, &[], &ctx) + .unwrap() + .is_err() + ); + ctx.allowed_repositories + .insert("trigger".into(), "Other/target".into()); + assert!( + resolve_pr_policy_target( + &PrTargetPolicy::Triggering, + None, + None, + &["self".into()], + &ctx + ) + .unwrap() + .is_err() + ); + env.insert( + "SYSTEM_COLLECTIONURI".into(), + "https://dev.azure.com/foreign/".into(), + ); + env.insert( + "BUILD_REPOSITORY_URI".into(), + "https://dev.azure.com/foreign/Other/_git/target".into(), + ); + ctx.triggering_pr = TriggeringPullRequest::from_env(&|key| env.get(key).cloned()); + assert!( + resolve_pr_policy_target(&PrTargetPolicy::Triggering, None, None, &[], &ctx) + .unwrap() + .is_err() + ); + } + + #[test] + fn known_collection_spellings_and_repository_guids_share_trusted_identity() { + let env = native_env(); + let identity = TriggeringPullRequest::from_env(&|key| env.get(key).cloned()).unwrap(); + let target = AdoRepositoryTarget { + alias: "trigger".into(), + organization: "org".into(), + organization_url: "https://org.visualstudio.com/DefaultCollection/".into(), + project: "Other".into(), + repository: TRIGGER_REPO_ID.into(), + repository_id: None, + cross_organization: false, + }; + assert!(identity.matches(&target)); + let mut wrong = target; + wrong.organization_url = "https://org.visualstudio.com.evil/DefaultCollection".into(); + assert!(!identity.matches(&wrong)); + } pub(crate) fn registered_context( organization_url: &str, @@ -350,4 +972,36 @@ pub(crate) mod tests { .is_none() ); } + + #[tokio::test] + async fn invalid_legacy_vote_metadata_never_enables_native_review_events() { + use crate::safe_outputs::SubmitPrReviewResult; + let server = wiremock::MockServer::start().await; + for vote in ["comment", "request-changes", "unknown"] { + let mut ctx = ExecutionContext { + access_token: Some("token".into()), + ado_org_url: Some(server.uri()), + ado_organization: Some("org".into()), + ado_project: Some("Other".into()), + repository_name: Some("target".into()), + ..Default::default() + }; + ctx.tool_configs.insert( + "submit-pull-request-review".into(), + serde_json::json!({ + "allowed-events": ["comment"], "legacy-update-pr": {"allowed-votes": [vote]} + }), + ); + let mut proposal: SubmitPrReviewResult = serde_json::from_value(serde_json::json!({ + "name":"submit-pull-request-review","pull_request_id":42,"event":"comment" + })) + .unwrap(); + let result = proposal.execute_sanitized(&ctx).await; + assert!( + result.is_err() || result.is_ok_and(|result| !result.success), + "{vote}" + ); + } + assert!(server.received_requests().await.unwrap().is_empty()); + } } diff --git a/src/safe_outputs/pr_mutations.rs b/src/safe_outputs/pr_mutations.rs index 488e3793f..8445d0fde 100644 --- a/src/safe_outputs/pr_mutations.rs +++ b/src/safe_outputs/pr_mutations.rs @@ -32,13 +32,7 @@ const VALID_OPERATIONS: &[&str] = &[ ]; /// Valid vote values -const VALID_VOTES: &[&str] = &[ - "approve", - "approve-with-suggestions", - "wait-for-author", - "reject", - "reset", -]; +const VALID_VOTES: &[&str] = crate::compile::pr_migration::LEGACY_PR_VOTES; /// Valid merge strategy values accepted by ADO's completionOptions.mergeStrategy const VALID_MERGE_STRATEGIES: &[&str] = &["squash", "noFastForward", "rebase", "rebaseMerge"]; diff --git a/src/safe_outputs/result.rs b/src/safe_outputs/result.rs index 0821db457..22a22b129 100644 --- a/src/safe_outputs/result.rs +++ b/src/safe_outputs/result.rs @@ -259,6 +259,8 @@ pub struct ExecutionContext { /// PR ID when `BUILD_REASON=PullRequest` (`SYSTEM_PULLREQUEST_PULLREQUESTID`) #[allow(dead_code)] pub pull_request_id: Option, + /// Trusted trigger identity, independent of compiler-owned self and CLI routing overrides. + pub triggering_pr: Option, /// PR source branch (`SYSTEM_PULLREQUEST_SOURCEBRANCH`) #[allow(dead_code)] pub pull_request_source_branch: Option, @@ -536,6 +538,7 @@ impl ExecutionContext { // Pull request variables pull_request_id: env("SYSTEM_PULLREQUEST_PULLREQUESTID"), + triggering_pr: super::pr_common::TriggeringPullRequest::from_env(&env), pull_request_source_branch: env("SYSTEM_PULLREQUEST_SOURCEBRANCH"), pull_request_target_branch: env("SYSTEM_PULLREQUEST_TARGETBRANCH"), diff --git a/src/safe_outputs/update_pull_request.rs b/src/safe_outputs/update_pull_request.rs index 31c6baaa4..5dc7838be 100644 --- a/src/safe_outputs/update_pull_request.rs +++ b/src/safe_outputs/update_pull_request.rs @@ -10,8 +10,8 @@ use ado_aw_derive::SanitizeConfig; use super::authenticate_ado_request; use super::pr_common::{ - PullRequestReference, legacy_policy, repository_api_base, resolve_pr_target, - resolved_reference_id, validate_description, validate_reference, + PrTargetPolicy, PullRequestReference, legacy_policy, repository_api_base, + resolve_pr_policy_target, resolve_pr_target, validate_description, validate_reference, }; use crate::safe_outputs::{ExecutionContext, ExecutionResult, Executor, Validate}; use crate::sanitize::{ @@ -292,6 +292,15 @@ pub(crate) fn validate_update_pull_request_config( Ok(()) } +impl UpdatePullRequestConfig { + pub(crate) fn target_policy(&self) -> anyhow::Result { + match &self.target { + UpdatePullRequestTarget::Id(id) => PrTargetPolicy::fixed(*id), + UpdatePullRequestTarget::Named(value) => PrTargetPolicy::named(value), + } + } +} + #[derive(Debug, Deserialize)] struct RawPullRequest { #[serde(rename = "pullRequestId")] @@ -407,20 +416,6 @@ fn build_updated_body( Ok(updated) } -fn ctx_pull_request_id(ctx: &ExecutionContext) -> Result { - let raw = ctx.pull_request_id.as_deref().ok_or_else(|| { - ExecutionResult::failure( - "SYSTEM_PULLREQUEST_PULLREQUESTID is required for target \"triggering\"", - ) - })?; - raw.parse::().ok().filter(|id| *id > 0).ok_or_else(|| { - ExecutionResult::failure(format!( - "SYSTEM_PULLREQUEST_PULLREQUESTID '{}' is not a positive pull request ID", - crate::sanitize::neutralize_pipeline_commands(raw) - )) - }) -} - impl UpdatePullRequestResult { fn requested_id(&self) -> anyhow::Result> { UpdatePullRequestParams { @@ -437,58 +432,6 @@ impl UpdatePullRequestResult { .requested_id() } - fn resolve_id( - &self, - config: &UpdatePullRequestConfig, - ctx: &ExecutionContext, - ) -> Result { - let requested = self - .requested_id() - .map_err(|error| ExecutionResult::failure(error.to_string()))? - .as_ref() - .map(|reference| resolved_reference_id(reference, ctx)) - .transpose()?; - match &config.target { - UpdatePullRequestTarget::Id(id) => { - if let Some(requested) = requested - && requested != *id - { - return Err(ExecutionResult::failure(format!( - "requested pull_request_id #{requested} does not match configured target #{id}" - ))); - } - Ok(*id) - } - UpdatePullRequestTarget::Named(target) if target == "*" => requested.ok_or_else(|| { - ExecutionResult::failure( - "pull_request_id is required when safe-outputs.update-pull-request.target is \"*\"", - ) - }), - UpdatePullRequestTarget::Named(target) if target == "triggering" => { - let triggering = ctx_pull_request_id(ctx)?; - if let Some(requested) = requested - && requested != triggering - { - return Err(ExecutionResult::failure(format!( - "requested pull_request_id #{requested} does not match triggering pull request #{triggering}" - ))); - } - Ok(triggering) - } - UpdatePullRequestTarget::Named(target) if target.parse::().is_ok_and(|id| id > 0) => { - let id = target.parse::().map_err(|error| ExecutionResult::failure(error.to_string()))?; - if requested.is_some_and(|requested| requested != id) { - return Err(ExecutionResult::failure(format!("requested pull_request_id does not match configured target #{id}"))); - } - Ok(id) - } - UpdatePullRequestTarget::Named(target) => Err(ExecutionResult::failure(format!( - "unsupported update-pull-request target '{}'", - crate::sanitize::neutralize_pipeline_commands(target) - ))), - } - } - fn requested_fields(&self) -> Vec<&'static str> { let mut fields = Vec::new(); if self.title.is_some() { @@ -630,15 +573,10 @@ impl Executor for UpdatePullRequestResult { "at least one of title or body is required", )); } - let pr_id = match self.resolve_id(&config, ctx) { - Ok(pr_id) => pr_id, - Err(result) => return Ok(result), - }; - let reference = self - .requested_id()? - .unwrap_or(PullRequestReference::Number(pr_id)); - let (_, target) = match resolve_pr_target( - &reference, + let requested = self.requested_id()?; + let (pr_id, target) = match resolve_pr_policy_target( + &config.target_policy()?, + requested.as_ref(), self.repository.as_deref(), &config.allowed_repositories, ctx, @@ -646,6 +584,7 @@ impl Executor for UpdatePullRequestResult { Ok(target) => target, Err(failure) => return Ok(failure), }; + let reference = requested.unwrap_or(PullRequestReference::Number(pr_id)); if let Some(legacy) = &legacy && let Err(failure) = resolve_pr_target( &reference, @@ -777,6 +716,13 @@ mod tests { access_token: Some("token".to_string()), repository_name: Some("repo".to_string()), pull_request_id: Some("7".to_string()), + triggering_pr: Some(super::super::pr_common::TriggeringPullRequest { + collection_uri: server.uri(), + project: "project".into(), + repository_name: "repo".into(), + repository_id: "11111111-1111-1111-1111-111111111111".into(), + id: "7".into(), + }), tool_configs, definition_id: Some(123), ..Default::default() @@ -1018,13 +964,13 @@ mod tests { { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/project/_apis/git/repositories/repo/pullRequests/7")) + .and(path("/project/_apis/git/repositories/11111111-1111-1111-1111-111111111111/pullRequests/7")) .respond_with(ResponseTemplate::new(200).set_body_json(pr(7))) .expect(1) .mount(&server) .await; Mock::given(method("PATCH")) - .and(path("/project/_apis/git/repositories/repo/pullRequests/7")) + .and(path("/project/_apis/git/repositories/11111111-1111-1111-1111-111111111111/pullRequests/7")) .and(body_json( serde_json::json!({"description": "Existing body\n\n---\n\n``"}), )) @@ -1101,13 +1047,13 @@ mod tests { async fn updates_triggering_pr_title_and_body() { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/project/_apis/git/repositories/repo/pullRequests/7")) + .and(path("/project/_apis/git/repositories/11111111-1111-1111-1111-111111111111/pullRequests/7")) .respond_with(ResponseTemplate::new(200).set_body_json(pr(7))) .expect(1) .mount(&server) .await; Mock::given(method("PATCH")) - .and(path("/project/_apis/git/repositories/repo/pullRequests/7")) + .and(path("/project/_apis/git/repositories/11111111-1111-1111-1111-111111111111/pullRequests/7")) .and(body_json(serde_json::json!({ "title": "Updated title", "description": "Existing body\n\n---\n\nNew body" @@ -1178,13 +1124,13 @@ mod tests { async fn repository_allowlist_uses_canonical_aliases() { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/project/_apis/git/repositories/repo/pullRequests/7")) + .and(path("/project/_apis/git/repositories/11111111-1111-1111-1111-111111111111/pullRequests/7")) .respond_with(ResponseTemplate::new(200).set_body_json(pr(7))) .expect(1) .mount(&server) .await; Mock::given(method("PATCH")) - .and(path("/project/_apis/git/repositories/repo/pullRequests/7")) + .and(path("/project/_apis/git/repositories/11111111-1111-1111-1111-111111111111/pullRequests/7")) .respond_with(ResponseTemplate::new(200).set_body_json(pr(7))) .expect(1) .mount(&server) @@ -1205,13 +1151,13 @@ mod tests { async fn rejects_filters_before_patch() { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/project/_apis/git/repositories/repo/pullRequests/7")) + .and(path("/project/_apis/git/repositories/11111111-1111-1111-1111-111111111111/pullRequests/7")) .respond_with(ResponseTemplate::new(200).set_body_json(pr(7))) .expect(1) .mount(&server) .await; Mock::given(method("PATCH")) - .and(path("/project/_apis/git/repositories/repo/pullRequests/7")) + .and(path("/project/_apis/git/repositories/11111111-1111-1111-1111-111111111111/pullRequests/7")) .respond_with(ResponseTemplate::new(200)) .expect(0) .mount(&server) @@ -1245,7 +1191,7 @@ mod tests { ] { let server = MockServer::start().await; Mock::given(method("GET")) - .and(path("/project/_apis/git/repositories/repo/pullRequests/7")) + .and(path("/project/_apis/git/repositories/11111111-1111-1111-1111-111111111111/pullRequests/7")) .respond_with(ResponseTemplate::new(get_status).set_body_json( if get_status == 200 { pr(7) @@ -1257,7 +1203,7 @@ mod tests { .mount(&server) .await; Mock::given(method("PATCH")) - .and(path("/project/_apis/git/repositories/repo/pullRequests/7")) + .and(path("/project/_apis/git/repositories/11111111-1111-1111-1111-111111111111/pullRequests/7")) .respond_with(ResponseTemplate::new(patch_status)) .expect(if get_status == 200 { 1 } else { 0 }) .mount(&server) diff --git a/src/safe_outputs/upload_build_attachment.rs b/src/safe_outputs/upload_build_attachment.rs index 139b2c36f..a1a703100 100644 --- a/src/safe_outputs/upload_build_attachment.rs +++ b/src/safe_outputs/upload_build_attachment.rs @@ -1067,6 +1067,7 @@ attachment-type: "agent-artifact" std::collections::HashMap::new(), )), budget_groups: Default::default(), + triggering_pr: Default::default(), triggered_by_build_id: None, triggered_by_definition_name: None, triggered_by_build_number: None, diff --git a/tests/codemod_tests.rs b/tests/codemod_tests.rs index 67f5120e3..bb69451fc 100644 --- a/tests/codemod_tests.rs +++ b/tests/codemod_tests.rs @@ -186,6 +186,74 @@ fn abbreviated_and_full_pr_keys_conflict_without_rewriting() { assert!(!source.with_extension("lock.yml").exists()); } +#[test] +fn bare_update_pr_migrates_without_voting_and_stays_stable() { + for spelling in ["", "null", "true"] { + let dir = fresh_git_temp_dir(); + let original = format!("---\nname: bare-pr\ndescription: d\nsafe-outputs:\n update-pr: {spelling}\n---\nbody\n"); + let source = write_source(dir.path(), &original); + let output = run_compile(&source); + assert!(output.status.success(), "{spelling}: {}", String::from_utf8_lossy(&output.stderr)); + let after = fs::read_to_string(&source).unwrap(); + let fm: serde_yaml::Value = serde_yaml::from_str(after.split("---").nth(1).unwrap()).unwrap(); + assert!(fm["safe-outputs"]["submit-pull-request-review"].is_null()); + assert_eq!(fm["safe-outputs"]["budget-groups"]["update-pr"]["max"], 1); + assert_eq!(fm["safe-outputs"]["budget-groups"]["update-pr"]["tools"].as_sequence().unwrap().len(), 4); + assert!(run_compile(&source).status.success()); + assert_eq!(fs::read_to_string(&source).unwrap(), after); + } +} + +#[test] +fn invalid_legacy_votes_fail_before_rewrite_but_empty_object_is_not_bare() { + for config in [ + "{allowed-operations: [vote], allowed-votes: [comment]}", + "{allowed-operations: [vote], allowed-votes: [request-changes]}", + "{allowed-operations: [vote], allowed-votes: [unknown]}", + "{allowed-operations: [vote], allowed-votes: comment}", + "{}", + "{allowed-operations: [vote]}", + ] { + let dir = fresh_git_temp_dir(); + let original = format!("---\nname: invalid-vote\ndescription: d\nsafe-outputs:\n update-pr: {config}\n---\nbody\n"); + let source = write_source(dir.path(), &original); + let output = run_compile(&source); + assert!(!output.status.success(), "accepted {config}"); + assert!(String::from_utf8_lossy(&output.stderr).contains("allowed-votes")); + assert_eq!(fs::read_to_string(&source).unwrap(), original); + assert!(!source.with_extension("lock.yml").exists()); + } + +} + +#[test] +fn execute_source_uses_imported_migrated_policy_without_rewriting_files() { + let dir = fresh_git_temp_dir(); + let component = "---\nsafe-outputs:\n add-pr-labels:\n max: 0\n---\nImported instructions.\n"; + let component_path = dir.path().join("shared.md"); + fs::write(&component_path, component).unwrap(); + let original = "---\nname: imported-execution\ndescription: d\nimports: [./shared.md]\n---\nRoot instructions.\n"; + let source = write_source(dir.path(), original); + fs::write( + dir.path().join("safe_outputs.ndjson"), + "{\"name\":\"add-pull-request-labels\",\"pull_request_id\":42,\"labels\":[\"test\"]}\n", + ).unwrap(); + let output = Command::new(ado_aw_binary()) + .arg("execute") + .arg("--source").arg(&source) + .arg("--safe-output-dir").arg(dir.path()) + .arg("--log-output-dir").arg(dir.path().join("logs")) + .arg("--dry-run") + .output().unwrap(); + assert_eq!(output.status.code(), Some(1), "{}", String::from_utf8_lossy(&output.stderr)); + let records = fs::read_to_string(dir.path().join("safe-outputs-executed.ndjson")).unwrap(); + let record: serde_json::Value = serde_json::from_str(records.trim()).unwrap(); + assert_eq!(record["status"], "budget_exhausted"); + assert!(record["error"].as_str().unwrap().contains("(0)")); + assert_eq!(fs::read_to_string(source).unwrap(), original); + assert_eq!(fs::read_to_string(component_path).unwrap(), component); +} + // ─── Legacy directory marker migration (codemod 0004) ────────────────────── #[test] diff --git a/tests/executor-e2e/README.md b/tests/executor-e2e/README.md index b5a24565b..e5481d600 100644 --- a/tests/executor-e2e/README.md +++ b/tests/executor-e2e/README.md @@ -43,6 +43,14 @@ gitignored, non-root path) and is **deliberately excluded** from the released ## Coverage +Negative scenarios may provide `assertFailure` to verify postconditions after +the expected status/error matches (for example, that a rejected description +left the PR unchanged). A matching error alone does not bypass that hook. +Ordinary `assert` and `postExecute` remain success-only, and cleanup still runs +after a failed assertion. An unexpected success is a scenario failure. +When `priorEntries` repeat the primary tool, the harness selects the primary +record after those occurrences rather than reusing the first matching record. + The PR family includes focused content updates (including the exact 4,000-character description boundary), abandonment with comment verification, and create-then-follow-up reviewer/content scenarios. Reviewer handoffs use From b3a7d3a8a4b715dd1f34effadd3b2573cddf157c Mon Sep 17 00:00:00 2001 From: James Devine Date: Thu, 24 Sep 2026 21:58:36 +0100 Subject: [PATCH 16/43] fix(tests): repair portable fixtures and PR label verification Use Node for script fixtures on all platforms, read PR labels from their dedicated endpoint, and make focused diagnostics report results without filing issues. Preserve cleanup failures and required coverage status. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: da8711de-7251-47cd-a006-6e4ece913b49 --- .github/workflows/ado-script.yml | 16 ++++ .../executor-e2e/__tests__/ado-rest.test.ts | 27 ++++++ .../__tests__/execute-cli.test.ts | 28 ++++++ .../src/executor-e2e/__tests__/index.test.ts | 56 ++++++++++- .../src/executor-e2e/__tests__/runner.test.ts | 4 + .../ado-script/src/executor-e2e/ado-rest.ts | 17 ++++ .../src/executor-e2e/execute-cli.ts | 9 +- scripts/ado-script/src/executor-e2e/index.ts | 94 ++++++++++++++++--- scripts/ado-script/src/executor-e2e/runner.ts | 10 +- .../ado-script/src/executor-e2e/scenario.ts | 1 + .../src/executor-e2e/scenarios/pr.ts | 10 +- tests/executor-e2e/README.md | 15 +++ tests/executor-e2e/azure-pipelines.yml | 22 +++++ 13 files changed, 286 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ado-script.yml b/.github/workflows/ado-script.yml index d5df1e43f..ac48a7008 100644 --- a/.github/workflows/ado-script.yml +++ b/.github/workflows/ado-script.yml @@ -50,6 +50,22 @@ env: CARGO_TERM_COLOR: always jobs: + executor-harness-windows: + name: Executor harness (Windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: scripts/ado-script/package-lock.json + - name: Install workspace dependencies + working-directory: scripts/ado-script + run: npm ci + - name: Exercise cross-platform process fixtures + working-directory: scripts/ado-script + run: npm test -- runner.test.ts execute-cli.test.ts ado-rest.test.ts ado-script: name: Build, Test & Drift-Check runs-on: ubuntu-latest diff --git a/scripts/ado-script/src/executor-e2e/__tests__/ado-rest.test.ts b/scripts/ado-script/src/executor-e2e/__tests__/ado-rest.test.ts index eb8ab81f3..b553ed3bc 100644 --- a/scripts/ado-script/src/executor-e2e/__tests__/ado-rest.test.ts +++ b/scripts/ado-script/src/executor-e2e/__tests__/ado-rest.test.ts @@ -14,6 +14,33 @@ function stubFetch(responder: (url: string) => Response): ReturnType { + afterEach(() => vi.unstubAllGlobals()); + + it("uses authoritative labels endpoint and preserves every returned label", async () => { + const fetch = stubFetch((url) => url.includes("/labels?") + ? Response.json({ count: 2, value: [{name: "existing-label"}, {name: "new-label"}] }) + : Response.json({ pullRequestId: 42, title: "PR without labels property" })); + const labels = await new AdoRest(options).listPullRequestLabels("repo name", 42); + expect(labels.map((label) => label.name)).toEqual(["existing-label", "new-label"]); + expect(fetch.mock.calls[0]?.[0]).toBe( + "https://dev.azure.com/org/My%20Project/_apis/git/repositories/repo%20name/pullRequests/42/labels?api-version=7.1", + ); + }); + + it.each([{}, { value: null }, { value: [null] }, { value: [{name: 1}] }])( + "does not report malformed %j as no labels", async (response) => { + stubFetch(() => Response.json(response)); + await expect(new AdoRest(options).listPullRequestLabels("repo", 42)).rejects.toThrow(/missing value|invalid label/); + }, + ); + + it("surfaces API failures instead of reporting missing labels", async () => { + stubFetch(() => new Response("forbidden", { status: 403 })); + await expect(new AdoRest(options).listPullRequestLabels("repo", 42)).rejects.toThrow("403"); + }); +}); + describe("AdoRest.workItemTypeExists", () => { afterEach(() => { vi.unstubAllGlobals(); diff --git a/scripts/ado-script/src/executor-e2e/__tests__/execute-cli.test.ts b/scripts/ado-script/src/executor-e2e/__tests__/execute-cli.test.ts index 5948fb74b..f22f9ed55 100644 --- a/scripts/ado-script/src/executor-e2e/__tests__/execute-cli.test.ts +++ b/scripts/ado-script/src/executor-e2e/__tests__/execute-cli.test.ts @@ -1,11 +1,39 @@ import { describe, expect, it } from "vitest"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { parseExecutedRecords, renderNdjsonLine, renderSourceMarkdown, + runExecute, } from "../execute-cli.js"; +it("executes a non-executable JavaScript fixture in a path containing spaces", async () => { + const dir = await mkdtemp(join(tmpdir(), "ado fixture with spaces ")); + try { + const bin = join(dir, "fake executor.js"); + await writeFile(bin, ` +const fs = require("node:fs"); +const path = require("node:path"); +const out = process.argv[process.argv.indexOf("--safe-output-dir") + 1]; +fs.writeFileSync(path.join(out,"safe-outputs-executed.ndjson"), JSON.stringify({ + name:"noop",status:"succeeded",result:{marker:process.env.FIXTURE_MARKER} +})+"\\n"); +`, { mode: 0o600 }); + const result = await runExecute({ + adoAwBin: bin, scenarioDir: dir, tool: "noop", config: {}, entry: {}, + orgUrl: "https://example.test", project: "test", token: "", + extraEnv: { FIXTURE_MARKER: "executed" }, log: () => {}, + }); + expect(result.exitCode).toBe(0); + expect(result.record?.result?.marker).toBe("executed"); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + describe("renderSourceMarkdown", () => { it("emits front matter with inline-JSON safe-outputs config", () => { const md = renderSourceMarkdown({ diff --git a/scripts/ado-script/src/executor-e2e/__tests__/index.test.ts b/scripts/ado-script/src/executor-e2e/__tests__/index.test.ts index 057493c14..25cf2f8ca 100644 --- a/scripts/ado-script/src/executor-e2e/__tests__/index.test.ts +++ b/scripts/ado-script/src/executor-e2e/__tests__/index.test.ts @@ -1,9 +1,61 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; -import { summarise } from "../index.js"; +import { booleanOption, main, selectScenarios, summarise } from "../index.js"; +import { fileFailureIssue } from "../github-issue.js"; import { allScenarios } from "../scenarios/index.js"; import type { ScenarioResult } from "../scenario.js"; +vi.mock("../github-issue.js", () => ({ + loadIssueEnv: () => ({ repo: "test/repo" }), + fileFailureIssue: vi.fn(async () => ({ filed: false })), +})); + +describe("diagnostic selection", () => { + afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks(); }); + it("only selects requested existing scenarios and rejects ambiguous input", () => { + expect(selectScenarios(allScenarios, "noop,add-pull-request-labels") + .map((scenario) => scenario.id ?? scenario.tool)).toEqual(["noop", "add-pull-request-labels"]); + for (const invalid of ["missing-case", ",", "noop,", "noop,noop", " "]) { + expect(() => selectScenarios(allScenarios, invalid)).toThrow(); + } + expect(selectScenarios(allScenarios, "")).toBe(allScenarios); + expect(booleanOption("False", true)).toBe(false); + expect(() => booleanOption("off", true)).toThrow(); + }); + + it("persists failed-run results and disables failure-issue reporting", async () => { + const dir = await mkdtemp(join(tmpdir(), "ado-diagnostic-report-")); + try { + const bin = join(dir, "fixture.js"); + await writeFile(bin, ` +const fs=require("node:fs"),path=require("node:path"); +const out=process.argv[process.argv.indexOf("--safe-output-dir")+1]; +fs.writeFileSync(path.join(out,"safe-outputs-executed.ndjson"), JSON.stringify({ +name:"noop",status:"failed",error:"synthetic diagnostic failure"})+"\\n"); +`); + for (const [key, value] of Object.entries({ + SYSTEM_COLLECTIONURI: "https://example.test/", SYSTEM_TEAMPROJECT: "test", + SYSTEM_ACCESSTOKEN: "not-a-real-token", EXECUTOR_E2E_ADO_AW_BIN: bin, + EXECUTOR_E2E_SCENARIOS: "noop", EXECUTOR_E2E_REQUIRE_SELECTED: "true", + EXECUTOR_E2E_FILE_FAILURE_ISSUE: "false", + EXECUTOR_E2E_RESULTS_PATH: join(dir, "results.json"), + BUILD_SOURCEVERSION: "candidate-sha", BUILD_BUILDID: "123", + })) vi.stubEnv(key, value); + expect(await main()).toBe(1); + expect(fileFailureIssue).not.toHaveBeenCalled(); + const report = JSON.parse(await readFile(join(dir, "results.json"), "utf8")); + expect(report).toMatchObject({ + commit: "candidate-sha", buildId: "123", selected: ["noop"], + results: [{tool: "noop", ok: false, phase: "execute"}], + }); + expect(JSON.stringify(report)).not.toContain("not-a-real-token"); + } finally { await rm(dir, {recursive: true, force: true}); } + }); +}); + describe("summarise", () => { it("renders PASS/FAIL/SKIP lines and a total", () => { const results: ScenarioResult[] = [ diff --git a/scripts/ado-script/src/executor-e2e/__tests__/runner.test.ts b/scripts/ado-script/src/executor-e2e/__tests__/runner.test.ts index 4de70c9a1..259421a21 100644 --- a/scripts/ado-script/src/executor-e2e/__tests__/runner.test.ts +++ b/scripts/ado-script/src/executor-e2e/__tests__/runner.test.ts @@ -177,6 +177,10 @@ fs.writeFileSync(path.join(out, "safe-outputs-executed.ndjson"), ${JSON.stringif }; const result = await runScenario({ ...fakeCtx(), adoAwBin: bin, workDir: dir }, scenario); expect(result).toMatchObject({ ok: false, phase: "execute" }); + expect(result.message).toContain(status === null + ? "no executed record" + : "expected rejection was not observed"); + expect(result.message).not.toMatch(/EACCES|ENOENT|spawn/); expect(flags).toEqual({ asserted: false, cleaned: true }); } finally { await rm(dir, { recursive: true, force: true }); diff --git a/scripts/ado-script/src/executor-e2e/ado-rest.ts b/scripts/ado-script/src/executor-e2e/ado-rest.ts index fd60738c2..3e1614a95 100644 --- a/scripts/ado-script/src/executor-e2e/ado-rest.ts +++ b/scripts/ado-script/src/executor-e2e/ado-rest.ts @@ -548,6 +548,23 @@ export class AdoRest { return res?.value ?? []; } + async listPullRequestLabels(repo: string, prId: number): Promise<{ name: string }[]> { + const path = this.projPath( + `_apis/git/repositories/${AdoRest.seg(repo)}/pullRequests/${prId}/labels?api-version=7.1`, + ); + const res = await this.request<{ value?: unknown }>(path); + if (!Array.isArray(res?.value)) { + throw new Error(`listPullRequestLabels(${prId}) response missing value array`); + } + return res.value.map((label: unknown) => { + if (label === null || typeof label !== "object" || !("name" in label) + || typeof label.name !== "string") { + throw new Error(`listPullRequestLabels(${prId}) returned an invalid label`); + } + return { name: label.name }; + }); + } + /** Abandon a PR (status=abandoned). Best-effort cleanup. */ async abandonPullRequest(repo: string, prId: number): Promise { const path = this.projPath( diff --git a/scripts/ado-script/src/executor-e2e/execute-cli.ts b/scripts/ado-script/src/executor-e2e/execute-cli.ts index bb382b19f..356022b8f 100644 --- a/scripts/ado-script/src/executor-e2e/execute-cli.ts +++ b/scripts/ado-script/src/executor-e2e/execute-cli.ts @@ -220,13 +220,10 @@ export async function runExecute(opts: RunExecuteOptions): Promise[], + requested: string | undefined, +): Scenario[] { + if (requested === undefined || requested === "") return available; + const names = requested.split(",").map((name) => name.trim()); + if (names.some((name) => !name) || new Set(names).size !== names.length) { + throw new Error("EXECUTOR_E2E_SCENARIOS must contain distinct nonempty scenario IDs"); + } + return names.map((name) => { + const scenario = available.find((scenario) => (scenario.id ?? scenario.tool) === name); + if (!scenario) throw new Error(`Unknown executor E2E scenario '${name}'`); + return scenario; + }); +} + +export function booleanOption(value: string | undefined, fallback: boolean): boolean { + if (value === undefined || value === "") return fallback; + if (value.toLowerCase() === "true") return true; + if (value.toLowerCase() === "false") return false; + throw new Error("E2E boolean options must be true or false"); +} + +async function requiredPreflight(ctx: ScenarioContext, scenarios: Scenario[]): Promise { + const names = scenarios.map((scenario) => scenario.id ?? scenario.tool); + if (names.some((name) => name.includes("cross-org"))) resolveCrossOrgEnv(ctx); + if (names.some((name) => name.includes("add-reviewers"))) { + const reviewer = resolveExecutorE2eReviewer(); + if (!await ctx.rest.resolveIdentityId(reviewer)) { + throw new Error("Required reviewer does not resolve to exactly one existing identity"); + } + } +} function requireEnv(name: string, alt?: string): string { const value = process.env[name]?.trim() || (alt ? process.env[alt]?.trim() : undefined); @@ -59,6 +95,9 @@ export function summarise(results: ScenarioResult[]): string { } export async function main(): Promise { + const selected = selectScenarios(allScenarios, process.env.EXECUTOR_E2E_SCENARIOS); + const required = booleanOption(process.env.EXECUTOR_E2E_REQUIRE_SELECTED, false); + const fileIssues = booleanOption(process.env.EXECUTOR_E2E_FILE_FAILURE_ISSUE, true); const orgUrl = requireEnv("SYSTEM_COLLECTIONURI", "AZURE_DEVOPS_ORG_URL"); const project = requireEnv("SYSTEM_TEAMPROJECT"); const token = requireEnv("SYSTEM_ACCESSTOKEN"); @@ -82,16 +121,34 @@ export async function main(): Promise { prefix: (tool) => `ado-aw-det-${buildId}-${tool}`, }; - log(`Running ${allScenarios.length} executor E2E scenarios against ${orgUrl}${project}`); + log(`Running ${selected.length} executor E2E scenarios against ${orgUrl}${project}`); + let results: ScenarioResult[] = []; try { - const results = await runAll(ctx, allScenarios); + if (required) { + try { + await requiredPreflight(ctx, selected); + } catch (error) { + results = [{ tool: "required-preflight", ok: false, phase: "preflight", + message: (error as Error).message, durationMs: 0 }]; + log(summarise(results)); + return 1; + } + } + results = await runAll(ctx, selected); + if (required) results = results.map((result) => result.skipped + ? { ...result, ok: false, skipped: false, phase: "required-coverage", message: `Required scenario skipped: ${result.message}` } + : result); log(summarise(results)); - const issueEnv = loadIssueEnv(); - try { - await fileFailureIssue(results, issueEnv, log); - } catch (err) { - log(`WARNING: failed to file GitHub issue: ${(err as Error).message}`); + if (fileIssues) { + const issueEnv = loadIssueEnv(); + try { + await fileFailureIssue(results, issueEnv, log); + } catch (err) { + log(`WARNING: failed to file GitHub issue: ${(err as Error).message}`); + } + } else { + log("Failure-issue filing disabled for this run."); } const failed = results.filter((r) => !r.ok).length; @@ -100,7 +157,22 @@ export async function main(): Promise { // Remove the scratch dir so CI agents and local runs don't accumulate // ado-aw-e2e-* directories (scenarios clean their own children, but the // parent mkdtemp dir would otherwise persist). - await rm(workDir, { recursive: true, force: true }); + try { + const report = process.env.EXECUTOR_E2E_RESULTS_PATH; + if (report) { + await mkdir(dirname(report), { recursive: true }); + await writeFile(report, JSON.stringify({ + schema: "ado-aw/executor-e2e-results/1", + commit: process.env.BUILD_SOURCEVERSION ?? null, + buildId, + required, + selected: selected.map((scenario) => scenario.id ?? scenario.tool), + results, + }, null, 2)); + } + } finally { + await rm(workDir, { recursive: true, force: true }); + } } } diff --git a/scripts/ado-script/src/executor-e2e/runner.ts b/scripts/ado-script/src/executor-e2e/runner.ts index b3f677b27..6c59ad02f 100644 --- a/scripts/ado-script/src/executor-e2e/runner.ts +++ b/scripts/ado-script/src/executor-e2e/runner.ts @@ -36,7 +36,8 @@ export async function runScenario( let setupDone = false; let executedRecords: ExecutedRecord[] | undefined; - const finish = (partial: Omit): ScenarioResult => ({ + let outcome: ScenarioResult | undefined; + const finish = (partial: Omit): ScenarioResult => (outcome = { tool: scenarioId, durationMs: Date.now() - start, ...partial, @@ -210,6 +211,13 @@ export async function runScenario( ctx.log(`[${scenarioId}] cleanup done`); } catch (err) { ctx.log(`[${scenarioId}] cleanup WARNING: ${errMessage(err)}`); + if (outcome) { + outcome.cleanupError = errMessage(err); + outcome.ok = false; + outcome.skipped = false; + outcome.message = `${outcome.message ? `${outcome.message}; ` : ""}cleanup failed: ${errMessage(err)}`; + outcome.phase ??= "cleanup"; + } } } } diff --git a/scripts/ado-script/src/executor-e2e/scenario.ts b/scripts/ado-script/src/executor-e2e/scenario.ts index dda1b3ee7..ff75020f2 100644 --- a/scripts/ado-script/src/executor-e2e/scenario.ts +++ b/scripts/ado-script/src/executor-e2e/scenario.ts @@ -238,6 +238,7 @@ export interface ScenarioResult { durationMs: number; /** True when the scenario was skipped for a missing precondition (not a failure). */ skipped?: boolean; + cleanupError?: string; } /** diff --git a/scripts/ado-script/src/executor-e2e/scenarios/pr.ts b/scripts/ado-script/src/executor-e2e/scenarios/pr.ts index 3848ca8b9..89835b07f 100644 --- a/scripts/ado-script/src/executor-e2e/scenarios/pr.ts +++ b/scripts/ado-script/src/executor-e2e/scenarios/pr.ts @@ -313,6 +313,10 @@ export const addPrLabels: Scenario = { const state = await setupPr(ctx, "add-pull-request-labels", false); try { await ctx.rest.setPullRequestLabels(state.repo, state.prId, ["existing-label"]); + const seeded = await ctx.rest.listPullRequestLabels(state.repo, state.prId); + if (!seeded.some((label) => label.name === "existing-label")) { + throw new Error(`Label setup did not persist existing-label: ${JSON.stringify(seeded)}`); + } } catch (error) { await teardownPr(ctx, state); throw error; @@ -323,10 +327,10 @@ export const addPrLabels: Scenario = { pull_request_id: state.prId, repository: ctx.adoRepo, labels: ["new-label"], }), assert: async (ctx, state) => { - const pr = await ctx.rest.getPullRequest(state.repo, state.prId); - const labels = pr.labels?.map((label) => label.name) ?? []; + const labels = (await ctx.rest.listPullRequestLabels(state.repo, state.prId)) + .map((label) => label.name); if (!labels.includes("existing-label") || !labels.includes("new-label")) { - throw new Error("Label addition did not preserve the existing label"); + throw new Error(`Label addition did not preserve both labels: ${JSON.stringify(labels)}`); } }, cleanup: teardownPr, diff --git a/tests/executor-e2e/README.md b/tests/executor-e2e/README.md index e5481d600..e88aadaed 100644 --- a/tests/executor-e2e/README.md +++ b/tests/executor-e2e/README.md @@ -43,6 +43,21 @@ gitignored, non-root path) and is **deliberately excluded** from the released ## Coverage +### Focused diagnostic runs + +Queue the existing pipeline with `scenarios` set to comma-separated exact IDs +(for example `add-pull-request-labels`), `requireSelected: true`, and +`fileFailureIssue: false`. Unknown selections fail rather than running a +different suite. Required reviewer/cross-org inputs are checked before scenario +resources are created; a required scenario that skips is not a pass. + +The harness environment equivalents are `EXECUTOR_E2E_SCENARIOS`, +`EXECUTOR_E2E_REQUIRE_SELECTED`, and `EXECUTOR_E2E_FILE_FAILURE_ISSUE`. +`EXECUTOR_E2E_RESULTS_PATH` writes a structured report with the candidate commit, +selected IDs, outcomes and cleanup failures. The pipeline publishes it as +`executor-e2e-results` even when scenarios fail. Normal scheduled runs retain +their existing failure-issue behavior unless explicitly disabled. + Negative scenarios may provide `assertFailure` to verify postconditions after the expected status/error matches (for example, that a rejected description left the PR unchanged). A matching error alone does not bypass that hook. diff --git a/tests/executor-e2e/azure-pipelines.yml b/tests/executor-e2e/azure-pipelines.yml index 942fb9847..b50daaded 100644 --- a/tests/executor-e2e/azure-pipelines.yml +++ b/tests/executor-e2e/azure-pipelines.yml @@ -13,6 +13,16 @@ # Register it in msazuresphere/AgentPlayground — see tests/executor-e2e/README.md. trigger: none +parameters: + - name: scenarios + type: string + default: "" + - name: requireSelected + type: boolean + default: false + - name: fileFailureIssue + type: boolean + default: false pr: branches: include: @@ -40,6 +50,7 @@ pool: name: AZS-1ES-L-Playground-ubuntu-22.04 variables: + EFFECTIVE_EXECUTOR_E2E_FILE_FAILURE_ISSUE: $[or(eq(variables['Build.Reason'], 'Schedule'), ${{ parameters.fileFailureIssue }})] # Keep YAML defaults under private names so same-named pipeline/definition UI # variables (EXECUTOR_E2E_ADO_REPO, E2E_QUEUE_PIPELINE_ID, E2E_WIKI_NAME, # E2E_WORK_ITEM_ASSIGNEE, EXECUTOR_E2E_REVIEWER, CRATES_IO_FEED) are not @@ -152,6 +163,10 @@ steps: # explicitly so the harness (and the ado-aw binary it spawns) can write. SYSTEM_ACCESSTOKEN: $(SC_WRITE_TOKEN) EXECUTOR_E2E_ADO_AW_BIN: $(ADO_AW_BIN) + EXECUTOR_E2E_SCENARIOS: ${{ parameters.scenarios }} + EXECUTOR_E2E_REQUIRE_SELECTED: ${{ parameters.requireSelected }} + EXECUTOR_E2E_FILE_FAILURE_ISSUE: $(EFFECTIVE_EXECUTOR_E2E_FILE_FAILURE_ISSUE) + EXECUTOR_E2E_RESULTS_PATH: $(Build.ArtifactStagingDirectory)/executor-e2e/results.json # Compiled conclusion.js driven by the conclusion work-item scenarios. # When unset those scenarios skip rather than fail. EXECUTOR_E2E_CONCLUSION_BUNDLE: $(CONCLUSION_BUNDLE) @@ -180,3 +195,10 @@ steps: EXECUTOR_E2E_CROSS_ORG_REPOSITORY: $(EXECUTOR_E2E_CROSS_ORG_REPOSITORY) EXECUTOR_E2E_CROSS_ORG_ENDPOINT: $(EXECUTOR_E2E_CROSS_ORG_ENDPOINT) EXECUTOR_E2E_CROSS_ORG_TOKEN: $(EXECUTOR_E2E_CROSS_ORG_TOKEN) + + - task: PublishPipelineArtifact@1 + displayName: Publish executor E2E results + condition: always() + inputs: + targetPath: $(Build.ArtifactStagingDirectory)/executor-e2e + artifact: executor-e2e-results From e5fe6ee9a8363767e8139e77c09fb30425ebb41d Mon Sep 17 00:00:00 2001 From: James Devine Date: Thu, 24 Sep 2026 22:19:13 +0100 Subject: [PATCH 17/43] test(safe-outputs): extend failure, Unicode and pipeline boundary coverage Add persisted failed-attempt budget and API-error regressions, Unicode and cross-organization PR scenarios, and opt-in real synthetic-PR pipeline tests. Verify rejected manual review leaves the PR unchanged; approved execution remains human-on-demand. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: da8711de-7251-47cd-a006-6e4ece913b49 --- .../__tests__/pr-boundary.test.ts | 44 +++ .../__tests__/runner.test.ts | 13 + .../src/compiler-smoke-e2e/ado-rest.ts | 69 ++++ .../src/compiler-smoke-e2e/assertions.ts | 3 +- .../src/compiler-smoke-e2e/cases.ts | 22 +- .../src/compiler-smoke-e2e/index.ts | 57 ++- .../src/compiler-smoke-e2e/pr-boundary.ts | 26 ++ .../src/compiler-smoke-e2e/runner.ts | 8 +- .../src/compiler-smoke-e2e/source.ts | 12 +- scripts/ado-script/src/executor-e2e/index.ts | 10 +- .../src/executor-e2e/scenarios/index.ts | 2 + .../executor-e2e/scenarios/pr-cross-org.ts | 45 +++ .../src/executor-e2e/scenarios/pr.ts | 78 ++++ src/execute.rs | 359 ++++++++++++++++++ tests/executor-e2e/README.md | 10 + tests/safe-outputs/pr-pipeline-boundary.md | 35 ++ tests/smoke/README.md | 21 + tests/smoke/azure-pipelines-candidate.yml | 6 + tests/smoke/cases.json | 27 ++ 19 files changed, 836 insertions(+), 11 deletions(-) create mode 100644 scripts/ado-script/src/compiler-smoke-e2e/__tests__/pr-boundary.test.ts create mode 100644 scripts/ado-script/src/compiler-smoke-e2e/pr-boundary.ts create mode 100644 scripts/ado-script/src/executor-e2e/scenarios/pr-cross-org.ts create mode 100644 tests/safe-outputs/pr-pipeline-boundary.md diff --git a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/pr-boundary.test.ts b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/pr-boundary.test.ts new file mode 100644 index 000000000..828e5d23f --- /dev/null +++ b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/pr-boundary.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vitest"; +import { verifyPrBoundary } from "../pr-boundary.js"; +import { prepareCaseSource } from "../source.js"; +import type { BoundaryTimelineRecord } from "../ado-rest.js"; + +const succeeded: BoundaryTimelineRecord[] = ["Setup", "Agent", "Detection", "SafeOutputs"] + .map((identifier) => ({ type: "Job", identifier, result: "succeeded" })); +const rejection = [ + ...succeeded, + { type: "Job", identifier: "ManualReview", result: "failed" }, + { type: "Job", identifier: "SafeOutputs_Reviewed", result: "skipped" }, +]; + +describe("PR pipeline boundary proof", () => { + it("requires a real proposal/gate rejection and unchanged PR", () => { + expect(() => verifyPrBoundary("rejected", 42, "original", "original", rejection)).not.toThrow(); + expect(() => verifyPrBoundary("rejected", 42, "original", "changed", rejection)).toThrow("changed"); + expect(() => verifyPrBoundary("rejected", 42, "original", "original", succeeded)).toThrow("rejection"); + expect(() => verifyPrBoundary("rejected", 42, "original", "original", + rejection.map((record) => record.identifier === "Detection" ? {...record, result:"failed"} : record))) + .toThrow("Detection"); + }); + + it("does not count a successful noop pipeline as a successful mutation", () => { + expect(() => verifyPrBoundary("automatic", 42, "original", "original", succeeded)).toThrow("persisted"); + expect(() => verifyPrBoundary("automatic", 42, "original", "ado-aw-pr-boundary-42", succeeded)).not.toThrow(); + }); + + it("only passes approved mode after the gate and reviewed executor succeeded", () => { + expect(() => verifyPrBoundary("approved", 42, "original", "ado-aw-pr-boundary-42", rejection)).toThrow("Approved"); + const approved = rejection.map((record) => ({...record, result:"succeeded"})); + expect(() => verifyPrBoundary("approved", 42, "original", "ado-aw-pr-boundary-42", approved)).not.toThrow(); + }); + + it("retains synthetic setup without enabling push triggers and defaults the gate to reject", () => { + const source = "---\nname: test\ndescription: test\nsafe-outputs:\n update-pull-request: {}\n---\nBody unchanged.\n"; + const result = prepareCaseSource(source, undefined, "rejected"); + expect(result).toContain("mode: synthetic"); + expect(result).toContain("push: none"); + expect(result).toContain("on-timeout: reject"); + expect(result).toContain("timeout-minutes: 1"); + expect(result.endsWith("Body unchanged.\n")).toBe(true); + }); +}); diff --git a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/runner.test.ts b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/runner.test.ts index 575119e05..6c7cfbf87 100644 --- a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/runner.test.ts +++ b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/runner.test.ts @@ -77,6 +77,19 @@ function req(caseId: string, definitionId: number): FixtureBuildRequest { const noopSleep = async (): Promise => {}; describe("runFixtures", () => { + it("allows only explicitly expected failed builds through to boundary verification", async () => { + for (const result of ["failed", "succeeded"]) { + const { client } = makeFakeClient({ + queueResults: { 1: { ok: true, id: 101 } }, + timelines: { 101: [{ status: "completed", result }] }, + }); + const outcome = await runFixtures(client, [{...req("review-rejected", 1), expectedResult:"failed"}], { + concurrency:1, timeoutMs:1000, pollMs:1, log:()=>{}, sleepImpl:noopSleep, + }); + expect(outcome.ok).toBe(result === "failed"); + expect(outcome.allTerminal).toBe(true); + } + }); it("succeeds when every fixture queues and completes successfully", async () => { const { client } = makeFakeClient({ queueResults: { diff --git a/scripts/ado-script/src/compiler-smoke-e2e/ado-rest.ts b/scripts/ado-script/src/compiler-smoke-e2e/ado-rest.ts index 3e953b320..1c6485b10 100644 --- a/scripts/ado-script/src/compiler-smoke-e2e/ado-rest.ts +++ b/scripts/ado-script/src/compiler-smoke-e2e/ado-rest.ts @@ -39,6 +39,19 @@ export interface ArtifactInfo { resource?: { downloadUrl?: string; type?: string }; } +export interface BoundaryPr { + pullRequestId: number; + status: string; + description?: string; +} + +export interface BoundaryTimelineRecord { + type?: string; + name?: string; + identifier?: string; + result?: string; +} + const DEFAULT_ARTIFACT_RETRIES = 5; const DEFAULT_ARTIFACT_RETRY_DELAY_MS = 5_000; const DEFAULT_TAG_RETRIES = 5; @@ -155,6 +168,62 @@ export class AdoRest { return res; } + async createBoundaryTarget(repo: string, ref: string, sha: string): Promise { + if (!ref.startsWith("refs/heads/ado-aw-smoke-candidate/") || !ref.endsWith("-target")) { + throw new Error("Boundary target must be a disposable candidate ref"); + } + const response = await this.request<{value?: {success?: boolean}[]}>( + this.projPath(`_apis/git/repositories/${AdoRest.seg(repo)}/refs?api-version=7.1`), + { method: "POST", body: [{ name: ref, oldObjectId: "0".repeat(40), newObjectId: sha }] }, + ); + if (response?.value?.length !== 1 || response.value[0]?.success !== true) { + throw new Error("Failed to create disposable PR boundary target"); + } + } + + async createBoundaryPr(repo: string, source: string, target: string, marker: string): Promise { + const response = await this.request( + this.projPath(`_apis/git/repositories/${AdoRest.seg(repo)}/pullrequests?api-version=7.1`), + { method: "POST", body: { + sourceRefName: source, targetRefName: target, title: marker, description: marker, + } }, + ); + if (!response?.pullRequestId) throw new Error("PR boundary setup returned no PR ID"); + return response; + } + + async boundaryPr(repo: string, id: number): Promise { + const response = await this.request( + this.projPath(`_apis/git/repositories/${AdoRest.seg(repo)}/pullRequests/${id}?api-version=7.1`), + ); + if (!response) throw new Error("Boundary PR readback returned no object"); + return response; + } + + async abandonBoundaryPr(repo: string, id: number): Promise { + const pr = await this.boundaryPr(repo, id); + if (pr.status === "active") { + await this.request(this.projPath(`_apis/git/repositories/${AdoRest.seg(repo)}/pullRequests/${id}?api-version=7.1`), + {method: "PATCH", body: {status: "abandoned"}}); + } + } + + async boundaryTimeline(buildId: number): Promise { + const response = await this.request<{records?: BoundaryTimelineRecord[]}>( + this.projPath(`_apis/build/builds/${buildId}/timeline?api-version=7.1`), + ); + if (!Array.isArray(response?.records)) throw new Error("Build timeline response is missing records"); + return response.records; + } + + async boundaryArtifacts(buildId: number): Promise { + const response = await this.request<{value?: ArtifactInfo[]}>( + this.projPath(`_apis/build/builds/${buildId}/artifacts?api-version=7.1`), + ); + if (!Array.isArray(response?.value)) throw new Error("Build artifacts response is missing value"); + return response.value.map((artifact) => artifact.name); + } + /** Read the observable tags on a completed child build. */ async getBuildTags( buildId: number, diff --git a/scripts/ado-script/src/compiler-smoke-e2e/assertions.ts b/scripts/ado-script/src/compiler-smoke-e2e/assertions.ts index 76f51d49f..5dc962330 100644 --- a/scripts/ado-script/src/compiler-smoke-e2e/assertions.ts +++ b/scripts/ado-script/src/compiler-smoke-e2e/assertions.ts @@ -57,13 +57,14 @@ export function assertReleaseUrlsPresent(yamlText: string, label: string): void * * Applies to `raw` cases too, where no front-matter transform runs at all. */ -export function assertNoTriggers(yamlText: string, label: string): void { +export function assertNoTriggers(yamlText: string, label: string, syntheticPr = false): void { const docs = parseAllDocuments(yamlText, { merge: false }).map((d) => d.toJS()); for (const doc of docs) { if (!doc || typeof doc !== "object" || Array.isArray(doc)) continue; const root = doc as Record; for (const key of ["trigger", "pr"] as const) { + if (key === "pr" && syntheticPr && root.pr && typeof root.pr === "object") continue; if (root[key] !== "none") { throw new Error( `${label}: staged pipeline must declare '${key}: none', got ${JSON.stringify(root[key] ?? null)}`, diff --git a/scripts/ado-script/src/compiler-smoke-e2e/cases.ts b/scripts/ado-script/src/compiler-smoke-e2e/cases.ts index 0dc4cfa3e..726b71bec 100644 --- a/scripts/ado-script/src/compiler-smoke-e2e/cases.ts +++ b/scripts/ado-script/src/compiler-smoke-e2e/cases.ts @@ -78,6 +78,7 @@ export interface SmokeCase { /** Repo-relative source path (`.md` for compiled, `.yml`/`.yaml` for raw). */ readonly source: string; readonly assertions?: CaseAssertions; + readonly prBoundary?: "automatic" | "rejected" | "approved"; } export interface SmokeManifest { @@ -316,7 +317,17 @@ export function parseManifest(text: string): SmokeManifest { const source = validateSourcePath(entry.source, id); validateKindMatchesExtension(kind, source, id); - cases.push({ id, lane, kind, modes, source, assertions: parseAssertions(entry.assertions, id) }); + let prBoundary: SmokeCase["prBoundary"]; + if (entry.prBoundary !== undefined) { + const mode = asString(entry.prBoundary, `case '${id}' prBoundary`); + if (!["automatic", "rejected", "approved"].includes(mode) + || kind !== "compiled" || modes.some((mode) => mode !== "candidate")) { + fail(`case '${id}' PR boundary must be a candidate-only compiled automatic/rejected/approved case`); + } + prBoundary = mode as SmokeCase["prBoundary"]; + } + cases.push({ id, lane, kind, modes, source, assertions: parseAssertions(entry.assertions, id), + ...(prBoundary ? { prBoundary } : {}) }); } if (cases.length === 0) fail("cases must declare at least one case"); @@ -361,7 +372,14 @@ export async function loadCases( const text = await readFile(join(worktreeDir, CASES_MANIFEST_PATH), "utf8"); const manifest = parseManifest(text); - const selected = manifest.cases.filter((entry) => entry.modes.includes(mode)); + const requested = env.SMOKE_CASE_IDS?.trim() + ? env.SMOKE_CASE_IDS.split(",").map((id) => id.trim()) : undefined; + if (requested && (requested.some((id) => !id || !manifest.cases.some((entry) => entry.id === id && entry.modes.includes(mode))) + || new Set(requested).size !== requested.length)) { + throw new Error("SMOKE_CASE_IDS must contain distinct case IDs available in the selected mode"); + } + const selected = manifest.cases.filter((entry) => entry.modes.includes(mode) + && (requested ? requested.includes(entry.id) : entry.prBoundary === undefined)); if (selected.length === 0) { throw new Error(`${CASES_MANIFEST_PATH}: no case participates in mode '${mode}'`); } diff --git a/scripts/ado-script/src/compiler-smoke-e2e/index.ts b/scripts/ado-script/src/compiler-smoke-e2e/index.ts index 110499ba9..47b4a6226 100644 --- a/scripts/ado-script/src/compiler-smoke-e2e/index.ts +++ b/scripts/ado-script/src/compiler-smoke-e2e/index.ts @@ -30,6 +30,7 @@ import { dirname, join } from "node:path"; import { mkdir } from "node:fs/promises"; import { AdoRest } from "./ado-rest.js"; +import { verifyPrBoundary } from "./pr-boundary.js"; import { assertAgentCommandPolicy, assertPipelineTextPolicy, @@ -133,7 +134,7 @@ async function stageCase( artifact: config.artifactName, } : undefined; - await writeFile(join(worktreeDir, relMd), prepareCaseSource(original, artifact), "utf8"); + await writeFile(join(worktreeDir, relMd), prepareCaseSource(original, artifact, entry.prBoundary), "utf8"); const result = await compileAndCheck({ adoAwBin: config.adoAwBin, @@ -179,7 +180,7 @@ async function stageCase( // `on:` is what makes the compiler emit `trigger: none` / `pr: none`; // assert it on the staged bytes rather than trusting it. await writeFile(target, yamlText, "utf8"); - assertNoTriggers(yamlText, entry.id); + assertNoTriggers(yamlText, entry.id, entry.prBoundary !== undefined); } /** Stage, commit and push every case, returning the per-case ref and commit SHA. */ @@ -313,6 +314,8 @@ export async function main(): Promise { // Refs actually pushed, so cleanup never touches a ref we failed to create. const pushedRefs = new Map(); + const boundaryResources = new Map(); + const boundaryTargetRefs = new Map(); let overallOk = true; // Whether we reached the point where builds may have been queued. Only // trustworthy because it is set immediately before `runFixtures`; see there. @@ -362,6 +365,18 @@ export async function main(): Promise { pushedRefs.set(caseId, ref); }); + for (const entry of resolved.cases) { + if (!entry.prBoundary) continue; + const source = staged.get(entry.id)!; + const targetRef = `${source.ref}-target`; + await rest.createBoundaryTarget(config.mirrorRepo, targetRef, config.sourceVersion); + boundaryTargetRefs.set(entry.id, targetRef); + const description = `ado-aw-boundary-original-${config.buildId}-${entry.id}`; + const pr = await rest.createBoundaryPr(config.mirrorRepo, source.ref, targetRef, description); + boundaryResources.set(entry.id, { id: pr.pullRequestId, targetRef, description }); + log(`[${entry.id}] disposable PR #${pr.pullRequestId} ready`); + } + const requests: FixtureBuildRequest[] = resolved.cases.map((entry) => ({ caseId: entry.id, lane: entry.lane, @@ -369,6 +384,7 @@ export async function main(): Promise { sourceBranch: staged.get(entry.id)!.ref, sourceVersion: staged.get(entry.id)!.sha, tags: [`smoke-case:${entry.id}`, `smoke-candidate:${config.buildId}`], + expectedResult: entry.prBoundary === "rejected" ? "failed" : "succeeded", })); // Fail-closed: set immediately before the call that might queue builds, so @@ -398,6 +414,27 @@ export async function main(): Promise { results = auditOutcome.results; overallOk = outcome.ok && signalOutcome.ok && auditOutcome.ok; allTerminal = outcome.allTerminal; + for (const entry of resolved.cases) { + if (!entry.prBoundary) continue; + const resource = boundaryResources.get(entry.id); + const result = results.find((result) => result.caseId === entry.id); + if (!resource || !result?.buildId || result.status !== "succeeded") continue; + try { + const [pr, records, artifacts] = await Promise.all([ + rest.boundaryPr(config.mirrorRepo, resource.id), + rest.boundaryTimeline(result.buildId), + rest.boundaryArtifacts(result.buildId), + ]); + for (const name of [`agent_outputs_${result.buildId}`, `analyzed_outputs_${result.buildId}`, "safe_outputs"]) { + if (!artifacts.includes(name)) throw new Error(`Boundary build did not publish ${name}`); + } + verifyPrBoundary(entry.prBoundary, result.buildId, resource.description, pr.description, records); + } catch (error) { + result.status = "failed"; + result.message = errMessage(error); + overallOk = false; + } + } if (!overallOk) failureMessage = "one or more smoke cases did not succeed"; if (!allTerminal) { overallOk = false; @@ -427,6 +464,22 @@ export async function main(): Promise { const proven = provenById.get(caseId) ?? !queueAttempted; (proven ? deletable : retained).push(ref); } + for (const [caseId, targetRef] of boundaryTargetRefs) { + const proven = provenById.get(caseId) ?? !queueAttempted; + if (!proven) { + retained.push(targetRef); + continue; + } + const resource = boundaryResources.get(caseId); + try { + if (resource) await rest.abandonBoundaryPr(config.mirrorRepo, resource.id); + deletable.push(targetRef); + } catch (error) { + overallOk = false; + failureMessage ??= `failed to clean boundary PR for ${caseId}: ${errMessage(error)}`; + retained.push(targetRef); + } + } if (deletable.length > 0) { try { await deleteRemoteRefs({ diff --git a/scripts/ado-script/src/compiler-smoke-e2e/pr-boundary.ts b/scripts/ado-script/src/compiler-smoke-e2e/pr-boundary.ts new file mode 100644 index 000000000..1a9cdcb6a --- /dev/null +++ b/scripts/ado-script/src/compiler-smoke-e2e/pr-boundary.ts @@ -0,0 +1,26 @@ +import type { BoundaryTimelineRecord } from "./ado-rest.js"; + +export function verifyPrBoundary( + mode: "automatic" | "rejected" | "approved", + buildId: number, + before: string, + after: string | undefined, + records: readonly BoundaryTimelineRecord[], +): void { + const job = (id: string) => records.find((record) => + record.type === "Job" && (record.identifier === id || record.identifier?.endsWith(`.${id}`))); + for (const id of ["Setup", "Agent", "Detection", "SafeOutputs"]) { + if (job(id)?.result !== "succeeded") throw new Error(`Boundary prerequisite ${id} did not succeed`); + } + if (mode === "rejected") { + if (job("ManualReview")?.result !== "failed") throw new Error("Expected manual rejection was not observed"); + if (job("SafeOutputs_Reviewed")?.result !== "skipped") throw new Error("Reviewed executor was not skipped"); + if (after !== before) throw new Error("Reviewed PR changed despite gate rejection"); + } else { + if (mode === "approved" && (job("ManualReview")?.result !== "succeeded" + || job("SafeOutputs_Reviewed")?.result !== "succeeded")) { + throw new Error("Approved gate and reviewed execution did not both succeed"); + } + if (after !== `ado-aw-pr-boundary-${buildId}`) throw new Error("Expected PR mutation was not persisted"); + } +} diff --git a/scripts/ado-script/src/compiler-smoke-e2e/runner.ts b/scripts/ado-script/src/compiler-smoke-e2e/runner.ts index 4e0370921..fd27518f0 100644 --- a/scripts/ado-script/src/compiler-smoke-e2e/runner.ts +++ b/scripts/ado-script/src/compiler-smoke-e2e/runner.ts @@ -72,6 +72,7 @@ export interface FixtureBuildRequest { sourceVersion: string; /** Tags applied to the queued run so it is identifiable in a shared lane's history. */ tags?: readonly string[]; + expectedResult?: "succeeded" | "failed"; } export type FixtureBuildStatus = @@ -187,7 +188,7 @@ interface PollOneResult { async function pollOne( client: FixtureBuildClient, buildId: number, - expected: { definitionId: number; sourceBranch: string; sourceVersion: string }, + expected: { definitionId: number; sourceBranch: string; sourceVersion: string; expectedResult?: "succeeded" | "failed" }, opts: { deadlineAt: number; cancelGraceMs: number; @@ -249,7 +250,7 @@ async function pollOne( if (cancelRequestedAt !== undefined) { return { status: "canceled", result: build.result, terminalProven: true }; } - if (build.result === "succeeded") { + if (build.result === (expected.expectedResult ?? "succeeded")) { return { status: "succeeded", result: build.result, terminalProven: true }; } opts.abort.signal(); @@ -364,7 +365,8 @@ export async function runFixtures( const outcome = await pollOne( client, q.buildId, - { definitionId: req.definitionId, sourceBranch: req.sourceBranch, sourceVersion: req.sourceVersion }, + { definitionId: req.definitionId, sourceBranch: req.sourceBranch, sourceVersion: req.sourceVersion, + expectedResult: req.expectedResult }, { deadlineAt, cancelGraceMs, diff --git a/scripts/ado-script/src/compiler-smoke-e2e/source.ts b/scripts/ado-script/src/compiler-smoke-e2e/source.ts index f78782cc8..921bf7a1d 100644 --- a/scripts/ado-script/src/compiler-smoke-e2e/source.ts +++ b/scripts/ado-script/src/compiler-smoke-e2e/source.ts @@ -79,6 +79,7 @@ function parseFrontMatter(yamlText: string): Document { export function prepareCaseSource( markdown: string, values: PipelineArtifactValues | undefined, + prBoundary?: "automatic" | "rejected" | "approved", ): string { const { yamlText, body } = splitFrontMatter(markdown); const doc = parseFrontMatter(yamlText); @@ -112,9 +113,18 @@ export function prepareCaseSource( // The orchestrator owns scheduling and queueing for every case, so no staged // case may carry a trigger of any kind. doc.delete("on"); + if (prBoundary) { + doc.set("on", doc.createNode({ push: "none", pr: { mode: "synthetic" } })); + if (prBoundary !== "automatic") { + doc.setIn(["safe-outputs", "update-pull-request", "require-approval"], doc.createNode({ + "timeout-minutes": prBoundary === "rejected" ? 1 : 60, + "on-timeout": "reject", + "instructions": "Test-only PR update. Approve only the on-demand approved-path case.", + })); + } + } const rendered = doc.toString({ lineWidth: 0 }); const frontMatter = rendered.endsWith("\n") ? rendered : `${rendered}\n`; return `---\n${frontMatter}---\n${body}`; } - diff --git a/scripts/ado-script/src/executor-e2e/index.ts b/scripts/ado-script/src/executor-e2e/index.ts index 37cbe07f2..496e9e365 100644 --- a/scripts/ado-script/src/executor-e2e/index.ts +++ b/scripts/ado-script/src/executor-e2e/index.ts @@ -58,9 +58,15 @@ export function booleanOption(value: string | undefined, fallback: boolean): boo async function requiredPreflight(ctx: ScenarioContext, scenarios: Scenario[]): Promise { const names = scenarios.map((scenario) => scenario.id ?? scenario.tool); if (names.some((name) => name.includes("cross-org"))) resolveCrossOrgEnv(ctx); - if (names.some((name) => name.includes("add-reviewers"))) { + if (names.some((name) => name.includes("reviewers"))) { const reviewer = resolveExecutorE2eReviewer(); - if (!await ctx.rest.resolveIdentityId(reviewer)) { + if (names.some((name) => name.endsWith("-general")) && + /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(reviewer)) { + throw new Error("Required General reviewer case needs an existing email or exact name, not a GUID"); + } + const cross = names.some((name) => name.includes("reviewers") && name.includes("cross-org")); + const rest = cross ? resolveCrossOrgEnv(ctx).rest : ctx.rest; + if (!await rest.resolveIdentityId(reviewer)) { throw new Error("Required reviewer does not resolve to exactly one existing identity"); } } diff --git a/scripts/ado-script/src/executor-e2e/scenarios/index.ts b/scripts/ado-script/src/executor-e2e/scenarios/index.ts index 35dd16ca4..245d56f3b 100644 --- a/scripts/ado-script/src/executor-e2e/scenarios/index.ts +++ b/scripts/ado-script/src/executor-e2e/scenarios/index.ts @@ -7,6 +7,7 @@ import { buildScenarios } from "./build.js"; import { conclusionScenarios } from "./conclusion.js"; import { createPullRequestScenarios } from "./create-pull-request.js"; import { crossOrgScenarios } from "./cross-org.js"; +import { crossOrgPrScenarios } from "./pr-cross-org.js"; import { gitScenarios } from "./git.js"; import { githubIssueScenarios } from "./github-issue.js"; import { prScenarios } from "./pr.js"; @@ -23,6 +24,7 @@ export const allScenarios: Scenario[] = [ ...prScenarios, ...gitScenarios, ...crossOrgScenarios, + ...crossOrgPrScenarios, ...buildScenarios, ...createPullRequestScenarios, ...githubIssueScenarios, diff --git a/scripts/ado-script/src/executor-e2e/scenarios/pr-cross-org.ts b/scripts/ado-script/src/executor-e2e/scenarios/pr-cross-org.ts new file mode 100644 index 000000000..ac5d4f877 --- /dev/null +++ b/scripts/ado-script/src/executor-e2e/scenarios/pr-cross-org.ts @@ -0,0 +1,45 @@ +import type { Scenario, ScenarioContext } from "../scenario.js"; +import { crossOrgSource, resolveCrossOrgEnv, type CrossOrgEnv } from "./cross-org.js"; +import { + updatePullRequest, addPrLabels, addPrReviewers, submitPrReview, + setPrAutoComplete, abandonPullRequest, +} from "./pr.js"; + +function crossOrgPr(scenario: Scenario): Scenario<{ env: CrossOrgEnv; state: S }> { + const targetContext = (ctx: ScenarioContext, env: CrossOrgEnv): ScenarioContext => ({ + ...ctx, orgUrl: env.orgUrl, project: env.project, adoRepo: env.repository, + token: env.token, rest: env.rest, + prefix: (tool) => ctx.prefix(`${tool}-cross-org`), + }); + return { + id: `${scenario.id ?? scenario.tool}-cross-org`, + tool: scenario.tool, + setup: async (ctx) => { + const env = resolveCrossOrgEnv(ctx); + return { env, state: await scenario.setup(targetContext(ctx, env)) }; + }, + source: async (_ctx, state) => crossOrgSource(state.env), + config: (ctx, state) => ({ + ...scenario.config(targetContext(ctx, state.env), state.state), + "allowed-repositories": [state.env.alias], + }), + env: async (_ctx, state) => ({ SYSTEM_ACCESSTOKEN: state.env.token }), + ndjson: async (ctx, state) => ({ + ...await scenario.ndjson(targetContext(ctx, state.env), state.state), + repository: state.env.alias, + }), + assert: async (ctx, state, record, records) => + scenario.assert(targetContext(ctx, state.env), state.state, record, records), + cleanup: async (ctx, state, records) => + scenario.cleanup(targetContext(ctx, state.env), state.state, records), + }; +} + +export const crossOrgPrScenarios: Scenario[] = [ + crossOrgPr(updatePullRequest), + crossOrgPr(addPrLabels), + crossOrgPr(addPrReviewers), + crossOrgPr(submitPrReview), + crossOrgPr(setPrAutoComplete), + crossOrgPr(abandonPullRequest), +]; diff --git a/scripts/ado-script/src/executor-e2e/scenarios/pr.ts b/scripts/ado-script/src/executor-e2e/scenarios/pr.ts index 89835b07f..96c07c966 100644 --- a/scripts/ado-script/src/executor-e2e/scenarios/pr.ts +++ b/scripts/ado-script/src/executor-e2e/scenarios/pr.ts @@ -11,6 +11,7 @@ */ import type { Scenario, ScenarioContext } from "../scenario.js"; import { defaultBranchShortName, detBody, Teardown } from "./common.js"; +import { resolveExecutorE2eReviewer } from "./create-pull-request.js"; interface PrState { repo: string; @@ -305,6 +306,79 @@ export const updatePullRequestOversized: Scenario = { cleanup: teardownPr, }; +export const updatePullRequestUnicode: Scenario = { + ...updatePullRequest, + id: "update-pull-request-unicode", + setup: (ctx) => setupPr(ctx, "update-pull-request-unicode", false), + ndjson: async (ctx, state) => ({ + pull_request_id: state.prId, repository: ctx.adoRepo, + body: "\u{1f600}".repeat(2000), + }), + assert: async (ctx, state) => { + const pr = await ctx.rest.getPullRequest(state.repo, state.prId); + if (pr.description !== "\u{1f600}".repeat(2000)) { + throw new Error("ADO did not preserve the exact 4000-UTF16-unit non-BMP description"); + } + }, +}; + +export const updatePullRequestUnicodeOversized: Scenario = { + ...updatePullRequestOversized, + id: "update-pull-request-unicode-oversized", + setup: (ctx) => setupPr(ctx, "update-pull-request-unicode-oversized", false), + ndjson: async (ctx, state) => ({ + pull_request_id: state.prId, repository: ctx.adoRepo, + body: "\u{1f600}".repeat(2000) + "x", + }), + assertFailure: async (ctx, state) => { + const pr = await ctx.rest.getPullRequest(state.repo, state.prId); + if (pr.description !== detBody(ctx, "update-pull-request-unicode-oversized")) { + throw new Error("Rejected Unicode description changed the live PR"); + } + }, +}; + +export const updatePullRequestComposedOversized: Scenario = { + ...updatePullRequestOversized, + id: "update-pull-request-composed-oversized", + setup: (ctx) => setupPr(ctx, "update-pull-request-composed-oversized", false), + ndjson: async (ctx, state) => ({ + pull_request_id: state.prId, repository: ctx.adoRepo, + body: "x".repeat(4000), operation: "append", + }), + assertFailure: async (ctx, state) => { + const pr = await ctx.rest.getPullRequest(state.repo, state.prId); + if (pr.description !== detBody(ctx, "update-pull-request-composed-oversized")) { + throw new Error("Rejected assembled description changed the live PR"); + } + }, +}; + +interface ReviewerState extends PrState { reviewer: string } +export const addPrReviewers: Scenario = { + tool: "add-pull-request-reviewers", + targetsAdoRepo: true, + config: (ctx, state) => ({ + "allowed-repositories": [ctx.adoRepo], "allowed-reviewers": [state.reviewer], "max-reviewers": 1, + }), + setup: async (ctx) => { + const name = resolveExecutorE2eReviewer(); + const reviewer = await ctx.rest.resolveIdentityId(name); + if (!reviewer) throw new Error("Configured reviewer does not resolve exactly"); + return { ...await setupPr(ctx, "add-pull-request-reviewers", false), reviewer }; + }, + ndjson: async (ctx, state) => ({ + pull_request_id: state.prId, repository: ctx.adoRepo, reviewers: [state.reviewer], + }), + assert: async (ctx, state) => { + const reviewers = await ctx.rest.listReviewers(state.repo, state.prId); + if (!reviewers.some((reviewer) => reviewer.id.toLowerCase() === state.reviewer.toLowerCase())) { + throw new Error("Requested reviewer is missing from the target PR"); + } + }, + cleanup: teardownPr, +}; + export const addPrLabels: Scenario = { tool: "add-pull-request-labels", targetsAdoRepo: true, @@ -400,6 +474,10 @@ export const prScenarios: Scenario[] = [ abandonPullRequest, updatePullRequestIsland, updatePullRequestOversized, + updatePullRequestUnicode, + updatePullRequestUnicodeOversized, + updatePullRequestComposedOversized, + addPrReviewers, addPrLabels, setPrAutoComplete, ]; diff --git a/src/execute.rs b/src/execute.rs index e6ebd518e..fd91826a4 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -937,6 +937,365 @@ mod tests { use std::collections::HashMap; use std::path::PathBuf; + #[tokio::test] + async fn abandonment_failures_never_post_a_comment() { + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{method, path}, + }; + for (get_status, patch_status, body) in [ + (401, 200, "{}"), + (403, 200, "{}"), + (404, 200, "{}"), + (500, 200, "{}"), + (200, 200, "not json"), + (200, 200, "{}"), + (200, 200, "{\"status\":\"unexpected\"}"), + (200, 403, "{\"status\":\"active\"}"), + (200, 500, "{\"status\":\"active\"}"), + ] { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/P/_apis/git/repositories/repo/pullRequests/7")) + .respond_with(ResponseTemplate::new(get_status).set_body_string(body)) + .expect(1) + .mount(&server) + .await; + let patch_count = usize::from(body == "{\"status\":\"active\"}"); + Mock::given(method("PATCH")) + .respond_with(ResponseTemplate::new(patch_status)) + .expect(patch_count as u64) + .mount(&server) + .await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + let ctx = ExecutionContext { + ado_org_url: Some(server.uri()), + ado_organization: Some("org".into()), + ado_project: Some("P".into()), + repository_name: Some("repo".into()), + access_token: Some("token".into()), + tool_configs: HashMap::from([( + "abandon-pull-request".into(), + serde_json::json!({"target":"*"}), + )]), + ..Default::default() + }; + let result = execute_safe_output( + &serde_json::json!({ + "name":"abandon-pull-request","pull_request_id":7,"body":"A closing comment." + }), + &ctx, + ) + .await; + assert!( + result.is_err() || !result.as_ref().unwrap().1.success, + "{get_status}/{patch_status}/{body}" + ); + assert_eq!( + server.received_requests().await.unwrap().len(), + 1 + patch_count + ); + } + } + + #[tokio::test] + async fn autocomplete_failures_do_not_report_success_or_write_without_identity() { + use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method}; + for (lookup_status, patch_status, body) in [ + (401, 200, "{}"), + (403, 200, "{}"), + (500, 200, "{}"), + (200, 200, "invalid"), + (200, 200, "{}"), + (200, 403, "{\"authenticatedUser\":{\"id\":\"actor\"}}"), + (200, 500, "{\"authenticatedUser\":{\"id\":\"actor\"}}"), + ] { + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(lookup_status).set_body_string(body)) + .expect(1) + .mount(&server) + .await; + let writes = u64::from(body.contains("actor")); + Mock::given(method("PATCH")) + .respond_with(ResponseTemplate::new(patch_status)) + .expect(writes) + .mount(&server) + .await; + let ctx = ExecutionContext { + ado_org_url: Some(server.uri()), + ado_organization: Some("org".into()), + ado_project: Some("P".into()), + repository_name: Some("repo".into()), + access_token: Some("token".into()), + tool_configs: HashMap::from([( + "set-pull-request-auto-complete".into(), + serde_json::json!({}), + )]), + ..Default::default() + }; + let result = execute_safe_output( + &serde_json::json!({ + "name":"set-pull-request-auto-complete","pull_request_id":7 + }), + &ctx, + ) + .await; + assert!( + result.is_err() || !result.as_ref().unwrap().1.success, + "{lookup_status}/{patch_status}/{body}" + ); + assert_eq!( + server.received_requests().await.unwrap().len(), + 1 + writes as usize + ); + } + } + + #[tokio::test] + async fn label_batches_report_mixed_and_total_write_failures() { + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{body_json, method}, + }; + for first_status in [200, 403] { + let server = MockServer::start().await; + for (label, status) in [("one", first_status), ("two", 500)] { + Mock::given(method("POST")) + .and(body_json(serde_json::json!({"name":label}))) + .respond_with(ResponseTemplate::new(status)) + .expect(1) + .mount(&server) + .await; + } + let ctx = ExecutionContext { + ado_org_url: Some(server.uri()), + ado_organization: Some("org".into()), + ado_project: Some("P".into()), + repository_name: Some("repo".into()), + access_token: Some("token".into()), + tool_configs: HashMap::from([( + "add-pull-request-labels".into(), + serde_json::json!({}), + )]), + ..Default::default() + }; + let (_, result) = execute_safe_output( + &serde_json::json!({ + "name":"add-pull-request-labels","pull_request_id":7,"labels":["one","two"] + }), + &ctx, + ) + .await + .unwrap(); + assert_eq!(result.success, first_status == 200); + if first_status == 200 { + let data = result.data.unwrap(); + assert_eq!(data["added"], serde_json::json!(["one"])); + assert!(data["failed"][0].as_str().unwrap().contains("two")); + } + assert_eq!(server.received_requests().await.unwrap().len(), 2); + } + } + + #[tokio::test] + async fn shared_budget_counts_failed_attempts_and_respects_different_tool_caps() { + use crate::compile::pr_migration::BudgetGroup; + use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method}; + for failure in ["parse", "policy", "http", "transport"] { + let server = MockServer::start().await; + let closed = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let unavailable = format!("http://{}", closed.local_addr().unwrap()); + drop(closed); + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(500)) + .expect(if failure == "http" { 1 } else { 0 }) + .mount(&server) + .await; + let first = match failure { + "parse" => { + serde_json::json!({"name":"add-pull-request-labels","pull_request_id":7,"labels":"invalid"}) + } + "policy" => { + serde_json::json!({"name":"add-pull-request-labels","pull_request_id":7,"labels":["one"],"repository":"unlisted"}) + } + _ => { + serde_json::json!({"name":"add-pull-request-labels","pull_request_id":7,"labels":["one"]}) + } + }; + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write(dir.path().join(SAFE_OUTPUT_FILENAME), format!("{first}\n{}\n", + serde_json::json!({"name":"update-pull-request","pull_request_id":7,"title":"Must not write"}))).await.unwrap(); + let ctx = ExecutionContext { + ado_org_url: Some(if failure == "transport" { + unavailable + } else { + server.uri() + }), + ado_organization: Some("org".into()), + ado_project: Some("P".into()), + repository_name: Some("repo".into()), + access_token: Some("token".into()), + tool_configs: HashMap::from([ + ( + "add-pull-request-labels".into(), + serde_json::json!({"max":3}), + ), + ( + "update-pull-request".into(), + serde_json::json!({"max":3,"target":"*"}), + ), + ]), + budget_groups: std::collections::BTreeMap::from([( + "shared".into(), + BudgetGroup { + max: 1, + tools: vec![ + "add-pull-request-labels".into(), + "update-pull-request".into(), + ], + }, + )]), + ..Default::default() + }; + let results = execute_safe_outputs(dir.path(), &ctx, &ToolFilter::default()) + .await + .unwrap(); + assert_eq!(results.len(), 2); + assert!( + !results[0].success && !results[0].is_budget_exhausted(), + "{failure}" + ); + assert!(results[1].is_budget_exhausted(), "{failure}"); + assert_eq!( + server.received_requests().await.unwrap().len(), + usize::from(failure == "http") + ); + } + } + + #[tokio::test] + async fn per_tool_exhaustion_does_not_consume_another_shared_attempt() { + use crate::compile::pr_migration::BudgetGroup; + use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method}; + let server = MockServer::start().await; + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(500)) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(404)) + .expect(1) + .mount(&server) + .await; + let label = serde_json::json!({"name":"add-pull-request-labels","pull_request_id":7,"labels":["label"]}); + let update = + serde_json::json!({"name":"update-pull-request","pull_request_id":7,"title":"attempt"}); + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write( + dir.path().join(SAFE_OUTPUT_FILENAME), + format!("{label}\n{label}\n{update}\n{update}\n"), + ) + .await + .unwrap(); + let ctx = ExecutionContext { + ado_org_url: Some(server.uri()), + ado_organization: Some("org".into()), + ado_project: Some("P".into()), + repository_name: Some("repo".into()), + access_token: Some("token".into()), + tool_configs: HashMap::from([ + ( + "add-pull-request-labels".into(), + serde_json::json!({"max":1}), + ), + ( + "update-pull-request".into(), + serde_json::json!({"max":3,"target":"*"}), + ), + ]), + budget_groups: std::collections::BTreeMap::from([( + "shared".into(), + BudgetGroup { + max: 2, + tools: vec![ + "add-pull-request-labels".into(), + "update-pull-request".into(), + ], + }, + )]), + ..Default::default() + }; + let results = execute_safe_outputs(dir.path(), &ctx, &ToolFilter::default()) + .await + .unwrap(); + assert_eq!( + results + .iter() + .map(|result| result.is_budget_exhausted()) + .collect::>(), + vec![false, true, false, true] + ); + assert!(results.iter().all(|result| !result.success)); + assert_eq!(server.received_requests().await.unwrap().len(), 2); + let records = read_executed_manifest(&dir).await; + assert_eq!( + records + .iter() + .map(|record| record["status"].as_str().unwrap()) + .collect::>(), + vec!["failed", "budget_exhausted", "failed", "budget_exhausted"] + ); + } + + #[tokio::test] + async fn connection_loss_on_autocomplete_write_is_not_reported_successfully() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + let (mut lookup, _) = listener.accept().await.unwrap(); + let mut buffer = [0; 4096]; + let read = lookup.read(&mut buffer).await.unwrap(); + assert!( + String::from_utf8_lossy(&buffer[..read]).starts_with("GET /_apis/connectiondata") + ); + let body = r#"{"authenticatedUser":{"id":"actor"}}"#; + lookup.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",body.len()).as_bytes()).await.unwrap(); + drop(lookup); + let (mut write, _) = listener.accept().await.unwrap(); + let read = write.read(&mut buffer).await.unwrap(); + assert!(String::from_utf8_lossy(&buffer[..read]).starts_with("PATCH ")); + }); + let ctx = ExecutionContext { + ado_org_url: Some(url), + ado_organization: Some("org".into()), + ado_project: Some("P".into()), + repository_name: Some("repo".into()), + access_token: Some("token".into()), + tool_configs: HashMap::from([( + "set-pull-request-auto-complete".into(), + serde_json::json!({}), + )]), + ..Default::default() + }; + let result = execute_safe_output( + &serde_json::json!({"name":"set-pull-request-auto-complete","pull_request_id":7}), + &ctx, + ) + .await; + assert!(result.is_err()); + tokio::time::timeout(std::time::Duration::from_secs(5), server) + .await + .unwrap() + .unwrap(); + } + #[tokio::test] async fn invalid_legacy_review_metadata_cannot_reset_a_vote() { let server = wiremock::MockServer::start().await; diff --git a/tests/executor-e2e/README.md b/tests/executor-e2e/README.md index e88aadaed..ac2837daa 100644 --- a/tests/executor-e2e/README.md +++ b/tests/executor-e2e/README.md @@ -99,6 +99,16 @@ scenario code or the relevant Rust schemas, validators, or executor change. ### Live coverage +The PR matrix also registers Unicode boundary/composed-body rejection cases +and optional cross-organization variants of content editing, reviewers, labels, +review submission, auto-complete and abandonment. These require the existing +cross-org variables below; selecting them with `requireSelected: true` fails +preflight rather than counting missing infrastructure as a pass. +Auto-complete scenarios use only disposable target branches. + +Label preservation is read through the dedicated PR labels-list API. General +PR metadata responses can omit labels and are not used as an empty-set oracle. + All deterministically-assertable ADO-write safe outputs plus the flagship `create-pull-request`, and the four signal-only tools: diff --git a/tests/safe-outputs/pr-pipeline-boundary.md b/tests/safe-outputs/pr-pipeline-boundary.md new file mode 100644 index 000000000..fa37563d1 --- /dev/null +++ b/tests/safe-outputs/pr-pipeline-boundary.md @@ -0,0 +1,35 @@ +--- +name: "Live synthetic PR boundary" +description: "Exercise real agent/detection/executor boundaries against an orchestrator-owned disposable PR" +target: standalone +pool: + name: AZS-1ES-L-Playground-ubuntu-22.04 +engine: + id: copilot + timeout-minutes: 10 +permissions: + read: agent-playground-read + write: agent-playground-write +safe-outputs: + report-failure-as-work-item: false + add-build-tag: + tag-prefix: "ado-aw-pr-boundary-" + max: 1 + update-pull-request: + target: triggering + title: false + include-stats: false + allowed-repositories: [self] + max: 1 +--- + +This is a test against a disposable pull request owned by this test run. +Do not create branches, edit files, or inspect unrelated repositories. +Emit exactly these two proposals: + +1. `add-build-tag`: build_id $(Build.BuildId), tag "$(Build.BuildId)". +2. `update-pull-request`: body "ado-aw-pr-boundary-$(Build.BuildId)", + operation "replace". Omit the PR ID and repository to use the trusted + triggering PR identity. + +Do not add any other text to the description. Stop after the two proposals. diff --git a/tests/smoke/README.md b/tests/smoke/README.md index d6ddf30c7..9ccb63c30 100644 --- a/tests/smoke/README.md +++ b/tests/smoke/README.md @@ -129,6 +129,27 @@ GitHub. ## Adding a smoke +### Opt-in live PR boundaries + +Queue the existing candidate orchestrator with `caseIds` containing `canary` +and one or more of `pr-synthetic-auto`, `pr-synthetic-rejected`, or +`pr-synthetic-approved`. These cases are not in default unattended selections. +`SMOKE_CASE_IDS` is the harness equivalent; unknown IDs are rejected. + +The orchestrator creates an owned PR between disposable candidate refs in the +existing mirror. The actual trusted Setup resolver discovers that PR. The +automatic case requires an exact description update; the rejection case +requires successful Setup/Agent/Detection and automatic execution, a failed +ManualReview gate, skipped reviewed execution and an unchanged description. +Expected failure alone is not enough. Build tags and published artifact +families are also verified before cleanup. + +The rejection case never auto-approves: its test gate times out to reject. +The approved case is human-on-demand and must not be queued unattended. +Native PR validation additionally requires a pre-existing branch-validation +policy/definition; these synthetic API-queued tests do not claim native live +coverage and do not provision that infrastructure. + 1. Write the markdown (anywhere under `tests/`; `tests/safe-outputs/` is the usual home). 2. Add an entry to [`cases.json`](cases.json): diff --git a/tests/smoke/azure-pipelines-candidate.yml b/tests/smoke/azure-pipelines-candidate.yml index 93477eca8..c73870130 100644 --- a/tests/smoke/azure-pipelines-candidate.yml +++ b/tests/smoke/azure-pipelines-candidate.yml @@ -14,6 +14,10 @@ # files so their trigger blocks cannot leak into one another. trigger: none +parameters: + - name: caseIds + type: string + default: "" pr: branches: @@ -42,6 +46,8 @@ pool: variables: - template: orchestrator-variables.yml + - name: SMOKE_CASE_IDS + value: ${{ parameters.caseIds }} jobs: - job: CandidateSmoke diff --git a/tests/smoke/cases.json b/tests/smoke/cases.json index a2f985e63..12773507a 100644 --- a/tests/smoke/cases.json +++ b/tests/smoke/cases.json @@ -80,6 +80,33 @@ "modes": ["candidate"], "source": "tests/safe-outputs/pr-tools-preview.md" }, + { + "id": "pr-synthetic-auto", + "lane": "agentic", + "kind": "compiled", + "modes": ["candidate"], + "source": "tests/safe-outputs/pr-pipeline-boundary.md", + "prBoundary": "automatic", + "assertions": {"requiredBuildTags": ["ado-aw-pr-boundary-{buildId}"]} + }, + { + "id": "pr-synthetic-rejected", + "lane": "agentic", + "kind": "compiled", + "modes": ["candidate"], + "source": "tests/safe-outputs/pr-pipeline-boundary.md", + "prBoundary": "rejected", + "assertions": {"requiredBuildTags": ["ado-aw-pr-boundary-{buildId}"]} + }, + { + "id": "pr-synthetic-approved", + "lane": "agentic", + "kind": "compiled", + "modes": ["candidate"], + "source": "tests/safe-outputs/pr-pipeline-boundary.md", + "prBoundary": "approved", + "assertions": {"requiredBuildTags": ["ado-aw-pr-boundary-{buildId}"]} + }, { "id": "janitor", "lane": "agentic", From d4f7f948cd3902970f028ff64d634b757ce266f4 Mon Sep 17 00:00:00 2001 From: James Devine Date: Thu, 24 Sep 2026 22:42:17 +0100 Subject: [PATCH 18/43] fix(safe-outputs): read authoritative PR labels and make test cleanup idempotent Cover malformed label policies, review partial failures, label connection loss, required preflight and cleanup outcomes. Add live label-policy and denied-repository cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: da8711de-7251-47cd-a006-6e4ece913b49 --- docs/safe-outputs.md | 9 +- .../executor-e2e/__tests__/ado-rest.test.ts | 31 +++ .../src/executor-e2e/__tests__/index.test.ts | 57 ++++- .../src/executor-e2e/__tests__/runner.test.ts | 19 ++ .../ado-script/src/executor-e2e/ado-rest.ts | 5 + .../src/executor-e2e/scenarios/pr.ts | 58 ++++-- src/execute.rs | 195 ++++++++++++++++++ src/safe_outputs/abandon_pull_request.rs | 49 +++-- src/safe_outputs/pr_common.rs | 40 ++++ src/safe_outputs/update_pull_request.rs | 41 ++-- tests/executor-e2e/README.md | 8 +- 11 files changed, 466 insertions(+), 46 deletions(-) diff --git a/docs/safe-outputs.md b/docs/safe-outputs.md index 00996545a..addda7e10 100644 --- a/docs/safe-outputs.md +++ b/docs/safe-outputs.md @@ -894,6 +894,11 @@ For another PR, use an explicit fixed target or `target: "*"`, subject to the same repository permissions. Numeric and quoted numeric fixed targets have the same meaning, including in the human-review preview. +`required-labels` reads the dedicated PR labels-list endpoint, not the optional +labels in general PR metadata. Every configured label must match +case-insensitively before writing; HTTP errors and malformed list responses +fail closed. + Numeric IDs, quoted numeric IDs and same-run temporary PR references are accepted. Repository destinations resolve their configured organization and project; cross-organization writes require the normal explicit write policy. @@ -1528,7 +1533,9 @@ ADO PR ID is used. The tool fetches the PR first, applies the optional title/label filters, patches the PR status to `abandoned`, then optionally posts `body` as a PR thread comment. -All required labels must match (case-insensitively). Completed PRs are rejected; +All required labels must match (case-insensitively), using the dedicated PR +labels-list endpoint. A missing label, failed lookup or malformed response +prevents mutation. Completed PRs are rejected; already-abandoned PRs are no-ops and do not post another comment. If abandonment succeeds but comment posting fails, execution is a warning with structured mutation/comment data. A transport error can leave comment delivery uncertain; diff --git a/scripts/ado-script/src/executor-e2e/__tests__/ado-rest.test.ts b/scripts/ado-script/src/executor-e2e/__tests__/ado-rest.test.ts index b553ed3bc..39a5c4c47 100644 --- a/scripts/ado-script/src/executor-e2e/__tests__/ado-rest.test.ts +++ b/scripts/ado-script/src/executor-e2e/__tests__/ado-rest.test.ts @@ -28,6 +28,37 @@ describe("AdoRest.listPullRequestLabels", () => { ); }); + describe("AdoRest.abandonPullRequest cleanup", () => { + afterEach(() => vi.unstubAllGlobals()); + + it.each(["active", "abandoned", "missing"])("cleans up a %s PR without repeating abandonment", async (status) => { + const fetch = stubFetch(() => status === "missing" + ? new Response("", { status: 404 }) + : Response.json({ status })); + await new AdoRest(options).abandonPullRequest("repo", 42); + expect(fetch).toHaveBeenCalledTimes(status === "active" ? 2 : 1); + if (status === "active") { + expect(fetch.mock.calls[1]?.[1]).toMatchObject({ + method: "PATCH", body: JSON.stringify({ status: "abandoned" }), + }); + } + }); + + it.each([{}, { status: "completed" }, { status: "unknown" }])( + "does not silently accept an unexpected PR state %j", async (state) => { + const fetch = stubFetch(() => Response.json(state)); + await expect(new AdoRest(options).abandonPullRequest("repo", 42)).rejects.toThrow("unexpected status"); + expect(fetch).toHaveBeenCalledTimes(1); + }, + ); + + it("retains cleanup read errors without attempting another mutation", async () => { + const fetch = stubFetch(() => new Response("forbidden", { status: 403 })); + await expect(new AdoRest(options).abandonPullRequest("repo", 42)).rejects.toThrow("403"); + expect(fetch).toHaveBeenCalledTimes(1); + }); + }); + it.each([{}, { value: null }, { value: [null] }, { value: [{name: 1}] }])( "does not report malformed %j as no labels", async (response) => { stubFetch(() => Response.json(response)); diff --git a/scripts/ado-script/src/executor-e2e/__tests__/index.test.ts b/scripts/ado-script/src/executor-e2e/__tests__/index.test.ts index 25cf2f8ca..9af2bdd0c 100644 --- a/scripts/ado-script/src/executor-e2e/__tests__/index.test.ts +++ b/scripts/ado-script/src/executor-e2e/__tests__/index.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { booleanOption, main, selectScenarios, summarise } from "../index.js"; import { fileFailureIssue } from "../github-issue.js"; import { allScenarios } from "../scenarios/index.js"; +import { SkipError } from "../scenario.js"; import type { ScenarioResult } from "../scenario.js"; vi.mock("../github-issue.js", () => ({ @@ -14,7 +15,7 @@ vi.mock("../github-issue.js", () => ({ })); describe("diagnostic selection", () => { - afterEach(() => { vi.unstubAllEnvs(); vi.clearAllMocks(); }); + afterEach(() => { vi.unstubAllEnvs(); vi.restoreAllMocks(); vi.clearAllMocks(); }); it("only selects requested existing scenarios and rejects ambiguous input", () => { expect(selectScenarios(allScenarios, "noop,add-pull-request-labels") .map((scenario) => scenario.id ?? scenario.tool)).toEqual(["noop", "add-pull-request-labels"]); @@ -54,6 +55,56 @@ name:"noop",status:"failed",error:"synthetic diagnostic failure"})+"\\n"); expect(JSON.stringify(report)).not.toContain("not-a-real-token"); } finally { await rm(dir, {recursive: true, force: true}); } }); + + it.each([ + ["add-pull-request-reviewers", " ", "EXECUTOR_E2E_REVIEWER is unavailable"], + ["update-pull-request-cross-org", "", "EXECUTOR_E2E_CROSS_ORG_ORGANIZATION"], + ["create-pull-request-add-reviewers-general", "11111111-1111-1111-1111-111111111111", "not a GUID"], + ])("fails required %s preflight before creating scenario resources", async (id, reviewer, error) => { + const dir = await mkdtemp(join(tmpdir(), "ado-required-preflight-")); + try { + for (const [key, value] of Object.entries({ + SYSTEM_COLLECTIONURI: "https://example.test/", SYSTEM_TEAMPROJECT: "test", + SYSTEM_ACCESSTOKEN: "not-a-real-token", EXECUTOR_E2E_ADO_AW_BIN: "must-not-run", + EXECUTOR_E2E_SCENARIOS: id, EXECUTOR_E2E_REQUIRE_SELECTED: "true", + EXECUTOR_E2E_REVIEWER: reviewer, EXECUTOR_E2E_CROSS_ORG_ORGANIZATION: "", + EXECUTOR_E2E_FILE_FAILURE_ISSUE: "false", + EXECUTOR_E2E_RESULTS_PATH: join(dir, "results.json"), + })) vi.stubEnv(key, value); + const scenario = selectScenarios(allScenarios, id)[0]!; + const setup = vi.spyOn(scenario, "setup"); + expect(await main()).toBe(1); + expect(setup).not.toHaveBeenCalled(); + expect(fileFailureIssue).not.toHaveBeenCalled(); + const report = JSON.parse(await readFile(join(dir, "results.json"), "utf8")); + expect(report.results).toEqual([expect.objectContaining({ + tool: "required-preflight", ok: false, phase: "preflight", + message: expect.stringContaining(error), + })]); + } finally { await rm(dir, { recursive: true, force: true }); } + }); + + it("reports a required runtime skip as failed coverage", async () => { + const dir = await mkdtemp(join(tmpdir(), "ado-required-skip-")); + try { + for (const [key, value] of Object.entries({ + SYSTEM_COLLECTIONURI: "https://example.test/", SYSTEM_TEAMPROJECT: "test", + SYSTEM_ACCESSTOKEN: "not-a-real-token", EXECUTOR_E2E_ADO_AW_BIN: "must-not-run", + EXECUTOR_E2E_SCENARIOS: "noop", EXECUTOR_E2E_REQUIRE_SELECTED: "true", + EXECUTOR_E2E_FILE_FAILURE_ISSUE: "false", + EXECUTOR_E2E_RESULTS_PATH: join(dir, "results.json"), + })) vi.stubEnv(key, value); + const scenario = selectScenarios(allScenarios, "noop")[0]!; + vi.spyOn(scenario, "setup").mockRejectedValue(new SkipError("prerequisite disappeared")); + expect(await main()).toBe(1); + const report = JSON.parse(await readFile(join(dir, "results.json"), "utf8")); + expect(report.results).toEqual([expect.objectContaining({ + tool: "noop", ok: false, skipped: false, phase: "required-coverage", + message: "Required scenario skipped: prerequisite disappeared", + })]); + expect(fileFailureIssue).not.toHaveBeenCalled(); + } finally { await rm(dir, { recursive: true, force: true }); } + }); }); describe("summarise", () => { @@ -82,6 +133,10 @@ describe("scenario registry", () => { expect(ids).toContain("create-pull-request-add-reviewers"); expect(ids).toContain("create-branch-cross-org"); expect(ids).toContain("create-git-tag-cross-org"); + for (const tool of [ + "update-pull-request", "add-pull-request-labels", "add-pull-request-reviewers", + "submit-pull-request-review", "set-pull-request-auto-complete", "abandon-pull-request", + ]) expect(ids).toContain(`${tool}-cross-org`); }); it("registers the GitHub issue scenarios with unique ids", () => { diff --git a/scripts/ado-script/src/executor-e2e/__tests__/runner.test.ts b/scripts/ado-script/src/executor-e2e/__tests__/runner.test.ts index 259421a21..32723c427 100644 --- a/scripts/ado-script/src/executor-e2e/__tests__/runner.test.ts +++ b/scripts/ado-script/src/executor-e2e/__tests__/runner.test.ts @@ -127,6 +127,25 @@ fs.writeFileSync(path.join(out, "safe-outputs-executed.ndjson"), ${JSON.stringif return bin; } + it.each([false, true])("fails on cleanup errors without losing an earlier assertion failure (%s)", async (assertionFails) => { + const dir = await mkdtemp(join(tmpdir(), "ado-cleanup-outcome-")); + try { + const bin = await outcomeBinary(dir, "succeeded", ""); + const scenario: Scenario = { + tool: "update-pull-request", config: () => ({}), + setup: async () => ({}), ndjson: async () => ({}), + assert: async () => { if (assertionFails) throw new Error("wrong PR state"); }, + cleanup: async () => { throw new Error("owned ref remains"); }, + }; + const result = await runScenario({ ...fakeCtx(), adoAwBin: bin, workDir: dir }, scenario); + expect(result).toMatchObject({ + ok: false, skipped: false, cleanupError: "owned ref remains", + phase: assertionFails ? "assert" : "cleanup", + message: `${assertionFails ? "wrong PR state; " : ""}cleanup failed: owned ref remains`, + }); + } finally { await rm(dir, { recursive: true, force: true }); } + }); + it.each([false, true])("checks postconditions after expected failure (mutated=%s)", async (mutated) => { const dir = await mkdtemp(join(tmpdir(), "ado-aw-negative-assert-")); try { diff --git a/scripts/ado-script/src/executor-e2e/ado-rest.ts b/scripts/ado-script/src/executor-e2e/ado-rest.ts index 3e1614a95..cef32ad21 100644 --- a/scripts/ado-script/src/executor-e2e/ado-rest.ts +++ b/scripts/ado-script/src/executor-e2e/ado-rest.ts @@ -570,6 +570,11 @@ export class AdoRest { const path = this.projPath( `_apis/git/repositories/${AdoRest.seg(repo)}/pullRequests/${prId}?api-version=7.1`, ); + const pr = await this.request<{ status: string }>(path, { allow404: true }); + if (!pr || pr.status === "abandoned") return; + if (pr.status !== "active") { + throw new Error(`Cannot clean up PR ${prId}: unexpected status '${pr.status}'`); + } await this.request(path, { method: "PATCH", body: { status: "abandoned" }, allow404: true }); } diff --git a/scripts/ado-script/src/executor-e2e/scenarios/pr.ts b/scripts/ado-script/src/executor-e2e/scenarios/pr.ts index 96c07c966..eb7fae30a 100644 --- a/scripts/ado-script/src/executor-e2e/scenarios/pr.ts +++ b/scripts/ado-script/src/executor-e2e/scenarios/pr.ts @@ -84,6 +84,21 @@ async function teardownPr(ctx: ScenarioContext, state: PrState): Promise { .run(); } +async function setupLabeledPr(ctx: ScenarioContext, id: string): Promise { + const state = await setupPr(ctx, id, false); + try { + await ctx.rest.setPullRequestLabels(state.repo, state.prId, ["existing-label"]); + const seeded = await ctx.rest.listPullRequestLabels(state.repo, state.prId); + if (!seeded.some((label) => label.name === "existing-label")) { + throw new Error(`Label setup did not persist existing-label: ${JSON.stringify(seeded)}`); + } + } catch (error) { + await teardownPr(ctx, state); + throw error; + } + return state; +} + export const addPrComment: Scenario = { tool: "add-pull-request-comment", targetsAdoRepo: true, @@ -383,20 +398,7 @@ export const addPrLabels: Scenario = { tool: "add-pull-request-labels", targetsAdoRepo: true, config: (ctx) => ({ "allowed-repositories": [ctx.adoRepo] }), - setup: async (ctx) => { - const state = await setupPr(ctx, "add-pull-request-labels", false); - try { - await ctx.rest.setPullRequestLabels(state.repo, state.prId, ["existing-label"]); - const seeded = await ctx.rest.listPullRequestLabels(state.repo, state.prId); - if (!seeded.some((label) => label.name === "existing-label")) { - throw new Error(`Label setup did not persist existing-label: ${JSON.stringify(seeded)}`); - } - } catch (error) { - await teardownPr(ctx, state); - throw error; - } - return state; - }, + setup: (ctx) => setupLabeledPr(ctx, "add-pull-request-labels"), ndjson: async (ctx, state) => ({ pull_request_id: state.prId, repository: ctx.adoRepo, labels: ["new-label"], }), @@ -465,6 +467,32 @@ export const setPrAutoComplete: Scenario = { }, }; +const requiredLabelScenarios: Scenario[] = [updatePullRequest, abandonPullRequest] + .map((scenario) => ({ + ...scenario, + id: `${scenario.tool}-required-labels`, + config: (ctx, state) => ({ + ...scenario.config(ctx, state), + "required-labels": ["existing-label"], + }), + setup: (ctx) => setupLabeledPr(ctx, `${scenario.tool}-required-labels`), + })); + +const updatePullRequestDeniedRepository: Scenario = { + ...updatePullRequest, + id: "update-pull-request-denied-repository", + config: () => ({ target: "*", "allowed-repositories": ["not-selected"] }), + setup: (ctx) => setupPr(ctx, "update-pull-request-denied-repository", false), + expectedFailure: { error: /allowed-repositories/ }, + assertFailure: async (ctx, state) => { + const pr = await ctx.rest.getPullRequest(state.repo, state.prId); + const id = "update-pull-request-denied-repository"; + if (pr.description !== detBody(ctx, id) || pr.title !== `${ctx.prefix(id)} (do not merge)`) { + throw new Error("Denied repository request changed the disposable PR"); + } + }, +}; + export const prScenarios: Scenario[] = [ addPrComment, replyToPrComment, @@ -472,6 +500,8 @@ export const prScenarios: Scenario[] = [ submitPrReview, updatePullRequest, abandonPullRequest, + ...requiredLabelScenarios, + updatePullRequestDeniedRepository, updatePullRequestIsland, updatePullRequestOversized, updatePullRequestUnicode, diff --git a/src/execute.rs b/src/execute.rs index fd91826a4..a18eda1d4 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -1296,6 +1296,201 @@ mod tests { .unwrap(); } + #[tokio::test] + async fn required_pr_labels_use_the_authoritative_list_and_fail_closed() { + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{method, path}, + }; + for tool in ["update-pull-request", "abandon-pull-request"] { + for (status, response, allowed) in [ + (200, r#"{"value":[{"name":"REQUIRED"}]}"#, true), + (200, r#"{"value":[]}"#, false), + (200, r#"{"count":0}"#, false), + (200, r#"{"value":[{}]}"#, false), + (200, "{", false), + (401, "denied", false), + (403, "denied", false), + (500, "unavailable", false), + ] { + let server = MockServer::start().await; + let pr_path = "/P/_apis/git/repositories/repo/pullRequests/7"; + Mock::given(method("GET")) + .and(path(pr_path)) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "pullRequestId":7, "status":"active", "title":"Example", "description":"Original", + "labels":[{"name":"required"}] + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path(format!("{pr_path}/labels"))) + .respond_with(ResponseTemplate::new(status).set_body_string(response)) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PATCH")) + .and(path(pr_path)) + .respond_with(ResponseTemplate::new(200)) + .expect(u64::from(allowed)) + .mount(&server) + .await; + let ctx = ExecutionContext { + ado_org_url: Some(server.uri()), + ado_organization: Some("org".into()), + ado_project: Some("P".into()), + repository_name: Some("repo".into()), + access_token: Some("token".into()), + tool_configs: HashMap::from([( + tool.into(), + serde_json::json!({ + "target":"*","required-labels":["required"],"include-stats":false + }), + )]), + ..Default::default() + }; + let mut entry = serde_json::json!({"name":tool,"pull_request_id":7}); + if tool == "update-pull-request" { + entry["body"] = "Updated".into(); + } + let (_, result) = execute_safe_output(&entry, &ctx).await.unwrap(); + assert_eq!(result.success, allowed, "{tool}: {}", result.message); + if !allowed { + assert!(result.message.contains("label"), "{}", result.message); + } + assert_eq!( + server.received_requests().await.unwrap().len(), + if allowed { 3 } else { 2 } + ); + } + } + } + + #[tokio::test] + async fn review_write_failure_does_not_post_a_rationale_or_report_success() { + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{body_json, method, path}, + }; + for (vote_status, comment_status) in [(403, 200), (500, 200), (200, 500)] { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/_apis/connectiondata")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "authenticatedUser":{"id":"actor"} + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("PUT")) + .and(path("/P/_apis/git/repositories/repo/pullRequests/7/reviewers/actor")) + .and(body_json(serde_json::json!({"vote": 0}))) + .respond_with(ResponseTemplate::new(vote_status)) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/P/_apis/git/repositories/repo/pullRequests/7/threads")) + .respond_with(ResponseTemplate::new(comment_status)) + .expect(u64::from(vote_status == 200)) + .mount(&server) + .await; + let ctx = ExecutionContext { + ado_org_url: Some(server.uri()), + ado_organization: Some("org".into()), + ado_project: Some("P".into()), + repository_name: Some("repo".into()), + access_token: Some("token".into()), + tool_configs: HashMap::from([( + "submit-pull-request-review".into(), + serde_json::json!({"allowed-events":["comment"]}), + )]), + ..Default::default() + }; + let (_, result) = execute_safe_output( + &serde_json::json!({ + "name":"submit-pull-request-review","pull_request_id":7,"event":"comment", + "body":"A review rationale." + }), + &ctx, + ) + .await + .unwrap(); + assert!(!result.success); + assert!(result.message.contains(if vote_status == 200 { + "comment" + } else { + "vote" + })); + let requests = server.received_requests().await.unwrap(); + assert_eq!(requests.len(), if vote_status == 200 { 3 } else { 2 }); + assert_eq!(requests[0].method.as_str(), "GET"); + assert_eq!(requests[1].method.as_str(), "PUT"); + if vote_status == 200 { + assert_eq!(requests[2].method.as_str(), "POST"); + assert!(result.message.starts_with("Vote submitted but failed")); + } + } + } + + #[tokio::test] + async fn label_batch_connection_loss_retains_the_successful_first_write() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let uri = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + for index in 0..2 { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut buffer = [0; 8192]; + let read = socket.read(&mut buffer).await.unwrap(); + assert!(String::from_utf8_lossy(&buffer[..read]).starts_with("POST ")); + if index == 0 { + socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}", + ) + .await + .unwrap(); + } + } + }); + let ctx = ExecutionContext { + ado_org_url: Some(uri), + ado_organization: Some("org".into()), + ado_project: Some("P".into()), + repository_name: Some("repo".into()), + access_token: Some("token".into()), + tool_configs: HashMap::from([( + "add-pull-request-labels".into(), + serde_json::json!({}), + )]), + ..Default::default() + }; + let (_, result) = execute_safe_output( + &serde_json::json!({ + "name":"add-pull-request-labels","pull_request_id":7,"labels":["first","second"] + }), + &ctx, + ) + .await + .unwrap(); + assert!(result.success); + assert!(result.message.contains("1 failed")); + let data = result.data.unwrap(); + assert_eq!(data["added"], serde_json::json!(["first"])); + assert!( + data["failed"][0] + .as_str() + .unwrap() + .contains("second (request error)") + ); + tokio::time::timeout(std::time::Duration::from_secs(5), server) + .await + .unwrap() + .unwrap(); + } + #[tokio::test] async fn invalid_legacy_review_metadata_cannot_reset_a_vote() { let server = wiremock::MockServer::start().await; diff --git a/src/safe_outputs/abandon_pull_request.rs b/src/safe_outputs/abandon_pull_request.rs index 73f541788..d90ed29ab 100644 --- a/src/safe_outputs/abandon_pull_request.rs +++ b/src/safe_outputs/abandon_pull_request.rs @@ -6,7 +6,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use super::pr_common::{ - PrTargetPolicy, PullRequestReference, repository_api_base, resolve_pr_policy_target, + PrTargetPolicy, PullRequestReference, fetch_pr_labels, repository_api_base, resolve_pr_policy_target, validate_reference, }; use crate::safe_outputs::{ExecutionContext, ExecutionResult, Executor, Validate}; @@ -237,16 +237,6 @@ impl AbandonPullRequestConfig { } } -fn pr_labels(pr: &serde_json::Value) -> Vec { - pr.get("labels") - .and_then(|labels| labels.as_array()) - .into_iter() - .flatten() - .filter_map(|label| label.get("name").and_then(|name| name.as_str())) - .map(ToOwned::to_owned) - .collect() -} - impl AbandonPullRequestResult { fn repository_selector<'a>(&'a self, config: &'a AbandonPullRequestConfig) -> Option<&'a str> { self.repository.as_deref().or(config.target_repo.as_deref()) @@ -255,6 +245,7 @@ impl AbandonPullRequestResult { fn validate_filters( &self, pr: &serde_json::Value, + labels: &[String], config: &AbandonPullRequestConfig, ) -> Result<(), ExecutionResult> { if let Some(prefix) = config.required_title_prefix.as_deref() { @@ -268,7 +259,6 @@ impl AbandonPullRequestResult { } if !config.required_labels.is_empty() { - let labels = pr_labels(pr); let missing: Vec<&str> = config .required_labels .iter() @@ -455,7 +445,15 @@ impl Executor for AbandonPullRequestResult { Ok(pr) => pr, Err(result) => return Ok(result), }; - if let Err(result) = self.validate_filters(&pr, &config) { + let labels = if config.required_labels.is_empty() { + Vec::new() + } else { + match fetch_pr_labels(&client, &base_url, pull_request_id, token, ctx).await? { + Ok(labels) => labels, + Err(result) => return Ok(result), + } + }; + if let Err(result) = self.validate_filters(&pr, &labels, &config) { return Ok(result); } @@ -603,8 +601,7 @@ mod tests { serde_json::json!({ "pullRequestId": 7, "title": "[bot] stale PR", - "status": status, - "labels": [{"name": "automated"}, {"name": "stale"}] + "status": status }) } @@ -837,12 +834,18 @@ mod tests { "abandon-pull-request", serde_json::json!({"target": "*", "required-labels": ["missing"]}), ); + Mock::given(method("GET")) + .and(path("/Other/_apis/git/repositories/repo-id/pullRequests/4294967296/labels")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"value": []}))) + .expect(1) + .mount(&server) + .await; let mut result: AbandonPullRequestResult = serde_json::from_value(serde_json::json!({ "name": "abandon-pull-request", "pull_request_id": "#aw_pr123" })) .unwrap(); assert!(!result.execute_sanitized(&ctx).await.unwrap().success); - assert_eq!(server.received_requests().await.unwrap().len(), 1); + assert_eq!(server.received_requests().await.unwrap().len(), 2); } #[test] @@ -963,6 +966,14 @@ mod tests { "required-title-prefix": "[bot]" }), ); + Mock::given(method("GET")) + .and(path("/proj/_apis/git/repositories/repo/pullRequests/7/labels")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "count": 2, "value": [{"name": "automated"}, {"name": "stale"}] + }))) + .expect(1) + .mount(&server) + .await; let mut result: AbandonPullRequestResult = AbandonPullRequestParams { pull_request_id: Some(PullRequestReference::Number(7)), body: Some("Closing as stale.".to_string()), @@ -1023,6 +1034,12 @@ mod tests { #[tokio::test] async fn missing_label_rejects_before_patch() { let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/proj/_apis/git/repositories/repo/pullRequests/7/labels")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"value": []}))) + .expect(1) + .mount(&server) + .await; Mock::given(method("GET")) .and(path("/proj/_apis/git/repositories/repo/pullRequests/7")) .and(query_param("api-version", "7.1")) diff --git a/src/safe_outputs/pr_common.rs b/src/safe_outputs/pr_common.rs index edc125763..ac056d280 100644 --- a/src/safe_outputs/pr_common.rs +++ b/src/safe_outputs/pr_common.rs @@ -16,6 +16,46 @@ use crate::secure::PullRequestTemporaryId; pub(crate) const MAX_DESCRIPTION_UTF16: usize = 4_000; +pub(crate) async fn fetch_pr_labels( + client: &reqwest::Client, + base_url: &str, + pr_id: u64, + token: &str, + ctx: &ExecutionContext, +) -> anyhow::Result, ExecutionResult>> { + #[derive(Deserialize)] + struct Label { + name: String, + } + #[derive(Deserialize)] + struct Labels { + value: Vec