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/.github/workflows/pr-data-prefetch.yml b/.github/workflows/pr-data-prefetch.yml index f03a407d3..007a800a9 100644 --- a/.github/workflows/pr-data-prefetch.yml +++ b/.github/workflows/pr-data-prefetch.yml @@ -55,10 +55,21 @@ jobs: set -euo pipefail mkdir -p /tmp/gh-aw/agent + gh pr view "$PR_NUMBER" \ + --repo "$TARGET_REPOSITORY" \ + --json number,title,body,headRefName,headRefOid,baseRefOid,additions,deletions,changedFiles,files \ + > /tmp/gh-aw/agent/pr-meta.json + HEAD_SHA=$(jq -er '.headRefOid' /tmp/gh-aw/agent/pr-meta.json) + BASE_SHA=$(jq -er '.baseRefOid' /tmp/gh-aw/agent/pr-meta.json) + if [ "$HEAD_SHA" != "$PR_HEAD_SHA" ]; then + echo "::error::PR head changed before prefetch; refusing to cache mismatched data." + exit 1 + fi + # Exclusions must stay in sync with # .github/workflows/shared/pr-diff-data-fetch.md — these are all # generated artefacts, and line-commenting on them is pure noise. - gh pr diff "$PR_NUMBER" --repo "$TARGET_REPOSITORY" \ + if ! gh pr diff "$PR_NUMBER" --repo "$TARGET_REPOSITORY" \ --exclude '**/*.lock.yml' \ --exclude 'scripts/ado-script/*.js' \ --exclude 'scripts/ado-script/test-bin/**' \ @@ -66,14 +77,31 @@ jobs: --exclude '**/*.gen.json' \ --exclude '**/dist/**' \ --exclude 'Cargo.lock' \ + > /tmp/gh-aw/agent/pr-diff.patch 2>/tmp/gh-aw/agent/pr-diff-error.txt; then + if ! grep -q 'diff exceeded the maximum number of lines' /tmp/gh-aw/agent/pr-diff-error.txt; then + cat /tmp/gh-aw/agent/pr-diff-error.txt >&2 + exit 1 + fi + [[ "$HEAD_SHA" =~ ^[a-fA-F0-9]{40}$ && "$BASE_SHA" =~ ^[a-fA-F0-9]{40}$ ]] + echo "::warning::PR exceeds GitHub's diff API limit; generating the full filtered diff from pinned Git objects." + OBJECTS=$(mktemp -d) + trap 'rm -rf -- "$OBJECTS"' EXIT + git init --bare --quiet "$OBJECTS" + AUTH=$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 -w0) + GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0="http.${GITHUB_SERVER_URL}/.extraheader" \ + GIT_CONFIG_VALUE_0="Authorization: Basic $AUTH" \ + git --git-dir="$OBJECTS" -c credential.helper= -c http.followRedirects=false \ + fetch --quiet --no-tags "${GITHUB_SERVER_URL}/${TARGET_REPOSITORY}.git" "$BASE_SHA" "$HEAD_SHA" + unset AUTH + git --git-dir="$OBJECTS" -c core.quotePath=false diff --no-ext-diff --no-textconv "$BASE_SHA...$HEAD_SHA" -- \ + . ':(glob,exclude)**/*.lock.yml' ':(glob,exclude)scripts/ado-script/*.js' \ + ':(glob,exclude)scripts/ado-script/test-bin/**' ':(glob,exclude)**/*.gen.ts' \ + ':(glob,exclude)**/*.gen.json' ':(glob,exclude)**/dist/**' ':(exclude)Cargo.lock' \ > /tmp/gh-aw/agent/pr-diff.patch + fi + rm -f /tmp/gh-aw/agent/pr-diff-error.txt LINES=$(wc -l < /tmp/gh-aw/agent/pr-diff.patch) - gh pr view "$PR_NUMBER" \ - --repo "$TARGET_REPOSITORY" \ - --json number,title,body,headRefName,headRefOid,additions,deletions,changedFiles,files \ - > /tmp/gh-aw/agent/pr-meta.json - gh api "repos/$TARGET_REPOSITORY/pulls/$PR_NUMBER/comments" \ --paginate \ --jq '.[] | {id, path, line: (.line // .original_line), body: .body[:200], user: .user.login}' \ diff --git a/.github/workflows/pr-sous-chef.lock.yml b/.github/workflows/pr-sous-chef.lock.yml index f1ce12c41..869c657c1 100644 --- a/.github/workflows/pr-sous-chef.lock.yml +++ b/.github/workflows/pr-sous-chef.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"d4e6e641206e227ad9c8c71f06a4f8d75b46814949bf0464d0bc3fb84cfc2706","body_hash":"0b760609f27236f11ded4123610e522e8a73558533e14609a69bf0d176abdd4f","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.79"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"d4e6e641206e227ad9c8c71f06a4f8d75b46814949bf0464d0bc3fb84cfc2706","body_hash":"9ef33b96fa59668efaa2968299078fe4e9f4440297c414d5f99064c6942281b8","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.79"}} # gh-aw-manifest: {"version":1,"secrets":["GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"6aab9e5b5c91c615506061f09bedd81a23babe3c","version":"v0.86.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.9","digest":"sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.9.0","digest":"sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e","pinned_image":"ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e"}]} # This file was automatically generated by gh-aw (v0.86.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # diff --git a/.github/workflows/pr-sous-chef.md b/.github/workflows/pr-sous-chef.md index 333c6595e..1d86e7404 100644 --- a/.github/workflows/pr-sous-chef.md +++ b/.github/workflows/pr-sous-chef.md @@ -269,7 +269,9 @@ a manual invocation is an acknowledgement, not a licence to clean up reviews. - the rest by most recent `updatedAt`. Break ties by lower PR number, so reruns behave deterministically. 6. Use the `pr-processor` sub-agent for each PR, passing only the PR number and - its compact entry. + its compact entry. Do not specify a model or model alias when launching the + sub-agent. Omit the model parameter so it inherits the parent/runtime model + selection. 7. If `pr-processor` returns non-JSON or errors, record `{pr_number: N, skip_reason: "sub_agent_error"}` in the report and move on. Do not retry. @@ -368,7 +370,6 @@ recommendations visible; wrap verbose detail in ## agent: `pr-processor` --- description: Decides skip/nudge actions for a single pull request using a minimal number of API calls -model: small --- You are given one PR number and its compact metadata. Decide what should happen to it, using as few tool calls as possible. diff --git a/.github/workflows/review-compiler-contract.lock.yml b/.github/workflows/review-compiler-contract.lock.yml index 89f512da1..aaa9d36e0 100644 --- a/.github/workflows/review-compiler-contract.lock.yml +++ b/.github/workflows/review-compiler-contract.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"9552eb645da378369a7ae1c4f3993bf1af7c04da4b953d09152b21befbc29238","body_hash":"7761a9c125a7340975dbeea0f4332c00ac32460b2f16be2fd34899e3ef487be1","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.79"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"23d35ec620ecba28546ce8ca62aa452fa4637121e208a02554b7901839f48b35","body_hash":"17ca43eed4732be462f336a0769d730c615022343b91153c26f8b00a3096ff25","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.79"}} # gh-aw-manifest: {"version":1,"secrets":["GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"6aab9e5b5c91c615506061f09bedd81a23babe3c","version":"v0.86.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.9","digest":"sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.9.0","digest":"sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e","pinned_image":"ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e"}],"has_pull_request":true} # This file was automatically generated by gh-aw (v0.86.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -569,7 +569,7 @@ jobs: PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) || '' }} name: Pre-fetch PR diff, metadata and review comments - run: "set -euo pipefail\nmkdir -p /tmp/gh-aw/agent\n\nif [ -z \"${PR_NUMBER:-}\" ]; then\n # The centralized router always dispatches on the PR head ref, so the\n # branch identifies the PR even when the context payload does not.\n BRANCH=\"${GITHUB_HEAD_REF:-${GITHUB_REF_NAME:-}}\"\n if [ -n \"$BRANCH\" ]; then\n PR_NUMBER=$(gh pr list --repo \"$EXPR_GITHUB_REPOSITORY\" --head \"$BRANCH\" \\\n --state open --limit 1 --json number --jq '.[0].number // empty' 2>/dev/null || true)\n if [ -n \"$PR_NUMBER\" ]; then\n echo \"::warning::PR number missing from the event payload; resolved #${PR_NUMBER} from branch ${BRANCH}.\"\n fi\n fi\nfi\n\nif [ -z \"${PR_NUMBER:-}\" ]; then\n # Every consumer of this component is a PR reviewer, so an unresolved PR\n # number is always a bug — most likely the routing context changed shape.\n # Fail loudly: silently writing empty files makes the reviewer report a\n # successful \"nothing to review\" run and hides the breakage.\n echo \"::error::Could not resolve a PR number from the event payload, the aw_context input or the checked-out branch.\" >&2\n echo \"event_name=${GITHUB_EVENT_NAME:-unknown} ref_name=${GITHUB_REF_NAME:-unknown}\" >&2\n exit 1\nfi\n\nCURRENT_HEAD_SHA=\"${PR_HEAD_SHA:-}\"\nif [ -z \"$CURRENT_HEAD_SHA\" ]; then\n CURRENT_HEAD_SHA=$(gh pr view \"$PR_NUMBER\" --repo \"$EXPR_GITHUB_REPOSITORY\" --json headRefOid --jq '.headRefOid' 2>/dev/null || true)\nfi\n\nCACHE_HEAD_SHA=\"\"\nif [ -f /tmp/gh-aw/agent/pr-data-head-sha.txt ]; then\n CACHE_HEAD_SHA=\"$(tr -d '\\n' < /tmp/gh-aw/agent/pr-data-head-sha.txt)\"\nfi\n\n# Only trust the cache when it was written for this exact head commit.\nif [ -n \"$CURRENT_HEAD_SHA\" ] && [ \"$CURRENT_HEAD_SHA\" = \"$CACHE_HEAD_SHA\" ] &&\n [ -f /tmp/gh-aw/agent/pr-diff.patch ] &&\n [ -f /tmp/gh-aw/agent/pr-meta.json ] &&\n [ -f /tmp/gh-aw/agent/pr-review-comments.json ]; then\n LINES=$(wc -l < /tmp/gh-aw/agent/pr-diff.patch)\n COMMENT_COUNT=$(jq 'length' /tmp/gh-aw/agent/pr-review-comments.json)\n echo \"Cache hit: reusing pre-fetched PR data for head ${CURRENT_HEAD_SHA} (${LINES} diff lines, ${COMMENT_COUNT} review comments)\"\n exit 0\nfi\n\ngh pr diff \"$PR_NUMBER\" --repo \"$EXPR_GITHUB_REPOSITORY\" \\\n --exclude '**/*.lock.yml' \\\n --exclude 'scripts/ado-script/*.js' \\\n --exclude 'scripts/ado-script/test-bin/**' \\\n --exclude '**/*.gen.ts' \\\n --exclude '**/*.gen.json' \\\n --exclude '**/dist/**' \\\n --exclude 'Cargo.lock' \\\n > /tmp/gh-aw/agent/pr-diff.patch\nLINES=$(wc -l < /tmp/gh-aw/agent/pr-diff.patch)\n\ngh pr view \"$PR_NUMBER\" \\\n --repo \"$EXPR_GITHUB_REPOSITORY\" \\\n --json number,title,body,headRefName,headRefOid,additions,deletions,changedFiles,files \\\n > /tmp/gh-aw/agent/pr-meta.json\n\nif [ -z \"$CURRENT_HEAD_SHA\" ]; then\n CURRENT_HEAD_SHA=\"$(jq -r '.headRefOid // empty' /tmp/gh-aw/agent/pr-meta.json)\"\nfi\n\ngh api \"repos/$EXPR_GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments\" \\\n --paginate \\\n --jq '.[] | {id, path, line: (.line // .original_line), body: .body[:200], user: .user.login}' \\\n 2>/dev/null | jq -s '.' > /tmp/gh-aw/agent/pr-review-comments.json ||\n echo '[]' > /tmp/gh-aw/agent/pr-review-comments.json\n\nif [ -n \"$CURRENT_HEAD_SHA\" ]; then\n printf '%s\\n' \"$CURRENT_HEAD_SHA\" > /tmp/gh-aw/agent/pr-data-head-sha.txt\nelse\n rm -f /tmp/gh-aw/agent/pr-data-head-sha.txt\nfi\n\nCOMMENT_COUNT=$(jq 'length' /tmp/gh-aw/agent/pr-review-comments.json)\necho \"Pre-fetched PR diff (${LINES} lines), metadata and ${COMMENT_COUNT} existing review comments for head ${CURRENT_HEAD_SHA:-unknown}\"\n" + run: "set -euo pipefail\nmkdir -p /tmp/gh-aw/agent\n\nif [ -z \"${PR_NUMBER:-}\" ]; then\n # The centralized router always dispatches on the PR head ref, so the\n # branch identifies the PR even when the context payload does not.\n BRANCH=\"${GITHUB_HEAD_REF:-${GITHUB_REF_NAME:-}}\"\n if [ -n \"$BRANCH\" ]; then\n PR_NUMBER=$(gh pr list --repo \"$EXPR_GITHUB_REPOSITORY\" --head \"$BRANCH\" \\\n --state open --limit 1 --json number --jq '.[0].number // empty' 2>/dev/null || true)\n if [ -n \"$PR_NUMBER\" ]; then\n echo \"::warning::PR number missing from the event payload; resolved #${PR_NUMBER} from branch ${BRANCH}.\"\n fi\n fi\nfi\n\nif [ -z \"${PR_NUMBER:-}\" ]; then\n # Every consumer of this component is a PR reviewer, so an unresolved PR\n # number is always a bug — most likely the routing context changed shape.\n # Fail loudly: silently writing empty files makes the reviewer report a\n # successful \"nothing to review\" run and hides the breakage.\n echo \"::error::Could not resolve a PR number from the event payload, the aw_context input or the checked-out branch.\" >&2\n echo \"event_name=${GITHUB_EVENT_NAME:-unknown} ref_name=${GITHUB_REF_NAME:-unknown}\" >&2\n exit 1\nfi\n\nCURRENT_HEAD_SHA=\"${PR_HEAD_SHA:-}\"\nif [ -z \"$CURRENT_HEAD_SHA\" ]; then\n CURRENT_HEAD_SHA=$(gh pr view \"$PR_NUMBER\" --repo \"$EXPR_GITHUB_REPOSITORY\" --json headRefOid --jq '.headRefOid' 2>/dev/null || true)\nfi\n\nCACHE_HEAD_SHA=\"\"\nif [ -f /tmp/gh-aw/agent/pr-data-head-sha.txt ]; then\n CACHE_HEAD_SHA=\"$(tr -d '\\n' < /tmp/gh-aw/agent/pr-data-head-sha.txt)\"\nfi\n\n# Only trust the cache when it was written for this exact head commit.\nif [ -n \"$CURRENT_HEAD_SHA\" ] && [ \"$CURRENT_HEAD_SHA\" = \"$CACHE_HEAD_SHA\" ] &&\n [ -f /tmp/gh-aw/agent/pr-diff.patch ] &&\n [ -f /tmp/gh-aw/agent/pr-meta.json ] &&\n [ -f /tmp/gh-aw/agent/pr-review-comments.json ]; then\n LINES=$(wc -l < /tmp/gh-aw/agent/pr-diff.patch)\n COMMENT_COUNT=$(jq 'length' /tmp/gh-aw/agent/pr-review-comments.json)\n echo \"Cache hit: reusing pre-fetched PR data for head ${CURRENT_HEAD_SHA} (${LINES} diff lines, ${COMMENT_COUNT} review comments)\"\n exit 0\nfi\n\ngh pr view \"$PR_NUMBER\" \\\n --repo \"$EXPR_GITHUB_REPOSITORY\" \\\n --json number,title,body,headRefName,headRefOid,baseRefOid,additions,deletions,changedFiles,files \\\n > /tmp/gh-aw/agent/pr-meta.json\nHEAD_SHA=$(jq -er '.headRefOid' /tmp/gh-aw/agent/pr-meta.json)\nBASE_SHA=$(jq -er '.baseRefOid' /tmp/gh-aw/agent/pr-meta.json)\nif [ -n \"$CURRENT_HEAD_SHA\" ] && [ \"$HEAD_SHA\" != \"$CURRENT_HEAD_SHA\" ]; then\n echo \"::error::PR head changed before prefetch; refusing to cache mismatched data.\"\n exit 1\nfi\nCURRENT_HEAD_SHA=\"$HEAD_SHA\"\n\nif ! gh pr diff \"$PR_NUMBER\" --repo \"$EXPR_GITHUB_REPOSITORY\" \\\n --exclude '**/*.lock.yml' \\\n --exclude 'scripts/ado-script/*.js' \\\n --exclude 'scripts/ado-script/test-bin/**' \\\n --exclude '**/*.gen.ts' \\\n --exclude '**/*.gen.json' \\\n --exclude '**/dist/**' \\\n --exclude 'Cargo.lock' \\\n > /tmp/gh-aw/agent/pr-diff.patch 2>/tmp/gh-aw/agent/pr-diff-error.txt; then\n if ! grep -q 'diff exceeded the maximum number of lines' /tmp/gh-aw/agent/pr-diff-error.txt; then\n cat /tmp/gh-aw/agent/pr-diff-error.txt >&2\n exit 1\n fi\n [[ \"$HEAD_SHA\" =~ ^[a-fA-F0-9]{40}$ && \"$BASE_SHA\" =~ ^[a-fA-F0-9]{40}$ ]]\n echo \"::warning::PR exceeds GitHub's diff API limit; generating the full filtered diff from pinned Git objects.\"\n OBJECTS=$(mktemp -d)\n trap 'rm -rf -- \"$OBJECTS\"' EXIT\n git init --bare --quiet \"$OBJECTS\"\n AUTH=$(printf 'x-access-token:%s' \"$GH_TOKEN\" | base64 -w0)\n GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=\"http.${GITHUB_SERVER_URL}/.extraheader\" \\\n GIT_CONFIG_VALUE_0=\"Authorization: Basic $AUTH\" \\\n git --git-dir=\"$OBJECTS\" -c credential.helper= -c http.followRedirects=false \\\n fetch --quiet --no-tags \"${GITHUB_SERVER_URL}/${EXPR_GITHUB_REPOSITORY}.git\" \"$BASE_SHA\" \"$HEAD_SHA\"\n unset AUTH\n git --git-dir=\"$OBJECTS\" -c core.quotePath=false diff --no-ext-diff --no-textconv \"$BASE_SHA...$HEAD_SHA\" -- \\\n . ':(glob,exclude)**/*.lock.yml' ':(glob,exclude)scripts/ado-script/*.js' \\\n ':(glob,exclude)scripts/ado-script/test-bin/**' ':(glob,exclude)**/*.gen.ts' \\\n ':(glob,exclude)**/*.gen.json' ':(glob,exclude)**/dist/**' ':(exclude)Cargo.lock' \\\n > /tmp/gh-aw/agent/pr-diff.patch\nfi\nrm -f /tmp/gh-aw/agent/pr-diff-error.txt\nLINES=$(wc -l < /tmp/gh-aw/agent/pr-diff.patch)\n\ngh api \"repos/$EXPR_GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments\" \\\n --paginate \\\n --jq '.[] | {id, path, line: (.line // .original_line), body: .body[:200], user: .user.login}' \\\n 2>/dev/null | jq -s '.' > /tmp/gh-aw/agent/pr-review-comments.json ||\n echo '[]' > /tmp/gh-aw/agent/pr-review-comments.json\n\nif [ -n \"$CURRENT_HEAD_SHA\" ]; then\n printf '%s\\n' \"$CURRENT_HEAD_SHA\" > /tmp/gh-aw/agent/pr-data-head-sha.txt\nelse\n rm -f /tmp/gh-aw/agent/pr-data-head-sha.txt\nfi\n\nCOMMENT_COUNT=$(jq 'length' /tmp/gh-aw/agent/pr-review-comments.json)\necho \"Pre-fetched PR diff (${LINES} lines), metadata and ${COMMENT_COUNT} existing review comments for head ${CURRENT_HEAD_SHA:-unknown}\"\n" - name: Download container images run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e diff --git a/.github/workflows/review-rust.lock.yml b/.github/workflows/review-rust.lock.yml index 1feaf1ebd..a943c806b 100644 --- a/.github/workflows/review-rust.lock.yml +++ b/.github/workflows/review-rust.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"ea67889e811c8c0a7ac2e9a1b512ef32f072734d71026f82eae0a8489198f59f","body_hash":"c6aacd86f655324be0dfa10d467221cdb5bf4e14431f715b874744d2ee0304c5","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.79"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"13b00dc8ac12ba140565a063c795c01920f434775757ee7808661ef7a8643f0f","body_hash":"b169b6bc78801442bdfafba020fcb86d044cd04aab40b0a9fc2f34231b65c9db","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.79"}} # gh-aw-manifest: {"version":1,"secrets":["GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"6aab9e5b5c91c615506061f09bedd81a23babe3c","version":"v0.86.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.9","digest":"sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.9.0","digest":"sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e","pinned_image":"ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e"}],"has_pull_request":true} # This file was automatically generated by gh-aw (v0.86.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -565,7 +565,7 @@ jobs: PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) || '' }} name: Pre-fetch PR diff, metadata and review comments - run: "set -euo pipefail\nmkdir -p /tmp/gh-aw/agent\n\nif [ -z \"${PR_NUMBER:-}\" ]; then\n # The centralized router always dispatches on the PR head ref, so the\n # branch identifies the PR even when the context payload does not.\n BRANCH=\"${GITHUB_HEAD_REF:-${GITHUB_REF_NAME:-}}\"\n if [ -n \"$BRANCH\" ]; then\n PR_NUMBER=$(gh pr list --repo \"$EXPR_GITHUB_REPOSITORY\" --head \"$BRANCH\" \\\n --state open --limit 1 --json number --jq '.[0].number // empty' 2>/dev/null || true)\n if [ -n \"$PR_NUMBER\" ]; then\n echo \"::warning::PR number missing from the event payload; resolved #${PR_NUMBER} from branch ${BRANCH}.\"\n fi\n fi\nfi\n\nif [ -z \"${PR_NUMBER:-}\" ]; then\n # Every consumer of this component is a PR reviewer, so an unresolved PR\n # number is always a bug — most likely the routing context changed shape.\n # Fail loudly: silently writing empty files makes the reviewer report a\n # successful \"nothing to review\" run and hides the breakage.\n echo \"::error::Could not resolve a PR number from the event payload, the aw_context input or the checked-out branch.\" >&2\n echo \"event_name=${GITHUB_EVENT_NAME:-unknown} ref_name=${GITHUB_REF_NAME:-unknown}\" >&2\n exit 1\nfi\n\nCURRENT_HEAD_SHA=\"${PR_HEAD_SHA:-}\"\nif [ -z \"$CURRENT_HEAD_SHA\" ]; then\n CURRENT_HEAD_SHA=$(gh pr view \"$PR_NUMBER\" --repo \"$EXPR_GITHUB_REPOSITORY\" --json headRefOid --jq '.headRefOid' 2>/dev/null || true)\nfi\n\nCACHE_HEAD_SHA=\"\"\nif [ -f /tmp/gh-aw/agent/pr-data-head-sha.txt ]; then\n CACHE_HEAD_SHA=\"$(tr -d '\\n' < /tmp/gh-aw/agent/pr-data-head-sha.txt)\"\nfi\n\n# Only trust the cache when it was written for this exact head commit.\nif [ -n \"$CURRENT_HEAD_SHA\" ] && [ \"$CURRENT_HEAD_SHA\" = \"$CACHE_HEAD_SHA\" ] &&\n [ -f /tmp/gh-aw/agent/pr-diff.patch ] &&\n [ -f /tmp/gh-aw/agent/pr-meta.json ] &&\n [ -f /tmp/gh-aw/agent/pr-review-comments.json ]; then\n LINES=$(wc -l < /tmp/gh-aw/agent/pr-diff.patch)\n COMMENT_COUNT=$(jq 'length' /tmp/gh-aw/agent/pr-review-comments.json)\n echo \"Cache hit: reusing pre-fetched PR data for head ${CURRENT_HEAD_SHA} (${LINES} diff lines, ${COMMENT_COUNT} review comments)\"\n exit 0\nfi\n\ngh pr diff \"$PR_NUMBER\" --repo \"$EXPR_GITHUB_REPOSITORY\" \\\n --exclude '**/*.lock.yml' \\\n --exclude 'scripts/ado-script/*.js' \\\n --exclude 'scripts/ado-script/test-bin/**' \\\n --exclude '**/*.gen.ts' \\\n --exclude '**/*.gen.json' \\\n --exclude '**/dist/**' \\\n --exclude 'Cargo.lock' \\\n > /tmp/gh-aw/agent/pr-diff.patch\nLINES=$(wc -l < /tmp/gh-aw/agent/pr-diff.patch)\n\ngh pr view \"$PR_NUMBER\" \\\n --repo \"$EXPR_GITHUB_REPOSITORY\" \\\n --json number,title,body,headRefName,headRefOid,additions,deletions,changedFiles,files \\\n > /tmp/gh-aw/agent/pr-meta.json\n\nif [ -z \"$CURRENT_HEAD_SHA\" ]; then\n CURRENT_HEAD_SHA=\"$(jq -r '.headRefOid // empty' /tmp/gh-aw/agent/pr-meta.json)\"\nfi\n\ngh api \"repos/$EXPR_GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments\" \\\n --paginate \\\n --jq '.[] | {id, path, line: (.line // .original_line), body: .body[:200], user: .user.login}' \\\n 2>/dev/null | jq -s '.' > /tmp/gh-aw/agent/pr-review-comments.json ||\n echo '[]' > /tmp/gh-aw/agent/pr-review-comments.json\n\nif [ -n \"$CURRENT_HEAD_SHA\" ]; then\n printf '%s\\n' \"$CURRENT_HEAD_SHA\" > /tmp/gh-aw/agent/pr-data-head-sha.txt\nelse\n rm -f /tmp/gh-aw/agent/pr-data-head-sha.txt\nfi\n\nCOMMENT_COUNT=$(jq 'length' /tmp/gh-aw/agent/pr-review-comments.json)\necho \"Pre-fetched PR diff (${LINES} lines), metadata and ${COMMENT_COUNT} existing review comments for head ${CURRENT_HEAD_SHA:-unknown}\"\n" + run: "set -euo pipefail\nmkdir -p /tmp/gh-aw/agent\n\nif [ -z \"${PR_NUMBER:-}\" ]; then\n # The centralized router always dispatches on the PR head ref, so the\n # branch identifies the PR even when the context payload does not.\n BRANCH=\"${GITHUB_HEAD_REF:-${GITHUB_REF_NAME:-}}\"\n if [ -n \"$BRANCH\" ]; then\n PR_NUMBER=$(gh pr list --repo \"$EXPR_GITHUB_REPOSITORY\" --head \"$BRANCH\" \\\n --state open --limit 1 --json number --jq '.[0].number // empty' 2>/dev/null || true)\n if [ -n \"$PR_NUMBER\" ]; then\n echo \"::warning::PR number missing from the event payload; resolved #${PR_NUMBER} from branch ${BRANCH}.\"\n fi\n fi\nfi\n\nif [ -z \"${PR_NUMBER:-}\" ]; then\n # Every consumer of this component is a PR reviewer, so an unresolved PR\n # number is always a bug — most likely the routing context changed shape.\n # Fail loudly: silently writing empty files makes the reviewer report a\n # successful \"nothing to review\" run and hides the breakage.\n echo \"::error::Could not resolve a PR number from the event payload, the aw_context input or the checked-out branch.\" >&2\n echo \"event_name=${GITHUB_EVENT_NAME:-unknown} ref_name=${GITHUB_REF_NAME:-unknown}\" >&2\n exit 1\nfi\n\nCURRENT_HEAD_SHA=\"${PR_HEAD_SHA:-}\"\nif [ -z \"$CURRENT_HEAD_SHA\" ]; then\n CURRENT_HEAD_SHA=$(gh pr view \"$PR_NUMBER\" --repo \"$EXPR_GITHUB_REPOSITORY\" --json headRefOid --jq '.headRefOid' 2>/dev/null || true)\nfi\n\nCACHE_HEAD_SHA=\"\"\nif [ -f /tmp/gh-aw/agent/pr-data-head-sha.txt ]; then\n CACHE_HEAD_SHA=\"$(tr -d '\\n' < /tmp/gh-aw/agent/pr-data-head-sha.txt)\"\nfi\n\n# Only trust the cache when it was written for this exact head commit.\nif [ -n \"$CURRENT_HEAD_SHA\" ] && [ \"$CURRENT_HEAD_SHA\" = \"$CACHE_HEAD_SHA\" ] &&\n [ -f /tmp/gh-aw/agent/pr-diff.patch ] &&\n [ -f /tmp/gh-aw/agent/pr-meta.json ] &&\n [ -f /tmp/gh-aw/agent/pr-review-comments.json ]; then\n LINES=$(wc -l < /tmp/gh-aw/agent/pr-diff.patch)\n COMMENT_COUNT=$(jq 'length' /tmp/gh-aw/agent/pr-review-comments.json)\n echo \"Cache hit: reusing pre-fetched PR data for head ${CURRENT_HEAD_SHA} (${LINES} diff lines, ${COMMENT_COUNT} review comments)\"\n exit 0\nfi\n\ngh pr view \"$PR_NUMBER\" \\\n --repo \"$EXPR_GITHUB_REPOSITORY\" \\\n --json number,title,body,headRefName,headRefOid,baseRefOid,additions,deletions,changedFiles,files \\\n > /tmp/gh-aw/agent/pr-meta.json\nHEAD_SHA=$(jq -er '.headRefOid' /tmp/gh-aw/agent/pr-meta.json)\nBASE_SHA=$(jq -er '.baseRefOid' /tmp/gh-aw/agent/pr-meta.json)\nif [ -n \"$CURRENT_HEAD_SHA\" ] && [ \"$HEAD_SHA\" != \"$CURRENT_HEAD_SHA\" ]; then\n echo \"::error::PR head changed before prefetch; refusing to cache mismatched data.\"\n exit 1\nfi\nCURRENT_HEAD_SHA=\"$HEAD_SHA\"\n\nif ! gh pr diff \"$PR_NUMBER\" --repo \"$EXPR_GITHUB_REPOSITORY\" \\\n --exclude '**/*.lock.yml' \\\n --exclude 'scripts/ado-script/*.js' \\\n --exclude 'scripts/ado-script/test-bin/**' \\\n --exclude '**/*.gen.ts' \\\n --exclude '**/*.gen.json' \\\n --exclude '**/dist/**' \\\n --exclude 'Cargo.lock' \\\n > /tmp/gh-aw/agent/pr-diff.patch 2>/tmp/gh-aw/agent/pr-diff-error.txt; then\n if ! grep -q 'diff exceeded the maximum number of lines' /tmp/gh-aw/agent/pr-diff-error.txt; then\n cat /tmp/gh-aw/agent/pr-diff-error.txt >&2\n exit 1\n fi\n [[ \"$HEAD_SHA\" =~ ^[a-fA-F0-9]{40}$ && \"$BASE_SHA\" =~ ^[a-fA-F0-9]{40}$ ]]\n echo \"::warning::PR exceeds GitHub's diff API limit; generating the full filtered diff from pinned Git objects.\"\n OBJECTS=$(mktemp -d)\n trap 'rm -rf -- \"$OBJECTS\"' EXIT\n git init --bare --quiet \"$OBJECTS\"\n AUTH=$(printf 'x-access-token:%s' \"$GH_TOKEN\" | base64 -w0)\n GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=\"http.${GITHUB_SERVER_URL}/.extraheader\" \\\n GIT_CONFIG_VALUE_0=\"Authorization: Basic $AUTH\" \\\n git --git-dir=\"$OBJECTS\" -c credential.helper= -c http.followRedirects=false \\\n fetch --quiet --no-tags \"${GITHUB_SERVER_URL}/${EXPR_GITHUB_REPOSITORY}.git\" \"$BASE_SHA\" \"$HEAD_SHA\"\n unset AUTH\n git --git-dir=\"$OBJECTS\" -c core.quotePath=false diff --no-ext-diff --no-textconv \"$BASE_SHA...$HEAD_SHA\" -- \\\n . ':(glob,exclude)**/*.lock.yml' ':(glob,exclude)scripts/ado-script/*.js' \\\n ':(glob,exclude)scripts/ado-script/test-bin/**' ':(glob,exclude)**/*.gen.ts' \\\n ':(glob,exclude)**/*.gen.json' ':(glob,exclude)**/dist/**' ':(exclude)Cargo.lock' \\\n > /tmp/gh-aw/agent/pr-diff.patch\nfi\nrm -f /tmp/gh-aw/agent/pr-diff-error.txt\nLINES=$(wc -l < /tmp/gh-aw/agent/pr-diff.patch)\n\ngh api \"repos/$EXPR_GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments\" \\\n --paginate \\\n --jq '.[] | {id, path, line: (.line // .original_line), body: .body[:200], user: .user.login}' \\\n 2>/dev/null | jq -s '.' > /tmp/gh-aw/agent/pr-review-comments.json ||\n echo '[]' > /tmp/gh-aw/agent/pr-review-comments.json\n\nif [ -n \"$CURRENT_HEAD_SHA\" ]; then\n printf '%s\\n' \"$CURRENT_HEAD_SHA\" > /tmp/gh-aw/agent/pr-data-head-sha.txt\nelse\n rm -f /tmp/gh-aw/agent/pr-data-head-sha.txt\nfi\n\nCOMMENT_COUNT=$(jq 'length' /tmp/gh-aw/agent/pr-review-comments.json)\necho \"Pre-fetched PR diff (${LINES} lines), metadata and ${COMMENT_COUNT} existing review comments for head ${CURRENT_HEAD_SHA:-unknown}\"\n" - name: Download container images run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e diff --git a/.github/workflows/review-rust.md b/.github/workflows/review-rust.md index 136182b1b..9b5c85205 100644 --- a/.github/workflows/review-rust.md +++ b/.github/workflows/review-rust.md @@ -78,6 +78,8 @@ Sub-agent contract: - Start `rust-critic` exactly once, immediately, and let it work while you do your own pass in Step 2. +- Do not specify a model or model alias when launching the sub-agent. Omit the + model parameter so it inherits the parent/runtime model selection. - It must return strict JSONL, one finding per line. - Collect its output before Step 3, and **wait for it** rather than polling: make a single blocking read that waits for the sub-agent to finish. Only give up @@ -169,7 +171,6 @@ and the themes in a `
` block. ## agent: `rust-critic` --- description: Hostile first-pass Rust reviewer that mines merge-blocking defects from changed lines -model: small --- You are a hostile senior Rust reviewer performing a first-pass audit. diff --git a/.github/workflows/review-security.lock.yml b/.github/workflows/review-security.lock.yml index 6f863e9b3..7d344fd73 100644 --- a/.github/workflows/review-security.lock.yml +++ b/.github/workflows/review-security.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"cd31cda3ad2065892b108b11e95047c7ef479edd0eb2c1be01ba86a00b952d7d","body_hash":"8df1eaeca304430bfc19c7bf3214a8b2a62b9022fd6bbbacb20227f1e97a01d5","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.79"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"2710e081b829b10926038fdcc2b709396f4ad971f1890de85a65c41c1ef2447c","body_hash":"8df1eaeca304430bfc19c7bf3214a8b2a62b9022fd6bbbacb20227f1e97a01d5","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.79"}} # gh-aw-manifest: {"version":1,"secrets":["GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"6aab9e5b5c91c615506061f09bedd81a23babe3c","version":"v0.86.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.9","digest":"sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.9.0","digest":"sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e","pinned_image":"ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e"}],"has_pull_request":true} # This file was automatically generated by gh-aw (v0.86.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -567,7 +567,7 @@ jobs: PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) || '' }} name: Pre-fetch PR diff, metadata and review comments - run: "set -euo pipefail\nmkdir -p /tmp/gh-aw/agent\n\nif [ -z \"${PR_NUMBER:-}\" ]; then\n # The centralized router always dispatches on the PR head ref, so the\n # branch identifies the PR even when the context payload does not.\n BRANCH=\"${GITHUB_HEAD_REF:-${GITHUB_REF_NAME:-}}\"\n if [ -n \"$BRANCH\" ]; then\n PR_NUMBER=$(gh pr list --repo \"$EXPR_GITHUB_REPOSITORY\" --head \"$BRANCH\" \\\n --state open --limit 1 --json number --jq '.[0].number // empty' 2>/dev/null || true)\n if [ -n \"$PR_NUMBER\" ]; then\n echo \"::warning::PR number missing from the event payload; resolved #${PR_NUMBER} from branch ${BRANCH}.\"\n fi\n fi\nfi\n\nif [ -z \"${PR_NUMBER:-}\" ]; then\n # Every consumer of this component is a PR reviewer, so an unresolved PR\n # number is always a bug — most likely the routing context changed shape.\n # Fail loudly: silently writing empty files makes the reviewer report a\n # successful \"nothing to review\" run and hides the breakage.\n echo \"::error::Could not resolve a PR number from the event payload, the aw_context input or the checked-out branch.\" >&2\n echo \"event_name=${GITHUB_EVENT_NAME:-unknown} ref_name=${GITHUB_REF_NAME:-unknown}\" >&2\n exit 1\nfi\n\nCURRENT_HEAD_SHA=\"${PR_HEAD_SHA:-}\"\nif [ -z \"$CURRENT_HEAD_SHA\" ]; then\n CURRENT_HEAD_SHA=$(gh pr view \"$PR_NUMBER\" --repo \"$EXPR_GITHUB_REPOSITORY\" --json headRefOid --jq '.headRefOid' 2>/dev/null || true)\nfi\n\nCACHE_HEAD_SHA=\"\"\nif [ -f /tmp/gh-aw/agent/pr-data-head-sha.txt ]; then\n CACHE_HEAD_SHA=\"$(tr -d '\\n' < /tmp/gh-aw/agent/pr-data-head-sha.txt)\"\nfi\n\n# Only trust the cache when it was written for this exact head commit.\nif [ -n \"$CURRENT_HEAD_SHA\" ] && [ \"$CURRENT_HEAD_SHA\" = \"$CACHE_HEAD_SHA\" ] &&\n [ -f /tmp/gh-aw/agent/pr-diff.patch ] &&\n [ -f /tmp/gh-aw/agent/pr-meta.json ] &&\n [ -f /tmp/gh-aw/agent/pr-review-comments.json ]; then\n LINES=$(wc -l < /tmp/gh-aw/agent/pr-diff.patch)\n COMMENT_COUNT=$(jq 'length' /tmp/gh-aw/agent/pr-review-comments.json)\n echo \"Cache hit: reusing pre-fetched PR data for head ${CURRENT_HEAD_SHA} (${LINES} diff lines, ${COMMENT_COUNT} review comments)\"\n exit 0\nfi\n\ngh pr diff \"$PR_NUMBER\" --repo \"$EXPR_GITHUB_REPOSITORY\" \\\n --exclude '**/*.lock.yml' \\\n --exclude 'scripts/ado-script/*.js' \\\n --exclude 'scripts/ado-script/test-bin/**' \\\n --exclude '**/*.gen.ts' \\\n --exclude '**/*.gen.json' \\\n --exclude '**/dist/**' \\\n --exclude 'Cargo.lock' \\\n > /tmp/gh-aw/agent/pr-diff.patch\nLINES=$(wc -l < /tmp/gh-aw/agent/pr-diff.patch)\n\ngh pr view \"$PR_NUMBER\" \\\n --repo \"$EXPR_GITHUB_REPOSITORY\" \\\n --json number,title,body,headRefName,headRefOid,additions,deletions,changedFiles,files \\\n > /tmp/gh-aw/agent/pr-meta.json\n\nif [ -z \"$CURRENT_HEAD_SHA\" ]; then\n CURRENT_HEAD_SHA=\"$(jq -r '.headRefOid // empty' /tmp/gh-aw/agent/pr-meta.json)\"\nfi\n\ngh api \"repos/$EXPR_GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments\" \\\n --paginate \\\n --jq '.[] | {id, path, line: (.line // .original_line), body: .body[:200], user: .user.login}' \\\n 2>/dev/null | jq -s '.' > /tmp/gh-aw/agent/pr-review-comments.json ||\n echo '[]' > /tmp/gh-aw/agent/pr-review-comments.json\n\nif [ -n \"$CURRENT_HEAD_SHA\" ]; then\n printf '%s\\n' \"$CURRENT_HEAD_SHA\" > /tmp/gh-aw/agent/pr-data-head-sha.txt\nelse\n rm -f /tmp/gh-aw/agent/pr-data-head-sha.txt\nfi\n\nCOMMENT_COUNT=$(jq 'length' /tmp/gh-aw/agent/pr-review-comments.json)\necho \"Pre-fetched PR diff (${LINES} lines), metadata and ${COMMENT_COUNT} existing review comments for head ${CURRENT_HEAD_SHA:-unknown}\"\n" + run: "set -euo pipefail\nmkdir -p /tmp/gh-aw/agent\n\nif [ -z \"${PR_NUMBER:-}\" ]; then\n # The centralized router always dispatches on the PR head ref, so the\n # branch identifies the PR even when the context payload does not.\n BRANCH=\"${GITHUB_HEAD_REF:-${GITHUB_REF_NAME:-}}\"\n if [ -n \"$BRANCH\" ]; then\n PR_NUMBER=$(gh pr list --repo \"$EXPR_GITHUB_REPOSITORY\" --head \"$BRANCH\" \\\n --state open --limit 1 --json number --jq '.[0].number // empty' 2>/dev/null || true)\n if [ -n \"$PR_NUMBER\" ]; then\n echo \"::warning::PR number missing from the event payload; resolved #${PR_NUMBER} from branch ${BRANCH}.\"\n fi\n fi\nfi\n\nif [ -z \"${PR_NUMBER:-}\" ]; then\n # Every consumer of this component is a PR reviewer, so an unresolved PR\n # number is always a bug — most likely the routing context changed shape.\n # Fail loudly: silently writing empty files makes the reviewer report a\n # successful \"nothing to review\" run and hides the breakage.\n echo \"::error::Could not resolve a PR number from the event payload, the aw_context input or the checked-out branch.\" >&2\n echo \"event_name=${GITHUB_EVENT_NAME:-unknown} ref_name=${GITHUB_REF_NAME:-unknown}\" >&2\n exit 1\nfi\n\nCURRENT_HEAD_SHA=\"${PR_HEAD_SHA:-}\"\nif [ -z \"$CURRENT_HEAD_SHA\" ]; then\n CURRENT_HEAD_SHA=$(gh pr view \"$PR_NUMBER\" --repo \"$EXPR_GITHUB_REPOSITORY\" --json headRefOid --jq '.headRefOid' 2>/dev/null || true)\nfi\n\nCACHE_HEAD_SHA=\"\"\nif [ -f /tmp/gh-aw/agent/pr-data-head-sha.txt ]; then\n CACHE_HEAD_SHA=\"$(tr -d '\\n' < /tmp/gh-aw/agent/pr-data-head-sha.txt)\"\nfi\n\n# Only trust the cache when it was written for this exact head commit.\nif [ -n \"$CURRENT_HEAD_SHA\" ] && [ \"$CURRENT_HEAD_SHA\" = \"$CACHE_HEAD_SHA\" ] &&\n [ -f /tmp/gh-aw/agent/pr-diff.patch ] &&\n [ -f /tmp/gh-aw/agent/pr-meta.json ] &&\n [ -f /tmp/gh-aw/agent/pr-review-comments.json ]; then\n LINES=$(wc -l < /tmp/gh-aw/agent/pr-diff.patch)\n COMMENT_COUNT=$(jq 'length' /tmp/gh-aw/agent/pr-review-comments.json)\n echo \"Cache hit: reusing pre-fetched PR data for head ${CURRENT_HEAD_SHA} (${LINES} diff lines, ${COMMENT_COUNT} review comments)\"\n exit 0\nfi\n\ngh pr view \"$PR_NUMBER\" \\\n --repo \"$EXPR_GITHUB_REPOSITORY\" \\\n --json number,title,body,headRefName,headRefOid,baseRefOid,additions,deletions,changedFiles,files \\\n > /tmp/gh-aw/agent/pr-meta.json\nHEAD_SHA=$(jq -er '.headRefOid' /tmp/gh-aw/agent/pr-meta.json)\nBASE_SHA=$(jq -er '.baseRefOid' /tmp/gh-aw/agent/pr-meta.json)\nif [ -n \"$CURRENT_HEAD_SHA\" ] && [ \"$HEAD_SHA\" != \"$CURRENT_HEAD_SHA\" ]; then\n echo \"::error::PR head changed before prefetch; refusing to cache mismatched data.\"\n exit 1\nfi\nCURRENT_HEAD_SHA=\"$HEAD_SHA\"\n\nif ! gh pr diff \"$PR_NUMBER\" --repo \"$EXPR_GITHUB_REPOSITORY\" \\\n --exclude '**/*.lock.yml' \\\n --exclude 'scripts/ado-script/*.js' \\\n --exclude 'scripts/ado-script/test-bin/**' \\\n --exclude '**/*.gen.ts' \\\n --exclude '**/*.gen.json' \\\n --exclude '**/dist/**' \\\n --exclude 'Cargo.lock' \\\n > /tmp/gh-aw/agent/pr-diff.patch 2>/tmp/gh-aw/agent/pr-diff-error.txt; then\n if ! grep -q 'diff exceeded the maximum number of lines' /tmp/gh-aw/agent/pr-diff-error.txt; then\n cat /tmp/gh-aw/agent/pr-diff-error.txt >&2\n exit 1\n fi\n [[ \"$HEAD_SHA\" =~ ^[a-fA-F0-9]{40}$ && \"$BASE_SHA\" =~ ^[a-fA-F0-9]{40}$ ]]\n echo \"::warning::PR exceeds GitHub's diff API limit; generating the full filtered diff from pinned Git objects.\"\n OBJECTS=$(mktemp -d)\n trap 'rm -rf -- \"$OBJECTS\"' EXIT\n git init --bare --quiet \"$OBJECTS\"\n AUTH=$(printf 'x-access-token:%s' \"$GH_TOKEN\" | base64 -w0)\n GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=\"http.${GITHUB_SERVER_URL}/.extraheader\" \\\n GIT_CONFIG_VALUE_0=\"Authorization: Basic $AUTH\" \\\n git --git-dir=\"$OBJECTS\" -c credential.helper= -c http.followRedirects=false \\\n fetch --quiet --no-tags \"${GITHUB_SERVER_URL}/${EXPR_GITHUB_REPOSITORY}.git\" \"$BASE_SHA\" \"$HEAD_SHA\"\n unset AUTH\n git --git-dir=\"$OBJECTS\" -c core.quotePath=false diff --no-ext-diff --no-textconv \"$BASE_SHA...$HEAD_SHA\" -- \\\n . ':(glob,exclude)**/*.lock.yml' ':(glob,exclude)scripts/ado-script/*.js' \\\n ':(glob,exclude)scripts/ado-script/test-bin/**' ':(glob,exclude)**/*.gen.ts' \\\n ':(glob,exclude)**/*.gen.json' ':(glob,exclude)**/dist/**' ':(exclude)Cargo.lock' \\\n > /tmp/gh-aw/agent/pr-diff.patch\nfi\nrm -f /tmp/gh-aw/agent/pr-diff-error.txt\nLINES=$(wc -l < /tmp/gh-aw/agent/pr-diff.patch)\n\ngh api \"repos/$EXPR_GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments\" \\\n --paginate \\\n --jq '.[] | {id, path, line: (.line // .original_line), body: .body[:200], user: .user.login}' \\\n 2>/dev/null | jq -s '.' > /tmp/gh-aw/agent/pr-review-comments.json ||\n echo '[]' > /tmp/gh-aw/agent/pr-review-comments.json\n\nif [ -n \"$CURRENT_HEAD_SHA\" ]; then\n printf '%s\\n' \"$CURRENT_HEAD_SHA\" > /tmp/gh-aw/agent/pr-data-head-sha.txt\nelse\n rm -f /tmp/gh-aw/agent/pr-data-head-sha.txt\nfi\n\nCOMMENT_COUNT=$(jq 'length' /tmp/gh-aw/agent/pr-review-comments.json)\necho \"Pre-fetched PR diff (${LINES} lines), metadata and ${COMMENT_COUNT} existing review comments for head ${CURRENT_HEAD_SHA:-unknown}\"\n" - name: Download container images run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e diff --git a/.github/workflows/review-tests.lock.yml b/.github/workflows/review-tests.lock.yml index f59d32606..ad332ff29 100644 --- a/.github/workflows/review-tests.lock.yml +++ b/.github/workflows/review-tests.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"589fb68aff7b171c98461847b5510d765500cb898a62e39be2654e4fb77d824b","body_hash":"0afe6eb8e888f42950031e3fb22064325bc152c22209afff66b7a81d944a15a9","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.79"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"deb447a72d4a5d79b101c82b8ed02338de347dffddf8221beded05350b36a9b4","body_hash":"0afe6eb8e888f42950031e3fb22064325bc152c22209afff66b7a81d944a15a9","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.79"}} # gh-aw-manifest: {"version":1,"secrets":["GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"6aab9e5b5c91c615506061f09bedd81a23babe3c","version":"v0.86.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.9","digest":"sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.9.0","digest":"sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e","pinned_image":"ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e"}],"has_pull_request":true} # This file was automatically generated by gh-aw (v0.86.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -565,7 +565,7 @@ jobs: PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) || '' }} name: Pre-fetch PR diff, metadata and review comments - run: "set -euo pipefail\nmkdir -p /tmp/gh-aw/agent\n\nif [ -z \"${PR_NUMBER:-}\" ]; then\n # The centralized router always dispatches on the PR head ref, so the\n # branch identifies the PR even when the context payload does not.\n BRANCH=\"${GITHUB_HEAD_REF:-${GITHUB_REF_NAME:-}}\"\n if [ -n \"$BRANCH\" ]; then\n PR_NUMBER=$(gh pr list --repo \"$EXPR_GITHUB_REPOSITORY\" --head \"$BRANCH\" \\\n --state open --limit 1 --json number --jq '.[0].number // empty' 2>/dev/null || true)\n if [ -n \"$PR_NUMBER\" ]; then\n echo \"::warning::PR number missing from the event payload; resolved #${PR_NUMBER} from branch ${BRANCH}.\"\n fi\n fi\nfi\n\nif [ -z \"${PR_NUMBER:-}\" ]; then\n # Every consumer of this component is a PR reviewer, so an unresolved PR\n # number is always a bug — most likely the routing context changed shape.\n # Fail loudly: silently writing empty files makes the reviewer report a\n # successful \"nothing to review\" run and hides the breakage.\n echo \"::error::Could not resolve a PR number from the event payload, the aw_context input or the checked-out branch.\" >&2\n echo \"event_name=${GITHUB_EVENT_NAME:-unknown} ref_name=${GITHUB_REF_NAME:-unknown}\" >&2\n exit 1\nfi\n\nCURRENT_HEAD_SHA=\"${PR_HEAD_SHA:-}\"\nif [ -z \"$CURRENT_HEAD_SHA\" ]; then\n CURRENT_HEAD_SHA=$(gh pr view \"$PR_NUMBER\" --repo \"$EXPR_GITHUB_REPOSITORY\" --json headRefOid --jq '.headRefOid' 2>/dev/null || true)\nfi\n\nCACHE_HEAD_SHA=\"\"\nif [ -f /tmp/gh-aw/agent/pr-data-head-sha.txt ]; then\n CACHE_HEAD_SHA=\"$(tr -d '\\n' < /tmp/gh-aw/agent/pr-data-head-sha.txt)\"\nfi\n\n# Only trust the cache when it was written for this exact head commit.\nif [ -n \"$CURRENT_HEAD_SHA\" ] && [ \"$CURRENT_HEAD_SHA\" = \"$CACHE_HEAD_SHA\" ] &&\n [ -f /tmp/gh-aw/agent/pr-diff.patch ] &&\n [ -f /tmp/gh-aw/agent/pr-meta.json ] &&\n [ -f /tmp/gh-aw/agent/pr-review-comments.json ]; then\n LINES=$(wc -l < /tmp/gh-aw/agent/pr-diff.patch)\n COMMENT_COUNT=$(jq 'length' /tmp/gh-aw/agent/pr-review-comments.json)\n echo \"Cache hit: reusing pre-fetched PR data for head ${CURRENT_HEAD_SHA} (${LINES} diff lines, ${COMMENT_COUNT} review comments)\"\n exit 0\nfi\n\ngh pr diff \"$PR_NUMBER\" --repo \"$EXPR_GITHUB_REPOSITORY\" \\\n --exclude '**/*.lock.yml' \\\n --exclude 'scripts/ado-script/*.js' \\\n --exclude 'scripts/ado-script/test-bin/**' \\\n --exclude '**/*.gen.ts' \\\n --exclude '**/*.gen.json' \\\n --exclude '**/dist/**' \\\n --exclude 'Cargo.lock' \\\n > /tmp/gh-aw/agent/pr-diff.patch\nLINES=$(wc -l < /tmp/gh-aw/agent/pr-diff.patch)\n\ngh pr view \"$PR_NUMBER\" \\\n --repo \"$EXPR_GITHUB_REPOSITORY\" \\\n --json number,title,body,headRefName,headRefOid,additions,deletions,changedFiles,files \\\n > /tmp/gh-aw/agent/pr-meta.json\n\nif [ -z \"$CURRENT_HEAD_SHA\" ]; then\n CURRENT_HEAD_SHA=\"$(jq -r '.headRefOid // empty' /tmp/gh-aw/agent/pr-meta.json)\"\nfi\n\ngh api \"repos/$EXPR_GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments\" \\\n --paginate \\\n --jq '.[] | {id, path, line: (.line // .original_line), body: .body[:200], user: .user.login}' \\\n 2>/dev/null | jq -s '.' > /tmp/gh-aw/agent/pr-review-comments.json ||\n echo '[]' > /tmp/gh-aw/agent/pr-review-comments.json\n\nif [ -n \"$CURRENT_HEAD_SHA\" ]; then\n printf '%s\\n' \"$CURRENT_HEAD_SHA\" > /tmp/gh-aw/agent/pr-data-head-sha.txt\nelse\n rm -f /tmp/gh-aw/agent/pr-data-head-sha.txt\nfi\n\nCOMMENT_COUNT=$(jq 'length' /tmp/gh-aw/agent/pr-review-comments.json)\necho \"Pre-fetched PR diff (${LINES} lines), metadata and ${COMMENT_COUNT} existing review comments for head ${CURRENT_HEAD_SHA:-unknown}\"\n" + run: "set -euo pipefail\nmkdir -p /tmp/gh-aw/agent\n\nif [ -z \"${PR_NUMBER:-}\" ]; then\n # The centralized router always dispatches on the PR head ref, so the\n # branch identifies the PR even when the context payload does not.\n BRANCH=\"${GITHUB_HEAD_REF:-${GITHUB_REF_NAME:-}}\"\n if [ -n \"$BRANCH\" ]; then\n PR_NUMBER=$(gh pr list --repo \"$EXPR_GITHUB_REPOSITORY\" --head \"$BRANCH\" \\\n --state open --limit 1 --json number --jq '.[0].number // empty' 2>/dev/null || true)\n if [ -n \"$PR_NUMBER\" ]; then\n echo \"::warning::PR number missing from the event payload; resolved #${PR_NUMBER} from branch ${BRANCH}.\"\n fi\n fi\nfi\n\nif [ -z \"${PR_NUMBER:-}\" ]; then\n # Every consumer of this component is a PR reviewer, so an unresolved PR\n # number is always a bug — most likely the routing context changed shape.\n # Fail loudly: silently writing empty files makes the reviewer report a\n # successful \"nothing to review\" run and hides the breakage.\n echo \"::error::Could not resolve a PR number from the event payload, the aw_context input or the checked-out branch.\" >&2\n echo \"event_name=${GITHUB_EVENT_NAME:-unknown} ref_name=${GITHUB_REF_NAME:-unknown}\" >&2\n exit 1\nfi\n\nCURRENT_HEAD_SHA=\"${PR_HEAD_SHA:-}\"\nif [ -z \"$CURRENT_HEAD_SHA\" ]; then\n CURRENT_HEAD_SHA=$(gh pr view \"$PR_NUMBER\" --repo \"$EXPR_GITHUB_REPOSITORY\" --json headRefOid --jq '.headRefOid' 2>/dev/null || true)\nfi\n\nCACHE_HEAD_SHA=\"\"\nif [ -f /tmp/gh-aw/agent/pr-data-head-sha.txt ]; then\n CACHE_HEAD_SHA=\"$(tr -d '\\n' < /tmp/gh-aw/agent/pr-data-head-sha.txt)\"\nfi\n\n# Only trust the cache when it was written for this exact head commit.\nif [ -n \"$CURRENT_HEAD_SHA\" ] && [ \"$CURRENT_HEAD_SHA\" = \"$CACHE_HEAD_SHA\" ] &&\n [ -f /tmp/gh-aw/agent/pr-diff.patch ] &&\n [ -f /tmp/gh-aw/agent/pr-meta.json ] &&\n [ -f /tmp/gh-aw/agent/pr-review-comments.json ]; then\n LINES=$(wc -l < /tmp/gh-aw/agent/pr-diff.patch)\n COMMENT_COUNT=$(jq 'length' /tmp/gh-aw/agent/pr-review-comments.json)\n echo \"Cache hit: reusing pre-fetched PR data for head ${CURRENT_HEAD_SHA} (${LINES} diff lines, ${COMMENT_COUNT} review comments)\"\n exit 0\nfi\n\ngh pr view \"$PR_NUMBER\" \\\n --repo \"$EXPR_GITHUB_REPOSITORY\" \\\n --json number,title,body,headRefName,headRefOid,baseRefOid,additions,deletions,changedFiles,files \\\n > /tmp/gh-aw/agent/pr-meta.json\nHEAD_SHA=$(jq -er '.headRefOid' /tmp/gh-aw/agent/pr-meta.json)\nBASE_SHA=$(jq -er '.baseRefOid' /tmp/gh-aw/agent/pr-meta.json)\nif [ -n \"$CURRENT_HEAD_SHA\" ] && [ \"$HEAD_SHA\" != \"$CURRENT_HEAD_SHA\" ]; then\n echo \"::error::PR head changed before prefetch; refusing to cache mismatched data.\"\n exit 1\nfi\nCURRENT_HEAD_SHA=\"$HEAD_SHA\"\n\nif ! gh pr diff \"$PR_NUMBER\" --repo \"$EXPR_GITHUB_REPOSITORY\" \\\n --exclude '**/*.lock.yml' \\\n --exclude 'scripts/ado-script/*.js' \\\n --exclude 'scripts/ado-script/test-bin/**' \\\n --exclude '**/*.gen.ts' \\\n --exclude '**/*.gen.json' \\\n --exclude '**/dist/**' \\\n --exclude 'Cargo.lock' \\\n > /tmp/gh-aw/agent/pr-diff.patch 2>/tmp/gh-aw/agent/pr-diff-error.txt; then\n if ! grep -q 'diff exceeded the maximum number of lines' /tmp/gh-aw/agent/pr-diff-error.txt; then\n cat /tmp/gh-aw/agent/pr-diff-error.txt >&2\n exit 1\n fi\n [[ \"$HEAD_SHA\" =~ ^[a-fA-F0-9]{40}$ && \"$BASE_SHA\" =~ ^[a-fA-F0-9]{40}$ ]]\n echo \"::warning::PR exceeds GitHub's diff API limit; generating the full filtered diff from pinned Git objects.\"\n OBJECTS=$(mktemp -d)\n trap 'rm -rf -- \"$OBJECTS\"' EXIT\n git init --bare --quiet \"$OBJECTS\"\n AUTH=$(printf 'x-access-token:%s' \"$GH_TOKEN\" | base64 -w0)\n GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=\"http.${GITHUB_SERVER_URL}/.extraheader\" \\\n GIT_CONFIG_VALUE_0=\"Authorization: Basic $AUTH\" \\\n git --git-dir=\"$OBJECTS\" -c credential.helper= -c http.followRedirects=false \\\n fetch --quiet --no-tags \"${GITHUB_SERVER_URL}/${EXPR_GITHUB_REPOSITORY}.git\" \"$BASE_SHA\" \"$HEAD_SHA\"\n unset AUTH\n git --git-dir=\"$OBJECTS\" -c core.quotePath=false diff --no-ext-diff --no-textconv \"$BASE_SHA...$HEAD_SHA\" -- \\\n . ':(glob,exclude)**/*.lock.yml' ':(glob,exclude)scripts/ado-script/*.js' \\\n ':(glob,exclude)scripts/ado-script/test-bin/**' ':(glob,exclude)**/*.gen.ts' \\\n ':(glob,exclude)**/*.gen.json' ':(glob,exclude)**/dist/**' ':(exclude)Cargo.lock' \\\n > /tmp/gh-aw/agent/pr-diff.patch\nfi\nrm -f /tmp/gh-aw/agent/pr-diff-error.txt\nLINES=$(wc -l < /tmp/gh-aw/agent/pr-diff.patch)\n\ngh api \"repos/$EXPR_GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments\" \\\n --paginate \\\n --jq '.[] | {id, path, line: (.line // .original_line), body: .body[:200], user: .user.login}' \\\n 2>/dev/null | jq -s '.' > /tmp/gh-aw/agent/pr-review-comments.json ||\n echo '[]' > /tmp/gh-aw/agent/pr-review-comments.json\n\nif [ -n \"$CURRENT_HEAD_SHA\" ]; then\n printf '%s\\n' \"$CURRENT_HEAD_SHA\" > /tmp/gh-aw/agent/pr-data-head-sha.txt\nelse\n rm -f /tmp/gh-aw/agent/pr-data-head-sha.txt\nfi\n\nCOMMENT_COUNT=$(jq 'length' /tmp/gh-aw/agent/pr-review-comments.json)\necho \"Pre-fetched PR diff (${LINES} lines), metadata and ${COMMENT_COUNT} existing review comments for head ${CURRENT_HEAD_SHA:-unknown}\"\n" - name: Download container images run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e diff --git a/.github/workflows/review-typescript.lock.yml b/.github/workflows/review-typescript.lock.yml index 5af4a1983..bd537a4e0 100644 --- a/.github/workflows/review-typescript.lock.yml +++ b/.github/workflows/review-typescript.lock.yml @@ -1,4 +1,4 @@ -# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"7259abb8515a4bb6dd1d124c1db92aa00b5e5357d4b61b78ae045ba045d425ac","body_hash":"684f375762c2d2dfb48458e8efe8760e29325adddb9edfdd9b22990ac7f3b8a7","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.79"}} +# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"58c6cba1d0370769d59ea2bec1c9d74028cc1bb7d3afa025abc1257e75f955ce","body_hash":"2ff1fa9dd8006f840285d6dab94f3642202923684f49deaa7bffb550af63052a","compiler_version":"v0.86.2","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.79"}} # gh-aw-manifest: {"version":1,"secrets":["GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/restore","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/cache/save","sha":"55cc8345863c7cc4c66a329aec7e433d2d1c52a9","version":"v6.1.0"},{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"6aab9e5b5c91c615506061f09bedd81a23babe3c","version":"v0.86.2"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44","digest":"sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44","digest":"sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44","digest":"sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.4.9","digest":"sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f"},{"image":"ghcr.io/github/gh-aw-node","digest":"sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196","pinned_image":"ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196"},{"image":"ghcr.io/github/github-mcp-server:v1.9.0","digest":"sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e","pinned_image":"ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e"}],"has_pull_request":true} # This file was automatically generated by gh-aw (v0.86.2). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md # @@ -566,7 +566,7 @@ jobs: PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }} PR_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) || '' }} name: Pre-fetch PR diff, metadata and review comments - run: "set -euo pipefail\nmkdir -p /tmp/gh-aw/agent\n\nif [ -z \"${PR_NUMBER:-}\" ]; then\n # The centralized router always dispatches on the PR head ref, so the\n # branch identifies the PR even when the context payload does not.\n BRANCH=\"${GITHUB_HEAD_REF:-${GITHUB_REF_NAME:-}}\"\n if [ -n \"$BRANCH\" ]; then\n PR_NUMBER=$(gh pr list --repo \"$EXPR_GITHUB_REPOSITORY\" --head \"$BRANCH\" \\\n --state open --limit 1 --json number --jq '.[0].number // empty' 2>/dev/null || true)\n if [ -n \"$PR_NUMBER\" ]; then\n echo \"::warning::PR number missing from the event payload; resolved #${PR_NUMBER} from branch ${BRANCH}.\"\n fi\n fi\nfi\n\nif [ -z \"${PR_NUMBER:-}\" ]; then\n # Every consumer of this component is a PR reviewer, so an unresolved PR\n # number is always a bug — most likely the routing context changed shape.\n # Fail loudly: silently writing empty files makes the reviewer report a\n # successful \"nothing to review\" run and hides the breakage.\n echo \"::error::Could not resolve a PR number from the event payload, the aw_context input or the checked-out branch.\" >&2\n echo \"event_name=${GITHUB_EVENT_NAME:-unknown} ref_name=${GITHUB_REF_NAME:-unknown}\" >&2\n exit 1\nfi\n\nCURRENT_HEAD_SHA=\"${PR_HEAD_SHA:-}\"\nif [ -z \"$CURRENT_HEAD_SHA\" ]; then\n CURRENT_HEAD_SHA=$(gh pr view \"$PR_NUMBER\" --repo \"$EXPR_GITHUB_REPOSITORY\" --json headRefOid --jq '.headRefOid' 2>/dev/null || true)\nfi\n\nCACHE_HEAD_SHA=\"\"\nif [ -f /tmp/gh-aw/agent/pr-data-head-sha.txt ]; then\n CACHE_HEAD_SHA=\"$(tr -d '\\n' < /tmp/gh-aw/agent/pr-data-head-sha.txt)\"\nfi\n\n# Only trust the cache when it was written for this exact head commit.\nif [ -n \"$CURRENT_HEAD_SHA\" ] && [ \"$CURRENT_HEAD_SHA\" = \"$CACHE_HEAD_SHA\" ] &&\n [ -f /tmp/gh-aw/agent/pr-diff.patch ] &&\n [ -f /tmp/gh-aw/agent/pr-meta.json ] &&\n [ -f /tmp/gh-aw/agent/pr-review-comments.json ]; then\n LINES=$(wc -l < /tmp/gh-aw/agent/pr-diff.patch)\n COMMENT_COUNT=$(jq 'length' /tmp/gh-aw/agent/pr-review-comments.json)\n echo \"Cache hit: reusing pre-fetched PR data for head ${CURRENT_HEAD_SHA} (${LINES} diff lines, ${COMMENT_COUNT} review comments)\"\n exit 0\nfi\n\ngh pr diff \"$PR_NUMBER\" --repo \"$EXPR_GITHUB_REPOSITORY\" \\\n --exclude '**/*.lock.yml' \\\n --exclude 'scripts/ado-script/*.js' \\\n --exclude 'scripts/ado-script/test-bin/**' \\\n --exclude '**/*.gen.ts' \\\n --exclude '**/*.gen.json' \\\n --exclude '**/dist/**' \\\n --exclude 'Cargo.lock' \\\n > /tmp/gh-aw/agent/pr-diff.patch\nLINES=$(wc -l < /tmp/gh-aw/agent/pr-diff.patch)\n\ngh pr view \"$PR_NUMBER\" \\\n --repo \"$EXPR_GITHUB_REPOSITORY\" \\\n --json number,title,body,headRefName,headRefOid,additions,deletions,changedFiles,files \\\n > /tmp/gh-aw/agent/pr-meta.json\n\nif [ -z \"$CURRENT_HEAD_SHA\" ]; then\n CURRENT_HEAD_SHA=\"$(jq -r '.headRefOid // empty' /tmp/gh-aw/agent/pr-meta.json)\"\nfi\n\ngh api \"repos/$EXPR_GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments\" \\\n --paginate \\\n --jq '.[] | {id, path, line: (.line // .original_line), body: .body[:200], user: .user.login}' \\\n 2>/dev/null | jq -s '.' > /tmp/gh-aw/agent/pr-review-comments.json ||\n echo '[]' > /tmp/gh-aw/agent/pr-review-comments.json\n\nif [ -n \"$CURRENT_HEAD_SHA\" ]; then\n printf '%s\\n' \"$CURRENT_HEAD_SHA\" > /tmp/gh-aw/agent/pr-data-head-sha.txt\nelse\n rm -f /tmp/gh-aw/agent/pr-data-head-sha.txt\nfi\n\nCOMMENT_COUNT=$(jq 'length' /tmp/gh-aw/agent/pr-review-comments.json)\necho \"Pre-fetched PR diff (${LINES} lines), metadata and ${COMMENT_COUNT} existing review comments for head ${CURRENT_HEAD_SHA:-unknown}\"\n" + run: "set -euo pipefail\nmkdir -p /tmp/gh-aw/agent\n\nif [ -z \"${PR_NUMBER:-}\" ]; then\n # The centralized router always dispatches on the PR head ref, so the\n # branch identifies the PR even when the context payload does not.\n BRANCH=\"${GITHUB_HEAD_REF:-${GITHUB_REF_NAME:-}}\"\n if [ -n \"$BRANCH\" ]; then\n PR_NUMBER=$(gh pr list --repo \"$EXPR_GITHUB_REPOSITORY\" --head \"$BRANCH\" \\\n --state open --limit 1 --json number --jq '.[0].number // empty' 2>/dev/null || true)\n if [ -n \"$PR_NUMBER\" ]; then\n echo \"::warning::PR number missing from the event payload; resolved #${PR_NUMBER} from branch ${BRANCH}.\"\n fi\n fi\nfi\n\nif [ -z \"${PR_NUMBER:-}\" ]; then\n # Every consumer of this component is a PR reviewer, so an unresolved PR\n # number is always a bug — most likely the routing context changed shape.\n # Fail loudly: silently writing empty files makes the reviewer report a\n # successful \"nothing to review\" run and hides the breakage.\n echo \"::error::Could not resolve a PR number from the event payload, the aw_context input or the checked-out branch.\" >&2\n echo \"event_name=${GITHUB_EVENT_NAME:-unknown} ref_name=${GITHUB_REF_NAME:-unknown}\" >&2\n exit 1\nfi\n\nCURRENT_HEAD_SHA=\"${PR_HEAD_SHA:-}\"\nif [ -z \"$CURRENT_HEAD_SHA\" ]; then\n CURRENT_HEAD_SHA=$(gh pr view \"$PR_NUMBER\" --repo \"$EXPR_GITHUB_REPOSITORY\" --json headRefOid --jq '.headRefOid' 2>/dev/null || true)\nfi\n\nCACHE_HEAD_SHA=\"\"\nif [ -f /tmp/gh-aw/agent/pr-data-head-sha.txt ]; then\n CACHE_HEAD_SHA=\"$(tr -d '\\n' < /tmp/gh-aw/agent/pr-data-head-sha.txt)\"\nfi\n\n# Only trust the cache when it was written for this exact head commit.\nif [ -n \"$CURRENT_HEAD_SHA\" ] && [ \"$CURRENT_HEAD_SHA\" = \"$CACHE_HEAD_SHA\" ] &&\n [ -f /tmp/gh-aw/agent/pr-diff.patch ] &&\n [ -f /tmp/gh-aw/agent/pr-meta.json ] &&\n [ -f /tmp/gh-aw/agent/pr-review-comments.json ]; then\n LINES=$(wc -l < /tmp/gh-aw/agent/pr-diff.patch)\n COMMENT_COUNT=$(jq 'length' /tmp/gh-aw/agent/pr-review-comments.json)\n echo \"Cache hit: reusing pre-fetched PR data for head ${CURRENT_HEAD_SHA} (${LINES} diff lines, ${COMMENT_COUNT} review comments)\"\n exit 0\nfi\n\ngh pr view \"$PR_NUMBER\" \\\n --repo \"$EXPR_GITHUB_REPOSITORY\" \\\n --json number,title,body,headRefName,headRefOid,baseRefOid,additions,deletions,changedFiles,files \\\n > /tmp/gh-aw/agent/pr-meta.json\nHEAD_SHA=$(jq -er '.headRefOid' /tmp/gh-aw/agent/pr-meta.json)\nBASE_SHA=$(jq -er '.baseRefOid' /tmp/gh-aw/agent/pr-meta.json)\nif [ -n \"$CURRENT_HEAD_SHA\" ] && [ \"$HEAD_SHA\" != \"$CURRENT_HEAD_SHA\" ]; then\n echo \"::error::PR head changed before prefetch; refusing to cache mismatched data.\"\n exit 1\nfi\nCURRENT_HEAD_SHA=\"$HEAD_SHA\"\n\nif ! gh pr diff \"$PR_NUMBER\" --repo \"$EXPR_GITHUB_REPOSITORY\" \\\n --exclude '**/*.lock.yml' \\\n --exclude 'scripts/ado-script/*.js' \\\n --exclude 'scripts/ado-script/test-bin/**' \\\n --exclude '**/*.gen.ts' \\\n --exclude '**/*.gen.json' \\\n --exclude '**/dist/**' \\\n --exclude 'Cargo.lock' \\\n > /tmp/gh-aw/agent/pr-diff.patch 2>/tmp/gh-aw/agent/pr-diff-error.txt; then\n if ! grep -q 'diff exceeded the maximum number of lines' /tmp/gh-aw/agent/pr-diff-error.txt; then\n cat /tmp/gh-aw/agent/pr-diff-error.txt >&2\n exit 1\n fi\n [[ \"$HEAD_SHA\" =~ ^[a-fA-F0-9]{40}$ && \"$BASE_SHA\" =~ ^[a-fA-F0-9]{40}$ ]]\n echo \"::warning::PR exceeds GitHub's diff API limit; generating the full filtered diff from pinned Git objects.\"\n OBJECTS=$(mktemp -d)\n trap 'rm -rf -- \"$OBJECTS\"' EXIT\n git init --bare --quiet \"$OBJECTS\"\n AUTH=$(printf 'x-access-token:%s' \"$GH_TOKEN\" | base64 -w0)\n GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=\"http.${GITHUB_SERVER_URL}/.extraheader\" \\\n GIT_CONFIG_VALUE_0=\"Authorization: Basic $AUTH\" \\\n git --git-dir=\"$OBJECTS\" -c credential.helper= -c http.followRedirects=false \\\n fetch --quiet --no-tags \"${GITHUB_SERVER_URL}/${EXPR_GITHUB_REPOSITORY}.git\" \"$BASE_SHA\" \"$HEAD_SHA\"\n unset AUTH\n git --git-dir=\"$OBJECTS\" -c core.quotePath=false diff --no-ext-diff --no-textconv \"$BASE_SHA...$HEAD_SHA\" -- \\\n . ':(glob,exclude)**/*.lock.yml' ':(glob,exclude)scripts/ado-script/*.js' \\\n ':(glob,exclude)scripts/ado-script/test-bin/**' ':(glob,exclude)**/*.gen.ts' \\\n ':(glob,exclude)**/*.gen.json' ':(glob,exclude)**/dist/**' ':(exclude)Cargo.lock' \\\n > /tmp/gh-aw/agent/pr-diff.patch\nfi\nrm -f /tmp/gh-aw/agent/pr-diff-error.txt\nLINES=$(wc -l < /tmp/gh-aw/agent/pr-diff.patch)\n\ngh api \"repos/$EXPR_GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments\" \\\n --paginate \\\n --jq '.[] | {id, path, line: (.line // .original_line), body: .body[:200], user: .user.login}' \\\n 2>/dev/null | jq -s '.' > /tmp/gh-aw/agent/pr-review-comments.json ||\n echo '[]' > /tmp/gh-aw/agent/pr-review-comments.json\n\nif [ -n \"$CURRENT_HEAD_SHA\" ]; then\n printf '%s\\n' \"$CURRENT_HEAD_SHA\" > /tmp/gh-aw/agent/pr-data-head-sha.txt\nelse\n rm -f /tmp/gh-aw/agent/pr-data-head-sha.txt\nfi\n\nCOMMENT_COUNT=$(jq 'length' /tmp/gh-aw/agent/pr-review-comments.json)\necho \"Pre-fetched PR diff (${LINES} lines), metadata and ${COMMENT_COUNT} existing review comments for head ${CURRENT_HEAD_SHA:-unknown}\"\n" - name: Download container images run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.44@sha256:0d727725c737b58c7bdf51f640cffb928385ec46517e0917c7f1a02f1bada8b4 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.44@sha256:b50fbadba138f6e9aba94aca09711335c489bb3b15861220cb66f6092e042dc7 ghcr.io/github/gh-aw-firewall/squid:0.27.44@sha256:83e48bbe12c634be8c228a576832fe45f66c529ac3659db92bddbcf2eeb6d627 ghcr.io/github/gh-aw-mcpg:v0.4.9@sha256:e5a1569aeaf41820fa7bdee3e94468cae448133cdbf00119ad24f5b74db1ab9f ghcr.io/github/gh-aw-node@sha256:0d9f1fb5fd6610c0ac1f5194a38e45a8a1e81f8a390d5142d8e4e6f26a4b3196 ghcr.io/github/github-mcp-server:v1.9.0@sha256:881b53d6f75f69bdbc1b5b10fc2f1361717c19054143b3a8529fb5c32061a50e diff --git a/.github/workflows/review-typescript.md b/.github/workflows/review-typescript.md index 4b99d2d16..2e44da87b 100644 --- a/.github/workflows/review-typescript.md +++ b/.github/workflows/review-typescript.md @@ -93,6 +93,8 @@ Sub-agent contract: - Start `ts-critic` exactly once, immediately, and let it work while you do your own pass in Step 2. +- Do not specify a model or model alias when launching the sub-agent. Omit the + model parameter so it inherits the parent/runtime model selection. - It must return strict JSONL, one finding per line. - Collect its output before Step 3, and **wait for it** rather than polling: make a single blocking read that waits for the sub-agent to finish. Only give up @@ -171,7 +173,6 @@ wrong output; otherwise `COMMENT`. ## agent: `ts-critic` --- description: Hostile first-pass TypeScript reviewer for bundled Azure DevOps runtime helpers -model: small --- You are a hostile senior TypeScript reviewer performing a first-pass audit of code that is bundled and executed on Azure DevOps build agents. diff --git a/.github/workflows/shared/pr-diff-data-fetch.md b/.github/workflows/shared/pr-diff-data-fetch.md index 0a5253e16..963fa6146 100644 --- a/.github/workflows/shared/pr-diff-data-fetch.md +++ b/.github/workflows/shared/pr-diff-data-fetch.md @@ -108,7 +108,19 @@ pre-agent-steps: exit 0 fi - gh pr diff "$PR_NUMBER" --repo "$EXPR_GITHUB_REPOSITORY" \ + gh pr view "$PR_NUMBER" \ + --repo "$EXPR_GITHUB_REPOSITORY" \ + --json number,title,body,headRefName,headRefOid,baseRefOid,additions,deletions,changedFiles,files \ + > /tmp/gh-aw/agent/pr-meta.json + HEAD_SHA=$(jq -er '.headRefOid' /tmp/gh-aw/agent/pr-meta.json) + BASE_SHA=$(jq -er '.baseRefOid' /tmp/gh-aw/agent/pr-meta.json) + if [ -n "$CURRENT_HEAD_SHA" ] && [ "$HEAD_SHA" != "$CURRENT_HEAD_SHA" ]; then + echo "::error::PR head changed before prefetch; refusing to cache mismatched data." + exit 1 + fi + CURRENT_HEAD_SHA="$HEAD_SHA" + + if ! gh pr diff "$PR_NUMBER" --repo "$EXPR_GITHUB_REPOSITORY" \ --exclude '**/*.lock.yml' \ --exclude 'scripts/ado-script/*.js' \ --exclude 'scripts/ado-script/test-bin/**' \ @@ -116,17 +128,30 @@ pre-agent-steps: --exclude '**/*.gen.json' \ --exclude '**/dist/**' \ --exclude 'Cargo.lock' \ + > /tmp/gh-aw/agent/pr-diff.patch 2>/tmp/gh-aw/agent/pr-diff-error.txt; then + if ! grep -q 'diff exceeded the maximum number of lines' /tmp/gh-aw/agent/pr-diff-error.txt; then + cat /tmp/gh-aw/agent/pr-diff-error.txt >&2 + exit 1 + fi + [[ "$HEAD_SHA" =~ ^[a-fA-F0-9]{40}$ && "$BASE_SHA" =~ ^[a-fA-F0-9]{40}$ ]] + echo "::warning::PR exceeds GitHub's diff API limit; generating the full filtered diff from pinned Git objects." + OBJECTS=$(mktemp -d) + trap 'rm -rf -- "$OBJECTS"' EXIT + git init --bare --quiet "$OBJECTS" + AUTH=$(printf 'x-access-token:%s' "$GH_TOKEN" | base64 -w0) + GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0="http.${GITHUB_SERVER_URL}/.extraheader" \ + GIT_CONFIG_VALUE_0="Authorization: Basic $AUTH" \ + git --git-dir="$OBJECTS" -c credential.helper= -c http.followRedirects=false \ + fetch --quiet --no-tags "${GITHUB_SERVER_URL}/${EXPR_GITHUB_REPOSITORY}.git" "$BASE_SHA" "$HEAD_SHA" + unset AUTH + git --git-dir="$OBJECTS" -c core.quotePath=false diff --no-ext-diff --no-textconv "$BASE_SHA...$HEAD_SHA" -- \ + . ':(glob,exclude)**/*.lock.yml' ':(glob,exclude)scripts/ado-script/*.js' \ + ':(glob,exclude)scripts/ado-script/test-bin/**' ':(glob,exclude)**/*.gen.ts' \ + ':(glob,exclude)**/*.gen.json' ':(glob,exclude)**/dist/**' ':(exclude)Cargo.lock' \ > /tmp/gh-aw/agent/pr-diff.patch - LINES=$(wc -l < /tmp/gh-aw/agent/pr-diff.patch) - - gh pr view "$PR_NUMBER" \ - --repo "$EXPR_GITHUB_REPOSITORY" \ - --json number,title,body,headRefName,headRefOid,additions,deletions,changedFiles,files \ - > /tmp/gh-aw/agent/pr-meta.json - - if [ -z "$CURRENT_HEAD_SHA" ]; then - CURRENT_HEAD_SHA="$(jq -r '.headRefOid // empty' /tmp/gh-aw/agent/pr-meta.json)" fi + rm -f /tmp/gh-aw/agent/pr-diff-error.txt + LINES=$(wc -l < /tmp/gh-aw/agent/pr-diff.patch) gh api "repos/$EXPR_GITHUB_REPOSITORY/pulls/$PR_NUMBER/comments" \ --paginate \ diff --git a/AGENTS.md b/AGENTS.md index 2cd957f00..deb6e8176 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -212,9 +212,12 @@ 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 +│ │ ├── add_pr_labels.rs +│ │ ├── add_pr_reviewers.rs │ │ ├── assign_github_issue_milestone.rs │ │ ├── assign_github_issue_to_user.rs │ │ ├── assign_work_item.rs @@ -235,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 legacy configuration validation │ │ ├── queue_build.rs │ │ ├── remove_github_issue_labels.rs │ │ ├── reply_to_pr_comment.rs @@ -243,10 +248,12 @@ 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 # Legacy configuration types used by migration (not a tool) +│ │ ├── update_pull_request.rs │ │ ├── update_wiki_page.rs │ │ ├── update_work_item.rs │ │ ├── upload_build_attachment.rs @@ -758,6 +765,10 @@ design, **all ado-aw-specific review logic belongs in `review-compiler-contract.md`**. Put a new domain invariant there, not in the language reviewers. +Inline subagents omit `model:` and launch-time model overrides, inheriting the +parent/runtime model selection. Do not pin a model alias such as `small` in +reviewers or PR Sous Chef: that can select a model unavailable to the workflow. + ### Shared review components - `shared/pr-review-base.md` — tools, network allowlist and the common review @@ -776,6 +787,10 @@ Generated artefacts are excluded from the pre-fetched diff (`*.lock.yml`, exclusion lists in `shared/pr-diff-data-fetch.md` and `pr-data-prefetch.yml` in sync. +Large PRs can exceed GitHub's 20,000-line diff API limit. Both prefetch paths +fall back only for that specific error to a bare Git object fetch at pinned +base/head SHAs, using identical exclusions and never checking out PR code. + ### PR Sous Chef `pr-sous-chef.md` runs every 15 minutes (and on `/souschef`) and keeps open diff --git a/Cargo.lock b/Cargo.lock index 9377cc52b..2ccaf8187 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,6 +21,7 @@ dependencies = [ "clap", "dirs", "env_logger", + "flate2", "glob-match", "indexmap", "inquire", @@ -613,6 +614,7 @@ version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ + "crc32fast", "miniz_oxide", "zlib-rs", ] diff --git a/Cargo.toml b/Cargo.toml index 608ac9e49..5a73b35f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,6 +38,7 @@ semver = "1.0.28" inventory = "0.3.24" ammonia = "4.1.4" pulldown-cmark = { version = "0.13.4", default-features = false } +flate2 = { version = "1.1.9", default-features = false, features = ["rust_backend"] } [dev-dependencies] reqwest = { version = "0.12", features = ["blocking"] } diff --git a/README.md b/README.md index 51e8b8904..dab2e266f 100644 --- a/README.md +++ b/README.md @@ -600,11 +600,15 @@ 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 | -| `update-pr` | Updates pull request metadata (reviewers, labels, auto-complete, vote, update-description) | +| `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-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 | | `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..29a84e7c6 100644 --- a/docs/ado-script.md +++ b/docs/ado-script.md @@ -383,6 +383,20 @@ GitHub-typed repos return before any SDK load. ## Bundle env contract +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 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..b733b0394 100644 --- a/docs/codemods.md +++ b/docs/codemods.md @@ -111,6 +111,56 @@ continues. ## Adding a codemod +### 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. +Legacy enabled shorthand such as `add-pr-comment: true` or +`reply-to-pr-comment: true` is normalized like a bare/null declaration before +the explicit PR target policy is preserved. This applies at the root and in +imports; imported component bytes stay unchanged. It does not make boolean +values valid in canonical runtime policy objects or enable a `false` declaration. + +`explicit_pr_policy` pins the target of each PR mutation. New configurations +use `triggering`; root declarations with a pre-0.53.0 compiled source version +retain their previous explicit-ID scope as `target: "*"`. Update/abandon already +defaulted to triggering and retain that behavior. The source rewrite also pins +new defaults explicitly, so recompiling a development build cannot reinterpret +its own source as legacy on the next `check`. +Compiled headers also record the PR policy contract. This distinguishes +pre-release builds sharing the same package version: removing an explicit target +or adding a tool after compiling under the new contract cannot accidentally +restore the old wildcard default. + +Abbreviated old tool keys and migrated `update-pr` operations prove their old +explicit-ID contract independently and preserve it, including inside imports. +An old consumer lock does not establish the age of a newly imported canonical +declaration: absent separate proof or an explicit target, it uses triggering. +Imported files and pinned cache bytes are never rewritten. + +The same codemod removes `update-pull-request.sync-stack`, with a warning: +it never synchronized Azure DevOps branches. Unsupported `update-branch: true` +still fails instead of silently doing nothing. + +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. + +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-output-permissions.md b/docs/safe-output-permissions.md index 95e1636f8..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-pr` | `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 3ec7588b0..94522b52a 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-pull-request-reviewers: allowed-reviewers: - "user@example.com" max-reviewers: 3 @@ -41,6 +39,12 @@ safe-outputs: Safe output configurations are passed to Stage 3 execution and used when processing safe outputs. +PR safe-output configuration and proposal fields are checked strictly. +Unsupported options fail instead of being silently ignored; a gh-aw option is +not supported merely because the tool has a similar name. Shared `max`, +`staged` and `require-approval` controls remain valid configuration. Proposal +`context` is execution metadata, not a way to supply additional tool policy. + ### Threat detection (`threat-detection`) Threat Detection runs between the Agent and SafeOutputs jobs. Configuration @@ -129,7 +133,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 ``` @@ -194,7 +198,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 @@ -294,7 +298,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 @@ -842,6 +846,72 @@ 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` 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`, 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: + update-pull-request: + 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 + 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 + target-repo: self # optional default destination within authorized repositories + allowed-repositories: [self] + required-labels: [automated] + required-title-prefix: "[bot] " +``` + +**Agent parameters:** + +- `title` *(optional)* - Replacement PR title. +- `body` *(optional)* - PR description content in Markdown. +- `operation` *(optional)* - Overrides the configured body operation for this + update. +- `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`. + +`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. + +`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. +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 @@ -1079,39 +1149,58 @@ runtime integrity check stays enabled. See [`docs/ado-script.md`](ado-script.md) > `target-branch`; enable `infer-target-from-checkout-ref` (and/or > `target-branches`) to give each repo its own base branch. -**Stage 3 Execution Architecture (Hybrid Git + ADO API):** - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ Stage 3 Execution │ -├─────────────────────────────────────────────────────────────────┤ -│ │ -│ 1. Security Validation │ -│ ├── Patch file size limit (5 MB) │ -│ └── Path validation (no .., .git, absolute paths) │ -│ │ -│ 2. Git Worktree (local operations only) │ -│ ├── Create worktree at target branch │ -│ ├── git apply --check (dry run) │ -│ ├── git apply (apply patch correctly) │ -│ └── git status --porcelain (detect changes) │ -│ │ -│ 3. ADO REST API (authenticated, no git config needed) │ -│ ├── Read full file contents from worktree │ -│ ├── POST /pushes (create branch + commit) │ -│ ├── POST /pullrequests (create PR) │ -│ ├── PATCH (set auto-complete if configured) │ -│ └── PUT (add reviewers) │ -│ │ -│ 4. Cleanup │ -│ └── WorktreeGuard removes worktree on drop │ -│ │ -└─────────────────────────────────────────────────────────────────┘ -``` - -This hybrid approach combines: -- **Git worktree + apply**: Correct patch application using git's battle-tested diff parser -- **ADO REST API**: No git config (user.email/name) needed, authentication handled via token +**Stage 3 execution (local Git + ADO REST):** + +1. Verify the patch hash, paths, operation selection and resource limits before + applying it. Resolve the exact target ref; a captured base behind that ref + must be a verified ancestor, not an unrelated commit. +2. Apply at the captured base in a detached temporary worktree. Unfiltered + mailbox input uses `git am --3way`; filtered patches and the raw-diff fallback + apply selected commit batches to the index. Copy/rename endpoints within + each commit refer to that commit's pre-change tree. +3. Read exact Git blobs from the resulting tree/index, then remove the private + worktree before remote writes. No temporary local source ref is created. +4. Use REST to create the branch/commit and PR, with the same validated base as + the commit parent, then apply configured follow-ups. Errors after a successful + push remain visible; creating the PR is not an atomic transaction with it. + +MCP capture also uses a private index and detached commit objects: it does not +commit or reset the author's real HEAD, index or working tree. + +#### Shared PR patch limits + +Creation and guarded source-branch pushes accept native text/binary copy and +rename records, rename-with-edit, and ordinary additions/edits/deletions. +After applying the native records, both serialize the resulting tree delta as +one REST add/edit/delete per path. This avoids ADO's rejection of separate +rename and edit operations on the same destination in one commit. +`excluded-files` uses application glob semantics: a basename matches at any +depth, and `**/name` matches both root and nested paths. If either endpoint of +a copy/rename is excluded, the **whole operation** is omitted and reported in +`omitted_operations` (operation, source, destination). A retained operation +depending on an omitted operation is rejected, not silently reinterpreted. +Malformed metadata is rejected even in excluded operations. + +| Limit | Contract for both PR code-output tools | +| --- | --- | +| `max-patch-size` | Integer KiB, default **4096** (4 MiB), range **1–10240**. Bounds the entire captured patch, each result blob and aggregate expanded selected content. | +| `max-files` | Default **100** unique paths; both endpoints of native moves/copies count. Push configuration additionally caps this at 1,000. | +| Source processing | Separate **10 MiB** bound on source/referenced/intermediate blob processing, including binary expansion. | +| Encoded push request | Separate **10 MiB** ceiling including JSON escaping, base64 and the enclosing REST body. | + +These checks run before application and again against actual Git output. +For example, a small native patch copying a 429,575-byte file 99 times is +rejected before Git application: it expands to over 40 MiB. Raising +`max-patch-size` cannot bypass the source-processing or encoded-request bounds. +Blob content comes from Git objects, not checkout files; `core.autocrlf`, EOL +attributes and filesystem conversions do not change the bytes sent to ADO. + +**Intentional tightening:** the previous 5 MiB patch default becomes 4 MiB; +creation also gains expanded-content and encoded-payload bounds. Authors who +need a larger patch can explicitly configure, for example, +`create-pull-request: {max-patch-size: 5120}`. Configure push separately. +There is no top-level inherited size setting, automatic larger-limit migration, +or agent-proposal override. **Agent parameters:** - `title` - PR title (required, 5-200 characters) @@ -1122,8 +1211,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):** @@ -1167,6 +1256,7 @@ input to `create-pull-request`. - `title-prefix` - Optional string prepended to all PR titles created by this agent (e.g., `"[Bot] "`) - `if-no-changes` - Behavior when the agent's patch produces no file changes: `"warn"` (default, succeed with a warning), `"error"` (fail the step), `"ignore"` (succeed silently) - `max-files` - Maximum number of files allowed in a single PR (default: 100). PRs exceeding this limit are rejected. +- `max-patch-size` - Patch and expanded selected content limit in KiB (default: 4096, integer range: 1–10240); see [shared PR patch limits](#shared-pr-patch-limits). - `protected-files` - Controls whether manifest/CI files (e.g., `package-lock.json`, `.github/`, `*.lock`) can be modified: `"blocked"` (default, reject changes to these files) or `"allowed"` (permit all files) - `excluded-files` - Glob patterns for files to strip from the patch before applying (e.g., `["*.lock", "dist/**"]`) - `allowed-labels` - Allowlist of labels the agent is permitted to apply. If empty (default), any labels are accepted. @@ -1220,107 +1310,211 @@ 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:** -- `pull_request_id` - The PR ID to comment on (required, must be positive) +- `pull_request_id` - Positive PR ID; required with `target: "*"`, otherwise optional and checked against the configured target. - `content` - Comment text in markdown format (required, at least 10 characters) -- `repository` - Repository alias (default: "self") +- `repository` - Optional repository alias; defaults to the configured/trusted target. - `file_path` *(optional)* - File path for an inline comment anchored to a specific file - `line` *(optional)* - Line number for an inline comment. Requires `file_path`. - `start_line` *(optional)* - Starting line for a multi-line inline comment range. Requires `file_path` and `line`, and must be strictly less than `line`. +- `side` *(optional)* - `right` (default) or `left` side of the PR diff. +- `expected_head_sha` - Exact reviewed 40-character source commit; required for inline comments. A stale head, missing diff path, invalid range or unavailable revision fails before posting. - `status` *(optional)* - Initial thread status: `"active"` (default), `"fixed"`, `"wont-fix"`, `"closed"`, or `"by-design"`. Subject to the `allowed-statuses` allowlist. **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) max: 1 # Maximum per run (default: 1) include-stats: true # Append agent stats to comment (default: true) + comment-key: default # Trusted report stream within this pipeline + supersede-older-comments: false # Opt in to preserving/closing older owned reports + max-superseded-comments: 20 # Per-call cleanup bound (1-100) ``` -### reply-to-pr-comment +Standalone comments are independent proposals, not a buffer for a later review. +Inline positioning uses the exact ADO iteration and source/common commits, +including renamed/deleted left-side paths; it does not assume the file exists +in the current checkout. Upgrade existing inline callers to provide the reviewed +head SHA. Comment content, including any prefix/stats, is bounded to 65,536 bytes. + +Owned comments carry executor-generated pipeline identity, report key and content +hash in thread properties, not merely a marker in their Markdown. Supersession +creates the replacement first, then preserves old text, marks it superseded and +closes eligible older active threads. It never deletes comments or changes votes. +Unmarked history, other pipelines/actors, externally edited comments and **all +conversations with replies** remain untouched: a shared PAT/build identity alone +cannot prove that a reply was automated. Discovery is bounded to 2,000 threads. + +### update-pull-request-comment + +Edits a verified workflow-owned **root comment with no replies**. Parameters are +`thread_id`, `comment_id`, `content`, and the shared optional PR/repository target. +Configuration supports the shared target/filter policy, `comment-key` (default +`default`), and normal budget/approval/staged controls. This edits a comment, +not the PR description. + +Stage 3 checks immutable pipeline ownership properties, server author, content +hash and a fresh conversation snapshot. Missing ownership or external edits +fail before writing. ADO does not permit updating thread properties: subsequent +edits carry content and its hash trailer together in one comment-content write. +The trailer alone never establishes ownership. Supersession's thread-status +change is a separate write; partial or uncertain outcomes remain in artifacts. +ADO supplies no atomic conversation lock, so concurrent changes discovered +during/after writes are reported rather than silently overwritten or rolled back. + +### reply-to-pull-request-comment Replies to an existing review comment thread on a pull request. **Agent parameters:** -- `pull_request_id` - The PR ID containing the thread (required) +- `pull_request_id` - Positive PR ID containing the thread; required with `target: "*"`. - `thread_id` - The thread ID to reply to (required) - `content` - Reply text in markdown format (required, at least 10 characters) -- `repository` - Repository alias (default: "self") +- `repository` - Optional repository alias; defaults to the configured/trusted target. **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:** -- `pull_request_id` - The PR ID containing the thread (required) +- `pull_request_id` - Positive PR ID containing the thread; required with `target: "*"`. - `thread_id` - The thread ID to resolve (required) - `status` - Target status: `fixed`, `wont-fix`, `closed`, `by-design`, or `active` (to reactivate) -- `repository` - Repository alias (default: "self") +- `repository` - Optional repository alias; defaults to the configured/trusted target. **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 -Submits a review vote on a pull request. +### submit-pull-request-review +Submits review feedback and, for voting events, a review vote on a pull request. +`comment` is non-voting: it posts feedback without changing an existing vote. +Use the separately authorized `reset` event to clear the authenticated actor's +vote. This is an intentional change for recompiled workflows; there is no +legacy behavior switch and prompt text is not rewritten automatically. **Agent parameters:** -- `pull_request_id` - The PR ID to review (required) +- `pull_request_id` - Positive PR ID to review; required with `target: "*"`. - `event` - Review decision: `approve`, `approve-with-suggestions`, `request-changes`, or `comment` (required) -- `body` *(optional)* - Review rationale in markdown (required for `request-changes`, at least 10 characters) -- `repository` - Repository alias (default: "self") +- `body` *(optional)* - Review summary in Markdown (required for `request-changes`; a non-voting `comment` needs this or inline findings). +- `comments` *(optional)* - Array of `{file_path, side, line, start_line?, content}` findings belonging to this review. `side` defaults to `right`. +- `expected_head_sha` - Reviewed source commit, required with inline findings; checked during preflight and again before subsequent writes/voting. +- `repository` - Optional repository alias; defaults to the configured/trusted target. **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 max: 1 # Maximum per run (default: 1) + max-comments: 10 # Explicit nested-comment authority; default 0, maximum 100 + supersede-older-comments: false # Optional same-workflow comment cleanup, never vote dismissal ``` -### update-pr -Updates pull request metadata (reviewers, labels, auto-complete, vote, description). +One call is one complete review proposal: summary, inline findings and an optional +explicit vote. Standalone comment calls are never collected into it or reposted. +Every finding is validated and anchored before the first write; comments are +posted before the vote. Failed/uncertain comments or head drift prevent the vote. +ADO does not provide an atomic review transaction or a SHA-bound vote lease: +partial writes are reported, not rolled back or blindly repeated. -**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") +`max` counts review proposals; `max-comments` independently bounds their inline +writes. Omitting it does not grant nested-comment authority. Nested comments +inherit the review's target, filter, approval and staged policy and cannot select +another PR or repository. Comment supersession uses the same ownership/hash +checks as standalone comments and runs only after the replacement review succeeds. + +Inline preparation fetches each distinct immutable `(commit, path)` once per +proposal, scans its requested lines together and processes one full file at a +time. Left/right comments can require separate reads of the same filename. +The existing 4 MiB file-content bound and 100-finding hard limit remain; +there is no additional aggregate unique-file content cap. All anchors must +validate before the first write, and later source-head checks still apply. + +### Focused PR tools + +Each PR intent has one agent-facing tool: + +| Intent | Tool | +|---|---| +| Title/description | `update-pull-request` | +| Add reviewers | `add-pull-request-reviewers` | +| Add labels | `add-pull-request-labels` | +| Remove labels | `remove-pull-request-labels` | +| Replace one label | `replace-pull-request-label` | +| Publish an existing draft | `mark-pull-request-as-ready-for-review` | +| Review/vote | `submit-pull-request-review` | +| Enable auto-complete | `set-pull-request-auto-complete` | +| Abandon | `abandon-pull-request` | + +**Comments versus reviews:** use standalone comment tools for ad hoc feedback or +conversation replies; use one `submit-pull-request-review` for a complete review. +Both enqueue proposals, but standalone comments execute independently in Stage 3. +They are never buffered, absorbed or reposted by a subsequent review. Do not emit +the same finding through both routes. `update-pull-request` edits the PR's +description; `update-pull-request-comment` edits a verified owned root comment. +`resolve-pull-request-thread` changes conversation status, not a vote. + +All PR mutation tools support `target`, `target-repo`, `allowed-repositories`, +`required-labels` and `required-title-prefix`. New configurations default to +`target: triggering`: the complete trusted triggering identity is required, +and an optional supplied PR ID must agree. Use a fixed ID or `target: "*"` for +another PR. With `"*"`, `pull_request_id` is required. Repository routing and +filters are enforced in Stage 3, including comment/reply/thread operations. + +All Azure DevOps PR operations, including creation and shared policy reads, use +a **30-second per-request HTTP deadline** and an **8 MiB consumed-response +bound**, including streamed bodies without a content length. These are per +request, not a deadline for the whole review. Oversized, malformed or incomplete +policy metadata blocks mutation. A timeout or unreadable response after a write +may mean ADO already applied it: delivery is uncertain, not proof of rollback, +and the executor does not blindly replay the mutation. + +Reviewer, label, auto-complete and review tools accept `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-pull-request-reviewers: + target: "*" + allowed-repositories: [self] + allowed-reviewers: ["owner@example.com"] + max-reviewers: 3 + max: 1 + add-pull-request-labels: + target: "*" + allowed-repositories: [self] + max: 1 + set-pull-request-auto-complete: + target: "*" + allowed-repositories: [self] + delete-source-branch: true + merge-strategy: squash + max: 1 ``` When `allowed-reviewers` is omitted or empty, any otherwise-valid reviewer is @@ -1333,29 +1527,273 @@ 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-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. + +Comment, reply and thread-status tools likewise require +`allow-temporary-ids: true` to consume a same-run PR reference. This does not +create temporary thread/comment IDs: those parameters remain existing server IDs. 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-pull-request-reviewers` call. + +### PR label policies and transitions + +`add-pull-request-labels` and `remove-pull-request-labels` accept `labels` and the +shared PR target policy. `allowed-labels` restricts names when nonempty; +`blocked-labels` always wins. Matching is case-insensitive and exact, not glob +matching. Names are trimmed and deduplicated before the configured count check. +Labels must be nonempty, contain no control/pipeline-command characters, and +fit 256 characters. A request has at most 1,000 raw entries. + +`max-labels` defaults to **10** per call, including recompiled existing workflows; +configure a larger deliberate batch explicitly (range 1-1,000). This is separate +from `max`, which limits proposals. Creation-only `allowed-labels` does not grant +or restrict an independent label mutation tool. + +```yaml +safe-outputs: + add-pull-request-labels: + allowed-labels: [triaged, ready] + blocked-labels: [approved] + max-labels: 10 + remove-pull-request-labels: + allowed-labels: [stale] + max-labels: 10 + replace-pull-request-label: + allowed-add: [done] + allowed-remove: [in-progress] + blocked-labels: [approved] + allowed-transitions: + - from: in-progress + to: done +``` + +Removal resolves IDs through the dedicated label-list endpoint and preserves +unrelated labels. Absent labels are idempotent no-ops. + +Replacement takes one `from`/`to` pair. It validates both permissions and any +`allowed-transitions` restriction before writes, adds/verifies `to`, then removes +`from` and checks the resulting state. This is **not atomic**: partial/uncertain +outcomes are recorded, with no blind replay or rollback. Failed or unverified +addition never authorizes removing `from`. If `from` is already absent and `to` +is present, the transition is a no-op; if both are absent, it fails. + +### mark-pull-request-as-ready-for-review + +Publishes an existing **active** draft PR by changing only `isDraft` to false. +It accepts optional `pull_request_id`/`repository` under the shared target, +repository and label/title policies. Default `max` is 1. Same-run temporary +PR references require the creation and publication tools to share approval +and staged settings. + +An already-ready PR is a no-op; completed/abandoned PRs are rejected. Stage 3 +reads the PR back and succeeds only when publication is persisted. Lost +responses and failed read-back are reported without blind retries. +Publishing does not vote, merge or enable auto-complete. + +### push-to-pull-request-branch + +Applies the agent's code delta to an **existing active PR's source branch**. +The destination ref comes from authoritative PR metadata, never an agent-supplied +branch name. Forks, the PR target branch and the repository default branch are +refused. There is no force-push, branch recreation, fallback PR or policy bypass. + +```yaml +safe-outputs: + push-to-pull-request-branch: + target: triggering + allowed-repositories: [self] + allowed-branches: ["agent/*"] # REQUIRED, short source-branch patterns; "*" matches any characters + protected-files: blocked + excluded-files: ["dist/**"] + max-files: 100 + max-patch-size: 4096 # KiB, integer range 1–10240 + if-no-changes: warn # warn, error, ignore + max: 1 +``` + +**Agent parameters:** `repository` is the required checkout alias (`self` or a +configured alias); `expected_head_sha` is the original 40-character PR source +commit, not an agent-created commit or native PR merge commit. +`pull_request_id` is required only for `target: "*"`. Temporary PR IDs are not +accepted. + +For triggering/fixed targets, a trusted pre-agent step selects the exact source +snapshot before user steps and writes public metadata to +`/tmp/ado-aw/pr-source-snapshot.json`. It uses the configured read token (or build +token), never injects that token into the agent, and refuses a dirty checkout. +Wildcard targets require an explicitly prepared checkout of the selected PR: +no arbitrary branch or implicit `origin/main` fallback is fetched. + +The MCP tool captures committed and uncommitted changes through a temporary +index without changing the original HEAD, index or working tree. Synthetic +merge history and sparse checkouts are rejected. Stage 3 verifies the patch +hash, paths, protected/excluded files and limits, applies it in an isolated +index at the expected source commit without checking out its tree, rechecks the PR, then uses ADO's +`oldObjectId` concurrency guard. A changed source head fails rather than +rebasing, replaying or overwriting another contributor's work. + +The [shared PR patch limits](#shared-pr-patch-limits) apply, including native +copy/rename support and whole-operation exclusions. The resulting push +represents renames as delete/add while preserving exact blob content. +Symlinks, submodules, +file-mode changes and LFS/custom-filtered changes are rejected explicitly. +Authenticated fetches require an exact approved ADO origin and do not persist +credentials or follow redirects. + +Failed/unconfirmed pushes prevent later same-PR publication, review and +auto-complete proposals from implying a successful repair. These follow-up +tools must share the push tool's approval/staged lane; diagnostic outputs and +independent targets remain separate. Lost responses are not blindly replayed, +and results distinguish an accepted-but-unconfirmed push from a verified head. + +#### gh-aw comparison boundary + +The comparison is pinned to `github/gh-aw` v0.89.21 and commit +`856e7fa3ca4f1597f9adbd519eec415ce92320e2` (reviewed September 30, 2026), not +an assertion about future releases. Native copy/rename acceptance and the +4096 KiB default / 1–10240 KiB configuration range align with that reference. +Its default code transport is a Git bundle, with a format-patch/git-am +alternative; ado-aw retains patch artifacts and full-blob ADO REST pushes. +ADO-native targeting, `max-files`, exact-head compare-and-swap pushes, and the +separate source-processing/encoded-request ceilings remain deliberate +differences. Creation history/base handling and standalone-comment versus +self-contained review semantics are not a drop-in gh-aw schema or transport. + +### Migrating PR tool names + +All public Azure DevOps safe-output tool names use `pull-request`, not `pr`. +Compilation migrates the following keys, preserving their settings and +explicit-ID scope as `target: "*"`: + +| 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. + +The `explicit_pr_policy` migration also preserves the explicit-ID scope of +root configurations with a pre-0.53.0 compiled source version. New configurations +are pinned to `target: triggering`; ambiguous imported provenance does not +grant wildcard authority. Existing explicit targets are never overwritten. +`sync-stack` is removed with a warning because it never acted in ADO. + +### Migrating the update-pr operation-based tool + +`compile` automatically migrates `safe-outputs.update-pr` to focused tools. +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`) | +| `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. +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 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=0. `comment` no longer writes a vote. Existing request-changes requires a rationale; migrated +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. + +**Agent parameters:** +- `pull_request_id` - The PR ID to abandon (required when `target: "*"`) +- `body` *(optional)* - Comment posted after abandoning the PR +- `repository` - Repository alias (default: configured `target-repo`, then `"self"`) + +**Configuration options (front matter):** +```yaml +safe-outputs: + 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 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) +``` + +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. + +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; +the tool does not blindly retry and risk duplicate comments. ### link-work-items Links two Azure DevOps work items together. diff --git a/prompts/create-ado-agentic-workflow.md b/prompts/create-ado-agentic-workflow.md index 5b72cd19b..fd93c7701 100644 --- a/prompts/create-ado-agentic-workflow.md +++ b/prompts/create-ado-agentic-workflow.md @@ -50,6 +50,27 @@ Use compact sections: Ensure "No Action" explicitly maps to `noop` when applicable. +For Azure DevOps PR work, choose tools by intent: +- Ad hoc general/inline feedback: `add-pull-request-comment`; replies use + `reply-to-pull-request-comment` with an existing thread ID. +- A complete review: one `submit-pull-request-review` proposal containing + `event`, optional `body` and `comments`. Standalone comments are never buffered + into it; do not submit the same finding through both routes. +- `comment` is non-voting; `reset` explicitly clears the authenticated actor's + vote. Resolving a thread does not approve a PR or clear a vote. +- Inline findings require `expected_head_sha`; nested review findings require + explicit `max-comments` (default 0). New mutation tools default to the complete + trusted triggering PR; arbitrary PR IDs require `target: "*"`. +- Edit PR text with `update-pull-request`; edit only a verified owned comment + with `update-pull-request-comment` and both thread/comment IDs. +- Code repairs use `push-to-pull-request-branch`, an explicit source-branch + allowlist and the original source-head snapshot, never an arbitrary git push. + Draft publication is separate from voting and auto-complete. + +All tools enqueue proposals in Stage 1; none publishes immediately. A complete +review can require several non-atomic Stage 3 writes. Consult `docs/safe-outputs.md` +for ownership, partial-outcome, approval and same-lane constraints. + ### 4. Validate Draft Quality Checklist: - field set is minimal and coherent, diff --git a/prompts/update-ado-agentic-workflow.md b/prompts/update-ado-agentic-workflow.md index 583110127..d84a1eeba 100644 --- a/prompts/update-ado-agentic-workflow.md +++ b/prompts/update-ado-agentic-workflow.md @@ -33,6 +33,19 @@ Identify current: - Touch only requested keys/sections. - Keep front-matter ordering stable when practical. - If changing `on.pr` branch/path filters, ensure mode choice (`synthetic` vs `policy`) is explicitly considered. +- For PR comment/review changes, retain the intent boundary: standalone + `add-pull-request-comment` proposals are never buffered into + `submit-pull-request-review`. Put a complete review's findings in its `comments` + array with explicit `max-comments`; do not duplicate them as standalone calls. +- `comment` is non-voting; `reset` explicitly clears the authenticated actor's + vote. Do not translate informational reviews into reset votes. +- Keep PR-description updates (`update-pull-request`) distinct from owned + comment updates (`update-pull-request-comment`, requiring thread/comment IDs). + Inline comments require `expected_head_sha`; existing implicit target scope + is preserved only by proven source migration, not by runtime aliases. +- Review changes to `target`, label limits and approval/staged lanes as authority + changes. Never enable arbitrary PR targeting, nested comments or code pushes + merely to make an old prompt compile. ### 3. Validate Run a compact checklist: diff --git a/scripts/ado-script/src/__tests__/pr-prefetch.test.ts b/scripts/ado-script/src/__tests__/pr-prefetch.test.ts new file mode 100644 index 000000000..d78fed1a4 --- /dev/null +++ b/scripts/ado-script/src/__tests__/pr-prefetch.test.ts @@ -0,0 +1,116 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import { parse } from "yaml"; + +const root = resolve(process.cwd(), "..", ".."); +let directory: string; +let base: string; +let head: string; +let bash: string; + +function git(cwd: string, args: string[]): string { + return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim(); +} + +beforeAll(() => { + directory = mkdtempSync(join(tmpdir(), "ado-aw-prefetch-test-")); + const repo = join(directory, "fixture.git"); + mkdirSync(repo); + git(repo, ["init", "--quiet", "--initial-branch=main"]); + git(repo, ["config", "core.autocrlf", "false"]); + writeFileSync(join(repo, "source.txt"), "base\n"); + git(repo, ["add", "."]); + git(repo, ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "base"]); + base = git(repo, ["rev-parse", "HEAD"]); + writeFileSync(join(repo, "source.txt"), Array.from({ length: 21_010 }, (_, index) => `line ${index}\n`).join("")); + for (const file of ["nested/workflow.lock.yml", "scripts/ado-script/bundle.js", + "scripts/ado-script/test-bin/test.js", "src/model.gen.ts", "src/catalog.gen.json", "pkg/dist/output.js", "Cargo.lock"]) { + mkdirSync(dirname(join(repo, file)), { recursive: true }); + writeFileSync(join(repo, file), "Generated noise.\n"); + } + git(repo, ["add", "."]); + git(repo, ["-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "large head"]); + head = git(repo, ["rev-parse", "HEAD"]); + bash = process.platform === "win32" + ? resolve(git(repo, ["--exec-path"]), "..", "..", "..", "bin", "bash.exe") + : "bash"; +}, 60_000); + +afterAll(() => { + if (directory) rmSync(directory, { recursive: true, force: true }); +}); + +function script(source: "prefetch" | "shared"): string { + if (source === "prefetch") { + const workflow = parse(readFileSync(join(root, ".github", "workflows", "pr-data-prefetch.yml"), "utf8")); + return workflow.jobs.prefetch.steps[0].run; + } + const markdown = readFileSync(join(root, ".github", "workflows", "shared", "pr-diff-data-fetch.md"), "utf8"); + const frontMatter = markdown.split(/^---\r?$/m)[1]; + if (frontMatter === undefined) throw new Error("Shared prefetch source has no front matter"); + return parse(frontMatter)["pre-agent-steps"][0].run; +} + +describe.each(["prefetch", "shared"] as const)("large PR %s diff fallback", (source) => { + function run(error: string) { + const output = join(directory, `${source}-${error === "diff exceeded the maximum number of lines" ? "large" : "denied"}`); + mkdirSync(output); + const full = script(source); + const start = full.indexOf('if ! gh pr diff "$PR_NUMBER"'); + const end = full.indexOf("LINES=$(wc", start); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + const commands = full.slice(start, end).replaceAll("/tmp/gh-aw/agent", "${PROBE_OUTPUT}"); + const probe = join(output, "probe.sh"); + writeFileSync(probe, [ + "set -euo pipefail", + 'PROBE_OUTPUT="$1"', + 'gh() { printf "%s\\n" "$PROBE_ERROR" >&2; return 1; }', + commands, + ].join("\n")); + return { + output, + result: spawnSync(bash, [probe, output.replaceAll("\\", "/")], { + cwd: directory, + encoding: "utf8", + timeout: 60_000, + env: { + ...process.env, + GH_TOKEN: "synthetic-fixture-only", + PR_NUMBER: "1", + HEAD_SHA: head, + BASE_SHA: base, + TARGET_REPOSITORY: "fixture", + EXPR_GITHUB_REPOSITORY: "fixture", + GITHUB_SERVER_URL: pathToFileURL(directory).href, + PROBE_ERROR: error, + GIT_TERMINAL_PROMPT: "0", + }, + }), + }; + } + + it("produces a complete filtered diff beyond 20,000 lines without checking out PR code", () => { + const { result, output } = run("diff exceeded the maximum number of lines"); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + const diff = readFileSync(join(output, "pr-diff.patch"), "utf8"); + expect(diff.split("\n").length).toBeGreaterThan(20_000); + expect(diff).toContain("+line 21009"); + expect(diff).not.toContain("Generated noise."); + expect(diff).not.toContain("diff --git a/Cargo.lock"); + expect(script(source)).toContain("git init --bare"); + expect(script(source)).not.toContain("git checkout"); + }); + + it("does not turn an unrelated API failure into empty or cached success", () => { + const { result } = run("HTTP 403: forbidden"); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("HTTP 403: forbidden"); + expect(result.stdout).not.toContain("generating the full filtered diff"); + }); +}); 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..478a5c0b5 --- /dev/null +++ b/scripts/ado-script/src/approval-summary/__tests__/compiler-policy.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeAll, describe, expect, it } from "vitest"; +import { execFileSync } from "node:child_process"; +import { 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("compiler-to-preview target policy contract", () => { + beforeAll(() => { + if (!process.env.ADO_AW_BIN) { + execFileSync("cargo", ["build", "--quiet", "--bin", "ado-aw"], { + cwd: resolve(process.cwd(), "..", ".."), stdio: "pipe", + }); + } + }, 600_000); + 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:"triggering"}); + 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 7d8c9bc1d..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,9 +16,20 @@ 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-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", () => { @@ -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 67d148cea..34037eb5a 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,145 @@ function ndjson(...records: Record[]): string { return records.map((r) => JSON.stringify(r)).join("\n") + "\n"; } +describe("focused PR tools", () => { + it("renders every declared inline finding and never trusts proposed ownership policy", () => { + const summary = renderSummary(parseProposals(ndjson({ + name: "submit-pull-request-review", event: "comment", pull_request_id: 42, + _trusted_comment_policy: "FORGED APPROVED POLICY", + comments: [ + { file_path: "src/a.rs", side: "left", line: 3, content: "First finding." }, + { file_path: "src/b.rs", line: 7, start_line: 5, content: "```\n##vso[task.complete result=Succeeded]forged" }, + ], + })), new Set(["submit-pull-request-review"]), { + policies: new Map(), + prPolicies: new Map([["submit-pull-request-review", { + target: { kind: "explicit" }, "comment-key": "report", "supersede-older-comments": true, + }]]), + }); + expect(summary).toContain("Inline findings: 2"); + expect(summary).toContain("src/a.rs; side left; lines 3-3"); + expect(summary).toContain("src/b.rs; side right; lines 5-7"); + expect(summary).toContain("Vote effect: none"); + expect(summary).toContain("supersede older comments: yes"); + expect(summary).not.toContain("FORGED APPROVED POLICY"); + expect(summary.match(/```/g)).toHaveLength(4); + expect(summary.replace(/```text\n[\s\S]*?\n```/g, "")).not.toContain("##vso["); + }); + + 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: {kind: "fixed", id: "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-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(), { + 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( tool: string, targetRepo = "octo-org/octo-repo", @@ -60,12 +199,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]); }); @@ -146,7 +285,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" }, ), @@ -164,7 +303,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/index.ts b/scripts/ado-script/src/approval-summary/index.ts index 8ea05c972..0b8175e13 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"; /** @@ -69,6 +79,7 @@ export function parseRepositoryPolicies( } catch { return policies; } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { return policies; } @@ -96,6 +107,46 @@ 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)) { + 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, + "supersede-older-comments": candidate["supersede-older-comments"] === true, + "comment-key": typeof candidate["comment-key"] === "string" ? candidate["comment-key"] : undefined, + }); + } 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 +181,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: 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 47eb6052c..fe4e9225e 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,6 +54,21 @@ export interface TrustedRepositoryContext { currentRepository?: string; currentProvider?: string; githubApiUrl?: 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; + "supersede-older-comments"?: boolean; + "comment-key"?: string; } interface RepositoryResolution { @@ -77,35 +93,102 @@ const INLINE_MAX_CHARS = 300; * serialization (the `tool_result!` macro emits field names verbatim). */ const TOOL_SPECS: Record = { - "create-pull-request": { - title: "Create pull request", + "update-pull-request": { + title: "Update pull request content", fields: [ + { label: "PR", key: "pull_request_id" }, { label: "Title", key: "title" }, - { label: "Source branch", key: "source_branch" }, - { label: "Repository", key: "repository" }, + { label: "Body operation", key: "operation" }, + { label: "Repository selector", key: "repository" }, ], - body: "description", + body: "body", }, - "update-pr": { - title: "Update pull request", + "abandon-pull-request": { + title: "Abandon pull request", fields: [ { label: "PR", key: "pull_request_id" }, - { label: "Operation", key: "operation" }, + { label: "Repository selector", key: "repository" }, + ], + body: "body", + }, + "add-pull-request-reviewers": { + title: "Add pull request reviewers", + fields: [ + { label: "PR", key: "pull_request_id" }, + { label: "Reviewers", key: "reviewers" }, + { label: "Repository selector", key: "repository" }, + ], + }, + "add-pull-request-labels": { + title: "Add pull request labels", + fields: [ + { label: "PR", key: "pull_request_id" }, + { label: "Labels", key: "labels" }, + { label: "Repository selector", key: "repository" }, + ], + }, + "set-pull-request-auto-complete": { + title: "Enable pull request auto-complete", + fields: [ + { label: "PR", key: "pull_request_id" }, + { label: "Repository selector", key: "repository" }, + ], + }, + "mark-pull-request-as-ready-for-review": { + title: "Publish draft pull request for review", + fields: [ + { label: "PR", key: "pull_request_id" }, + { label: "Repository selector", key: "repository" }, + ], + }, + "push-to-pull-request-branch": { + title: "Push code to the guarded PR source branch", + fields: [ + { label: "PR", key: "pull_request_id" }, + { label: "Repository selector", key: "repository" }, + { label: "Expected source head", key: "expected_head_sha" }, + { label: "Patch", key: "patch_file" }, + { label: "Patch SHA-256", key: "patch_sha256" }, + ], + }, + "remove-pull-request-labels": { + title: "Remove pull request labels", + fields: [ + { label: "PR", key: "pull_request_id" }, + { label: "Labels to remove", key: "labels" }, + { label: "Repository selector", key: "repository" }, + ], + }, + "replace-pull-request-label": { + title: "Replace pull request label (non-atomic)", + fields: [ + { label: "PR", key: "pull_request_id" }, + { label: "Remove after addition", key: "from" }, + { label: "Add first", key: "to" }, + { label: "Repository selector", key: "repository" }, + ], + }, + "create-pull-request": { + title: "Create pull request", + fields: [ + { label: "Title", key: "title" }, + { label: "Source branch", key: "source_branch" }, { 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" }, { label: "File", key: "file_path" }, { label: "Line", key: "line" }, + { label: "Side", key: "side" }, + { label: "Reviewed head", key: "expected_head_sha" }, ], body: "content", }, - "reply-to-pr-comment": { + "reply-to-pull-request-comment": { title: "Reply to PR comment", fields: [ { label: "PR", key: "pull_request_id" }, @@ -113,15 +196,26 @@ const TOOL_SPECS: Record = { ], body: "content", }, - "submit-pr-review": { + "update-pull-request-comment": { + title: "Update verified owned PR comment", + fields: [ + { label: "PR", key: "pull_request_id" }, + { label: "Thread", key: "thread_id" }, + { label: "Comment", key: "comment_id" }, + ], + body: "content", + }, + "submit-pull-request-review": { title: "Submit PR review", fields: [ { label: "PR", key: "pull_request_id" }, { label: "Event", key: "event" }, + { label: "Reviewed head", key: "expected_head_sha" }, + { label: "Repository selector", key: "repository" }, ], body: "body", }, - "resolve-pr-thread": { + "resolve-pull-request-thread": { title: "Resolve PR thread", fields: [ { label: "PR", key: "pull_request_id" }, @@ -898,6 +992,30 @@ function renderProposal( } } } + if (["add-pull-request-comment", "submit-pull-request-review", "update-pull-request-comment"].includes(p.name)) { + lines.push("", `Owned-comment policy: ${sanitizeInline(p.record._trusted_comment_policy)}.`); + } + if (p.name === "submit-pull-request-review") { + lines.push("", p.record.event === "comment" ? "Vote effect: none; existing votes are preserved." + : p.record.event === "reset" ? "Vote effect: explicitly clear the authenticated actor's vote." + : "Vote effect: the requested allowed ADO vote, only after all review comments succeed."); + const comments = p.record.comments; + if (comments !== undefined && comments !== null && !Array.isArray(comments)) { + lines.push("", "Invalid inline-comments array; execution will reject it."); + } else if (Array.isArray(comments) && comments.length > 0) { + lines.push("", `Inline findings: ${comments.length}. One proposal, multiple non-atomic ADO writes.`); + for (const [index, value] of comments.slice(0, 100).entries()) { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + lines.push(`Finding ${index + 1}: invalid object.`); + continue; + } + const comment = value as Record; + lines.push("", `Finding ${index + 1}: ${sanitizeInline(comment.file_path)}; side ${sanitizeInline(comment.side ?? "right")}; lines ${sanitizeInline(comment.start_line ?? comment.line)}-${sanitizeInline(comment.line)}`, + "```text", sanitizeBlock(comment.content), "```"); + } + if (comments.length > 100) lines.push("Additional findings omitted; execution rejects more than 100."); + } + } return lines.join("\n"); } @@ -934,6 +1052,22 @@ export function renderSummary( repositoryContext?: TrustedRepositoryContext, ): string { if (proposals.length === 0) return ""; + const producers = new Map(); + proposals = proposals.map((proposal) => { + const record = { ...proposal.record }; + if (["add-pull-request-comment", "submit-pull-request-review", "update-pull-request-comment"].includes(proposal.name)) { + const trusted = repositoryContext?.prPolicies?.get(proposal.name); + record._trusted_comment_policy = trusted + ? `key '${trusted["comment-key"] ?? "default"}'; supersede older comments: ${trusted["supersede-older-comments"] === true ? "yes, verified reply-free threads only" : "no"}` + : ""; + } + 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) renderPrTarget(proposal.name, record, policy, repositoryContext?.triggeringPr, producers); + return { ...proposal, record }; + }); const lines: string[] = ["# Proposed safe outputs", ""]; const repositoryResolutions = buildRepositoryResolutions( @@ -976,6 +1110,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 @@ -989,7 +1223,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/compiler-smoke-e2e/__tests__/ado-rest.test.ts b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/ado-rest.test.ts index bce3a951c..249edfafe 100644 --- a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/ado-rest.test.ts +++ b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/ado-rest.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { AdoRest, redactToken } from "../ado-rest.js"; +import { AdoHttpError, AdoRest, redactToken, transientReadRetryAfter, type OwnedBoundaryPr } from "../ado-rest.js"; function jsonResponse(status: number, body: unknown): Response { return new Response(JSON.stringify(body), { @@ -77,6 +77,63 @@ describe("AdoRest.getArtifact", () => { }); }); +describe("build status read failures", () => { + it.each([408, 429, 500, 502, 503, 504])("preserves transient HTTP %i and Retry-After without retrying the request", async (status) => { + const fetchImpl = vi.fn(async () => new Response("try later", { + status, headers: { "retry-after": "2" }, + })); + const error = await makeRest(fetchImpl).getBuild(1).catch((failure: unknown) => failure); + expect(error).toBeInstanceOf(AdoHttpError); + expect(error).toMatchObject({ status, retryAfterMs: 2_000 }); + expect(transientReadRetryAfter(error)).toBe(2_000); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it.each([400, 401, 403, 404, 409, 422])("never retries permanent HTTP %i", async (status) => { + const error = await makeRest(vi.fn(async () => new Response("rejected", { + status, headers: { "retry-after": "2" }, + }))).getBuild(1).catch((failure: unknown) => failure); + expect(error).toBeInstanceOf(AdoHttpError); + expect(transientReadRetryAfter(error)).toBeUndefined(); + }); + + it("distinguishes socket errors from programming and malformed-response errors", () => { + for (const error of [ + new TypeError("fetch failed", { cause: Object.assign(new Error("reset"), { code: "ECONNRESET" }) }), + new DOMException("request timed out", "TimeoutError"), + ]) expect(transientReadRetryAfter(error)).toBe(0); + for (const error of [ + new TypeError("fetch failed"), + new TypeError("bad URL", { cause: Object.assign(new Error("bad URL"), { code: "ERR_INVALID_URL" }) }), + new SyntaxError("truncated JSON"), + new Error("transient network error"), + ]) expect(transientReadRetryAfter(error)).toBeUndefined(); + }); + + it.each([{}, [], "invalid", { id: 2, status: "completed" }, { id: 1 }, { id: 1, status: "unknown" }])("rejects malformed build summaries %j", async (body) => { + const error = await makeRest(vi.fn(async () => jsonResponse(200, body))) + .getBuild(1).catch((failure: unknown) => failure); + expect(error).toBeInstanceOf(Error); + expect(transientReadRetryAfter(error)).toBeUndefined(); + }); + + it("leaves malformed JSON non-retryable", async () => { + const fetchImpl = vi.fn(async () => new Response('{"id":1,', { status: 200 })); + const error = await makeRest(fetchImpl).getBuild(1).catch((failure: unknown) => failure); + expect(error).toBeInstanceOf(SyntaxError); + expect(transientReadRetryAfter(error)).toBeUndefined(); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("does not retry a mutating request even when its HTTP error is transient", async () => { + const fetchImpl = vi.fn(async () => new Response("unavailable", { status: 503 })); + await expect(makeRest(fetchImpl).queueBuild(1, { + sourceBranch: "refs/heads/x", sourceVersion: "sha", + })).rejects.toThrow(AdoHttpError); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); +}); + describe("AdoRest.queueBuild", () => { it("always sends both sourceBranch and sourceVersion", async () => { let sentBody: unknown; @@ -185,29 +242,38 @@ describe("AdoRest.getBuild / cancelBuild", () => { }); }); -describe("AdoRest.listBuildsForDefinitionBranch", () => { - it("queries a single definition + exact branch and returns every build regardless of status", async () => { +describe("AdoRest.listBuildsForBranch", () => { + it.each([ + {}, + { value: null }, + { value: [{ id: 1, status: "completed" }] }, + { value: [{ id: 1, status: "completed", definition: { id: 3002 }, sourceBranch: "refs/heads/wrong" }] }, + { value: Array.from({ length: 50 }, (_, i) => ({ + id: i + 1, status: "completed", definition: { id: 3001 }, sourceBranch: "refs/heads/case", + })) }, + ])("rejects malformed, mismatched or possibly truncated child metadata", async (body) => { + const rest = makeRest(vi.fn(async () => jsonResponse(200, body))); + await expect(rest.listBuildsForBranch("refs/heads/case")).rejects.toThrow("incomplete"); + }); + + it("queries every definition on the exact branch, regardless of status", async () => { let requestedPath = ""; const fetchImpl = vi.fn(async (input: RequestInfo | URL) => { requestedPath = String(input); return jsonResponse(200, { value: [ - { id: 1, status: "completed", result: "succeeded" }, - { id: 2, status: "inProgress" }, + { id: 1, status: "completed", result: "succeeded", definition: { id: 3001 }, sourceBranch: "refs/heads/ado-aw-smoke-candidate/1" }, + { id: 2, status: "inProgress", definition: { id: 3999 }, sourceBranch: "refs/heads/ado-aw-smoke-candidate/1" }, ], }); }); const rest = makeRest(fetchImpl as unknown as typeof fetch); - const builds = await rest.listBuildsForDefinitionBranch( - 3001, + const builds = await rest.listBuildsForBranch( "refs/heads/ado-aw-smoke-candidate/1", ); expect(builds).toHaveLength(2); expect(builds[1]?.status).toBe("inProgress"); - // The query is scoped to exactly one definition id + the exact branch — - // never a comma-separated statusFilter (status is inspected client-side - // instead, per the stale-scan safety requirement). - expect(requestedPath).toContain("definitions=3001"); + expect(requestedPath).not.toContain("definitions="); expect(requestedPath).toContain( encodeURIComponent("refs/heads/ado-aw-smoke-candidate/1"), ); @@ -217,14 +283,80 @@ describe("AdoRest.listBuildsForDefinitionBranch", () => { it("returns an empty array when there are no builds on that branch", async () => { const fetchImpl = vi.fn(async () => jsonResponse(200, { value: [] })); const rest = makeRest(fetchImpl as unknown as typeof fetch); - const builds = await rest.listBuildsForDefinitionBranch( - 3001, + const builds = await rest.listBuildsForBranch( "refs/heads/ado-aw-smoke-candidate/2", ); expect(builds).toEqual([]); }); }); +describe("owned boundary PR recovery", () => { + const source = "refs/heads/ado-aw-smoke-candidate/42/check"; + const pr: OwnedBoundaryPr = { + pullRequestId: 7, status: "active", + title: "ado-aw-boundary-original-42-check", + sourceRefName: source, targetRefName: "refs/heads/ado-aw-smoke-boundary-target/42/check", + repository: { name: "mirror", project: { name: "AgentPlayground" } }, + }; + + it.each([pr.targetRefName, `${source}-target`])("recovers exact source/target/marker identity (%s)", async (targetRefName) => { + const fetch = vi.fn(async (input) => { + const url = new URL(String(input)); + expect(url.searchParams.get("searchCriteria.sourceRefName")).toBe(source); + expect(url.searchParams.get("searchCriteria.status")).toBe("all"); + expect(url.searchParams.get("$top")).toBe("2"); + return jsonResponse(200, { value: [{ ...pr, targetRefName }] }); + }); + await expect(makeRest(fetch).findBoundaryPr("mirror", source)).resolves.toMatchObject({ targetRefName }); + }); + + it.each([ + {}, + { value: [null] }, + { value: [pr, pr] }, + { value: [{ ...pr, sourceRefName: `${source}-other` }] }, + { value: [{ ...pr, targetRefName: "refs/heads/main" }] }, + { value: [{ ...pr, title: "human PR" }] }, + { value: [{ ...pr, repository: { name: "other", project: { name: "AgentPlayground" } } }] }, + { value: [{ ...pr, repository: { name: "mirror", project: { name: "other" } } }] }, + { value: [{ ...pr, forkSource: {} }] }, + ])("rejects incomplete or contradictory ownership evidence", async (body) => { + await expect(makeRest(vi.fn(async () => jsonResponse(200, body))).findBoundaryPr("mirror", source)) + .rejects.toThrow(); + }); + + it("does not treat a continuation as complete discovery", async () => { + const fetch = vi.fn(async () => new Response(JSON.stringify({ value: [] }), { + headers: { "content-type": "application/json", "x-ms-continuationtoken": "more" }, + })); + await expect(makeRest(fetch).findBoundaryPr("mirror", source)).rejects.toThrow("Incomplete"); + }); + + it.each(["abandoned", "completed", "active", "wrong-target", "read-error"])( + "reconciles a lost abandonment response without retrying (%s)", async (outcome) => { + let writes = 0; + const fetch = vi.fn(async (_input, init) => { + if (init?.method === "PATCH") { writes += 1; throw new Error("lost response"); } + if (writes === 0) return jsonResponse(200, pr); + if (outcome === "read-error") return new Response("forbidden", { status: 403 }); + return jsonResponse(200, outcome === "wrong-target" + ? { ...pr, status: "abandoned", targetRefName: "refs/heads/main" } + : { ...pr, status: outcome }); + }); + const operation = makeRest(fetch).abandonBoundaryPr("mirror", pr); + if (outcome === "abandoned") await expect(operation).resolves.toBeUndefined(); + else await expect(operation).rejects.toThrow(); + expect(writes).toBe(1); + }, + ); + + it("retains an ordinary boundary PR that unexpectedly completed", async () => { + const fetch = vi.fn(async () => jsonResponse(200, { ...pr, status: "completed" })); + await expect(makeRest(fetch).abandonBoundaryPr("mirror", pr)).rejects.toThrow("unexpectedly completed"); + expect(fetch).toHaveBeenCalledTimes(1); + }); +}); + describe("AdoRest.buildUrl", () => { it("builds a human-facing build results URL", () => { const rest = makeRest((async () => diff --git a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/cleanup.test.ts b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/cleanup.test.ts new file mode 100644 index 000000000..9a16ebd62 --- /dev/null +++ b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/cleanup.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it, vi } from "vitest"; +import { cleanupCaseResources } from "../cleanup.js"; +import type { OwnedBoundaryPr } from "../ado-rest.js"; + +const source = { ref: "refs/heads/ado-aw-smoke-candidate/42/check", sha: "a".repeat(40) }; +const target = { ref: "refs/heads/ado-aw-smoke-boundary-target/42/check", sha: "b".repeat(40) }; +const pr: OwnedBoundaryPr = { + pullRequestId: 7, status: "active", title: "ado-aw-boundary-original-42-check", + sourceRefName: source.ref, targetRefName: target.ref, +}; + +function setup(found: OwnedBoundaryPr | undefined = pr) { + const order: string[] = []; + const client = { + findBoundaryPr: vi.fn(async (): Promise => found), + abandonBoundaryPr: vi.fn(async () => { order.push("abandon"); }), + }; + const deleteRefs = vi.fn(async () => { order.push("delete"); }); + const opts = { client, repository: "mirror", sourceRef: source.ref, refs: [source, target], deleteRefs }; + return { client, deleteRefs, opts, order }; +} + +describe("paired case cleanup", () => { + it("confirms PR abandonment before either leased ref is deleted", async () => { + const test = setup(); + await cleanupCaseResources({ ...test.opts, expectedPrId: 7 }); + expect(test.order).toEqual(["abandon", "delete"]); + expect(test.deleteRefs).toHaveBeenCalledWith([source, target]); + }); + + it.each(["discovery", "abandonment", "wrong-id"])("retains both refs after %s failure", async (failure) => { + const test = setup(); + if (failure === "discovery") test.client.findBoundaryPr.mockRejectedValue(new Error("lookup failed")); + if (failure === "abandonment") test.client.abandonBoundaryPr.mockRejectedValue(new Error("unconfirmed")); + await expect(cleanupCaseResources({ + ...test.opts, expectedPrId: failure === "wrong-id" ? 8 : 7, + })).rejects.toThrow(); + expect(test.deleteRefs).not.toHaveBeenCalled(); + }); + + it("recovers a PR whose setup response was lost", async () => { + const test = setup(); + await cleanupCaseResources(test.opts); + expect(test.client.abandonBoundaryPr).toHaveBeenCalledWith("mirror", pr); + expect(test.order).toEqual(["abandon", "delete"]); + }); + + it("cleans a confirmed target-only orphan in the new namespace", async () => { + const test = setup(); + test.client.findBoundaryPr.mockResolvedValue(undefined); + await cleanupCaseResources({ ...test.opts, refs: [target] }); + expect(test.client.abandonBoundaryPr).not.toHaveBeenCalled(); + expect(test.deleteRefs).toHaveBeenCalledWith([target]); + }); + + it("requires corroboration for a legacy orphan", async () => { + const test = setup(); + test.client.findBoundaryPr.mockResolvedValue(undefined); + await expect(cleanupCaseResources({ + ...test.opts, refs: [{ ...target, ref: `${source.ref}-target` }], + })).rejects.toThrow("ownership"); + expect(test.deleteRefs).not.toHaveBeenCalled(); + }); + + it("recovers a legacy pair only through its validated PR identity", async () => { + const legacy = { ...target, ref: `${source.ref}-target` }; + const test = setup({ ...pr, targetRefName: legacy.ref }); + await cleanupCaseResources({ ...test.opts, refs: [source, legacy] }); + expect(test.order).toEqual(["abandon", "delete"]); + }); + + it("does not delete the source when its observed target was withheld", async () => { + const test = setup(); + await expect(cleanupCaseResources({ + ...test.opts, refs: [source], observedRefs: [source, target], + })).rejects.toThrow("unproven or ambiguous"); + expect(test.client.abandonBoundaryPr).not.toHaveBeenCalled(); + expect(test.deleteRefs).not.toHaveBeenCalled(); + }); +}); diff --git a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/git.test.ts b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/git.test.ts index 973d77510..adc513ca6 100644 --- a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/git.test.ts +++ b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/git.test.ts @@ -1,4 +1,7 @@ import { describe, expect, it } from "vitest"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import type { GitRunner, GitRunOptions } from "../git.js"; import { @@ -7,10 +10,13 @@ import { COMMIT_IDENTITY, createDetachedWorktree, deleteRemoteRef, + deleteRemoteRefs, + defaultGitRunner, disallowedChanges, listCandidateRefs, mirrorRepoUrl, parseCandidateRef, + parseBoundaryTargetRef, pushCandidate, removeWorktree, verifyLocalCommit, @@ -240,6 +246,38 @@ describe("commitAll", () => { }); describe("pushCandidate / verifyRemoteRef / deleteRemoteRef", () => { + it("preserves a genuinely advanced remote ref and deletes only its observed tip", async () => { + const root = await mkdtemp(join(tmpdir(), "ado-smoke-lease-")); + try { + const work = join(root, "work"); + const remote = join(root, "remote.git"); + const runGit = async (args: string[], cwd = root) => { + const result = await defaultGitRunner(args, { cwd, timeoutMs: 10_000 }); + expect(result.status, result.stderr).toBe(0); + return result.stdout.trim(); + }; + await runGit(["init", "--bare", remote]); + await runGit(["init", work]); + const commit = async (message: string) => { + await runGit(["-c", "user.name=Smoke Test", "-c", "user.email=smoke@example.test", + "commit", "--allow-empty", "-m", message], work); + return runGit(["rev-parse", "HEAD"], work); + }; + const original = await commit("original"); + const ref = "refs/heads/ado-aw-smoke-candidate/42/check"; + await runGit(["push", remote, `HEAD:${ref}`], work); + const advanced = await commit("advanced"); + await runGit(["push", remote, `HEAD:${ref}`], work); + const opts = { cwd: work, mirrorUrl: remote, ref, token: "opaque-test-token", timeoutMs: 10_000 }; + await expect(deleteRemoteRef({ ...opts, sha: original })).rejects.toThrow("retained"); + expect(await runGit(["--git-dir", remote, "rev-parse", ref])).toBe(advanced); + await deleteRemoteRef({ ...opts, sha: advanced }); + expect(await runGit(["ls-remote", "--heads", remote, ref], work)).toBe(""); + } finally { + await rm(root, { recursive: true, force: true }); + } + }, 30_000); + it("pushes HEAD to the ref without --force", async () => { const { runner, calls } = fakeRunner(() => ({ status: 0 })); await pushCandidate( @@ -336,17 +374,71 @@ describe("pushCandidate / verifyRemoteRef / deleteRemoteRef", () => { ).rejects.toThrow(); }); - it("deleteRemoteRef pushes a --delete for exactly the given ref", async () => { + it("deleteRemoteRef deletes exactly the owned ref with its observed SHA lease", async () => { const { runner, calls } = fakeRunner(() => ({ status: 0 })); + const ref = "refs/heads/ado-aw-smoke-candidate/1/canary"; + const sha = "a".repeat(40); await deleteRemoteRef( - { cwd: "/repo", mirrorUrl: "https://example/_git/r", ref: "refs/heads/x/1", token: "t", timeoutMs: 1000 }, + { cwd: "/repo", mirrorUrl: "https://example/_git/r", ref, sha, token: "t", timeoutMs: 1000 }, runner, ); - expect(calls[0]?.args).toEqual(["push", "--porcelain", "https://example/_git/r", "--delete", "refs/heads/x/1"]); + expect(calls[0]?.args).toEqual([ + "push", "--porcelain", `--force-with-lease=${ref}:${sha}`, "https://example/_git/r", `:${ref}`, + ]); + }); + + it("does not retry a failed lease and reports partial deletion from read-back", async () => { + const source = { ref: "refs/heads/ado-aw-smoke-candidate/1/canary", sha: "a".repeat(40) }; + const target = { ref: "refs/heads/ado-aw-smoke-boundary-target/1/canary", sha: "b".repeat(40) }; + const { runner, calls } = fakeRunner((args) => args[0] === "push" + ? { status: 1, stderr: "stale info" } + : { status: 0, stdout: `${"c".repeat(40)}\t${source.ref}\n` }); + await expect(deleteRemoteRefs({ + cwd: "/repo", mirrorUrl: "https://example/_git/r", refs: [source, target], token: "opaque-test-token", timeoutMs: 1000, + }, runner)).rejects.toThrow(`retained: ${source.ref}; confirmed absent: ${target.ref}`); + expect(calls.filter((call) => call.args[0] === "push")).toHaveLength(1); + expect(calls[0]?.args).toContain(`--force-with-lease=${source.ref}:${source.sha}`); + expect(calls[0]?.args).toContain(`--force-with-lease=${target.ref}:${target.sha}`); + }); + + it("accepts confirmed absence after a lost deletion response without replay", async () => { + const { runner, calls } = fakeRunner((args) => args[0] === "push" + ? { status: 1, stderr: "connection lost" } : { status: 0, stdout: "" }); + await expect(deleteRemoteRefs({ + cwd: "/repo", mirrorUrl: "https://example/_git/r", token: "opaque-test-token", timeoutMs: 1000, + refs: [{ ref: "refs/heads/ado-aw-smoke-candidate/1/canary", sha: "a".repeat(40) }], + }, runner)).resolves.toBeUndefined(); + expect(calls.filter((call) => call.args[0] === "push")).toHaveLength(1); + }); + + it("requires an exact owned name and SHA before deletion", async () => { + const { runner, calls } = fakeRunner(() => ({ status: 0 })); + for (const ref of ["refs/heads/main", "refs/heads/ado-aw-smoke-candidate/0/canary"]) { + await expect(deleteRemoteRef({ + cwd: "/repo", mirrorUrl: "https://example/_git/r", ref, sha: "a".repeat(40), token: "t", timeoutMs: 1000, + }, runner)).rejects.toThrow("owned ref"); + } + expect(calls).toEqual([]); }); }); describe("listCandidateRefs", () => { + it("discovers the distinct target namespace without confusing -target case names", async () => { + const source = "refs/heads/ado-aw-smoke-candidate/1/real-target"; + const target = "refs/heads/ado-aw-smoke-boundary-target/1/real-target"; + const { runner, calls } = fakeRunner(() => ({ + status: 0, stdout: `${"a".repeat(40)}\t${source}\n${"b".repeat(40)}\t${target}\n`, + })); + const refs = await listCandidateRefs({ + cwd: "/repo", mirrorUrl: "https://example/_git/r", token: "opaque-test-token", timeoutMs: 1000, + }, runner); + expect(refs.map((entry) => entry.ref)).toEqual([source, target]); + expect(parseCandidateRef(source)).toEqual({ buildId: 1, caseId: "real-target" }); + expect(parseBoundaryTargetRef(source)).toBeUndefined(); + expect(parseBoundaryTargetRef(target)).toEqual({ buildId: 1, caseId: "real-target" }); + expect(parseCandidateRef(target)).toBeUndefined(); + expect(calls[0]?.args).toContain("refs/heads/ado-aw-smoke-boundary-target/**"); + }); it("lists only refs under the exact candidate prefix", async () => { const { runner } = fakeRunner(() => ({ status: 0, 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..8b9784d7e 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 @@ -3,6 +3,8 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; import type { FixtureBuildResult } from "../runner.js"; +import type { RemoteRef } from "../git.js"; +import type { OwnedBoundaryPr } from "../ado-rest.js"; const mockCalls: string[] = []; const compiledCasePaths: string[] = []; @@ -10,6 +12,10 @@ const stagedWrites: { to: string; contents: string }[] = []; let queuedCaseIds: string[] = []; let queuedRequests: { caseId: string; lane: string; definitionId: number; sourceBranch: string }[] = []; let deletedRefs: string[] = []; +const remoteRefs = new Map(); +const boundaryPrs = new Map(); +let failBoundaryCleanup = false; +let lateChild = false; const HERE = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = join(HERE, "..", "..", "..", "..", ".."); @@ -98,6 +104,9 @@ vi.mock("../ado-rest.js", () => { return { name: "ado-aw-candidate" }; }), getBuild: vi.fn(async () => ({ status: "completed", result: "succeeded" })), + listBuildsForBranch: vi.fn(async (sourceBranch: string) => lateChild + ? [{ id: 2, definition: { id: 3999 }, status: "notStarted", sourceBranch }] + : []), // The real manifest has two cases with runtime tag proofs. Returning // both here keeps the generic build-id-only ADO mock independent of // which case is currently being verified. @@ -109,6 +118,21 @@ vi.mock("../ado-rest.js", () => { cancelBuild: vi.fn(async () => {}), addBuildTags: vi.fn(async () => {}), buildUrl: (id: number) => `https://example/${id}`, + findBoundaryPr: vi.fn(async (_repo: string, source: string) => boundaryPrs.get(source)), + createBoundaryTarget: vi.fn(async (_repo: string, ref: string) => { + remoteRefs.set(ref, "b".repeat(40)); + }), + createBoundaryPr: vi.fn(async (_repo: string, source: string, target: string, marker: string) => { + const pr: OwnedBoundaryPr = { + pullRequestId: 7, status: "active", sourceRefName: source, targetRefName: target, title: marker, + }; + boundaryPrs.set(source, pr); + return pr; + }), + abandonBoundaryPr: vi.fn(async () => { + mockCalls.push("abandonBoundaryPr"); + if (failBoundaryCleanup) throw new Error("PR abandonment unconfirmed"); + }), }; }), redactToken: (text: string) => text, @@ -142,19 +166,23 @@ vi.mock("../git.js", async (importOriginal) => { mockCalls.push("commitAll"); return "candidate-sha"; }), - pushCandidate: vi.fn(async () => { + pushCandidate: vi.fn(async (opts: { ref: string }) => { mockCalls.push("pushCandidate"); + remoteRefs.set(opts.ref, "a".repeat(40)); }), verifyRemoteRef: vi.fn(async () => { mockCalls.push("verifyRemoteRef"); }), - deleteRemoteRefs: vi.fn(async (opts: { refs: readonly string[] }) => { + deleteRemoteRefs: vi.fn(async (opts: { refs: readonly RemoteRef[] }) => { mockCalls.push("deleteRemoteRefs"); - deletedRefs.push(...opts.refs); + for (const { ref } of opts.refs) { + deletedRefs.push(ref); + remoteRefs.delete(ref); + } }), listCandidateRefs: vi.fn(async () => { mockCalls.push("listCandidateRefs"); - return []; + return [...remoteRefs].map(([ref, sha]) => ({ ref, sha })); }), }; }); @@ -238,6 +266,10 @@ beforeEach(() => { queuedCaseIds = []; queuedRequests = []; deletedRefs = []; + remoteRefs.clear(); + boundaryPrs.clear(); + failBoundaryCleanup = false; + lateChild = false; vi.clearAllMocks(); }); @@ -271,6 +303,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 +312,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 +331,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 +364,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" }); } @@ -422,6 +457,40 @@ describe("smoke-e2e index.main (PR base-ref regression)", () => { }); describe("smoke-e2e index.main (per-case ref retention)", () => { + it.each(["unproven-child", "failed-abandonment", "late-child", "confirmed-cleanup"])( + "keeps source/target cleanup paired (%s)", async (outcome) => { + const runnerModule = await import("../runner.js"); + vi.mocked(runnerModule.runFixtures).mockImplementationOnce(async (_client, requests) => ({ + ok: false, allTerminal: outcome !== "unproven-child", + results: requests.map((request) => ({ + ...request, buildId: 1, status: "failed", result: "failed", durationMs: 1, + terminalProven: outcome !== "unproven-child", + })), + })); + failBoundaryCleanup = outcome === "failed-abandonment"; + lateChild = outcome === "late-child"; + const previous = process.env; + process.env = { ...process.env, ...baseEnv, SMOKE_CASE_IDS: "pr-synthetic-auto", VITEST: "true" }; + try { + const { main } = await import("../index.js"); + expect(await main()).toBe(1); + const source = "refs/heads/ado-aw-smoke-candidate/630001/pr-synthetic-auto"; + const target = "refs/heads/ado-aw-smoke-boundary-target/630001/pr-synthetic-auto"; + expect(boundaryPrs.get(source)?.targetRefName).toBe(target); + if (outcome === "confirmed-cleanup") { + expect(deletedRefs).toEqual([source, target]); + expect(mockCalls.indexOf("abandonBoundaryPr")).toBeLessThan(mockCalls.indexOf("deleteRemoteRefs")); + } else { + expect(deletedRefs).toEqual([]); + expect(remoteRefs.has(source) && remoteRefs.has(target)).toBe(true); + if (outcome === "unproven-child" || outcome === "late-child") { + expect(mockCalls).not.toContain("abandonBoundaryPr"); + } + } + } finally { process.env = previous; } + }, + ); + it("retains only the unproven case's ref and still deletes the proven ones", async () => { const runnerModule = await import("../runner.js"); vi.mocked(runnerModule.runFixtures).mockImplementationOnce( @@ -456,6 +525,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/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..fc2d45ce1 --- /dev/null +++ b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/pr-boundary.test.ts @@ -0,0 +1,76 @@ +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.each(["", "stage."])("accepts an explicitly skipped Phase without an allocated Job (%s)", (prefix) => { + const records = rejection.map((record) => ({ + ...record, + type: record.identifier === "SafeOutputs_Reviewed" ? "Phase" : "Job", + identifier: `${prefix}${record.identifier}`, + })); + expect(() => verifyPrBoundary("rejected", 42, "original", "original", records)).not.toThrow(); + expect(() => verifyPrBoundary("rejected", 42, "original", "changed", records)).toThrow("changed"); + expect(() => verifyPrBoundary("rejected", 42, "original", "original", + records.filter((record) => record.type !== "Phase"))).toThrow("not skipped"); + for (const result of ["succeeded", "failed", "canceled", undefined]) { + expect(() => verifyPrBoundary("rejected", 42, "original", "original", + records.map((record) => record.type === "Phase" ? { ...record, result } : record))) + .toThrow("not skipped"); + } + expect(() => verifyPrBoundary("rejected", 42, "original", "original", [ + ...records, + { type: "Job", identifier: `${prefix}SafeOutputs_Reviewed.__default`, result: "succeeded" }, + ])).toThrow("not skipped"); + }); + + it.each(["", "stage."])("recognizes real ADO job identifiers with prefix '%s'", (prefix) => { + const records = rejection.map((record) => ({ + ...record, identifier: `${prefix}${record.identifier}.__default`, + })); + expect(() => verifyPrBoundary("rejected", 42, "original", "original", records)).not.toThrow(); + const wrongJob = records.map((record) => ({ + ...record, identifier: record.identifier.replace("Setup.__default", "Setup.Other.__default"), + })); + expect(() => verifyPrBoundary("rejected", 42, "original", "original", wrongJob)).toThrow("Setup"); + }); + + 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..dc056080d 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 @@ -1,7 +1,8 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { FixtureBuildClient, FixtureBuildRequest } from "../runner.js"; import { runFixtures } from "../runner.js"; +import { AdoHttpError } from "../ado-rest.js"; interface FakeBuild { status: string; @@ -29,7 +30,7 @@ interface FakeBuild { function makeFakeClient(opts: { queueResults: Record; /** For each queued build id, the sequence of statuses returned on successive getBuild() polls (last value repeats). */ - timelines: Record; + timelines: Record; onCancel?: (buildId: number) => void; }): { client: FixtureBuildClient; cancelled: number[] } { const cancelled: number[] = []; @@ -51,6 +52,7 @@ function makeFakeClient(opts: { const idx = pollCounts[buildId] ?? 0; pollCounts[buildId] = idx + 1; const entry = timeline[Math.min(idx, timeline.length - 1)]!; + if (entry instanceof Error) throw entry; const defaultIdentity = { definition: { id: definitionIdByBuildId.get(buildId) }, sourceBranch: "refs/heads/x", @@ -76,7 +78,179 @@ function req(caseId: string, definitionId: number): FixtureBuildRequest { const noopSleep = async (): Promise => {}; +describe("bounded status-read recovery", () => { + afterEach(() => vi.useRealTimers()); + + function options(overrides: Partial[2]> = {}) { + vi.useFakeTimers(); + vi.setSystemTime(0); + return { + concurrency: 1, + timeoutMs: 1_000, + pollMs: 10, + cancelGraceMs: 100, + log: vi.fn(), + sleepImpl: async (ms: number) => { vi.setSystemTime(Date.now() + ms); }, + ...overrides, + }; + } + + const socketError = () => new TypeError("fetch failed", { + cause: Object.assign(new Error("connection reset"), { code: "ECONNRESET" }), + }); + + it.each([ + socketError(), + new DOMException("request expired", "TimeoutError"), + new AdoHttpError("unavailable", 503), + new AdoHttpError("throttled", 429, 25), + ])("recovers from %s without cancelling healthy builds", async (error) => { + const { client, cancelled } = makeFakeClient({ + queueResults: { 1: { ok: true, id: 101 } }, + timelines: { 101: [error, { status: "completed", result: "succeeded" }] }, + }); + const reads = vi.spyOn(client, "getBuild"); + const outcome = await runFixtures(client, [req("canary", 1)], options()); + expect(outcome.ok).toBe(true); + expect(outcome.allTerminal).toBe(true); + expect(reads).toHaveBeenCalledTimes(2); + expect(cancelled).toEqual([]); + expect(Date.now()).toBe(error instanceof AdoHttpError && error.status === 429 ? 25 : 10); + }); + + it("resets the consecutive failure count after a successful observation", async () => { + const { client, cancelled } = makeFakeClient({ + queueResults: { 1: { ok: true, id: 101 } }, + timelines: { 101: [ + socketError(), socketError(), { status: "inProgress" }, + socketError(), socketError(), { status: "completed", result: "succeeded" }, + ] }, + }); + const outcome = await runFixtures(client, [req("canary", 1)], options()); + expect(outcome.ok).toBe(true); + expect(cancelled).toEqual([]); + }); + + it("cancels on the third failed read and preserves the reason after terminal proof", async () => { + const atCancel: number[] = []; + const { client, cancelled } = makeFakeClient({ + queueResults: { 1: { ok: true, id: 101 } }, + timelines: { 101: [ + socketError(), socketError(), socketError(), { status: "completed", result: "canceled" }, + ] }, + onCancel: () => { atCancel.push(reads.mock.calls.length); }, + }); + const reads = vi.spyOn(client, "getBuild"); + const outcome = await runFixtures(client, [req("canary", 1)], options()); + expect(outcome.ok).toBe(false); + expect(cancelled).toEqual([101]); + expect(atCancel).toEqual([3]); + expect(outcome.allTerminal).toBe(true); + expect(outcome.results[0]).toMatchObject({ status: "canceled", message: expect.stringContaining("status-read failure") }); + }); + + it.each([ + new AdoHttpError("unauthenticated", 401), + new AdoHttpError("forbidden", 403), + new AdoHttpError("missing", 404), + new AdoHttpError("invalid", 400), + new SyntaxError("invalid JSON"), + new Error("unclassified error"), + ])("immediately cancels on permanent or unknown failure %s", async (error) => { + const atCancel: number[] = []; + const { client } = makeFakeClient({ + queueResults: { 1: { ok: true, id: 101 } }, + timelines: { 101: [error, { status: "completed", result: "canceled" }] }, + onCancel: () => { atCancel.push(reads.mock.calls.length); }, + }); + const reads = vi.spyOn(client, "getBuild"); + const outcome = await runFixtures(client, [req("canary", 1)], options()); + expect(atCancel).toEqual([1]); + expect(outcome.ok).toBe(false); + expect(outcome.allTerminal).toBe(true); + }); + + it("never treats persistent read failures as terminal proof", async () => { + const { client, cancelled } = makeFakeClient({ + queueResults: { 1: { ok: true, id: 101 } }, + timelines: { 101: [socketError()] }, + }); + const outcome = await runFixtures(client, [req("canary", 1)], options()); + expect(outcome.ok).toBe(false); + expect(outcome.allTerminal).toBe(false); + expect(outcome.results[0]).toMatchObject({ + status: "failed", terminalProven: false, + message: expect.stringContaining("never confirmed a terminal state"), + }); + expect(cancelled).toEqual([101]); + expect(Date.now()).toBe(120); + }); + + it("bounds Retry-After and each request by the original deadline", async () => { + const atCancel: number[] = []; + const { client } = makeFakeClient({ + queueResults: { 1: { ok: true, id: 101 } }, + timelines: { 101: [ + new AdoHttpError("throttled", 429, 10_000), + { status: "completed", result: "canceled" }, + ] }, + onCancel: () => { atCancel.push(Date.now()); }, + }); + const reads = vi.spyOn(client, "getBuild"); + const outcome = await runFixtures(client, [req("canary", 1)], options({ timeoutMs: 50 })); + expect(atCancel).toEqual([50]); + expect(outcome.ok).toBe(false); + expect(outcome.allTerminal).toBe(true); + expect(reads.mock.calls.map((call) => call[1]?.timeoutMs)).toEqual([50, 100]); + }); + + it("interrupts Retry-After when another child fails", async () => { + const { client, cancelled } = makeFakeClient({ + queueResults: { 1: { ok: true, id: 101 }, 2: { ok: true, id: 102 } }, + timelines: { + 101: [new AdoHttpError("throttled", 429, 900), { status: "completed", result: "canceled" }], + 102: [{ status: "completed", result: "failed" }], + }, + }); + const outcome = await runFixtures(client, [req("canary", 1), req("other", 2)], options({ concurrency: 2 })); + expect(outcome.ok).toBe(false); + expect(outcome.allTerminal).toBe(true); + expect(cancelled).toEqual([101]); + expect(Date.now()).toBeLessThan(900); + }); + + it("rejects identity drift after an initially valid observation and transient error", async () => { + const { client, cancelled } = makeFakeClient({ + queueResults: { 1: { ok: true, id: 101 } }, + timelines: { 101: [ + { status: "inProgress" }, socketError(), + { status: "completed", result: "succeeded", sourceVersion: "different-sha" }, + ] }, + }); + const outcome = await runFixtures(client, [req("canary", 1)], options()); + expect(outcome.ok).toBe(false); + expect(outcome.results[0]).toMatchObject({ + status: "failed", terminalProven: true, + message: expect.stringContaining("different-sha"), + }); + expect(cancelled).toEqual([101]); + }); +}); + 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: { @@ -294,7 +468,7 @@ describe("runFixtures", () => { expect(canary.message).toMatch(/never confirmed a terminal state/); }); - it("recovers from a transient getBuild error: once a later call confirms completion, terminalProven is true", async () => { + it("cancels an unclassified getBuild error, but still accepts later terminal proof", async () => { let calls = 0; const client: FixtureBuildClient = { async queueBuild(definitionId) { diff --git a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/stale.test.ts b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/stale.test.ts index 4eae96f7c..53a0d0343 100644 --- a/scripts/ado-script/src/compiler-smoke-e2e/__tests__/stale.test.ts +++ b/scripts/ado-script/src/compiler-smoke-e2e/__tests__/stale.test.ts @@ -5,7 +5,6 @@ import { scanStaleRefs, type StaleScanBuild, type StaleScanClient } from "../sta const NOW = new Date("2024-06-01T00:00:00Z").getTime(); const HOUR = 3_600_000; -const CHILD_DEFINITION_IDS = [901, 902, 903]; function ref(buildId: number): RemoteRef { return { ref: `refs/heads/ado-aw-smoke-candidate/${buildId}/canary`, sha: `sha-${buildId}` }; @@ -25,11 +24,15 @@ function client(builds: Record, opts: ClientOpts = {}): if (!b) throw new Error(`no such build ${buildId}`); return b; }, - async listBuildsForDefinitionBranch(definitionId, branch) { - if (opts.childLookupErrorFor === definitionId) { - throw new Error(`child lookup failed for definition ${definitionId}`); + async listBuildsForBranch(branch) { + if (opts.childLookupErrorFor !== undefined) { + throw new Error(`child lookup failed for definition ${opts.childLookupErrorFor}`); } - return opts.childBuilds?.[`${definitionId}:${branch}`] ?? []; + return Object.entries(opts.childBuilds ?? {}) + .filter(([key]) => key.endsWith(`:${branch}`)) + .flatMap(([key, builds]) => builds.map((build) => ({ + ...build, definition: { id: Number(key.split(":")[0]) }, + }))); }, }; } @@ -38,11 +41,66 @@ const baseOpts = { baseRef: "refs/heads/main", ownRef: "refs/heads/ado-aw-smoke-candidate/999/canary", definitionId: 42, - laneDefinitionIds: CHILD_DEFINITION_IDS, staleRefHours: 24, }; describe("scanStaleRefs", () => { + it.each(["notStarted", "inProgress", "completed"])("uses the source child's %s state for new targets", async (status) => { + const source = ref(8); + const target = { ref: "refs/heads/ado-aw-smoke-boundary-target/8/canary", sha: "target-sha" }; + const decisions = await scanStaleRefs({ + ...baseOpts, refs: [source, target], + client: client({ + 8: { status: "completed", definition: { id: 42 }, finishTime: new Date(NOW - 48 * HOUR).toISOString() }, + }, { childBuilds: { [`902:${source.ref}`]: [{ status }] } }), + now: () => NOW, + }); + expect(decisions.map((decision) => decision.sourceRef)).toEqual([source.ref, source.ref]); + expect(decisions.map((decision) => decision.outcome)).toEqual(status === "completed" + ? ["eligible", "eligible"] : ["active", "active"]); + }); + + it("requires legacy PR corroboration and then checks source-child state", async () => { + const source = ref(8); + const target = { ref: `${source.ref}-target`, sha: "target-sha" }; + const decisions = await scanStaleRefs({ + ...baseOpts, refs: [target], + client: client({ + 8: { status: "completed", definition: { id: 42 }, finishTime: new Date(NOW - 48 * HOUR).toISOString() }, + }, { childBuilds: { [`902:${source.ref}`]: [{ status: "notStarted" }] } }), + boundaryPrForSource: async (ref) => ref === source.ref ? { targetRefName: target.ref } : undefined, + now: () => NOW, + }); + expect(decisions[0]).toMatchObject({ sourceRef: source.ref, outcome: "active" }); + }); + + it("does not mistake an explicit new-namespace source ending in -target for a legacy target", async () => { + const source = { ref: `${ref(8).ref}-target`, sha: "source-sha" }; + const target = { ref: "refs/heads/ado-aw-smoke-boundary-target/8/canary-target", sha: "target-sha" }; + const decisions = await scanStaleRefs({ + ...baseOpts, refs: [source, target], + client: client({ + 8: { status: "completed", definition: { id: 42 }, finishTime: new Date(NOW - 48 * HOUR).toISOString() }, + }), + now: () => NOW, + }); + expect(decisions.every((decision) => decision.sourceRef === source.ref && decision.outcome === "eligible")).toBe(true); + }); + + it("never deletes a legacy boundary target while its source child is queued", async () => { + const source = ref(8); + const decisions = await scanStaleRefs({ + ...baseOpts, + refs: [source, { ref: `${source.ref}-target`, sha: "target-sha" }], + client: client({ + 8: { status: "completed", definition: { id: 42 }, finishTime: new Date(NOW - 48 * HOUR).toISOString() }, + }, { childBuilds: { [`902:${source.ref}`]: [{ status: "notStarted" }] } }), + now: () => NOW, + }); + expect(decisions).toHaveLength(2); + expect(decisions.every((decision) => decision.outcome !== "eligible")).toBe(true); + }); + it("marks a completed, own-definition, old-enough build as eligible when no child builds are found", async () => { const decisions = await scanStaleRefs({ ...baseOpts, @@ -99,7 +157,7 @@ describe("scanStaleRefs", () => { getBuild: async () => { throw new Error("network error"); }, - listBuildsForDefinitionBranch: async () => [], + listBuildsForBranch: async () => [], }, now: () => NOW, }); 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..d0e4e2be0 100644 --- a/scripts/ado-script/src/compiler-smoke-e2e/ado-rest.ts +++ b/scripts/ado-script/src/compiler-smoke-e2e/ado-rest.ts @@ -12,6 +12,8 @@ */ import { redact } from "./process.js"; import { sleep as defaultSleep } from "./process.js"; +import { boundaryMarker, boundaryTargetRef } from "./config.js"; +import { parseBoundaryTargetRef, parseCandidateRef } from "./git.js"; export interface AdoRestOptions { orgUrl: string; @@ -39,11 +41,75 @@ export interface ArtifactInfo { resource?: { downloadUrl?: string; type?: string }; } +export interface BoundaryPr { + pullRequestId: number; + status: string; + description?: string; + title?: string; + sourceRefName?: string; + targetRefName?: string; + repository?: { id?: string; name?: string; project?: { id?: string; name?: string } }; + forkSource?: unknown; +} + +export interface OwnedBoundaryPr extends BoundaryPr { + title: string; + sourceRefName: string; + targetRefName: 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; const DEFAULT_TAG_RETRY_DELAY_MS = 2_000; +export class AdoHttpError extends Error { + constructor( + message: string, + readonly status: number, + readonly retryAfterMs?: number, + ) { + super(message); + this.name = "AdoHttpError"; + } +} + +/** Undefined means permanent/unknown; zero means transient without a server delay. */ +export function transientReadRetryAfter(error: unknown): number | undefined { + if (error instanceof AdoHttpError) { + return [408, 429, 500, 502, 503, 504].includes(error.status) + ? error.retryAfterMs ?? 0 + : undefined; + } + for (let cause: unknown = error, depth = 0; depth < 5; depth++) { + if (typeof cause !== "object" || cause === null) break; + if (cause instanceof Error && ["TimeoutError", "AbortError"].includes(cause.name)) return 0; + if ("code" in cause && typeof cause.code === "string" && [ + "ECONNRESET", "ECONNREFUSED", "ECONNABORTED", "ETIMEDOUT", "EPIPE", + "EAI_AGAIN", "ENETUNREACH", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", + "UND_ERR_BODY_TIMEOUT", "UND_ERR_SOCKET", + ].includes(cause.code)) return 0; + cause = "cause" in cause ? cause.cause : undefined; + } + return undefined; +} + +function parseRetryAfter(value: string | null): number | undefined { + if (!value?.trim()) return undefined; + const seconds = Number(value); + const delay = Number.isFinite(seconds) + ? seconds >= 0 ? seconds * 1_000 : NaN + : Date.parse(value) - Date.now(); + return Number.isFinite(delay) ? Math.max(0, delay) : undefined; +} + export class AdoRest { private readonly base: string; private readonly project: string; @@ -73,7 +139,7 @@ export class AdoRest { private async request( path: string, - opts: { method?: string; body?: unknown; allow404?: boolean } = {}, + opts: { method?: string; body?: unknown; allow404?: boolean; timeoutMs?: number; complete?: boolean } = {}, ): Promise { const headers: Record = { Authorization: this.authHeader, @@ -88,12 +154,19 @@ export class AdoRest { method: opts.method ?? "GET", headers, body, - signal: AbortSignal.timeout(this.timeoutMs), + signal: AbortSignal.timeout(Math.max(1, Math.min(this.timeoutMs, opts.timeoutMs ?? this.timeoutMs))), }); if (res.status === 404 && opts.allow404) return undefined; if (!res.ok) { const text = await res.text().catch(() => ""); - throw new Error(`ADO ${opts.method ?? "GET"} ${path} -> HTTP ${res.status}: ${text}`); + throw new AdoHttpError( + `ADO ${opts.method ?? "GET"} ${path} -> HTTP ${res.status}: ${text}`, + res.status, + parseRetryAfter(res.headers.get("retry-after")), + ); + } + if (opts.complete && res.headers.get("x-ms-continuationtoken")?.trim()) { + throw new Error("Incomplete ADO metadata cannot authorize smoke resource cleanup"); } if (res.status === 204) return undefined; const text = await res.text(); @@ -148,13 +221,151 @@ export class AdoRest { ); } - async getBuild(buildId: number): Promise { + async getBuild(buildId: number, opts: { timeoutMs?: number } = {}): Promise { const path = this.projPath(`_apis/build/builds/${buildId}?api-version=7.1`); - const res = await this.request(path); + const res = await this.request(path, opts); if (!res) throw new Error(`getBuild(${buildId}) returned no body`); + if (typeof res !== "object" || res.id !== buildId || typeof res.status !== "string" + || !["none", "notStarted", "postponed", "inProgress", "cancelling", "completed"].includes(res.status)) { + throw new Error(`getBuild(${buildId}) returned an invalid build summary`); + } return res; } + async createBoundaryTarget(repo: string, ref: string, sha: string): Promise { + if (!parseBoundaryTargetRef(ref)) { + 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 identity = parseCandidateRef(source); + if (!identity || target !== boundaryTargetRef(identity.buildId, identity.caseId) + || marker !== boundaryMarker(identity.buildId, identity.caseId)) { + throw new Error("Boundary PR must use the exact owned source, target and marker"); + } + 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; + } + + private validateBoundaryPr(repo: string, source: string, pr: BoundaryPr): asserts pr is OwnedBoundaryPr { + if (!pr || typeof pr !== "object") throw new Error("Boundary PR metadata must be an object"); + const identity = parseCandidateRef(source); + const repository = pr.repository; + if (!identity || !Number.isSafeInteger(pr.pullRequestId) || pr.pullRequestId <= 0 + || pr.sourceRefName !== source + || ![boundaryTargetRef(identity.buildId, identity.caseId), `${source}-target`].includes(pr.targetRefName ?? "") + || pr.title !== boundaryMarker(identity.buildId, identity.caseId) + || !["active", "abandoned", "completed"].includes(pr.status) + || pr.forkSource != null + || ![repository?.id, repository?.name].some((value) => typeof value === "string" && value.toLowerCase() === repo.toLowerCase()) + || ![repository?.project?.id, repository?.project?.name].some((value) => typeof value === "string" && value.toLowerCase() === this.project.toLowerCase())) { + throw new Error(`Cannot establish boundary PR ownership for ${source}`); + } + } + + async findBoundaryPr(repo: string, source: string): Promise { + if (!parseCandidateRef(source)) throw new Error("Boundary PR discovery requires an owned source ref"); + const query = new URLSearchParams({ + "searchCriteria.sourceRefName": source, "searchCriteria.status": "all", "$top": "2", "api-version": "7.1", + }); + const response = await this.request<{ value?: BoundaryPr[] }>( + this.projPath(`_apis/git/repositories/${AdoRest.seg(repo)}/pullrequests?${query}`), { complete: true }, + ); + if (!Array.isArray(response?.value) || response.value.length > 1) { + throw new Error(`Boundary PR discovery is incomplete or ambiguous for ${source}`); + } + const pr = response.value[0]; + if (pr !== undefined) this.validateBoundaryPr(repo, source, pr); + return pr; + } + + async abandonBoundaryPr(repo: string, expected: OwnedBoundaryPr): Promise { + const id = expected.pullRequestId; + const verify = (pr: BoundaryPr) => { + this.validateBoundaryPr(repo, expected.sourceRefName, pr); + if (pr.pullRequestId !== id || pr.targetRefName !== expected.targetRefName) { + throw new Error("Boundary PR identity changed during cleanup"); + } + }; + const pr = await this.boundaryPr(repo, id); + verify(pr); + if (pr.status === "active") { + let failure: unknown; + try { + await this.request(this.projPath(`_apis/git/repositories/${AdoRest.seg(repo)}/pullRequests/${id}?api-version=7.1`), + {method: "PATCH", body: {status: "abandoned"}}); + } catch (error) { + failure = error; + } + const confirmed = await this.boundaryPr(repo, id); + verify(confirmed); + if (confirmed.status !== "abandoned") { + throw new Error(`Boundary PR #${id} abandonment is unconfirmed: ${confirmed.status}`, { cause: failure }); + } + } else if (pr.status !== "abandoned") { + throw new Error(`Boundary PR #${id} unexpectedly ${pr.status}; retaining refs`); + } + } + + 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); + } + + async verifyBoundaryPush(repo: string, sourceRef: string, originalHead: string, path: string, content: string): Promise { + if (!sourceRef.startsWith("refs/heads/ado-aw-smoke-candidate/")) throw new Error("Push proof must use an owned candidate ref"); + const root = this.projPath(`_apis/git/repositories/${AdoRest.seg(repo)}`); + const refs = await this.request<{ value?: { name?: string; objectId?: string }[] }>( + `${root}/refs?filter=${encodeURIComponent(sourceRef.replace(/^refs\//, ""))}&api-version=7.1`, + ); + const matches = refs?.value?.filter((entry) => entry.name === sourceRef) ?? []; + const head = matches[0]?.objectId; + if (matches.length !== 1 || typeof head !== "string" || !/^[a-f0-9]{40}$/i.test(head) || head === originalHead) { + throw new Error("PR push proof did not advance the exact owned source ref"); + } + const commit = await this.request<{ parents?: string[] }>(`${root}/commits/${head}?api-version=7.1`); + if (commit?.parents?.length !== 1 || commit.parents[0] !== originalHead) { + throw new Error("PR push proof was not a direct child of the prepared source head"); + } + const item = await this.request<{ content?: string }>( + `${root}/items?path=${encodeURIComponent(`/${path}`)}&versionDescriptor.versionType=commit&versionDescriptor.version=${head}&includeContent=true&%24format=json&api-version=7.1`, + ); + if (item?.content !== content) throw new Error("PR push proof file did not contain the exact expected content"); + } + /** Read the observable tags on a completed child build. */ async getBuildTags( buildId: number, @@ -262,24 +473,19 @@ export class AdoRest { return res; } - /** - * List every build of `definitionId` on the exact `branch` (a full ref, - * e.g. `refs/heads/ado-aw-smoke-candidate/123`), regardless of status. - * - * Deliberately queries a single definition + exact branch and inspects - * each build's own `status` client-side, rather than asking ADO's - * `statusFilter` for a comma-separated set of "still running" states — - * whether that filter reliably matches every non-terminal status across - * ADO Build REST versions is not something this harness can assume. - * Used by the stale-ref scanner to prove NO fixture child build is still - * active on a candidate branch before it is deleted. - */ - async listBuildsForDefinitionBranch(definitionId: number, branch: string): Promise { - const path = this.projPath( - `_apis/build/builds?definitions=${definitionId}&branchName=${AdoRest.seg(branch)}&api-version=7.1&$top=50`, - ); - const res = await this.request<{ value?: BuildSummary[] }>(path); - return res?.value ?? []; + /** Include every definition: today's selected lanes cannot describe an older run. */ + async listBuildsForBranch(branch: string): Promise { + const query = new URLSearchParams({ branchName: branch, "api-version": "7.1", "$top": "50" }); + const path = this.projPath(`_apis/build/builds?${query}`); + const res = await this.request<{ value?: BuildSummary[] }>(path, { complete: true }); + if (!Array.isArray(res?.value) || res.value.length >= 50 + || res.value.some((build) => !build || !Number.isSafeInteger(build.definition?.id) + || (build.definition?.id ?? 0) <= 0 + || build.sourceBranch !== branch || !Number.isSafeInteger(build.id) || build.id <= 0 + || !["none", "notStarted", "postponed", "inProgress", "cancelling", "completed"].includes(build.status ?? ""))) { + throw new Error("Child build discovery is incomplete or contains mismatched build identities"); + } + return res.value; } } 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..4ca68729d 100644 --- a/scripts/ado-script/src/compiler-smoke-e2e/cases.ts +++ b/scripts/ado-script/src/compiler-smoke-e2e/cases.ts @@ -62,6 +62,7 @@ export interface CaseAssertions { readonly pipelineText?: AgentCommandAssertion; /** Build tags the child run must carry, with `{buildId}` expanded to the child build id. */ readonly requiredBuildTags?: readonly string[]; + readonly pushedFile?: { readonly path: string; readonly content: string }; } export interface SmokeLane { @@ -78,6 +79,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 { @@ -217,16 +219,25 @@ function parseAssertions(raw: unknown, caseId: string): CaseAssertions | undefin } } + let pushedFile: CaseAssertions["pushedFile"]; + if (obj.pushedFile !== undefined) { + const file = asRecord(obj.pushedFile, `case '${caseId}' assertions.pushedFile`); + const path = validateSourcePath(file.path, caseId); + const content = asString(file.content, `case '${caseId}' pushedFile.content`); + if (content.length > 4096) fail(`case '${caseId}' pushedFile.content exceeds the assertion bound`); + pushedFile = { path, content }; + } if ( agentCommand === undefined && pipelineText === undefined && - requiredBuildTags === undefined + requiredBuildTags === undefined && + pushedFile === undefined ) { fail( `case '${caseId}' assertions must declare agentCommand, pipelineText and/or requiredBuildTags`, ); } - return { agentCommand, pipelineText, requiredBuildTags }; + return { agentCommand, pipelineText, requiredBuildTags, pushedFile }; } /** Expand `{buildId}` in a declared build tag. */ @@ -316,7 +327,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 +382,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/cleanup.ts b/scripts/ado-script/src/compiler-smoke-e2e/cleanup.ts new file mode 100644 index 000000000..f81817e01 --- /dev/null +++ b/scripts/ado-script/src/compiler-smoke-e2e/cleanup.ts @@ -0,0 +1,45 @@ +import type { OwnedBoundaryPr } from "./ado-rest.js"; +import { boundaryTargetRef } from "./config.js"; +import { parseCandidateRef, type RemoteRef } from "./git.js"; + +interface CleanupClient { + findBoundaryPr(repo: string, source: string): Promise; + abandonBoundaryPr(repo: string, pr: OwnedBoundaryPr): Promise; +} + +/** Caller must prove the source's child builds terminal before entering cleanup. */ +export async function cleanupCaseResources(opts: { + client: CleanupClient; + repository: string; + sourceRef: string; + refs: readonly RemoteRef[]; + observedRefs?: readonly RemoteRef[]; + expectedPrId?: number; + deleteRefs: (refs: readonly RemoteRef[]) => Promise; +}): Promise { + const identity = parseCandidateRef(opts.sourceRef); + if (!identity) throw new Error("Case cleanup requires an owned source ref"); + const pr = await opts.client.findBoundaryPr(opts.repository, opts.sourceRef); + if (pr && (opts.observedRefs ?? opts.refs).some((entry) => entry.ref === pr.targetRefName) + && !opts.refs.some((entry) => entry.ref === pr.targetRefName)) { + throw new Error("Boundary target belongs to an unproven or ambiguous resource group; retaining both refs"); + } + if (identity.caseId.endsWith("-target")) { + const possibleParent = opts.sourceRef.slice(0, -"-target".length); + const parentPr = await opts.client.findBoundaryPr(opts.repository, possibleParent); + if (parentPr?.targetRefName === opts.sourceRef) { + throw new Error("Source ref is also a legacy boundary target; retaining ambiguous resources"); + } + } + if (opts.expectedPrId !== undefined && pr?.pullRequestId !== opts.expectedPrId) { + throw new Error("Known boundary PR could not be recovered; retaining both refs"); + } + const target = boundaryTargetRef(identity.buildId, identity.caseId); + for (const { ref } of opts.refs) { + if (ref !== opts.sourceRef && ref !== target && ref !== pr?.targetRefName) { + throw new Error(`Cannot establish ownership of paired ref ${ref}`); + } + } + if (pr) await opts.client.abandonBoundaryPr(opts.repository, pr); + await opts.deleteRefs(opts.refs); +} diff --git a/scripts/ado-script/src/compiler-smoke-e2e/config.ts b/scripts/ado-script/src/compiler-smoke-e2e/config.ts index cbaacbdd9..a3a1f763d 100644 --- a/scripts/ado-script/src/compiler-smoke-e2e/config.ts +++ b/scripts/ado-script/src/compiler-smoke-e2e/config.ts @@ -16,6 +16,7 @@ import { COMPILER_SOURCES, type CompilerSource } from "./cases.js"; /** Per-run candidate branch prefix (never the base ref). */ export const CANDIDATE_BRANCH_PREFIX = "ado-aw-smoke-candidate"; +export const BOUNDARY_TARGET_BRANCH_PREFIX = "ado-aw-smoke-boundary-target"; export const DEFAULT_CONCURRENCY = 5; export const MIN_CONCURRENCY = 1; @@ -195,3 +196,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): SmokeConfig { export function candidateRef(buildId: number, caseId: string): string { return `refs/heads/${CANDIDATE_BRANCH_PREFIX}/${buildId}/${caseId}`; } + +export function boundaryTargetRef(buildId: number, caseId: string): string { + return `refs/heads/${BOUNDARY_TARGET_BRANCH_PREFIX}/${buildId}/${caseId}`; +} + +export function boundaryMarker(buildId: number, caseId: string): string { + return `ado-aw-boundary-original-${buildId}-${caseId}`; +} diff --git a/scripts/ado-script/src/compiler-smoke-e2e/git.ts b/scripts/ado-script/src/compiler-smoke-e2e/git.ts index e9ee04aa5..bfe88df26 100644 --- a/scripts/ado-script/src/compiler-smoke-e2e/git.ts +++ b/scripts/ado-script/src/compiler-smoke-e2e/git.ts @@ -15,7 +15,7 @@ */ import { bearerEnv } from "../shared/git.js"; import { redact, safeSpawn, type SpawnOutcome } from "./process.js"; -import { CANDIDATE_BRANCH_PREFIX } from "./config.js"; +import { BOUNDARY_TARGET_BRANCH_PREFIX, CANDIDATE_BRANCH_PREFIX } from "./config.js"; export interface GitRunOptions { cwd: string; @@ -258,55 +258,51 @@ export async function verifyRemoteRef( /** Delete the candidate ref on the mirror repo (best-effort; caller decides how to handle failure). */ export async function deleteRemoteRef( - opts: { cwd: string; mirrorUrl: string; ref: string; token: string; timeoutMs: number }, + opts: { cwd: string; mirrorUrl: string; ref: string; sha: string; token: string; timeoutMs: number }, runner: GitRunner = defaultGitRunner, ): Promise { - await deleteRemoteRefs({ ...opts, refs: [opts.ref] }, runner); + await deleteRemoteRefs({ ...opts, refs: [{ ref: opts.ref, sha: opts.sha }] }, runner); } /** * Delete one or more candidate refs on the mirror repo in a single push. * - * Batched because the lane model creates one ref per case per run, so a - * five-case run would otherwise pay five round trips. Falls back to - * individual deletes if the batch fails, so one bad ref cannot strand the - * rest. + * Every deletion has an exact tip lease. A failed batch is reconciled by + * reading refs, never retried unconditionally. */ export async function deleteRemoteRefs( - opts: { cwd: string; mirrorUrl: string; refs: readonly string[]; token: string; timeoutMs: number }, + opts: { cwd: string; mirrorUrl: string; refs: readonly RemoteRef[]; token: string; timeoutMs: number }, runner: GitRunner = defaultGitRunner, ): Promise { if (opts.refs.length === 0) return; + for (const { ref, sha } of opts.refs) { + if ((!parseCandidateRef(ref) && !parseBoundaryTargetRef(ref)) || !/^[0-9a-f]{40}$/i.test(sha)) { + throw new Error(`Ref deletion requires an owned ref and an exact commit SHA: ${ref}`); + } + } const env = bearerEnv(opts.token); - const run1 = (refs: readonly string[]): Promise => - run( - ["push", "--porcelain", opts.mirrorUrl, "--delete", ...refs], + try { + await run( + ["push", "--porcelain", ...opts.refs.map(({ ref, sha }) => `--force-with-lease=${ref}:${sha}`), + opts.mirrorUrl, ...opts.refs.map(({ ref }) => `:${ref}`)], { cwd: opts.cwd, env, timeoutMs: opts.timeoutMs }, runner, [opts.token], ); - - if (opts.refs.length === 1) { - await run1(opts.refs); - return; - } - - try { - await run1(opts.refs); - } catch (batchErr) { - const failures: string[] = []; - for (const ref of opts.refs) { - try { - await run1([ref]); - } catch (err) { - failures.push(`${ref}: ${err instanceof Error ? err.message : String(err)}`); - } + } catch (error) { + let observed: RemoteRef[]; + try { + observed = await listCandidateRefs(opts, runner); + } catch (readError) { + throw new Error(`Conditional ref deletion failed; remaining refs could not be confirmed: ${String(readError)}`, { + cause: error, + }); } - if (failures.length > 0) { - throw new Error( - `batched ref delete failed (${batchErr instanceof Error ? batchErr.message : String(batchErr)}); ` + - `per-ref fallback also failed for: ${failures.join("; ")}`, - ); + const remaining = opts.refs.filter(({ ref }) => observed.some((entry) => entry.ref === ref)); + if (remaining.length > 0) { + const deleted = opts.refs.filter(({ ref }) => !remaining.some((entry) => entry.ref === ref)); + throw new Error(`Conditional ref deletion failed; retained: ${remaining.map((entry) => entry.ref).join(", ")}; ` + + `confirmed absent: ${deleted.map((entry) => entry.ref).join(", ") || "none"}`, { cause: error }); } } } @@ -316,7 +312,7 @@ export interface RemoteRef { sha: string; } -/** List every remote ref under the exact `refs/heads//` prefix. */ +/** List source and boundary-target refs under the two exact owned prefixes. */ export async function listCandidateRefs( opts: { cwd: string; mirrorUrl: string; token: string; timeoutMs: number }, runner: GitRunner = defaultGitRunner, @@ -327,13 +323,14 @@ export async function listCandidateRefs( // (`/`), and some git/server implementations do not match // `/` with a single `*`. The exact-prefix guard below remains the real // filter either way. - ["ls-remote", "--heads", opts.mirrorUrl, `refs/heads/${CANDIDATE_BRANCH_PREFIX}/**`], + ["ls-remote", "--heads", opts.mirrorUrl, `refs/heads/${CANDIDATE_BRANCH_PREFIX}/**`, + `refs/heads/${BOUNDARY_TARGET_BRANCH_PREFIX}/**`], { cwd: opts.cwd, env, timeoutMs: opts.timeoutMs }, runner, [opts.token], ); if (!stdout) return []; - const prefix = `refs/heads/${CANDIDATE_BRANCH_PREFIX}/`; + const prefixes = [CANDIDATE_BRANCH_PREFIX, BOUNDARY_TARGET_BRANCH_PREFIX].map((prefix) => `refs/heads/${prefix}/`); const refs: RemoteRef[] = []; for (const line of stdout.split("\n")) { if (!line.trim()) continue; @@ -341,7 +338,7 @@ export async function listCandidateRefs( // Exact-prefix guard: ls-remote's glob can match unintended refs on some // git/server implementations (e.g. a sibling branch containing the // pattern as a substring) — never treat those as our candidate refs. - if (sha && ref && ref.startsWith(prefix)) { + if (sha && ref && prefixes.some((prefix) => ref.startsWith(prefix))) { refs.push({ ref, sha }); } } @@ -364,9 +361,17 @@ export interface ParsedCandidateRef { * identity. */ export function parseCandidateRef(ref: string): ParsedCandidateRef | undefined { - const prefix = `refs/heads/${CANDIDATE_BRANCH_PREFIX}/`; + return parseOwnedRef(ref, CANDIDATE_BRANCH_PREFIX); +} + +export function parseBoundaryTargetRef(ref: string): ParsedCandidateRef | undefined { + return parseOwnedRef(ref, BOUNDARY_TARGET_BRANCH_PREFIX); +} + +function parseOwnedRef(ref: string, branchPrefix: string): ParsedCandidateRef | undefined { + const prefix = `refs/heads/${branchPrefix}/`; if (!ref.startsWith(prefix)) return undefined; - const match = /^([0-9]+)\/([a-z0-9][a-z0-9-]{0,48})$/.exec(ref.slice(prefix.length)); + const match = /^([1-9][0-9]*)\/([a-z0-9][a-z0-9-]{0,48})$/.exec(ref.slice(prefix.length)); if (!match) return undefined; const buildId = Number(match[1]); if (!Number.isSafeInteger(buildId) || buildId <= 0) return undefined; diff --git a/scripts/ado-script/src/compiler-smoke-e2e/index.ts b/scripts/ado-script/src/compiler-smoke-e2e/index.ts index 110499ba9..652ce22a7 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, @@ -40,7 +41,8 @@ import { assertReleaseUrlsPresent, } from "./assertions.js"; import { loadCases, type ResolvedCase, type ResolvedCases } from "./cases.js"; -import { candidateRef, loadConfig, type SmokeConfig } from "./config.js"; +import { boundaryMarker, boundaryTargetRef, candidateRef, loadConfig, type SmokeConfig } from "./config.js"; +import { cleanupCaseResources } from "./cleanup.js"; import { compileAndCheck } from "./compile-cli.js"; import { commitAll, @@ -60,7 +62,7 @@ import { prepareCaseSource } from "./source.js"; import { renderResultsTable } from "./report.js"; import { runFixtures, type FixtureBuildRequest, type FixtureBuildResult } from "./runner.js"; import { verifyCandidateAudit, verifyCaseSignals } from "./signals.js"; -import { scanStaleRefs } from "./stale.js"; +import { scanStaleRefs, type StaleRefDecision } from "./stale.js"; function log(msg: string): void { // Percent-encode a leading '#' so a message cannot smuggle a ##vso command. @@ -133,7 +135,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 +181,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. */ @@ -244,7 +246,6 @@ async function stageAllCases( async function cleanupStaleRefs( config: SmokeConfig, - resolved: ResolvedCases, rest: AdoRest, mirrorUrl: string, ownRefs: ReadonlySet, @@ -256,35 +257,43 @@ async function cleanupStaleRefs( token: config.token, timeoutMs: config.childTimeoutMs, }); - const decisions = await scanStaleRefs({ + const scanOptions = { refs: refs.filter((entry) => !ownRefs.has(entry.ref)), baseRef: config.sourceBranch, ownRef: "", definitionId: config.definitionId, - laneDefinitionIds: resolved.laneDefinitionIds, staleRefHours: config.staleRefHours, client: rest, - }); - const eligible = decisions.filter((decision) => decision.outcome === "eligible"); + boundaryPrForSource: (source: string) => rest.findBoundaryPr(config.mirrorRepo, source), + }; + const decisions = await scanStaleRefs(scanOptions); + const groups = new Map(); for (const decision of decisions) { + const group = groups.get(decision.sourceRef) ?? []; + group.push(decision); + groups.set(decision.sourceRef, group); if (decision.outcome !== "eligible") { log(`[stale-scan] ${decision.ref}: ${decision.outcome} — ${decision.reason}`); } } - if (eligible.length === 0) return; - try { - await deleteRemoteRefs({ - cwd: config.sourcesDirectory, - mirrorUrl, - refs: eligible.map((decision) => decision.ref), - token: config.token, - timeoutMs: config.childTimeoutMs, - }); - for (const decision of eligible) { - log(`[stale-scan] deleted ${decision.ref}: ${decision.reason}`); + for (const [sourceRef, group] of groups) { + if (group.some((decision) => decision.outcome !== "eligible")) continue; + try { + const checked = await scanStaleRefs({ ...scanOptions, refs: group }); + if (checked.some((decision) => decision.outcome !== "eligible")) { + log(`[stale-scan] retaining group ${sourceRef}: eligibility changed`); + continue; + } + await cleanupCaseResources({ + client: rest, repository: config.mirrorRepo, sourceRef, refs: group, observedRefs: refs, + deleteRefs: (refs) => deleteRemoteRefs({ + cwd: config.sourcesDirectory, mirrorUrl, refs, token: config.token, timeoutMs: config.childTimeoutMs, + }), + }); + for (const decision of group) log(`[stale-scan] deleted ${decision.ref}: ${decision.reason}`); + } catch (err) { + log(`[stale-scan] WARNING: cleanup of ${sourceRef} failed: ${errMessage(err)}`); } - } catch (err) { - log(`[stale-scan] WARNING: failed to delete stale ref(s): ${errMessage(err)}`); } } catch (err) { log(`[stale-scan] WARNING: scan failed (best-effort, continuing): ${errMessage(err)}`); @@ -313,6 +322,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. @@ -356,12 +367,27 @@ export async function main(): Promise { ); const ownRefs = new Set(resolved.cases.map((entry) => candidateRef(config.buildId, entry.id))); - await cleanupStaleRefs(config, resolved, rest, mirrorUrl, ownRefs); + for (const entry of resolved.cases) { + if (entry.prBoundary) ownRefs.add(boundaryTargetRef(config.buildId, entry.id)); + } + await cleanupStaleRefs(config, rest, mirrorUrl, ownRefs); const staged = await stageAllCases(config, resolved, worktreeDir, mirrorUrl, (caseId, ref) => { pushedRefs.set(caseId, ref); }); + for (const entry of resolved.cases) { + if (!entry.prBoundary) continue; + const source = staged.get(entry.id)!; + const targetRef = boundaryTargetRef(config.buildId, entry.id); + boundaryTargetRefs.set(entry.id, targetRef); + await rest.createBoundaryTarget(config.mirrorRepo, targetRef, config.sourceVersion); + const description = boundaryMarker(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 +395,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 +425,37 @@ 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}`); + } + if (entry.prBoundary === "rejected" && artifacts.includes("safe_outputs_reviewed")) { + throw new Error("Rejected boundary unexpectedly published reviewed executor artifacts"); + } + verifyPrBoundary(entry.prBoundary, result.buildId, resource.description, pr.description, records); + if (entry.assertions?.pushedFile) { + const source = staged.get(entry.id); + if (!source) throw new Error("Push proof is missing the staged source identity"); + await rest.verifyBoundaryPush(config.mirrorRepo, source.ref, source.sha, + entry.assertions.pushedFile.path, + entry.assertions.pushedFile.content.replaceAll("{buildId}", String(result.buildId))); + } + } 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; @@ -417,35 +475,45 @@ export async function main(): Promise { // positively proven. One unproven case no longer strands every other // case's ref, as it did when all cases shared one ref. const provenById = new Map(results.map((result) => [result.caseId, result.terminalProven])); - const deletable: string[] = []; - const retained: string[] = []; for (const [caseId, ref] of pushedRefs) { // A pushed case with no result is only safe to clean up if we never got // as far as queueing. If queueing was attempted, a missing result means // `runFixtures` threw and a build may still be running — fail closed and // let the stale-ref scanner reclaim it once ADO can prove it stopped. const proven = provenById.get(caseId) ?? !queueAttempted; - (proven ? deletable : retained).push(ref); - } - if (deletable.length > 0) { + const targetRef = boundaryTargetRefs.get(caseId); + const requested = [ref, ...(targetRef ? [targetRef] : [])]; + if (!proven) { + log(`WARNING: retaining ${requested.join(", ")} because the source build's terminal state is unconfirmed`); + continue; + } + const resource = boundaryResources.get(caseId); try { - await deleteRemoteRefs({ - cwd: config.sourcesDirectory, - mirrorUrl, - refs: deletable, - token: config.token, - timeoutMs: config.childTimeoutMs, + for (const branch of requested) { + const children = await rest.listBuildsForBranch(branch); + if (children.some((child) => child.status !== "completed")) { + throw new Error(`Another build on ${branch} is not terminal; retaining the resource group`); + } + } + const snapshot = await listCandidateRefs({ + cwd: config.sourcesDirectory, mirrorUrl, token: config.token, timeoutMs: config.childTimeoutMs, }); - log(`[git] deleted ${deletable.length} candidate ref(s)`); - } catch (err) { + await cleanupCaseResources({ + client: rest, repository: config.mirrorRepo, sourceRef: ref, + refs: snapshot.filter((entry) => requested.includes(entry.ref)), + observedRefs: snapshot, + expectedPrId: resource?.id, + deleteRefs: (refs) => deleteRemoteRefs({ + cwd: config.sourcesDirectory, mirrorUrl, refs, token: config.token, timeoutMs: config.childTimeoutMs, + }), + }); + log(`[git] confirmed cleanup of ${requested.join(", ")}`); + } catch (error) { overallOk = false; - failureMessage ??= `failed to delete candidate ref(s): ${errMessage(err)}`; - log(`WARNING: failed to delete candidate ref(s): ${errMessage(err)}`); + failureMessage ??= `failed to clean case ${caseId}: ${errMessage(error)}`; + log(`WARNING: cleanup of ${requested.join(", ")} failed: ${errMessage(error)}`); } } - for (const ref of retained) { - log(`WARNING: retaining ${ref} because its build's terminal state could not be confirmed`); - } try { await removeWorktree({ 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..a6f861d96 --- /dev/null +++ b/scripts/ado-script/src/compiler-smoke-e2e/pr-boundary.ts @@ -0,0 +1,33 @@ +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 matches = (record: BoundaryTimelineRecord, id: string) => { + const identifier = record.identifier?.replace(/\.__default$/, ""); + return identifier === id || identifier?.endsWith(`.${id}`); + }; + const job = (id: string) => records.find((record) => record.type === "Job" && matches(record, 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"); + const reviewed = job("SafeOutputs_Reviewed"); + // ADO never creates a Job record for a job skipped before agent allocation. + const skipped = reviewed?.result === "skipped" || (!reviewed && records.some((record) => + record.type === "Phase" && matches(record, "SafeOutputs_Reviewed") && record.result === "skipped")); + if (!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..36e642418 100644 --- a/scripts/ado-script/src/compiler-smoke-e2e/runner.ts +++ b/scripts/ado-script/src/compiler-smoke-e2e/runner.ts @@ -17,7 +17,9 @@ * stale-ref scanner's per-child-definition build check is what * eventually proves (or disproves) that an orphaned build exists, * - successfully queued builds are polled with bounded concurrency, - * - the FIRST failure or timeout flips a shared abort flag; every other + * - up to three consecutive transient status-read failures are tolerated + * within the original deadline; the third failure requests cancellation, + * - the FIRST permanent failure or timeout flips a shared abort flag; every other * still-polling build is cancelled and polled to a terminal state * before this function returns, * - each polled build's identity (definition id, sourceBranch, @@ -38,6 +40,7 @@ * Test-harness module; not shipped in `ado-script.zip`. */ import { sleep as defaultSleep } from "./process.js"; +import { transientReadRetryAfter } from "./ado-rest.js"; /** What a queued build looks like once polled — kept narrow (a subset of `AdoRest.BuildSummary`) so tests never need a full AdoRest fake. */ export interface PolledBuild { @@ -51,7 +54,7 @@ export interface PolledBuild { /** The minimal ADO Build surface this state machine needs. */ export interface FixtureBuildClient { queueBuild(definitionId: number, opts: { sourceBranch: string; sourceVersion: string }): Promise<{ id: number }>; - getBuild(buildId: number): Promise; + getBuild(buildId: number, opts?: { timeoutMs?: number }): Promise; cancelBuild(buildId: number): Promise; buildUrl(buildId: number): string; /** Best-effort run labelling; a tagging failure never fails the case. */ @@ -72,6 +75,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 +191,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; @@ -199,7 +203,10 @@ async function pollOne( ): Promise { let cancelRequestedAt: number | undefined; let mismatchReason: string | undefined; - let verified = false; + let readFailures = 0; + let lastReadFailure: string | undefined; + let nextReadAt: number | undefined; + let cancellationReason: string | undefined; const requestCancel = async (): Promise => { opts.abort.signal(); @@ -210,36 +217,53 @@ async function pollOne( }; for (;;) { + if (cancelRequestedAt !== undefined && Date.now() - cancelRequestedAt >= opts.cancelGraceMs) { + return { + status: lastReadFailure ? "failed" : "timed-out", + message: lastReadFailure + ? `build #${buildId}: getBuild kept failing and never confirmed a terminal state within the cancellation grace period: ${lastReadFailure}` + : mismatchReason ?? `build #${buildId} did not reach a terminal state within the cancellation grace period`, + terminalProven: false, + }; + } + if (nextReadAt !== undefined && cancelRequestedAt === undefined) { + if (opts.abort.aborted || Date.now() >= opts.deadlineAt) { + await requestCancel(); + } else if (Date.now() < nextReadAt) { + await opts.sleepImpl(Math.max(0, Math.min(opts.pollMs, nextReadAt - Date.now(), opts.deadlineAt - Date.now()))); + continue; + } + } + const readDeadline = cancelRequestedAt === undefined + ? opts.deadlineAt + : cancelRequestedAt + opts.cancelGraceMs; let build: PolledBuild; try { - build = await client.getBuild(buildId); + build = await client.getBuild(buildId, { timeoutMs: Math.max(1, readDeadline - Date.now()) }); } catch (err) { - // A poll error never proves the build stopped. Request cancellation - // and keep retrying (bounded by the same cancellation grace period) - // in case a LATER call confirms a genuinely terminal state; only - // give up as "unproven" once that grace period elapses. - opts.log(`WARNING: getBuild(${buildId}) failed: ${errMessage(err)}`); - const hadCancelRequest = cancelRequestedAt !== undefined; - await requestCancel(); - if (hadCancelRequest && Date.now() - cancelRequestedAt! >= opts.cancelGraceMs) { - return { - status: "failed", - message: `build #${buildId}: getBuild kept failing and never confirmed a terminal state within the cancellation grace period: ${errMessage(err)}`, - terminalProven: false, - }; + lastReadFailure = errMessage(err); + readFailures++; + opts.log(`WARNING: getBuild(${buildId}) failed (consecutive ${readFailures}/3): ${lastReadFailure}`); + const retryAfterMs = transientReadRetryAfter(err); + if (cancelRequestedAt === undefined && retryAfterMs !== undefined && readFailures < 3 + && !opts.abort.aborted && Date.now() < opts.deadlineAt) { + nextReadAt = Date.now() + Math.max(opts.pollMs, retryAfterMs); + continue; } - await opts.sleepImpl(opts.pollMs); + cancellationReason ??= `build #${buildId}: status-read failure: ${lastReadFailure}`; + await requestCancel(); + await opts.sleepImpl(Math.max(0, Math.min(opts.pollMs, cancelRequestedAt! + opts.cancelGraceMs - Date.now()))); continue; } + readFailures = 0; + lastReadFailure = undefined; + nextReadAt = undefined; - if (!verified) { - const mismatch = describeMismatch(build, expected); - if (mismatch) { - mismatchReason = `build #${buildId} ${mismatch}`; - opts.log(`WARNING: ${mismatchReason}`); - await requestCancel(); - } - verified = true; + const mismatch = describeMismatch(build, expected); + if (mismatch) { + mismatchReason ??= `build #${buildId} ${mismatch}`; + opts.log(`WARNING: ${mismatchReason}`); + await requestCancel(); } if (build.status === "completed") { @@ -247,9 +271,9 @@ async function pollOne( return { status: "failed", result: build.result, message: mismatchReason, terminalProven: true }; } if (cancelRequestedAt !== undefined) { - return { status: "canceled", result: build.result, terminalProven: true }; + return { status: "canceled", result: build.result, message: cancellationReason, terminalProven: true }; } - if (build.result === "succeeded") { + if (build.result === (expected.expectedResult ?? "succeeded")) { return { status: "succeeded", result: build.result, terminalProven: true }; } opts.abort.signal(); @@ -272,7 +296,10 @@ async function pollOne( }; } - await opts.sleepImpl(opts.pollMs); + const sleepDeadline = cancelRequestedAt === undefined + ? opts.deadlineAt + : cancelRequestedAt + opts.cancelGraceMs; + await opts.sleepImpl(Math.max(0, Math.min(opts.pollMs, sleepDeadline - Date.now()))); } } @@ -364,7 +391,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/compiler-smoke-e2e/stale.ts b/scripts/ado-script/src/compiler-smoke-e2e/stale.ts index f78a70d0a..d6da41648 100644 --- a/scripts/ado-script/src/compiler-smoke-e2e/stale.ts +++ b/scripts/ado-script/src/compiler-smoke-e2e/stale.ts @@ -11,9 +11,8 @@ * terminal. Note that (c) is NOT by itself proof the orchestration it * started is done — an abruptly canceled/killed parent process can reach a * terminal ADO build status while the fixture builds it queued are still - * running. The scanner therefore also queries each configured lane - * definition on the ref's exact branch (see - * `listBuildsForDefinitionBranch`) and inspects their statuses directly; + * running. The scanner therefore queries all definitions on the source's + * exact branch and inspects their statuses directly; * only when every child build found there is ALSO terminal (or none exist) * is a ref considered `"eligible"` for deletion. Any active child, or any * error looking one up, marks the ref `"active"`/`"ambiguous"` instead. @@ -25,13 +24,15 @@ * * Test-harness module; not shipped in `ado-script.zip`. */ -import { parseCandidateRef, type RemoteRef } from "./git.js"; +import { parseBoundaryTargetRef, parseCandidateRef, type RemoteRef } from "./git.js"; +import { boundaryTargetRef, candidateRef } from "./config.js"; export type StaleRefOutcome = "eligible" | "too-recent" | "active" | "ambiguous"; export interface StaleRefDecision { ref: string; sha: string; + sourceRef: string; outcome: StaleRefOutcome; reason: string; } @@ -46,8 +47,7 @@ export interface StaleScanBuild { export interface StaleScanClient { getBuild(buildId: number): Promise; - /** List builds of `definitionId` on the exact candidate `branch` (see {@link AdoRest.listBuildsForDefinitionBranch}). */ - listBuildsForDefinitionBranch(definitionId: number, branch: string): Promise; + listBuildsForBranch(branch: string): Promise; } export interface ScanStaleRefsOptions { @@ -58,17 +58,10 @@ export interface ScanStaleRefsOptions { ownRef: string; /** This orchestrator pipeline's own definition id (SYSTEM_DEFINITIONID). */ definitionId: number; - /** - * Every fixed fixture ("child") pipeline definition id the orchestrator - * queues builds against. An orchestrator run completing (even abruptly, - * e.g. cancelled) does NOT prove these have also finished — they are - * independently queued builds. A candidate ref is only ever eligible for - * deletion once none of these definitions has a still-active build on - * that ref's exact branch. - */ - laneDefinitionIds: readonly number[]; staleRefHours: number; client: StaleScanClient; + /** Must validate exact repository, source/target and test-marker ownership. */ + boundaryPrForSource?: (sourceRef: string) => Promise<{ targetRefName: string } | undefined>; /** Injectable clock for deterministic tests. */ now?: () => number; } @@ -88,11 +81,18 @@ export async function scanStaleRefs(opts: ScanStaleRefsOptions): Promise + entry.ref === boundaryTargetRef(parsed.buildId, parsed.caseId)); + const legacyTarget = !target && !explicitSource && parsed?.caseId.endsWith("-target"); + const sourceRef = target ? candidateRef(target.buildId, target.caseId) + : legacyTarget ? ref.slice(0, -"-target".length) : ref; if (parsed === undefined) { decisions.push({ ref, sha, + sourceRef, outcome: "ambiguous", reason: "ref name does not match the expected // pattern", }); @@ -107,6 +107,7 @@ export async function scanStaleRefs(opts: ScanStaleRefsOptions): Promise b.status !== "completed")) { - activeLaneDefinitionId = laneDefinitionId; - break; - } + for (const branch of new Set([sourceRef, ref])) { + let childBuilds: StaleScanBuild[]; + try { + childBuilds = await opts.client.listBuildsForBranch(branch); + } catch (err) { + childLookupError = `child build lookup on ${branch} failed: ${ + err instanceof Error ? err.message : String(err) + }`; + break; + } + const active = childBuilds.find((b) => b.status !== "completed"); + if (active) { + activeLaneDefinitionId = active.definition?.id ?? 0; + break; + } } if (childLookupError) { - decisions.push({ ref, sha, outcome: "ambiguous", reason: childLookupError }); + decisions.push({ ref, sha, sourceRef, outcome: "ambiguous", reason: childLookupError }); continue; } @@ -191,8 +213,9 @@ export async function scanStaleRefs(opts: ScanStaleRefsOptions): Promise): 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 Response): ReturnType { + afterEach(() => vi.unstubAllGlobals()); + const name = "refs/heads/owned"; + const objectId = "a".repeat(40); + + it.each([{}, { value: null }, { value: [null] }, { value: [{ name }] }, + { value: [{ name, objectId }, { name, objectId }] }])("rejects incomplete or ambiguous discovery %j", async (body) => { + const fetch = stubFetch(() => Response.json(body)); + await expect(new AdoRest(options).deleteRef("repo", name)).rejects.toThrow(/incomplete|ambiguous/); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it("uses the exact observed SHA and verifies absence", async () => { + let reads = 0; + const fetch = vi.fn(async (_url, init) => { + if (init?.method === "POST") { + expect(JSON.parse(String(init.body))).toEqual([{ name, oldObjectId: objectId, newObjectId: "0".repeat(40) }]); + return Response.json({ value: [{ name, success: true }] }); + } + return Response.json({ value: ++reads === 1 ? [{ name: `${name}-other`, objectId: "b".repeat(40) }, { name, objectId }] : [] }); + }); + vi.stubGlobal("fetch", fetch); + await expect(new AdoRest(options).deleteRef("repo", name)).resolves.toBeUndefined(); + expect(fetch).toHaveBeenCalledTimes(3); + }); + + it.each(["per-entry-failure", "malformed-response", "ref-remains", "lost-response"])( + "does not retry an unconfirmed deletion: %s", async (mode) => { + let writes = 0; + vi.stubGlobal("fetch", vi.fn(async (_url, init) => { + if (init?.method !== "POST") return Response.json({ value: [{ name, objectId }] }); + writes += 1; + if (mode === "lost-response") throw new Error("lost response"); + if (mode === "malformed-response") return Response.json({}); + return Response.json({ value: [{ name, success: mode === "ref-remains", updateStatus: "staleOldObjectId" }] }); + })); + await expect(new AdoRest(options).deleteRef("repo", name)).rejects.toThrow(); + expect(writes).toBe(1); + }, + ); +}); + +describe("auto-complete scenario cleanup", () => { + afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); }); + + function cleanup() { + const rest = new AdoRest(options); + const deleteRef = vi.spyOn(rest, "deleteRef").mockResolvedValue(undefined); + const ctx: ScenarioContext = { + orgUrl: options.orgUrl, project: options.project, token: options.token, rest, + adoRepo: "repo", buildId: "42", adoAwBin: "unused", workDir: "unused", + log: () => {}, prefix: (tool) => `ado-aw-det-42-${tool}`, + }; + return { + deleteRef, + run: () => setPrAutoComplete.cleanup(ctx, { + repo: "repo", prId: 42, branch: "ado-aw-det-42-src", targetBranch: "ado-aw-det-42-target", + }), + }; + } + + it.each(["completed", "abandoned", "missing"])("accepts an already %s auto-complete PR", async (status) => { + const fetch = stubFetch(() => status === "missing" + ? new Response("", { status: 404 }) + : Response.json({ status })); + const test = cleanup(); + await expect(test.run()).resolves.toBeUndefined(); + expect(fetch).toHaveBeenCalledTimes(1); + expect(test.deleteRef).toHaveBeenCalledTimes(2); + }); + + it.each(["between-reads", "during-patch", "lost-response"])( + "accepts confirmed legitimate completion %s without retrying writes", async (race) => { + let reads = 0; + let writes = 0; + vi.stubGlobal("fetch", vi.fn(async (_url, init) => { + if (init?.method === "PATCH") { + writes += 1; + if (race === "lost-response") throw new Error("lost abandonment response"); + return race === "during-patch" + ? new Response("already completed", { status: 409 }) + : Response.json({ status: "completed" }); + } + reads += 1; + const completed = race === "between-reads" ? reads > 1 : writes > 0; + return Response.json({ status: completed ? "completed" : "active" }); + })); + const test = cleanup(); + await expect(test.run()).resolves.toBeUndefined(); + expect(writes).toBeLessThanOrEqual(1); + expect(test.deleteRef.mock.calls).toEqual([ + ["repo", "refs/heads/ado-aw-det-42-src"], + ["repo", "refs/heads/ado-aw-det-42-target"], + ]); + }, + ); + + it.each(["unknown", "unconfirmed-write", "failed-readback"])( + "surfaces %s while independently attempting both branch deletions", async (failure) => { + let writes = 0; + vi.stubGlobal("fetch", vi.fn(async (_url, init) => { + if (init?.method === "PATCH") { + writes += 1; + throw new Error("lost abandonment response"); + } + if (writes > 0 && failure === "failed-readback") return new Response("forbidden", { status: 403 }); + return Response.json({ status: failure === "unknown" ? "unknown" : "active" }); + })); + const test = cleanup(); + await expect(test.run()).rejects.toThrow(); + expect(writes).toBeLessThanOrEqual(1); + expect(test.deleteRef).toHaveBeenCalledTimes(2); + }, + ); +}); + +describe("AdoRest.listPullRequestLabels", () => { + 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", + ); + }); + + 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)); + 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__/create-pull-request-scenarios.test.ts b/scripts/ado-script/src/executor-e2e/__tests__/create-pull-request-scenarios.test.ts index cfe86862e..a19bf8dff 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 @@ -1,10 +1,12 @@ import { spawnSync } from "node:child_process"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { beforeAll, describe, expect, it } from "vitest"; +import { parse as parseYaml } from "yaml"; +import { main as renderApprovalSummary } from "../../approval-summary/index.js"; import { runExecute } from "../execute-cli.js"; import type { @@ -126,7 +128,7 @@ 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"], + target: "*", "allowed-repositories": ["agent-definitions"], "allowed-reviewers": [submittedReviewer], "max-reviewers": 1, @@ -134,7 +136,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 +164,7 @@ describe("create-pull-request add-reviewers handoff", () => { }, }; const updated: ExecutedRecord = { - name: "update_pr", + name: "add_pull_request_reviewers", status: "succeeded", result: { pull_request_id: 42, @@ -266,7 +267,7 @@ describe("create-pull-request add-reviewers handoff", () => { result: created, }, { - name: "update_pr", + name: "add_pull_request_reviewers", status: "succeeded", result: updated, }, @@ -296,7 +297,7 @@ describe("create-pull-request add-reviewers handoff", () => { }, }; const updated: ExecutedRecord = { - name: "update_pr", + name: "add_pull_request_reviewers", status: "succeeded", result: { pull_request_id: 42, @@ -357,6 +358,74 @@ describe("Rust executor payload contract", () => { 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, @@ -408,7 +477,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-pull-request-reviewers", index: 1 }, ] as const)( "rejects an overlong $target temporary ID through Rust deserialization", async ({ target, tool, index }) => { @@ -458,7 +527,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-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..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({ @@ -23,8 +51,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..e5a19128d 100644 --- a/scripts/ado-script/src/executor-e2e/__tests__/index.test.ts +++ b/scripts/ado-script/src/executor-e2e/__tests__/index.test.ts @@ -1,19 +1,168 @@ -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 { SkipError } from "../scenario.js"; import type { ScenarioResult } from "../scenario.js"; +import { AdoRest } from "../ado-rest.js"; + +vi.mock("../github-issue.js", () => ({ + loadIssueEnv: () => ({ repo: "test/repo" }), + fileFailureIssue: vi.fn(async () => ({ filed: false })), +})); + +describe("diagnostic selection", () => { + 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"]); + 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}); } + }); + + 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 }); } + }); + + it.each([ + { ids: "add-pull-request-reviewers", unavailable: "local", scopes: ["local"] }, + { ids: "add-pull-request-reviewers-cross-org", unavailable: "cross", scopes: ["cross"] }, + { ids: "update-pull-request,add-pull-request-reviewers,add-pull-request-reviewers-cross-org", unavailable: "local", scopes: ["local"] }, + { ids: "update-pull-request,add-pull-request-reviewers-cross-org,add-pull-request-reviewers", unavailable: "cross", scopes: ["local", "cross"] }, + { ids: "add-pull-request-reviewers,add-pull-request-reviewers-cross-org", unavailable: "", scopes: ["local", "cross"] }, + ])("preflights every selected reviewer scope ($unavailable unavailable)", async ({ ids, unavailable, scopes }) => { + const dir = await mkdtemp(join(tmpdir(), "ado-reviewer-preflight-")); + try { + for (const [key, value] of Object.entries({ + SYSTEM_COLLECTIONURI: "https://dev.azure.com/local/", SYSTEM_TEAMPROJECT: "test", + SYSTEM_ACCESSTOKEN: "not-a-real-token", EXECUTOR_E2E_ADO_AW_BIN: "must-not-run", + EXECUTOR_E2E_SCENARIOS: ids, EXECUTOR_E2E_REQUIRE_SELECTED: "true", + EXECUTOR_E2E_REVIEWER: "reviewer@example.test", + EXECUTOR_E2E_CROSS_ORG_ORGANIZATION: "cross", EXECUTOR_E2E_CROSS_ORG_PROJECT: "test", + EXECUTOR_E2E_CROSS_ORG_REPOSITORY: "repo", EXECUTOR_E2E_CROSS_ORG_ENDPOINT: "existing", + EXECUTOR_E2E_CROSS_ORG_TOKEN: "not-a-cross-token", + EXECUTOR_E2E_FILE_FAILURE_ISSUE: "false", + EXECUTOR_E2E_RESULTS_PATH: join(dir, "results.json"), + })) vi.stubEnv(key, value); + const visited: string[] = []; + vi.spyOn(AdoRest.prototype, "resolveIdentityId").mockImplementation(async function (this: AdoRest) { + const scope = this.orgBase.endsWith("/local") ? "local" : "cross"; + visited.push(scope); + return scope === unavailable ? undefined : "11111111-1111-1111-1111-111111111111"; + }); + const setups = selectScenarios(allScenarios, ids).map((scenario) => + vi.spyOn(scenario, "setup").mockRejectedValue(new SkipError("stop after preflight"))); + expect(await main()).toBe(1); + expect(visited).toEqual(scopes); + const report = JSON.parse(await readFile(join(dir, "results.json"), "utf8")); + if (unavailable) { + for (const setup of setups) expect(setup).not.toHaveBeenCalled(); + expect(report.results).toEqual([expect.objectContaining({ + tool: "required-preflight", phase: "preflight", ok: false, + message: expect.stringContaining(unavailable), + })]); + } else { + for (const setup of setups) expect(setup).toHaveBeenCalledTimes(1); + expect(report.results.every((result: ScenarioResult) => result.phase === "required-coverage")).toBe(true); + } + expect(fileFailureIssue).not.toHaveBeenCalled(); + } finally { await rm(dir, { recursive: true, force: true }); } + }); +}); 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"); }); @@ -30,6 +179,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__/pr-api-contracts.test.ts b/scripts/ado-script/src/executor-e2e/__tests__/pr-api-contracts.test.ts new file mode 100644 index 000000000..763309396 --- /dev/null +++ b/scripts/ado-script/src/executor-e2e/__tests__/pr-api-contracts.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AdoRest } from "../ado-rest.js"; +import type { ScenarioContext } from "../scenario.js"; +import { prApiContractScenarios } from "../scenarios/pr-api-contracts.js"; + +afterEach(() => vi.unstubAllGlobals()); + +function context(): ScenarioContext { + return { + orgUrl: "https://dev.azure.com/org", + project: "P", + adoRepo: "repo", + buildId: "42", + token: "test-token", + adoAwBin: "unused", + workDir: "unused", + rest: new AdoRest({ orgUrl: "https://dev.azure.com/org", project: "P", token: "test-token" }), + log: () => {}, + prefix: (tool) => `ado-aw-det-42-${tool}`, + }; +} + +function probe(id: string) { + const scenario = prApiContractScenarios.find((value) => value.id === id); + if (!scenario) throw new Error(`missing scenario ${id}`); + return scenario; +} + +function json(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json" } }); +} + +describe("PR API contract probes", () => { + it("identifies prerequisite checks separately from executor feature coverage", () => { + expect(prApiContractScenarios.map((s) => s.id)).toEqual([ + "pr-api-draft-publication", "pr-api-label-replacement", "pr-api-owned-comments", "pr-api-push-concurrency", + ]); + expect(prApiContractScenarios.every((s) => s.tool === "noop")).toBe(true); + }); + + it.each([false, true])("requires persisted draft publication, not HTTP success (persisted=%s)", async (persisted) => { + const before = { isDraft: true, status: "active", title: "original", description: "body", sourceRefName: "source", targetRefName: "target" }; + const fetchImpl = vi.fn() + .mockResolvedValueOnce(json(before)) + .mockResolvedValueOnce(json({})) + .mockResolvedValueOnce(json({ ...before, isDraft: !persisted })); + vi.stubGlobal("fetch", fetchImpl); + const ctx = context(); + const promise = probe("pr-api-draft-publication").assert(ctx, + { repo: "repo", prId: 1, branch: ctx.prefix("probe") }, { name: "noop", status: "succeeded" }, []); + if (persisted) await expect(promise).resolves.toBeUndefined(); + else await expect(promise).rejects.toThrow("not persisted"); + expect(fetchImpl.mock.calls[1]?.[1]?.body).toBe('{"isDraft":false}'); + }); + + it("refuses API requests for a branch outside the scenario namespace", async () => { + const fetchImpl = vi.fn(); + vi.stubGlobal("fetch", fetchImpl); + await expect(probe("pr-api-draft-publication").assert(context(), + { repo: "repo", prId: 1, branch: "main" }, { name: "noop", status: "succeeded" }, [])) + .rejects.toThrow("must own"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it("does not remove the old label when both labels were not observed", async () => { + const fetchImpl = vi.fn() + .mockResolvedValueOnce(json({ id: "old-label" })) + .mockResolvedValueOnce(json({ id: "new-label" })); + vi.stubGlobal("fetch", fetchImpl); + const ctx = context(); + vi.spyOn(ctx.rest, "listPullRequestLabels").mockResolvedValue([{ name: "ado-aw-probe-from" }]); + await expect(probe("pr-api-label-replacement").assert(ctx, + { repo: "repo", prId: 1, branch: ctx.prefix("probe") }, { name: "noop", status: "succeeded" }, [])) + .rejects.toThrow("must precede removal"); + expect(fetchImpl.mock.calls.map((call) => call[1]?.method)).toEqual(["POST", "POST"]); + }); + + it.each(["GitReferenceStaleException", "UnauthorizedException"])("requires a stale-ref error, not arbitrary rejection (%s)", async (typeKey) => { + const fetchImpl = vi.fn() + .mockResolvedValueOnce(json({ commits: [{ commitId: "b".repeat(40) }] })) + .mockResolvedValueOnce(json({ typeKey }, 409)); + vi.stubGlobal("fetch", fetchImpl); + const ctx = context(); + const refs = vi.spyOn(ctx.rest, "getRefObjectId") + .mockResolvedValueOnce("a".repeat(40)) + .mockResolvedValue("b".repeat(40)); + const promise = probe("pr-api-push-concurrency").assert(ctx, + { repo: "repo", prId: 1, branch: ctx.prefix("probe") }, { name: "noop", status: "succeeded" }, []); + if (typeKey === "GitReferenceStaleException") { + await expect(promise).resolves.toBeUndefined(); + expect(refs).toHaveBeenCalledTimes(3); + } else await expect(promise).rejects.toThrow("Expected stale-ref rejection"); + const sent = JSON.parse(String(fetchImpl.mock.calls[1]?.[1]?.body)); + expect(sent.refUpdates[0].oldObjectId).toBe("a".repeat(40)); + expect(sent.commits[0].parents).toEqual(["a".repeat(40)]); + }); +}); diff --git a/scripts/ado-script/src/executor-e2e/__tests__/pr-patch-scenarios.test.ts b/scripts/ado-script/src/executor-e2e/__tests__/pr-patch-scenarios.test.ts new file mode 100644 index 000000000..6d66ee599 --- /dev/null +++ b/scripts/ado-script/src/executor-e2e/__tests__/pr-patch-scenarios.test.ts @@ -0,0 +1,59 @@ +import { mkdtemp, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { AdoRest } from "../ado-rest.js"; +import type { ScenarioContext } from "../scenario.js"; +import { createPullRequestScenarios } from "../scenarios/create-pull-request.js"; +import { prPushScenarios } from "../scenarios/pr-push.js"; + +describe("native patch live coverage", () => { + it("registers mandatory create and push fidelity cases", () => { + expect(createPullRequestScenarios.map((scenario) => scenario.id)).toEqual(expect.arrayContaining( + ["native-copy", "native-rename", "excluded-copy", "crlf", "binary", "expansion-denied"].map((mode) => `create-pull-request-${mode}`), + )); + expect(prPushScenarios.map((scenario) => scenario.id)).toEqual(expect.arrayContaining( + ["native-copy", "native-rename", "excluded-native-copy", "crlf", "binary", "expansion-denied"].map((mode) => `pr-push-${mode}`), + )); + const denied = prPushScenarios.find((scenario) => scenario.id === "pr-push-expansion-denied")!; + expect(denied.expectedFailure).toBeDefined(); + expect(denied.assertFailure).toBeDefined(); + }); + + it.each(["abandoned", "active", "completed", "wrong-target", "unconfirmed"])( + "requires owned abandoned PR before deleting paired refs: %s", async (mode) => { + const sourcesDir = await mkdtemp(join(tmpdir(), "ado-aw-patch-cleanup-")); + const rest = new AdoRest({ orgUrl: "https://dev.azure.com/org", project: "project", token: "test" }); + const deleted = vi.spyOn(rest, "deleteRef").mockResolvedValue(); + const abandon = vi.spyOn(rest, "abandonPullRequest").mockResolvedValue(); + vi.spyOn(rest, "getPullRequest").mockResolvedValue({ + pullRequestId: 42, title: "owned (do not merge)", status: mode, + sourceRefName: "refs/heads/source", targetRefName: mode === "wrong-target" ? "refs/heads/other" : "refs/heads/target", + }); + const context: ScenarioContext = { + orgUrl: "https://dev.azure.com/org", project: "project", token: "test", rest, + adoRepo: "repo", buildId: "42", adoAwBin: "unused", workDir: sourcesDir, + log: () => {}, prefix: () => "owned", + }; + const scenario = createPullRequestScenarios.find((candidate) => candidate.id === "create-pull-request-native-copy")!; + try { + const result = scenario.cleanup(context, { + sourcesDir, rest, repo: "repo", sourceBranch: "source", ownedTarget: "target", + prId: mode === "unconfirmed" ? undefined : 42, + }); + if (mode === "abandoned") { + await expect(result).resolves.toBeUndefined(); + expect(deleted.mock.calls).toEqual([["repo", "refs/heads/source"], ["repo", "refs/heads/target"]]); + } else { + await expect(result).rejects.toThrow(); + expect(deleted).not.toHaveBeenCalled(); + } + if (mode === "wrong-target" || mode === "unconfirmed") expect(abandon).not.toHaveBeenCalled(); + await expect(stat(sourcesDir)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + vi.restoreAllMocks(); + await rm(sourcesDir, { recursive: true, force: true }); + } + }, + ); +}); 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..32723c427 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,126 @@ 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])("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 { + 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(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 }); + } + }); + it("passes an expected executor rejection without running assertions", async () => { const dir = await mkdtemp(join(tmpdir(), "ado-aw-runner-test-")); try { @@ -141,7 +264,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 +277,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 +299,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/ado-rest.ts b/scripts/ado-script/src/executor-e2e/ado-rest.ts index def64f6d2..bc377c6a1 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. */ @@ -319,17 +319,24 @@ export class AdoRest { // `filter` is a prefix match (heads/main also matches heads/main-foo), so // select the exact ref by name rather than trusting the first result. const fullName = `refs/${refFilter}`; - return res?.value?.find((r) => r.name === fullName)?.objectId; + if (!Array.isArray(res?.value) || res.value.some((ref) => + !ref || typeof ref.name !== "string" || typeof ref.objectId !== "string")) { + throw new Error(`Ref discovery for ${fullName} is incomplete`); + } + const matches = res.value.filter((ref) => ref.name === fullName); + if (matches.length > 1) throw new Error(`Ref discovery for ${fullName} is ambiguous`); + return matches[0]?.objectId; } /** Delete a ref (branch or tag) by setting its newObjectId to zeros. */ async deleteRef(repo: string, refName: string): Promise { const oldId = await this.getRefObjectId(repo, refName.replace(/^refs\//, "")); if (!oldId) return; + if (!/^[a-f0-9]{40}$/i.test(oldId)) throw new Error(`Cannot delete ${refName}: invalid observed SHA`); const path = this.projPath( `_apis/git/repositories/${AdoRest.seg(repo)}/refs?api-version=7.1`, ); - await this.request(path, { + const response = await this.request<{ value?: { name?: string; success?: boolean; updateStatus?: string }[] }>(path, { method: "POST", body: [ { @@ -340,6 +347,13 @@ export class AdoRest { ], allow404: true, }); + const fullName = refName.startsWith("refs/") ? refName : `refs/${refName}`; + if (!Array.isArray(response?.value) || response.value.length !== 1 || response.value[0]?.name !== fullName || response.value[0]?.success !== true) { + throw new Error(`Ref deletion was not confirmed for ${fullName}: ${response?.value?.[0]?.updateStatus ?? "invalid response"}`); + } + if (await this.getRefObjectId(repo, fullName.replace(/^refs\//, ""))) { + throw new Error(`Ref remains after deletion: ${fullName}`); + } } /** @@ -390,6 +404,22 @@ export class AdoRest { return commitId; } + async pushDeleteFileBranch(repo: string, branch: string, parent: string, filePath: string): Promise { + const response = await this.request<{ commits?: { commitId?: string }[] }>(this.projPath( + `_apis/git/repositories/${AdoRest.seg(repo)}/pushes?api-version=7.1`, + ), { + method: "POST", + body: { + refUpdates: [{ name: `refs/heads/${branch}`, oldObjectId: "0".repeat(40) }], + commits: [{ parents: [parent], comment: "Disposable inline-comment deletion fixture", + changes: [{ changeType: "delete", item: { path: filePath } }] }], + }, + }); + const commit = response?.commits?.[0]?.commitId; + if (typeof commit !== "string" || !/^[a-f0-9]{40}$/i.test(commit)) throw new Error("Deletion fixture push returned no valid commit"); + return commit; + } + /** * Create a NEW branch and a single commit adding one OR MORE files in one * push. Returns the new commit id. @@ -470,7 +500,11 @@ 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; isDraft?: boolean; + sourceRefName?: string; targetRefName?: 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 +513,11 @@ export class AdoRest { status: string; title: string; description?: string; + isDraft?: boolean; + sourceRefName?: string; + targetRefName?: string; + labels?: { name: string }[]; + autoCompleteSetBy?: { id?: string }; }>(path); if (!res) throw new Error(`getPullRequest(${prId}) returned no body`); return res; @@ -527,7 +566,8 @@ export class AdoRest { const res = await this.request<{ value?: { id: number; comments?: { content?: string }[] }[] }>( path, ); - return res?.value ?? []; + if (!Array.isArray(res?.value)) throw new Error(`listThreads(${prId}) response missing value array`); + return res.value; } async listReviewers( @@ -543,12 +583,62 @@ export class AdoRest { return res?.value ?? []; } - /** Abandon a PR (status=abandoned). Best-effort cleanup. */ - async abandonPullRequest(repo: string, prId: number): Promise { + 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 }; + }); + } + + /** Completion is acceptable only for a scenario explicitly testing auto-completion. */ + async abandonPullRequest( + repo: string, + prId: number, + opts: { allowCompleted?: boolean } = {}, + ): Promise { const path = this.projPath( `_apis/git/repositories/${AdoRest.seg(repo)}/pullRequests/${prId}?api-version=7.1`, ); - await this.request(path, { method: "PATCH", body: { status: "abandoned" }, allow404: true }); + const read = () => this.request<{ status: string }>(path, { allow404: true }); + const terminal = (pr: { status: string } | undefined) => + !pr || pr.status === "abandoned" || (opts.allowCompleted === true && pr.status === "completed"); + const pr = await read(); + if (terminal(pr)) return; + if (pr?.status !== "active") { + throw new Error(`Cannot clean up PR ${prId}: unexpected status '${pr?.status}'`); + } + let failure: unknown; + try { + await this.request(path, { method: "PATCH", body: { status: "abandoned" }, allow404: true }); + } catch (error) { + if (!opts.allowCompleted) throw error; + failure = error; + } + if (opts.allowCompleted) { + let confirmed: { status: string } | undefined; + try { + confirmed = await read(); + } catch (error) { + throw new Error(`Cannot confirm cleanup of auto-complete PR ${prId}: ${String(error)}`, { + cause: failure ?? error, + }); + } + if (!terminal(confirmed)) { + throw new Error(`Cannot confirm cleanup of auto-complete PR ${prId}: status '${confirmed?.status}'`, { + cause: failure, + }); + } + } } /** diff --git a/scripts/ado-script/src/executor-e2e/execute-cli.ts b/scripts/ado-script/src/executor-e2e/execute-cli.ts index c483a58e5..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 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/index.ts b/scripts/ado-script/src/executor-e2e/index.ts index bdf878ad3..e1f3f4e32 100644 --- a/scripts/ado-script/src/executor-e2e/index.ts +++ b/scripts/ado-script/src/executor-e2e/index.ts @@ -20,15 +20,64 @@ * * Test-harness module; not shipped in `ado-script.zip`. */ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { AdoRest } from "./ado-rest.js"; import { fileFailureIssue, loadIssueEnv } from "./github-issue.js"; import { runAll } from "./runner.js"; import { allScenarios } from "./scenarios/index.js"; -import type { ScenarioContext, ScenarioResult } from "./scenario.js"; +import type { Scenario, ScenarioContext, ScenarioResult } from "./scenario.js"; +import { resolveCrossOrgEnv } from "./scenarios/cross-org.js"; +import { resolveExecutorE2eReviewer } from "./scenarios/create-pull-request.js"; + +export function selectScenarios( + available: Scenario[], + 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); + const cross = names.some((name) => name.includes("cross-org")) ? resolveCrossOrgEnv(ctx) : undefined; + if (names.some((name) => name.includes("reviewers"))) { + const reviewer = resolveExecutorE2eReviewer(); + 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 scopes: { name: string; rest: AdoRest }[] = []; + if (names.some((name) => name.includes("reviewers") && !name.includes("cross-org"))) { + scopes.push({ name: "local", rest: ctx.rest }); + } + if (cross && names.some((name) => name.includes("reviewers") && name.includes("cross-org"))) { + scopes.push({ name: "cross-org", rest: cross.rest }); + } + for (const scope of scopes) { + if (!await scope.rest.resolveIdentityId(reviewer)) { + throw new Error(`Required ${scope.name} 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 +108,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 +134,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 +170,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 12ee5e477..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, @@ -143,17 +144,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", @@ -198,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 0717e1963..33e515ed1 100644 --- a/scripts/ado-script/src/executor-e2e/scenario.ts +++ b/scripts/ado-script/src/executor-e2e/scenario.ts @@ -19,7 +19,7 @@ export interface ExecutedRecord { /** "succeeded" | "failed" | "warning" | "budget_exhausted". */ status: string; context?: string | null; - /** Present only on success; carries the tool's result data. */ + /** Tool result data, including partial/uncertain mutations on failure. */ result?: Record | null; /** Present only on non-success; the failure message. */ error?: string | null; @@ -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. * @@ -231,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/create-pull-request.ts b/scripts/ado-script/src/executor-e2e/scenarios/create-pull-request.ts index 8300e17b3..d89eb849a 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 @@ -3,9 +3,8 @@ * * Stage 3's create-pull-request executor operates on a real git checkout on * disk: it reads a staged patch file, verifies its SHA-256, applies it via - * `git apply --3way` on top of the target branch, and pushes a new source - * branch + opens the PR via ADO REST using the recorded `base_commit` as the - * parent. + * Git on the verified captured base, and pushes a new source branch + opens + * the PR via ADO REST using that same `base_commit` as the parent. * * We reproduce both supported checkout layouts deterministically (no LLM): * - a named additional checkout at `/`, @@ -23,7 +22,7 @@ */ import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; -import { mkdir, rm, writeFile } from "node:fs/promises"; +import { copyFile, mkdir, rename, rm, writeFile } from "node:fs/promises"; import { join } from "node:path"; import type { @@ -34,7 +33,7 @@ import type { } from "../scenario.js"; import { SkipError } from "../scenario.js"; import { partialOutput } from "../execute-cli.js"; -import { detBody, numResult, strResult, Teardown } from "./common.js"; +import { defaultBranchShortName, detBody, numResult, strResult, Teardown } from "./common.js"; import { crossOrgSource, resolveCrossOrgEnv, @@ -42,6 +41,12 @@ import { } from "./cross-org.js"; interface CreatePrState { + rejectedWithoutSource?: boolean; + ownedTarget?: string; + expectedFile?: string; + expectedBlob?: string; + originalFile?: string; + omittedCopy?: string; repo: string; sourceBranch: string; targetBranch: string; @@ -66,6 +71,7 @@ interface CreatePrScenarioOptions { readonly repositorySelector: "named" | "self" | "cross-org"; readonly patchRelPath: string; readonly changedFileSuffix?: string; + readonly patchMode?: "native-copy" | "native-rename" | "excluded-copy" | "crlf" | "binary" | "expansion-denied"; } const CREATE_PR_TEMPORARY_ID = "#aw_prcreate"; @@ -149,7 +155,7 @@ function runGit( }); } -async function git( +export async function git( ctx: ScenarioContext, args: string[], cwd: string, @@ -168,6 +174,44 @@ async function git( async function setupCreatePullRequest( ctx: ScenarioContext, options: CreatePrScenarioOptions, +): Promise { + if (!options.patchMode) return setupCreatePullRequestCheckout(ctx, options); + if (options.repositorySelector !== "named") throw new Error("Native patch fixtures require the existing primary ADO repo"); + const target = `${ctx.prefix(options.id)}-target`; + if (await ctx.rest.getRefObjectId(ctx.adoRepo, `heads/${target}`)) { + throw new Error(`Disposable patch target already exists: ${target}`); + } + const branch = await defaultBranchShortName(ctx, ctx.adoRepo); + const sha = await ctx.rest.getRefObjectId(ctx.adoRepo, `heads/${branch}`); + if (!sha) throw new Error("Patch fixture base is unavailable"); + const original = `ado-aw-det/${ctx.buildId}/${options.id}-seed.txt`; + try { + await ctx.rest.pushAddFileBranch(ctx.adoRepo, target, sha, `/${original}`, + options.patchMode === "expansion-denied" ? "x".repeat(429_575) : `${detBody(ctx, options.id)} seed\n`, + "Disposable native patch target"); + } catch (error) { + throw new Error(`Target creation could not be confirmed; inspect owned ref refs/heads/${target}`, { cause: error }); + } + try { + const state = await setupCreatePullRequestCheckout(ctx, options, target); + state.ownedTarget = target; + return state; + } catch (error) { + try { + await new Teardown() + .add("delete target", () => ctx.rest.deleteRef(ctx.adoRepo, `refs/heads/${target}`)) + .add("remove local checkout", () => rm(join(ctx.workDir, options.id, "src-checkout"), { recursive: true, force: true })) + .run(); + } + catch (cleanup) { throw new AggregateError([error, cleanup], `Patch setup and target cleanup failed: ${target}`); } + throw error; + } +} + +async function setupCreatePullRequestCheckout( + ctx: ScenarioContext, + options: CreatePrScenarioOptions, + targetOverride?: string, ): Promise { const crossOrg = options.repositorySelector === "cross-org" @@ -192,14 +236,15 @@ async function setupCreatePullRequest( const cloneUrl = `${orgUrl.replace(/\/+$/, "")}/${encodeURIComponent(project)}/_git/${encodeURIComponent(repo)}`; ctx.log(`[${options.id}] cloning ${repo}`); - await git(ctx, ["clone", cloneUrl, checkoutDir], sourcesDir, authHeader, options.id); + await git(ctx, ["clone", ...(targetOverride ? ["--branch", targetOverride] : []), cloneUrl, checkoutDir], + sourcesDir, authHeader, options.id); // Determine the default branch and its HEAD (the patch base commit). // `symbolic-ref refs/remotes/origin/HEAD` exits non-zero (not empty) when // the remote HEAD symref isn't configured, which git() turns into a throw. // Catch that and fall back to "main" so the `|| "main"` isn't dead code. - let targetBranch = "main"; - try { + let targetBranch = targetOverride ?? "main"; + if (!targetOverride) try { const symref = await git( ctx, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], @@ -223,22 +268,52 @@ async function setupCreatePullRequest( const relFile = `ado-aw-det/${ctx.buildId}${options.changedFileSuffix ?? ""}.md`; const absFile = join(checkoutDir, relFile); await mkdir(join(absFile, ".."), { recursive: true }); - await writeFile(absFile, `${detBody(ctx, options.id)}\n`, "utf8"); - await git(ctx, ["add", "-N", relFile], checkoutDir, authHeader, options.id); - const patchContent = await git( - ctx, - ["diff", "--", relFile], - checkoutDir, - authHeader, - options.id, - ); + const originalFile = options.patchMode ? `ado-aw-det/${ctx.buildId}/${options.id}-seed.txt` : undefined; + let omittedCopy: string | undefined; + let expectedBlob: string | undefined; + let patchContent: string; + if (options.patchMode === "expansion-denied") { + patchContent = Array.from({ length: 99 }, (_, index) => + `diff --git a/${originalFile} b/expanded-${index}.txt\nsimilarity index 100%\ncopy from ${originalFile}\ncopy to expanded-${index}.txt\n`).join(""); + } else if (options.patchMode) { + if (!originalFile) throw new Error("Native fixture source is missing"); + if (options.patchMode === "crlf") await git(ctx, ["config", "core.autocrlf", "true"], checkoutDir, authHeader, options.id); + if (options.patchMode === "native-copy") await copyFile(join(checkoutDir, originalFile), absFile); + else if (options.patchMode === "native-rename") await rename(join(checkoutDir, originalFile), absFile); + else if (options.patchMode === "binary") await writeFile(absFile, Buffer.from([0, 255, 128, 10])); + else await writeFile(absFile, options.patchMode === "excluded-copy" + ? "Independent retained content, not derived from the excluded seed.\n".repeat(20) + : `${detBody(ctx, options.id)}\n`, "utf8"); + if (options.patchMode === "excluded-copy") { + omittedCopy = `${relFile}.excluded`; + await copyFile(join(checkoutDir, originalFile), join(checkoutDir, omittedCopy)); + } + await git(ctx, ["add", "-A"], checkoutDir, authHeader, options.id); + expectedBlob = (await git(ctx, ["rev-parse", `:${relFile}`], checkoutDir, authHeader, options.id)).trim(); + patchContent = await git(ctx, ["diff", "--cached", "--binary", "--full-index", "--find-renames", + "--find-copies", "--find-copies-harder", baseCommit, "--"], checkoutDir, authHeader, options.id); + if ((options.patchMode === "native-copy" || options.patchMode === "excluded-copy") && !patchContent.includes("copy from ")) { + throw new Error("Native create fixture did not contain copy metadata"); + } + if (options.patchMode === "native-rename" && !patchContent.includes("rename from ")) { + throw new Error("Native create fixture did not contain rename metadata"); + } + } else { + await writeFile(absFile, `${detBody(ctx, options.id)}\n`, "utf8"); + await git(ctx, ["add", "-N", relFile], checkoutDir, authHeader, options.id); + patchContent = await git(ctx, ["diff", "--", relFile], checkoutDir, authHeader, options.id); + } if (!patchContent.trim()) throw new Error("generated patch is empty"); // Reset the intent-to-add so the checkout stays clean. - await git(ctx, ["reset", "--", relFile], checkoutDir, authHeader, options.id); + await git(ctx, ["reset", "--", ...(options.patchMode ? [] : [relFile])], checkoutDir, authHeader, options.id); const patchSha256 = createHash("sha256").update(patchContent, "utf8").digest("hex"); return { + expectedFile: options.patchMode ? relFile : undefined, + expectedBlob, + originalFile, + omittedCopy, repo, sourceBranch: ctx.prefix(options.id), targetBranch, @@ -270,8 +345,19 @@ function createPullRequestScenario( "delete-source-branch": true, "if-no-changes": "error", "include-stats": false, + ...(options.patchMode === "excluded-copy" ? { "excluded-files": [`${options.id}-seed.txt`] } : {}), }), setup: (ctx) => setupCreatePullRequest(ctx, options), + ...(options.patchMode === "expansion-denied" ? { + expectedFailure: { error: /pre-application expansion/ }, + } : {}), + assertFailure: async (_ctx, state) => { + if (await state.rest.getRefObjectId(state.repo, `heads/${state.sourceBranch}`) || + await state.rest.getRefObjectId(state.repo, `heads/${state.targetBranch}`) !== state.baseCommit) { + throw new Error("Rejected creation changed remote refs"); + } + state.rejectedWithoutSource = true; + }, source: async (_ctx, state) => state.crossOrg ? crossOrgSource(state.crossOrg) : {}, files: async (_ctx, state) => ({ [state.patchRelPath]: state.patchContent }), @@ -319,8 +405,50 @@ function createPullRequestScenario( if (pr.status === "abandoned") throw new Error(`PR #${prId} is abandoned`); const sha = await state.rest.getRefObjectId(state.repo, `heads/${state.sourceBranch}`); if (!sha) throw new Error(`source branch '${state.sourceBranch}' was not pushed`); + if (state.expectedFile && state.expectedBlob) { + const header = "Basic " + Buffer.from(`:${state.executorToken}`).toString("base64"); + await git(ctx, ["fetch", "origin", state.sourceBranch], state.checkoutDir, header, options.id); + const blob = (await git(ctx, ["rev-parse", `FETCH_HEAD:${state.expectedFile}`], state.checkoutDir, header, options.id)).trim(); + if (blob !== state.expectedBlob) throw new Error("Created PR does not preserve exact proposed Git blob bytes"); + const parent = (await git(ctx, ["rev-parse", "FETCH_HEAD^"], state.checkoutDir, header, options.id)).trim(); + if (parent !== state.baseCommit) throw new Error("Created PR uses a different parent from its validated patch base"); + const files = (await git(ctx, ["ls-tree", "-r", "--name-only", "FETCH_HEAD"], state.checkoutDir, header, options.id)).trim().split("\n"); + if (options.patchMode === "native-rename" && state.originalFile && files.includes(state.originalFile)) { + throw new Error("Created PR retained a renamed source"); + } + if (state.omittedCopy && (files.includes(state.omittedCopy) || !Array.isArray(record.result?.omitted_operations) + || record.result.omitted_operations.length !== 1)) { + throw new Error("Creation did not omit and report the excluded native copy"); + } + } }, cleanup: async (ctx, state) => { + if (state.ownedTarget) { + await new Teardown().add("clean owned PR and refs", async () => { + if (state.rejectedWithoutSource) { + await state.rest.deleteRef(state.repo, `refs/heads/${state.ownedTarget}`); + return; + } + if (state.prId === undefined) { + throw new Error(`PR creation is unconfirmed; retaining source ${state.sourceBranch} and target ${state.ownedTarget}`); + } + const pr = await state.rest.getPullRequest(state.repo, state.prId); + if (pr.sourceRefName !== `refs/heads/${state.sourceBranch}` || + pr.targetRefName !== `refs/heads/${state.ownedTarget}` || + pr.title !== `${ctx.prefix(options.id)} (do not merge)`) { + throw new Error("Cannot establish owned native-fixture PR identity"); + } + await state.rest.abandonPullRequest(state.repo, state.prId); + if ((await state.rest.getPullRequest(state.repo, state.prId)).status !== "abandoned") { + throw new Error("Fixture PR abandonment is unconfirmed; retaining both refs"); + } + await new Teardown() + .add("delete source", () => state.rest.deleteRef(state.repo, `refs/heads/${state.sourceBranch}`)) + .add("delete target", () => state.rest.deleteRef(state.repo, `refs/heads/${state.ownedTarget}`)) + .run(); + }).add("remove local checkout", () => rm(state.sourcesDir, { recursive: true, force: true })).run(); + return; + } // Attempt each cleanup independently so an early failure never skips the // rest — otherwise a throwing abandonPullRequest would leak the source // branch and the local checkout dir. @@ -420,14 +548,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 +565,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 +599,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 +639,7 @@ function createPullRequestAddReviewersScenario( return { id: options.id, - tool: "update-pr", + tool: "add-pull-request-reviewers", targetsAdoRepo: true, setup: async (ctx) => { const reviewer = resolveExecutorE2eReviewer(); @@ -540,7 +668,7 @@ function createPullRequestAddReviewersScenario( return { ...state, reviewer, reviewerId }; }, config: (_ctx, state) => ({ - "allowed-operations": ["add-reviewers"], + target: "*", "allowed-repositories": [state.repo], "allowed-reviewers": [submittedReviewer(state)], "max-reviewers": 1, @@ -577,7 +705,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 +724,7 @@ function createPullRequestAddReviewersScenario( ); } if (strResult(record, "operation") !== "add-reviewers") { - throw new Error("update-pr reported an unexpected operation"); + throw new Error("add-pull-request-reviewers reported an unexpected operation"); } const failed = stringArrayResult(record, "failed"); if (failed.length !== 0) { @@ -661,6 +788,15 @@ export const createPullRequestAddReviewersGeneral = export const createPullRequestScenarios: Scenario[] = [ createPullRequest, + ...(["native-copy", "native-rename", "excluded-copy", "crlf", "binary", "expansion-denied"] as const).map((patchMode) => + createPullRequestScenario({ + id: `create-pull-request-${patchMode}`, + repositorySelector: "named", + patchRelPath: `create-pr-${patchMode}.patch`, + changedFileSuffix: `-${patchMode}`, + patchMode, + }), + ), createPullRequestSelfMultiCheckout, createPullRequestCrossOrg, createPullRequestTemporaryIdHandoff, diff --git a/scripts/ado-script/src/executor-e2e/scenarios/index.ts b/scripts/ado-script/src/executor-e2e/scenarios/index.ts index 35dd16ca4..ffa1756cc 100644 --- a/scripts/ado-script/src/executor-e2e/scenarios/index.ts +++ b/scripts/ado-script/src/executor-e2e/scenarios/index.ts @@ -7,9 +7,13 @@ 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"; +import { prApiContractScenarios } from "./pr-api-contracts.js"; +import { prOwnedCommentScenarios } from "./pr-comments.js"; +import { prPushScenarios } from "./pr-push.js"; import { signalScenarios } from "./signals.js"; import { wikiScenarios } from "./wiki.js"; import { workItemScenarios } from "./work-item.js"; @@ -21,8 +25,12 @@ export const allScenarios: Scenario[] = [ ...workItemScenarios, ...wikiScenarios, ...prScenarios, + ...prApiContractScenarios, + ...prOwnedCommentScenarios, + ...prPushScenarios, ...gitScenarios, ...crossOrgScenarios, + ...crossOrgPrScenarios, ...buildScenarios, ...createPullRequestScenarios, ...githubIssueScenarios, diff --git a/scripts/ado-script/src/executor-e2e/scenarios/pr-api-contracts.ts b/scripts/ado-script/src/executor-e2e/scenarios/pr-api-contracts.ts new file mode 100644 index 000000000..fe4f8f33f --- /dev/null +++ b/scripts/ado-script/src/executor-e2e/scenarios/pr-api-contracts.ts @@ -0,0 +1,170 @@ +/** + * Live platform-contract probes, not claims of executor capability coverage. + * Writes stay on PRs/branches created and cleaned by the existing PR harness. + */ +import type { Scenario, ScenarioContext } from "../scenario.js"; +import { setupPr, teardownPr, type PrState } from "./pr.js"; + +function object(value: unknown, label: string): Record { + if (value === null || typeof value !== "object" || Array.isArray(value)) { + throw new Error(`${label}: expected an object`); + } + return value as Record; +} + +function required(value: boolean, message: string): asserts value { + if (!value) throw new Error(message); +} + +async function request( + ctx: ScenarioContext, state: PrState, suffix: string, + method = "GET", body?: unknown, +): Promise { + required(state.branch.startsWith(ctx.prefix("")), "API probe must own the source branch"); + const base = `${ctx.orgUrl.replace(/\/+$/, "")}/${encodeURIComponent(ctx.project)}` + + `/_apis/git/repositories/${encodeURIComponent(state.repo)}`; + return fetch(`${base}/${suffix}?api-version=7.1`, { + method, + headers: { + Authorization: `Basic ${Buffer.from(`:${ctx.token}`).toString("base64")}`, + "Content-Type": "application/json", + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(30_000), + }); +} + +async function json( + ctx: ScenarioContext, state: PrState, suffix: string, method = "GET", body?: unknown, +): Promise> { + const response = await request(ctx, state, suffix, method, body); + if (!response.ok) throw new Error(`PR API contract ${method} ${suffix}: HTTP ${response.status}`); + return object(await response.json(), suffix); +} + +function scenario(id: string, probe: (ctx: ScenarioContext, state: PrState) => Promise): Scenario { + return { + id, + tool: "noop", + targetsAdoRepo: true, + config: () => ({}), + setup: (ctx) => setupPr(ctx, id, false, true), + ndjson: async () => ({ context: `${id}: live API prerequisite check, not executor feature coverage` }), + assert: async (ctx, state) => probe(ctx, state), + cleanup: teardownPr, + }; +} + +const draft = scenario("pr-api-draft-publication", async (ctx, state) => { + const path = `pullRequests/${state.prId}`; + const before = await json(ctx, state, path); + required(before.isDraft === true, "Draft precondition was not persisted"); + await json(ctx, state, path, "PATCH", { isDraft: false }); + const after = await json(ctx, state, path); + required(after.isDraft === false && after.status === "active", "Draft publication was not persisted"); + for (const field of ["title", "description", "sourceRefName", "targetRefName"]) { + required(after[field] === before[field], `Publication changed ${field}`); + } + required(!after.autoCompleteSetBy, "Publication unexpectedly enabled auto-complete"); +}); + +const labels = scenario("pr-api-label-replacement", async (ctx, state) => { + const path = `pullRequests/${state.prId}/labels`; + const from = await json(ctx, state, path, "POST", { name: "ado-aw-probe-from" }); + const to = await json(ctx, state, path, "POST", { name: "ado-aw-probe-to" }); + required(typeof from.id === "string" && typeof to.id === "string", "Label IDs were not returned"); + const both = await ctx.rest.listPullRequestLabels(state.repo, state.prId); + required(both.some((l) => l.name === "ado-aw-probe-from") && both.some((l) => l.name === "ado-aw-probe-to"), + "Label addition must precede removal"); + const deleted = await request(ctx, state, `${path}/${encodeURIComponent(from.id)}`, "DELETE"); + required(deleted.ok, `Label removal failed: HTTP ${deleted.status}`); + const after = await ctx.rest.listPullRequestLabels(state.repo, state.prId); + required(!after.some((l) => l.name === "ado-aw-probe-from") && after.some((l) => l.name === "ado-aw-probe-to"), + "Label replacement was not persisted"); +}); + +const comments = scenario("pr-api-owned-comments", async (ctx, state) => { + const path = `pullRequests/${state.prId}/threads`; + const marker = `ado-aw-api-probe-${ctx.buildId}`; + const created = await json(ctx, state, path, "POST", { + comments: [{ parentCommentId: 0, content: "Original probe comment.", commentType: 1 }], + status: 1, + properties: { "ado-aw-probe-owner": { $type: "System.String", $value: marker } }, + }); + required(typeof created.id === "number", "Thread response missing ID"); + required(Array.isArray(created.comments) && created.comments.length === 1, "Thread response missing comment"); + const comment = object(created.comments[0], "created comment"); + required(typeof comment.id === "number", "Comment response missing ID"); + required(typeof object(comment.author, "comment author").id === "string", "Comment author identity is unavailable"); + await json(ctx, state, `${path}/${created.id}/comments/${comment.id}`, "PATCH", { + content: "Original probe comment.\n\nSuperseded by the API contract probe.", + }); + await json(ctx, state, `${path}/${created.id}`, "PATCH", { status: 4 }); + const after = await json(ctx, state, `${path}/${created.id}`); + required(after.status === "closed" || after.status === 4, "Thread was not closed"); + const properties = object(after.properties, "thread properties"); + required(object(properties["ado-aw-probe-owner"], "ownership property").$value === marker, + "Thread ownership property did not round-trip"); + required(Array.isArray(after.comments) && after.comments.length === 1, "Closing deleted the comment"); + required(object(after.comments[0], "updated comment").content + === "Original probe comment.\n\nSuperseded by the API contract probe.", "Comment update was not preserved"); + + const iterations = await json(ctx, state, `pullRequests/${state.prId}/iterations`); + required(Array.isArray(iterations.value) && iterations.value.length > 0, "PR has no iteration context"); + const iteration = object(iterations.value[iterations.value.length - 1], "iteration"); + required(typeof iteration.id === "number", "Iteration ID missing"); + const changes = await json(ctx, state, `pullRequests/${state.prId}/iterations/${iteration.id}/changes`); + required(Array.isArray(changes.changeEntries), "Iteration changes missing"); + const filePath = `/ado-aw-det/${ctx.buildId}/pr-api-owned-comments.md`; + const change = changes.changeEntries.map((entry) => object(entry, "iteration change")) + .find((entry) => object(entry.item, "change item").path === filePath); + required(typeof change?.changeTrackingId === "number", "Probe file has no changeTrackingId"); + const inline = await json(ctx, state, path, "POST", { + comments: [{ parentCommentId: 0, content: "Iteration-bound probe comment.", commentType: 1 }], + status: 1, + threadContext: { + filePath, + rightFileStart: { line: 1, offset: 1 }, + rightFileEnd: { line: 1, offset: 2 }, + }, + pullRequestThreadContext: { + changeTrackingId: change.changeTrackingId, + iterationContext: { firstComparingIteration: iteration.id, secondComparingIteration: iteration.id }, + }, + }); + const inlineAfter = await json(ctx, state, `${path}/${inline.id}`); + required(object(inlineAfter.threadContext, "inline context").filePath === filePath, "Inline path changed"); + const extended = object(inlineAfter.pullRequestThreadContext, "extended context"); + required(extended.changeTrackingId === change.changeTrackingId, "Change tracking ID was not preserved"); +}); + +const push = scenario("pr-api-push-concurrency", async (ctx, state) => { + const oldHead = await ctx.rest.getRefObjectId(state.repo, `heads/${state.branch}`); + required(typeof oldHead === "string" && oldHead.length === 40, "Source head unavailable"); + const payload = (name: string) => ({ + refUpdates: [{ name: `refs/heads/${state.branch}`, oldObjectId: oldHead }], + commits: [{ + comment: "ADO API contract probe", + parents: [oldHead], + changes: [{ + changeType: "add", + item: { path: `/ado-aw-det/${ctx.buildId}/${name}.md` }, + newContent: { content: "Probe content.", contentType: "rawtext" }, + }], + }], + }); + const applied = await json(ctx, state, "pushes", "POST", payload("head-guard-first")); + required(Array.isArray(applied.commits) && applied.commits.length === 1, "Push response missing commit"); + const commit = object(applied.commits[0], "pushed commit"); + required(typeof commit.commitId === "string" && commit.commitId !== oldHead, "Push did not advance source"); + required(await ctx.rest.getRefObjectId(state.repo, `heads/${state.branch}`) === commit.commitId, "Push readback mismatch"); + const stale = await request(ctx, state, "pushes", "POST", payload("head-guard-stale")); + required(!stale.ok, "Stale oldObjectId was unexpectedly accepted"); + const error = object(await stale.json(), "stale push error"); + required(error.typeKey === "GitReferenceStaleException" || error.typeKey === "GitReferenceUpdateException", + `Expected stale-ref rejection, got HTTP ${stale.status} ${String(error.typeKey)}`); + required(await ctx.rest.getRefObjectId(state.repo, `heads/${state.branch}`) === commit.commitId, + "Rejected stale push changed source head"); +}); + +export const prApiContractScenarios: Scenario[] = [draft, labels, comments, push] as Scenario[]; diff --git a/scripts/ado-script/src/executor-e2e/scenarios/pr-comments.ts b/scripts/ado-script/src/executor-e2e/scenarios/pr-comments.ts new file mode 100644 index 000000000..f19d8d98a --- /dev/null +++ b/scripts/ado-script/src/executor-e2e/scenarios/pr-comments.ts @@ -0,0 +1,264 @@ +import { join } from "node:path"; +import { createHash } from "node:crypto"; +import type { Scenario, ScenarioContext } from "../scenario.js"; +import { runExecute } from "../execute-cli.js"; +import { setupPr, teardownPr, type PrState } from "./pr.js"; +import { defaultBranchShortName, Teardown } from "./common.js"; + +const original = "An owned automated report with `code` and preserved history."; +const replacement = "The updated automated report with `Vec` preserved."; + +function updatedOwnedContent(content: string): string { + return `${content}\n\n`; +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +interface OwnedState extends PrState { + threadId: number; + commentId: number; + before: string; +} + +async function seed(ctx: ScenarioContext, id: string, mutation?: "edit" | "reply"): Promise { + const pr = await setupPr(ctx, id, false); + try { + const run = await runExecute({ + adoAwBin: ctx.adoAwBin, scenarioDir: join(ctx.workDir, `${id}-seed`), + tool: "add-pull-request-comment", config: { target: "*", "include-stats": false, "comment-key": "owned-report" }, + entry: { pull_request_id: pr.prId, repository: pr.repo, content: original, status: "active" }, + adoRepo: pr.repo, orgUrl: ctx.orgUrl, project: ctx.project, token: ctx.token, log: ctx.log, + extraEnv: { BUILD_BUILDID: "1" }, + }); + const threadId = run.record?.result?.thread_id; + if (run.exitCode !== 0 || typeof threadId !== "number") { + throw new Error(`Owned-comment seed failed: ${run.stderr}`); + } + const thread = await ctx.rest.getThread(pr.repo, pr.prId, threadId); + const commentId = thread.comments?.[0]?.id; + if (typeof commentId !== "number") throw new Error("Seed comment has no ID"); + let before = original; + if (mutation) { + const path = `${ctx.orgUrl.replace(/\/+$/, "")}/${encodeURIComponent(ctx.project)}` + + `/_apis/git/repositories/${encodeURIComponent(pr.repo)}/pullRequests/${pr.prId}/threads/${threadId}/comments` + + (mutation === "edit" ? `/${commentId}` : ""); + before = mutation === "edit" ? "A manual edit to the old automated report." : original; + const response = await fetch(`${path}?api-version=7.1`, { + method: mutation === "edit" ? "PATCH" : "POST", + headers: { Authorization: `Basic ${Buffer.from(`:${ctx.token}`).toString("base64")}`, "Content-Type": "application/json" }, + body: JSON.stringify(mutation === "edit" ? { content: before } : + { parentCommentId: commentId, content: "A discussion reply must remain untouched.", commentType: 1 }), + signal: AbortSignal.timeout(30_000), + }); + if (!response.ok) throw new Error(`Owned-comment precondition failed: HTTP ${response.status}`); + } + return { ...pr, threadId, commentId, before }; + } catch (error) { + await teardownPr(ctx, pr); + throw error; + } +} + +const update: Scenario = { + tool: "update-pull-request-comment", + targetsAdoRepo: true, + config: () => ({ target: "*", "comment-key": "owned-report" }), + setup: (ctx) => seed(ctx, "update-owned-comment"), + ndjson: async (_ctx, state) => ({ + pull_request_id: state.prId, repository: state.repo, + thread_id: state.threadId, comment_id: state.commentId, content: replacement, + }), + assert: async (ctx, state) => { + const thread = await ctx.rest.getThread(state.repo, state.prId, state.threadId); + if (thread.comments?.find((comment) => comment.id === state.commentId)?.content !== updatedOwnedContent(replacement)) { + throw new Error("Owned comment content was not updated faithfully"); + } + if (String(thread.status).toLowerCase() !== "active" && thread.status !== 1) { + throw new Error("Owned content update unexpectedly changed thread status"); + } + }, + cleanup: teardownPr, +}; + +const denied: Scenario = { + ...update, + id: "pr-owned-comment-human-edit-denied", + setup: (ctx) => seed(ctx, "owned-comment-edit-denied", "edit"), + expectedFailure: { error: /content hash/ }, + assertFailure: async (ctx, state) => { + const thread = await ctx.rest.getThread(state.repo, state.prId, state.threadId); + if (thread.comments?.find((comment) => comment.id === state.commentId)?.content !== state.before) { + throw new Error("The manually edited bot comment was overwritten"); + } + }, +}; + +const supersession: Scenario[] = [false, true].map((withReply): Scenario => ({ + id: withReply ? "pr-owned-comment-replies-preserved" : "pr-owned-comment-superseded", + tool: "add-pull-request-comment", + targetsAdoRepo: true, + config: () => ({ + target: "*", "comment-key": "owned-report", "include-stats": false, + "supersede-older-comments": true, "max-superseded-comments": 2, + }), + setup: (ctx) => seed(ctx, withReply ? "preserve-owned-conversation" : "supersede-owned-comment", withReply ? "reply" : undefined), + ndjson: async (_ctx, state) => ({ pull_request_id: state.prId, repository: state.repo, content: replacement, status: "active" }), + assert: async (ctx, state, record) => { + const thread = await ctx.rest.getThread(state.repo, state.prId, state.threadId); + const content = thread.comments?.find((comment) => comment.id === state.commentId)?.content; + if (withReply) { + if (content !== original || thread.comments?.length !== 2 || !["1", "active"].includes(String(thread.status).toLowerCase())) { + throw new Error("Supersession modified a conversation with replies"); + } + } else if (!content?.startsWith(original) || !content.includes("Superseded") || + !["4", "closed"].includes(String(thread.status).toLowerCase())) { + throw new Error("Supersession did not preserve history and close the old thread"); + } + const newId = record.result?.thread_id; + if (typeof newId !== "number" || newId === state.threadId) throw new Error("Replacement comment was not created"); + const newer = await ctx.rest.getThread(state.repo, state.prId, newId); + if (newer.comments?.[0]?.content !== replacement) throw new Error("Replacement comment content was not persisted"); + }, + cleanup: teardownPr, +})); + +interface InlineState extends PrState { + target?: string; + head: string; + file: string; +} + +async function inlineSetup(ctx: ScenarioContext, side: "left" | "right"): Promise { + if (side === "right") { + const state = await setupPr(ctx, "inline-right", false); + try { + const head = await ctx.rest.getRefObjectId(state.repo, `heads/${state.branch}`); + if (!head) throw new Error("Inline fixture source head missing"); + return { ...state, head, file: `ado-aw-det/${ctx.buildId}/inline-right.md` }; + } catch (error) { + await teardownPr(ctx, state); + throw error; + } + } + const repo = ctx.adoRepo; + const base = await defaultBranchShortName(ctx, repo); + const baseSha = await ctx.rest.getRefObjectId(repo, `heads/${base}`); + if (!baseSha) throw new Error("Inline deletion fixture base missing"); + const target = `${ctx.prefix("inline-left")}-target`; + const branch = `${ctx.prefix("inline-left")}-source`; + const file = `ado-aw-det/${ctx.buildId}/deleted.md`; + const parent = await ctx.rest.pushAddFileBranch(repo, target, baseSha, `/${file}`, "Deleted fixture line.\n", "Disposable inline target"); + let head: string; + try { + head = await ctx.rest.pushDeleteFileBranch(repo, branch, parent, `/${file}`); + const pr = await ctx.rest.createPullRequest(repo, branch, target, `${ctx.prefix("inline-left")} (do not merge)`, "Disposable left-side deleted-file test.", true); + return { repo, prId: pr.pullRequestId, branch, target, head, file }; + } catch (error) { + await new Teardown() + .add("delete owned inline source", () => ctx.rest.deleteRef(repo, `refs/heads/${branch}`)) + .add("delete owned inline target", () => ctx.rest.deleteRef(repo, `refs/heads/${target}`)).run(); + throw error; + } +} + +const inlineCases: Scenario[] = (["right", "left", "stale"] as const).map((mode): Scenario => ({ + id: `pr-inline-${mode}`, + tool: "add-pull-request-comment", + targetsAdoRepo: true, + config: () => ({ target: "*", "include-stats": false }), + setup: (ctx) => inlineSetup(ctx, mode === "left" ? "left" : "right"), + ndjson: async (_ctx, state) => ({ + pull_request_id: state.prId, repository: state.repo, file_path: state.file, + side: mode === "left" ? "left" : "right", line: 1, + expected_head_sha: mode === "stale" ? "f".repeat(40) : state.head, + content: "Comment on the exact reviewed file revision.", status: "active", + }), + ...(mode === "stale" ? { expectedFailure: { error: /head changed/ } } : {}), + assertFailure: async (ctx, state) => { + if ((await ctx.rest.listThreads(state.repo, state.prId)).length !== 0) throw new Error("Stale inline proposal created a thread"); + }, + assert: async (ctx, state, record) => { + const id = record.result?.thread_id; + if (typeof id !== "number") throw new Error("Inline comment response has no thread ID"); + const thread = await ctx.rest.getThread(state.repo, state.prId, id); + if (!thread.comments?.some((comment) => comment.content === "Comment on the exact reviewed file revision.")) { + throw new Error("Inline comment was not persisted"); + } + const url = `${ctx.orgUrl.replace(/\/+$/, "")}/${encodeURIComponent(ctx.project)}/_apis/git/repositories/${encodeURIComponent(state.repo)}` + + `/pullRequests/${state.prId}/threads/${id}?api-version=7.1`; + const response = await fetch(url, { headers: { Authorization: `Basic ${Buffer.from(`:${ctx.token}`).toString("base64")}` } }); + if (!response.ok) throw new Error(`Inline readback failed: HTTP ${response.status}`); + const detail: unknown = await response.json(); + if (!isRecord(detail) || !isRecord(detail.threadContext) || !isRecord(detail.pullRequestThreadContext)) { + throw new Error("Inline readback omitted its context"); + } + const start = mode === "left" ? detail.threadContext.leftFileStart : detail.threadContext.rightFileStart; + if (!isRecord(start) || start.line !== 1 || detail.threadContext.filePath !== `/${state.file}` || + typeof detail.pullRequestThreadContext.changeTrackingId !== "number") { + throw new Error("Inline side/path/iteration tracking was not persisted"); + } + }, + cleanup: async (ctx, state) => { + const cleanup = new Teardown().add("PR/source cleanup", () => teardownPr(ctx, state)); + if (state.target) cleanup.add("inline target cleanup", () => ctx.rest.deleteRef(state.repo, `refs/heads/${state.target}`)); + await cleanup.run(); + }, +})); + +const batches: Scenario[] = (["valid", "invalid-last", "not-enabled"] as const).map((mode): Scenario => ({ + id: `pr-review-batch-${mode}`, + tool: "submit-pull-request-review", + targetsAdoRepo: true, + config: () => ({ + target: "*", "allowed-events": ["comment"], "max-comments": mode === "not-enabled" ? 0 : 2, + "comment-key": "batch-report", + }), + setup: (ctx) => inlineSetup(ctx, "right"), + ndjson: async (_ctx, state) => ({ + pull_request_id: state.prId, repository: state.repo, event: "comment", + body: "Consolidated review summary without a vote.", expected_head_sha: state.head, + comments: [ + { file_path: state.file, side: "right", line: 1, content: "First inline finding in this review." }, + { file_path: state.file, side: "right", line: mode === "invalid-last" ? 99999 : 1, + content: "Second inline finding in this review." }, + ], + }), + ...(mode !== "valid" ? { + expectedFailure: { error: mode === "not-enabled" ? /max-comments/ : /ending line.*outside/ }, + } : {}), + assertFailure: async (ctx, state) => { + const threads = await ctx.rest.listThreads(state.repo, state.prId); + if (threads.some((thread) => thread.comments?.some((comment) => + comment.content?.includes("inline finding") || comment.content?.includes("Consolidated review summary")))) { + throw new Error("Rejected review batch made a partial comment write"); + } + }, + assert: async (ctx, state, record) => { + const inline = record.result?.inline_comments; + if (!Array.isArray(inline) || inline.length !== 2 || record.result?.vote_changed !== false) { + throw new Error("Review batch did not report exactly two non-voting findings"); + } + const ids = inline.map((value: unknown) => { + if (!isRecord(value) || typeof value.thread_id !== "number" || value.status !== "posted") { + throw new Error("Review batch reported an unconfirmed finding"); + } + return value.thread_id; + }); + const summary = record.result?.thread_id; + if (typeof summary !== "number" || new Set([...ids, summary]).size !== 3) { + throw new Error("Review batch did not create distinct finding and summary threads"); + } + for (const id of [...ids, summary]) { + const thread = await ctx.rest.getThread(state.repo, state.prId, id); + if (!thread.comments?.[0]?.content) throw new Error("A confirmed review thread was not persisted"); + } + if ((await ctx.rest.listReviewers(state.repo, state.prId)).some((reviewer) => reviewer.vote !== 0)) { + throw new Error("Comment-only review batch unexpectedly changed a vote"); + } + }, + cleanup: teardownPr, +})); + +export const prOwnedCommentScenarios: Scenario[] = [update, denied, ...supersession, ...inlineCases, ...batches] as Scenario[]; 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-push.ts b/scripts/ado-script/src/executor-e2e/scenarios/pr-push.ts new file mode 100644 index 000000000..c423f504a --- /dev/null +++ b/scripts/ado-script/src/executor-e2e/scenarios/pr-push.ts @@ -0,0 +1,158 @@ +import { createHash } from "node:crypto"; +import { copyFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import type { Scenario, ScenarioContext } from "../scenario.js"; +import { setupPr, teardownPr, type PrState } from "./pr.js"; +import { git } from "./create-pull-request.js"; + +interface State extends PrState { + head: string; + sources: string; + checkout: string; + patch: string; + path: string; + original: string; + expected?: string; + expectedBlob?: string; + omittedCopy?: string; +} + +type Mode = "success" | "stale" | "blocked-branch" | "protected" | "bad-hash" | "empty" + | "native-copy" | "native-rename" | "excluded-native-copy" | "crlf" | "binary" | "expansion-denied"; + +const failures: Partial> = { + stale: /source head changed/, "blocked-branch": /allowed-branches/, + protected: /protected files/, "bad-hash": /SHA-256 mismatch/, + "expansion-denied": /pre-application expansion/, +}; + +async function setup(ctx: ScenarioContext, mode: Mode): Promise { + const id = `pr-push-${mode}`; + const pr = await setupPr(ctx, id, false, true, + mode === "expansion-denied" ? "x".repeat(429_575) : undefined); + const sources = join(ctx.workDir, id, "source-checkouts"); + const checkout = join(sources, pr.repo); + try { + await mkdir(sources, { recursive: true }); + const header = "Basic " + Buffer.from(`:${ctx.token}`).toString("base64"); + const remote = `${ctx.orgUrl.replace(/\/+$/, "")}/${encodeURIComponent(ctx.project)}/_git/${encodeURIComponent(pr.repo)}`; + await git(ctx, ["clone", "--depth=1", "--branch", pr.branch, remote, checkout], sources, header, id); + const head = (await git(ctx, ["rev-parse", "HEAD"], checkout, header, id)).trim(); + const path = mode === "protected" ? "package.json" : `ado-aw-det/${ctx.buildId}/${id}-applied.txt`; + const original = `ado-aw-det/${ctx.buildId}/${id}.md`; + let expected: string | undefined = `Applied PR source delta for ${ctx.buildId}.\n`; + let expectedBlob: string | undefined; + let omittedCopy: string | undefined; + if (mode === "crlf") await git(ctx, ["config", "core.autocrlf", "true"], checkout, header, id); + if (mode === "native-copy" || mode === "native-rename") { + expected = await readFile(join(checkout, original), "utf8"); + if (mode === "native-copy") await copyFile(join(checkout, original), join(checkout, path)); + else await rename(join(checkout, original), join(checkout, path)); + } else if (mode !== "empty" && mode !== "expansion-denied") { + await mkdir(dirname(join(checkout, path)), { recursive: true }); + if (mode === "binary") { + await writeFile(join(checkout, path), Buffer.from([0, 255, 128, 10])); + expected = undefined; + } else { + await writeFile(join(checkout, path), expected, "utf8"); + } + } + if (mode === "excluded-native-copy") { + omittedCopy = `${path}.excluded`; + await copyFile(join(checkout, original), join(checkout, omittedCopy)); + } + let patch: string; + if (mode === "expansion-denied") { + patch = Array.from({ length: 99 }, (_, index) => + `diff --git a/${original} b/expanded-${index}.txt\nsimilarity index 100%\ncopy from ${original}\ncopy to expanded-${index}.txt\n`).join(""); + } else { + await git(ctx, ["add", "-A"], checkout, header, id); + if (mode !== "empty") expectedBlob = (await git(ctx, ["rev-parse", `:${path}`], checkout, header, id)).trim(); + patch = await git(ctx, ["diff", "--cached", "--binary", "--full-index", + "--find-renames", "--find-copies", "--find-copies-harder", head, "--"], checkout, header, id); + } + if ((mode === "native-copy" || mode === "excluded-native-copy") && !patch.includes("copy from ")) { + throw new Error("Native copy fixture did not contain native copy metadata"); + } + if (mode === "native-rename" && !patch.includes("rename from ")) { + throw new Error("Native rename fixture did not contain native rename metadata"); + } + return { ...pr, head, sources, checkout, patch, path, original, expected, expectedBlob, omittedCopy }; + } catch (error) { + await teardownPr(ctx, pr); + await rm(sources, { recursive: true, force: true }); + throw error; + } +} + +const cases = (["success", "stale", "blocked-branch", "protected", "bad-hash", "empty", + "native-copy", "native-rename", "excluded-native-copy", "crlf", "binary", "expansion-denied"] as const) + .map((mode): Scenario => ({ + id: `pr-push-${mode}`, + tool: "push-to-pull-request-branch", + targetsAdoRepo: true, + config: (ctx) => ({ + target: "*", "allowed-repositories": [ctx.adoRepo], + "allowed-branches": [mode === "blocked-branch" ? "not-allowed/*" : `${ctx.prefix("")}*`], + "if-no-changes": "ignore", + ...(mode === "excluded-native-copy" ? { "excluded-files": ["pr-push-excluded-native-copy.md"] } : {}), + }), + setup: (ctx) => setup(ctx, mode), + env: async (_ctx, state) => ({ + BUILD_SOURCESDIRECTORY: state.sources, + ADO_AW_SELF_REPOSITORY_DIRECTORY: state.checkout, + }), + files: async (_ctx, state) => ({ "push.patch": state.patch }), + ndjson: async (_ctx, state) => ({ + pull_request_id: state.prId, repository: state.repo, + expected_head_sha: mode === "stale" ? "f".repeat(40) : state.head, + patch_file: "push.patch", + patch_sha256: mode === "bad-hash" ? "0".repeat(64) : createHash("sha256").update(state.patch).digest("hex"), + }), + ...(failures[mode] ? { + expectedFailure: { error: failures[mode] }, + } : {}), + assertFailure: async (ctx, state) => { + if (await ctx.rest.getRefObjectId(state.repo, `heads/${state.branch}`) !== state.head) { + throw new Error("Rejected push changed the remote source head"); + } + }, + assert: async (ctx, state, record) => { + const head = await ctx.rest.getRefObjectId(state.repo, `heads/${state.branch}`); + if (mode === "empty") { + if (head !== state.head) throw new Error("Empty push changed the source branch"); + return; + } + if (head === state.head || record.result?.push_status !== "confirmed" || record.result?.commit_id !== head) { + throw new Error("Push did not confirm the exact new source head"); + } + const header = "Basic " + Buffer.from(`:${ctx.token}`).toString("base64"); + await git(ctx, ["fetch", "--depth=2", "origin", state.branch], state.checkout, header, `pr-push-${mode}`); + const blob = (await git(ctx, ["rev-parse", `FETCH_HEAD:${state.path}`], state.checkout, header, `pr-push-${mode}`)).trim(); + if (blob !== state.expectedBlob) throw new Error("Applied Git blob differs from the proposed exact bytes"); + if (state.expected !== undefined) { + const applied = await git(ctx, ["show", `FETCH_HEAD:${state.path}`], state.checkout, header, `pr-push-${mode}`); + if (applied !== state.expected) throw new Error("Applied file content differs"); + } + const files = (await git(ctx, ["ls-tree", "-r", "--name-only", "FETCH_HEAD"], state.checkout, header, `pr-push-${mode}`)) + .trim().split("\n"); + if (mode === "native-rename") { + if (files.includes(state.original)) throw new Error("Native rename retained the source path"); + } else { + const original = await git(ctx, ["show", `FETCH_HEAD:${state.original}`], state.checkout, header, `pr-push-${mode}`); + if (!original.includes(`build ${ctx.buildId}`)) throw new Error("Push lost the PR's pre-existing change"); + } + if (state.omittedCopy && (files.includes(state.omittedCopy) || !Array.isArray(record.result?.omitted_operations) + || record.result.omitted_operations.length !== 1)) { + throw new Error("Excluded native copy was not omitted and reported as one operation"); + } + const parent = (await git(ctx, ["rev-parse", "FETCH_HEAD^"], state.checkout, header, `pr-push-${mode}`)).trim(); + if (parent !== state.head) throw new Error("Push was not a direct child of the expected PR source head"); + }, + cleanup: async (ctx, state) => { + try { await teardownPr(ctx, state); } + finally { await rm(state.sources, { recursive: true, force: true }); } + }, + })); + +export const prPushScenarios: Scenario[] = cases as Scenario[]; diff --git a/scripts/ado-script/src/executor-e2e/scenarios/pr.ts b/scripts/ado-script/src/executor-e2e/scenarios/pr.ts index 2eaf73d09..30a692ced 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. + * 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, * so ADO accepts it) and, where needed, a comment thread; asserts the effect; @@ -11,18 +11,21 @@ */ import type { Scenario, ScenarioContext } from "../scenario.js"; import { defaultBranchShortName, detBody, Teardown } from "./common.js"; +import { resolveExecutorE2eReviewer } from "./create-pull-request.js"; -interface PrState { +export interface PrState { repo: string; prId: number; branch: string; threadId?: number; } -async function setupPr( +export async function setupPr( ctx: ScenarioContext, tool: string, withThread: boolean, + draft?: boolean, + fixtureContent?: string, ): Promise { const repo = ctx.adoRepo; const baseBranch = await defaultBranchShortName(ctx, repo); @@ -35,7 +38,7 @@ async function setupPr( branch, baseSha, `/ado-aw-det/${ctx.buildId}/${tool}.md`, - `${detBody(ctx, tool)}\n`, + fixtureContent ?? `${detBody(ctx, tool)}\n`, `deterministic executor e2e ${tool}`, ); @@ -50,6 +53,7 @@ async function setupPr( baseBranch, `${ctx.prefix(tool)} (do not merge)`, detBody(ctx, tool), + draft, ); } catch (err) { await ctx.rest.deleteRef(repo, `refs/heads/${branch}`).catch(() => {}); @@ -71,7 +75,7 @@ async function setupPr( return state; } -async function teardownPr(ctx: ScenarioContext, state: PrState): Promise { +export async function teardownPr(ctx: ScenarioContext, state: PrState): Promise { // Attempt both cleanups independently: if abandoning the PR throws (e.g. a // transient network error), the source branch must still be deleted so it is // not left orphaned for the janitor backstop to reap. @@ -83,18 +87,34 @@ 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-pr-comment", + tool: "add-pull-request-comment", targetsAdoRepo: true, config: (ctx) => ({ + target: "*", "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 +129,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), + config: (ctx) => ({ target: "*", "allowed-repositories": [ctx.adoRepo], max: 1 }), + 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 +152,17 @@ export const replyToPrComment: Scenario = { }; export const resolvePrThread: Scenario = { - tool: "resolve-pr-thread", + tool: "resolve-pull-request-thread", targetsAdoRepo: true, config: (ctx) => ({ + target: "*", "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 +171,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 +187,15 @@ export const resolvePrThread: Scenario = { }; export const submitPrReview: Scenario = { - tool: "submit-pr-review", + tool: "submit-pull-request-review", targetsAdoRepo: true, config: (ctx) => ({ + target: "*", "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 +204,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,28 +217,415 @@ export const submitPrReview: Scenario = { cleanup: teardownPr, }; -export const updatePr: Scenario = { - tool: "update-pr", +export const updatePullRequest: Scenario = { + tool: "update-pull-request", targetsAdoRepo: true, config: (ctx) => ({ - "allowed-operations": ["update-description"], + target: "*", "allowed-repositories": [ctx.adoRepo], - max: 1, + "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, "update-pr", false), + setup: (ctx) => setupPr(ctx, "abandon-pull-request", false), ndjson: async (ctx, state) => ({ pull_request_id: state.prId, repository: ctx.adoRepo, - operation: "update-description", - description: `${detBody(ctx, "update-pr")} (updated)`, + 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/ }, + 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, +}; + +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) => ({ + target: "*", + "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, + config: (ctx) => ({ target: "*", "allowed-repositories": [ctx.adoRepo] }), + setup: (ctx) => setupLabeledPr(ctx, "add-pull-request-labels"), + ndjson: async (ctx, state) => ({ + pull_request_id: state.prId, repository: ctx.adoRepo, labels: ["new-label"], + }), + assert: async (ctx, state) => { + 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 both labels: ${JSON.stringify(labels)}`); + } + }, + cleanup: teardownPr, +}; + +interface AutoCompleteState extends PrState { targetBranch: string } + +export const setPrAutoComplete: Scenario = { + tool: "set-pull-request-auto-complete", + targetsAdoRepo: true, + config: (ctx) => ({ + target: "*", + "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-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; + 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-pull-request-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.description ?? "").includes("(updated)")) { - throw new Error(`PR #${state.prId} description was not updated`); + 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", () => + ctx.rest.abandonPullRequest(state.repo, state.prId, { allowCompleted: true })) + .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(); + }, +}; + +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"); + } + }, +}; + +const reviewVoteScenarios: Scenario[] = (["comment", "reset"] as const).map((event): Scenario => ({ + id: `pr-review-${event}-vote`, + tool: "submit-pull-request-review", + targetsAdoRepo: true, + config: (ctx) => ({ + target: "*", "allowed-repositories": [ctx.adoRepo], + "allowed-events": ["request-changes", event], max: 2, + }), + setup: (ctx) => setupPr(ctx, `pr-review-${event}-vote`, false), + priorEntries: async (ctx, state) => [{ + tool: "submit-pull-request-review", + config: { target: "*", "allowed-events": ["request-changes", event], max: 2 }, + entry: { pull_request_id: state.prId, repository: state.repo, event: "request-changes", + body: detBody(ctx, "seed negative review vote") }, + }], + ndjson: async (ctx, state) => ({ + pull_request_id: state.prId, repository: state.repo, event, + ...(event === "comment" ? { body: detBody(ctx, "non-voting informational review") } : {}), + }), + assert: async (ctx, state, record, records) => { + const actor = records[0]?.result?.reviewer_id; + if (typeof actor !== "string") throw new Error("Seed review did not report its authenticated reviewer ID"); + const reviewers = await ctx.rest.listReviewers(state.repo,state.prId); + const own = reviewers.find((reviewer) => reviewer.id === actor); + if (own?.vote !== (event === "comment" ? -5 : 0)) { + throw new Error(`${event} did not preserve/reset the exact seeded actor's vote`); + } + if (record.result?.vote_changed !== (event === "reset")) { + throw new Error(`${event} misreported its vote effect`); + } + if (event === "comment" && record.result?.comment_status !== "posted") { + throw new Error("Non-voting review did not post its informational content"); + } + }, + cleanup: teardownPr, +})); + +const labelLifecycleScenarios: Scenario[] = (["remove", "replace"] as const).map((operation): Scenario => ({ + tool: operation === "remove" ? "remove-pull-request-labels" : "replace-pull-request-label", + targetsAdoRepo: true, + config: (ctx) => ({ + target: "*", "allowed-repositories": [ctx.adoRepo], + ...(operation === "remove" ? { "allowed-labels": ["existing-label"] } : { + "allowed-add": ["replacement-label"], "allowed-remove": ["existing-label"], + "allowed-transitions": [{ from: "existing-label", to: "replacement-label" }], + }), + }), + setup: async (ctx) => { + const state = await setupLabeledPr(ctx, `pr-label-${operation}`); + try { + await ctx.rest.setPullRequestLabels(state.repo,state.prId,["preserved-label"]); + return state; + } catch (error) { + await teardownPr(ctx,state); + throw error; + } + }, + ndjson: async (_ctx,state) => ({ + pull_request_id: state.prId, repository: state.repo, + ...(operation === "remove" ? { labels: ["existing-label"] } : { from: "existing-label", to: "replacement-label" }), + }), + assert: async (ctx,state) => { + const labels=(await ctx.rest.listPullRequestLabels(state.repo,state.prId)).map((label)=>label.name); + if (labels.includes("existing-label") || !labels.includes("preserved-label") + || (operation === "replace" && !labels.includes("replacement-label"))) { + throw new Error(`Label ${operation} did not persist exactly or lost an unrelated label`); + } + }, + cleanup: teardownPr, +})); + +const labelPolicyScenarios: Scenario[] = (["limit", "blocked"] as const).map((kind): Scenario => ({ + id: `pr-label-${kind}-denied`, + tool: "add-pull-request-labels", + targetsAdoRepo: true, + config: (ctx) => ({ target:"*", "allowed-repositories":[ctx.adoRepo], "max-labels":10, + ...(kind === "blocked" ? { "allowed-labels":["permitted","blocked"], "blocked-labels":["blocked"] } : {}), + }), + setup: (ctx) => setupLabeledPr(ctx,`pr-label-${kind}-denied`), + ndjson: async (_ctx,state) => ({pull_request_id:state.prId,repository:state.repo, + labels:kind === "limit" ? Array.from({length:11},(_,index)=>`limit-${index}`) : ["permitted","blocked"]}), + expectedFailure: { error:kind === "limit" ? /max-labels/ : /blocked-labels/ }, + assert: async () => { throw new Error("A denied label batch must not succeed"); }, + assertFailure: async (ctx,state) => { + const labels=await ctx.rest.listPullRequestLabels(state.repo,state.prId); + if (labels.length !== 1 || labels[0]?.name !== "existing-label") { + throw new Error("A denied label batch made a partial mutation"); } }, cleanup: teardownPr, +})); + +const publishDraft: Scenario = { + tool: "mark-pull-request-as-ready-for-review", + targetsAdoRepo: true, + config: (ctx) => ({ target:"*", "allowed-repositories":[ctx.adoRepo], max:2 }), + setup: async (ctx) => { + const state=await setupPr(ctx,"publish-draft",false,true); + try { + if ((await ctx.rest.getPullRequest(state.repo,state.prId)).isDraft !== true) { + throw new Error("Publication test did not create a persisted draft"); + } + return state; + } catch(error) { + await teardownPr(ctx,state); + throw error; + } + }, + priorEntries: async (_ctx,state) => [{ + tool:"mark-pull-request-as-ready-for-review", + config:{target:"*",max:2}, + entry:{pull_request_id:state.prId,repository:state.repo}, + }], + ndjson: async (_ctx,state) => ({pull_request_id:state.prId,repository:state.repo}), + assert: async (ctx,state,record,records) => { + const pr=await ctx.rest.getPullRequest(state.repo,state.prId); + if (pr.isDraft !== false || pr.status !== "active" || pr.autoCompleteSetBy) { + throw new Error("Publication was not persisted or unexpectedly enabled completion"); + } + if (pr.title !== `${ctx.prefix("publish-draft")} (do not merge)` || pr.description !== detBody(ctx,"publish-draft")) { + throw new Error("Publication changed PR content"); + } + if (records[0]?.result?.publication_status !== "confirmed" || record.result?.already_ready !== true) { + throw new Error("Publication/repeat no-op results are not authoritative"); + } + }, + cleanup:teardownPr, }; export const prScenarios: Scenario[] = [ @@ -224,5 +633,20 @@ export const prScenarios: Scenario[] = [ replyToPrComment, resolvePrThread, submitPrReview, - updatePr, + ...reviewVoteScenarios, + updatePullRequest, + abandonPullRequest, + ...requiredLabelScenarios, + updatePullRequestDeniedRepository, + updatePullRequestIsland, + updatePullRequestOversized, + updatePullRequestUnicode, + updatePullRequestUnicodeOversized, + updatePullRequestComposedOversized, + addPrReviewers, + addPrLabels, + ...labelLifecycleScenarios, + ...labelPolicyScenarios, + publishDraft, + setPrAutoComplete, ]; 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 176fb2e0b..33ac35094 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}; @@ -1185,6 +1183,20 @@ fn build_setup_job( Ok(Some(job)) } +shell_script! { + /// Select and publish a non-secret PR source snapshot before user/agent code runs. + PREPARE_PR_PUSH_SOURCE { + interpreter: Bash, + bindings: [SNAPSHOT_PATH], + externals: [ADO_AW_PR_PUSH_CONFIG], + fragments: [], + body: r#" +set -euo pipefail +/tmp/awf-tools/ado-aw prepare-pr-push --resolved-config "$ADO_AW_PR_PUSH_CONFIG" --snapshot-path "$SNAPSHOT_PATH" +"#, + } +} + fn build_agent_job( front_matter: &FrontMatter, extensions: &[Extension], @@ -1273,6 +1285,29 @@ fn build_agent_job( steps.extend(prepull_images_step(true, front_matter.supply_chain())); // 13. Extension prepare steps (typed) + user steps (RawYaml) + if front_matter.safe_outputs.contains_key("push-to-pull-request-branch") { + let resolved: serde_json::Value = serde_json::from_str(&cfg.resolved_execution_config_json)?; + let minimal = serde_json::json!({ + "name": resolved["name"], + "toolConfigs": {"push-to-pull-request-branch": resolved["toolConfigs"]["push-to-pull-request-branch"]}, + "repositories": resolved["repositories"], "checkout": resolved["checkout"], + "repoRefs": resolved["repoRefs"], "writePermissions": resolved["writePermissions"], + }); + let config_path = "$(Agent.TempDirectory)/ado-aw-pr-push-config.json"; + steps.push(Step::Bash(write_custom_runtime_config_step(&serde_json::to_string(&minimal)?, config_path)?)); + let step = ShellScript::new(&PREPARE_PR_PUSH_SOURCE) + .bind_text("SNAPSHOT_PATH", "/tmp/ado-aw/pr-source-snapshot.json") + .into_step("Prepare exact PR source snapshot") + .with_env("ADO_AW_PR_PUSH_CONFIG", EnvValue::literal(config_path)) + .with_env("ADO_AW_SELF_REPOSITORY_DIRECTORY", EnvValue::literal(&cfg.trigger_repo_directory)) + .with_env("ADO_AW_SELF_REPOSITORY_NAME", cfg.self_repository_name.clone()) + .with_env("SYSTEM_ACCESSTOKEN", EnvValue::secret( + if front_matter.permissions.as_ref().and_then(|permissions|permissions.read.as_ref()).is_some() { + "SC_READ_TOKEN" + } else { "System.AccessToken" } + )); + steps.push(Step::Bash(project_triggering_pr_env(step, front_matter))); + } steps.extend(ext_agent_prepare.iter().cloned()); for user_step_val in &front_matter.steps { steps.push(Step::RawYaml(step_to_raw_yaml_string(user_step_val)?)); @@ -1519,7 +1554,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 +1575,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 +2410,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 +2734,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 +2771,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 +3317,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 +3484,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 +4053,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 +4831,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,36 +4921,90 @@ 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 = 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_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::PrMutationPolicy; + let mut policies = serde_json::Map::new(); + for tool in front_matter.safe_outputs.keys() { + let mut 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, "target-repo": config.target_repo}) + } + "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" + | "remove-pull-request-labels" + | "replace-pull-request-label" + | "mark-pull-request-as-ready-for-review" + | "update-pull-request-comment" + | "push-to-pull-request-branch" + | "set-pull-request-auto-complete" + | "submit-pull-request-review" + | "add-pull-request-comment" + | "reply-to-pull-request-comment" + | "resolve-pull-request-thread" => { + let config = PrMutationPolicy::parse(&front_matter.safe_outputs[tool])?; + serde_json::json!({"target": config.target_policy()?, "target-repo": config.target_repo}) + } + _ => continue, + }; + if matches!(tool.as_str(), "add-pull-request-comment" | "update-pull-request-comment" | "submit-pull-request-review") { + let raw = &front_matter.safe_outputs[tool]; + policy["supersede-older-comments"] = serde_json::json!(raw.get("supersede-older-comments").and_then(serde_json::Value::as_bool).unwrap_or(false)); + policy["comment-key"] = serde_json::json!(raw.get("comment-key").and_then(serde_json::Value::as_str).unwrap_or("default")); + } + policies.insert(tool.clone(), policy); + } + Ok(policies) } shell_script! { @@ -5183,10 +5322,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()), @@ -5321,23 +5457,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), @@ -5379,8 +5506,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( @@ -5388,8 +5514,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( @@ -5420,8 +5545,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"), @@ -6681,7 +6805,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"), + ) } // ───────────────────────────────────────────────────────────────────── @@ -6969,6 +7096,217 @@ 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"], + "triggering" + ); + } + + #[test] + fn pr_push_preparation_uses_only_read_credentials_and_precedes_user_steps() { + use crate::compile::extensions::{CompileContext,collect_extensions}; + for target in ["standalone","1es","job","stage"] { + let fm=test_front_matter(&format!( + "name: push-contract\ndescription: Test\ntarget: {target}\npermissions:\n read: read-sc\n write: write-sc\nsafe-outputs:\n push-to-pull-request-branch:\n allowed-branches: ['agent/*']\n max-patch-size: 2048\nsteps:\n - bash: echo user-step-after-source-preparation\n" + )); + let extensions=collect_extensions(&fm); + let ctx=CompileContext::for_test(&fm); + let input=Path::new("push.md"); + let output=Path::new("push.lock.yml"); + let pipeline=match target { + "standalone"=>super::super::standalone_ir::build_standalone_pipeline(&fm,&extensions,&ctx,input,output,"Push the PR",true,false), + "1es"=>super::super::onees_ir::build_onees_pipeline(&fm,&extensions,&ctx,input,output,"Push the PR",true,false), + "job"=>super::super::job_ir::build_job_pipeline(&fm,&extensions,&ctx,input,output,"Push the PR",true,false), + _=>super::super::stage_ir::build_stage_pipeline(&fm,&extensions,&ctx,input,output,"Push the PR",true,false), + }.unwrap(); + let emitted=super::super::ir::emit::emit(&pipeline).unwrap(); + let prepare=emitted.find("displayName: Prepare exact PR source snapshot").unwrap(); + let user=emitted.find("echo user-step-after-source-preparation").unwrap(); + let tooling=emitted.find("displayName: Prepare tooling").unwrap(); + assert!(tooling) { + 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 { @@ -7755,7 +8093,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] @@ -7767,12 +8109,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] @@ -7896,9 +8246,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 ); @@ -7957,8 +8307,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""#), @@ -7995,9 +8344,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!( @@ -8050,10 +8398,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}" ); } @@ -8316,7 +8662,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/0009_split_update_pr.rs b/src/compile/codemods/0009_split_update_pr.rs new file mode 100644 index 000000000..9db43e627 --- /dev/null +++ b/src/compile/codemods/0009_split_update_pr.rs @@ -0,0 +1,27 @@ +use anyhow::Result; +use serde_yaml::Mapping; + +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 { + if front_matter + .get("safe-outputs") + .and_then(|outputs| outputs.get("update-pr")) + .is_none() + { + return Ok(false); + } + 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 new file mode 100644 index 000000000..1966bf26d --- /dev/null +++ b/src/compile/codemods/0010_pull_request_tool_names.rs @@ -0,0 +1,26 @@ +use anyhow::Result; +use serde_yaml::Mapping; + +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 Some(outputs) = front_matter.get("safe-outputs") else { + return Ok(false); + }; + 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); + } + 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/codemods/0011_explicit_pr_policy.rs b/src/compile/codemods/0011_explicit_pr_policy.rs new file mode 100644 index 000000000..c504a85dc --- /dev/null +++ b/src/compile/codemods/0011_explicit_pr_policy.rs @@ -0,0 +1,137 @@ +use anyhow::Result; +use serde_yaml::{Mapping, Value}; + +use super::{Codemod, CodemodContext}; + +/// Keep in sync with the release introducing triggering-only defaults. +pub(crate) const INTRODUCED_IN: &str = "0.53.0"; + +pub static CODEMOD: Codemod = Codemod { + id: "explicit_pr_policy", + summary: "pin PR target defaults; remove sync-stack, which never synchronized Azure DevOps branches", + introduced_in: INTRODUCED_IN, + apply, +}; + +fn apply(front_matter: &mut Mapping, ctx: &CodemodContext) -> Result { + if !crate::safe_outputs::pr_common::PR_MUTATION_TOOLS + .iter() + .any(|tool| { + front_matter + .get("safe-outputs") + .and_then(|outputs| outputs.get(*tool)) + .is_some() + }) + { + return Ok(false); + } + let custom = crate::compile::imports::pr_policy::local_custom_job_names(front_matter)?; + let Some(Value::Mapping(outputs)) = front_matter.get_mut("safe-outputs") else { + return Ok(false); + }; + let old = ctx + .source_compiler_version + .as_deref() + .is_some_and(|version| crate::version::is_older_than(version, INTRODUCED_IN)); + let mut changed = false; + for tool in crate::safe_outputs::pr_common::PR_MUTATION_TOOLS { + if custom.contains(*tool) { + continue; + } + let Some(config) = outputs.get_mut(*tool) else { + continue; + }; + if config.is_null() { + *config = Value::Mapping(Mapping::new()); + } + let Some(config) = config.as_mapping_mut() else { + continue; + }; + if !config.contains_key("target") { + let was_explicit = matches!( + *tool, + "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" + ); + config.insert( + Value::String("target".into()), + Value::String( + if old && was_explicit { + "*" + } else { + "triggering" + } + .into(), + ), + ); + changed = true; + } + if *tool == "update-pull-request" && config.remove("sync-stack").is_some() { + changed = true; + } + } + Ok(changed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn explicit_pr_policy_preserves_old_scope_and_pins_new_defaults_idempotently() { + for (version, expected) in [ + (None, "triggering"), + (Some("0.52.1"), "*"), + (Some("0.53.0"), "triggering"), + ] { + let mut mapping: Mapping = serde_yaml::from_str( + "safe-outputs:\n add-pull-request-comment: {}\n update-pull-request:\n sync-stack: true\n" + ).unwrap(); + let ctx = CodemodContext::for_source(version.map(str::to_string)); + assert!(apply(&mut mapping, &ctx).unwrap()); + assert_eq!( + mapping["safe-outputs"]["add-pull-request-comment"]["target"].as_str(), + Some(expected) + ); + assert_eq!( + mapping["safe-outputs"]["update-pull-request"]["target"].as_str(), + Some("triggering") + ); + assert!( + mapping["safe-outputs"]["update-pull-request"] + .get("sync-stack") + .is_none() + ); + assert!( + !apply( + &mut mapping, + &CodemodContext::for_source(Some("0.52.1".into())) + ) + .unwrap() + ); + } + } + + #[test] + fn explicit_pr_policy_does_not_override_declared_target() { + let mut mapping: Mapping = serde_yaml::from_str( + "safe-outputs:\n submit-pull-request-review:\n target: '42'\n allowed-events: [comment]\n" + ).unwrap(); + assert!( + !apply( + &mut mapping, + &CodemodContext::for_source(Some("0.52.1".into())) + ) + .unwrap() + ); + assert_eq!( + mapping["safe-outputs"]["submit-pull-request-review"]["target"].as_str(), + Some("42") + ); + } +} diff --git a/src/compile/codemods/mod.rs b/src/compile/codemods/mod.rs index 098bc46ba..c9814b0e2 100644 --- a/src/compile/codemods/mod.rs +++ b/src/compile/codemods/mod.rs @@ -49,6 +49,13 @@ 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; +#[path = "0010_pull_request_tool_names.rs"] +mod m0010_pull_request_tool_names; +#[path = "0011_explicit_pr_policy.rs"] +mod m0011_explicit_pr_policy; +pub(crate) use m0011_explicit_pr_policy::CODEMOD as PR_POLICY_DEFAULTS; #[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 +162,9 @@ 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, + &m0010_pull_request_tool_names::CODEMOD, + &m0011_explicit_pr_policy::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 69a82dac6..936983b75 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,8 +268,10 @@ 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) + parse_markdown_detailed_for_source(content, None) } /// Variant of [`parse_markdown_detailed`] that supplies the compiler @@ -298,6 +300,15 @@ pub(crate) fn parse_markdown_detailed_with_registry( content: &str, registry: &[&'static super::codemods::Codemod], source_compiler_version: Option<&str>, +) -> Result { + parse_markdown_detailed_with_policy(content, registry, source_compiler_version, false) +} + +pub(crate) fn parse_markdown_detailed_with_policy( + content: &str, + registry: &[&'static super::codemods::Codemod], + source_compiler_version: Option<&str>, + current_pr_policy: bool, ) -> Result { use sha2::Digest; @@ -326,10 +337,30 @@ 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)) + .filter(|codemod| !current_pr_policy || codemod.id != "explicit_pr_policy") + .collect(); + let mut report = super::codemods::apply_codemods_with( + &mut mapping, + &initial_registry, + source_compiler_version, + ) + .context("Failed to apply codemods")?; + if current_pr_policy + && registry.iter().any(|codemod| codemod.id == "explicit_pr_policy") + { + report.applied.extend(super::codemods::apply_codemods_with( + &mut mapping, &[&super::codemods::PR_POLICY_DEFAULTS], None, + )?.applied); + } // Stage 3: deserialize the (possibly modified) mapping into the // typed FrontMatter. Errors here mean either the user wrote an @@ -360,6 +391,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 @@ -1888,6 +1954,12 @@ pub fn normalize_source_path(input_path: &std::path::Path) -> String { source_path } +pub(crate) const PR_POLICY_HEADER: &str = "# ado-aw-pr-policy: triggering-v1"; + +pub(crate) fn has_current_pr_policy(content: &str) -> bool { + content.lines().take(5).any(|line| line == PR_POLICY_HEADER) +} + pub fn generate_header_comment(input_path: &std::path::Path) -> String { let version = env!("CARGO_PKG_VERSION"); // The header comment embeds the source inside double quotes @@ -1898,8 +1970,8 @@ pub fn generate_header_comment(input_path: &std::path::Path) -> String { format!( "# This file is auto-generated by ado-aw. Do not edit manually.\n\ - # @ado-aw source=\"{}\" version={}\n", - source_path, version + # @ado-aw source=\"{}\" version={}\n{}\n", + source_path, version, PR_POLICY_HEADER ) } @@ -3022,12 +3094,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 { @@ -3035,27 +3107,145 @@ 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 \ + "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, comment\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" ); } } 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_legacy_metadata(front_matter)?; + super::pr_migration::validate_budget_groups(front_matter)?; + for tool in crate::safe_outputs::pr_common::PR_MUTATION_TOOLS { + if let Some(raw) = front_matter.safe_outputs.get(*tool) { + crate::safe_outputs::pr_common::PrMutationPolicy::parse(raw) + .map_err(|error| anyhow::anyhow!("safe-outputs.{tool} has invalid PR policy: {error:#}"))?; + } + } + front_matter.typed_safe_output_config::( + "create-pull-request", + )?; + front_matter.typed_safe_output_config::( + "mark-pull-request-as-ready-for-review", + )?; + if let Some(config) = front_matter.typed_safe_output_config::( + "push-to-pull-request-branch", + )? { + crate::safe_outputs::validate_push_config(&config)?; + for dependent in ["mark-pull-request-as-ready-for-review","submit-pull-request-review","set-pull-request-auto-complete"] { + if front_matter.safe_outputs.contains_key(dependent) { + require_same_approval_lane(front_matter,"push-to-pull-request-branch",dependent)?; + require_same_staged_lane(front_matter,"push-to-pull-request-branch",dependent)?; + } + } + } + if let Some(config) = front_matter.typed_safe_output_config::( + "update-pull-request-comment", + )? { + anyhow::ensure!(config.comment_key.len() <= 100, "comment-key must fit 100 bytes"); + } + if let Some(config) = front_matter.typed_safe_output_config::( + "add-pull-request-comment", + )? { + crate::safe_outputs::validate_add_pr_comment_config(&config)?; + } + front_matter.typed_safe_output_config::( + "reply-to-pull-request-comment", + )?; + front_matter.typed_safe_output_config::( + "resolve-pull-request-thread", + )?; + if let Some(config) = front_matter + .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::( + "remove-pull-request-labels", + )? { + crate::safe_outputs::validate_add_pr_labels_config(&config)?; + } + if let Some(config) = front_matter.typed_safe_output_config::( + "replace-pull-request-label", + )? { + crate::safe_outputs::validate_replace_pr_label_config(&config)?; + } + if let Some(config) = front_matter + .typed_safe_output_config::( + "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-pull-request-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-pull-request-reviewers", + "add-pull-request-labels", + "remove-pull-request-labels", + "replace-pull-request-label", + "mark-pull-request-as-ready-for-review", + "update-pull-request-comment", + "set-pull-request-auto-complete", + "update-pull-request", + "abandon-pull-request", + "submit-pull-request-review", + "add-pull-request-comment", + "reply-to-pull-request-comment", + "resolve-pull-request-thread", + ] { + if !front_matter.safe_outputs.contains_key(tool) { + continue; + } + let temporary_capable = !matches!(tool, "submit-pull-request-review" | "add-pull-request-comment" + | "reply-to-pull-request-comment" | "resolve-pull-request-thread") + || 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") @@ -3067,21 +3257,28 @@ 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-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-pull-request-reviewers", + )? + { + crate::safe_outputs::validate_add_pr_reviewers_config(&config)?; + } Ok(()) } @@ -3094,7 +3291,21 @@ 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") + 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: @@ -3131,13 +3342,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 { @@ -3146,19 +3357,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" ); } @@ -3701,6 +3912,21 @@ pub fn compile_mcpg( registry_base, ); let mut safeoutputs_entrypoint_args = vec!["mcp".to_string()]; + let patch_limits = [ + ("--create-pull-request-max-patch-size", + front_matter.typed_safe_output_config::("create-pull-request")? + .map(|config| config.max_patch_size)), + ("--push-to-pull-request-branch-max-patch-size", + front_matter.typed_safe_output_config::("push-to-pull-request-branch")? + .map(|config| config.max_patch_size)), + ]; + for (flag, limit) in patch_limits { + if let Some(limit) = limit + && limit != crate::safe_outputs::pr_patch::PatchSizeKiB::default() + { + safeoutputs_entrypoint_args.extend([flag.to_string(), limit.to_string()]); + } + } safeoutputs_entrypoint_args.extend( generate_enabled_tools_args(front_matter) .split_whitespace() @@ -5539,7 +5765,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()); @@ -5550,7 +5776,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()); @@ -5561,7 +5787,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); @@ -5571,7 +5797,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()); } @@ -5656,7 +5882,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()); @@ -5667,7 +5893,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()); @@ -5678,7 +5904,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); @@ -5688,7 +5914,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()); } @@ -6046,6 +6272,65 @@ safe-outputs: } } + #[test] + fn pr_config_rejects_unknown_fields_and_preserves_shared_controls() { + for (tool, extra) in [ + ("create-pull-request", serde_json::json!({})), + ("add-pull-request-comment", serde_json::json!({})), + ("reply-to-pull-request-comment", serde_json::json!({})), + ("resolve-pull-request-thread", serde_json::json!({"allowed-statuses": ["fixed"]})), + ("submit-pull-request-review", serde_json::json!({"allowed-events": ["comment"]})), + ("update-pull-request", serde_json::json!({})), + ("abandon-pull-request", serde_json::json!({})), + ("add-pull-request-reviewers", serde_json::json!({})), + ("add-pull-request-labels", serde_json::json!({})), + ("set-pull-request-auto-complete", serde_json::json!({})), + ] { + for max in [0, 1, 10] { + let mut config = extra.clone(); + config["max"] = serde_json::json!(max); + config["staged"] = serde_json::json!(true); + config["require-approval"] = serde_json::json!(false); + let source = format!( + "---\nname: test\ndescription: test\nsafe-outputs:\n {tool}: {config}\n---\n" + ); + let (fm, _) = parse_markdown(&source).unwrap(); + validate_pull_request_outputs_config(&fm) + .unwrap_or_else(|error| panic!("{tool}: {error:#}")); + + config["unsupported-policy"] = serde_json::json!(true); + let source = format!( + "---\nname: test\ndescription: test\nsafe-outputs:\n {tool}: {config}\n---\n" + ); + let (fm, _) = parse_markdown(&source).unwrap(); + let error = validate_pull_request_outputs_config(&fm).unwrap_err(); + let message = format!("{error:#}"); + assert!(message.contains(tool), "{message}"); + assert!(message.contains("unknown field `unsupported-policy`"), "{message}"); + } + } + } + + #[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_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}" + ); + } + #[test] fn test_validate_safe_outputs_keys_accepts_known_keys() { let yaml = r#"--- @@ -6454,7 +6739,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}" ); } @@ -6530,7 +6815,7 @@ safe-outputs: ] { let (mut fm, _) = parse_markdown(yaml).unwrap(); fm.safe_outputs - .get_mut("update-pr") + .get_mut("add-pull-request-reviewers") .unwrap() .as_object_mut() .unwrap() @@ -6540,7 +6825,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}" ); @@ -8218,6 +8503,30 @@ safe-outputs: ); } + #[test] + fn patch_size_overrides_reach_mcp_without_becoming_agent_parameters() { + let mut fm = minimal_front_matter(); + fm.safe_outputs.insert("create-pull-request".into(), serde_json::json!({"max-patch-size":1})); + fm.safe_outputs.insert("push-to-pull-request-branch".into(), serde_json::json!({ + "allowed-branches":["agent/*"], "max-patch-size":10240, + })); + validate_pull_request_outputs_config(&fm).unwrap(); + let config = generate_mcpg_config(&fm, &collect_exts_and_decls(&fm).1).unwrap(); + let args = config.mcp_servers["safeoutputs"].entrypoint_args.as_ref().unwrap(); + assert!(args.windows(2).any(|pair| pair == ["--create-pull-request-max-patch-size", "1"])); + assert!(args.windows(2).any(|pair| pair == ["--push-to-pull-request-branch-max-patch-size", "10240"])); + for tool in ["create-pull-request", "push-to-pull-request-branch"] { + for value in [serde_json::json!(0), serde_json::json!(10241), serde_json::json!("4096"), serde_json::json!(true)] { + fm.safe_outputs.get_mut(tool).unwrap()["max-patch-size"] = value; + assert!(validate_pull_request_outputs_config(&fm).is_err()); + } + fm.safe_outputs.get_mut(tool).unwrap()["max-patch-size"] = serde_json::json!(4096); + } + let config = generate_mcpg_config(&fm, &collect_exts_and_decls(&fm).1).unwrap(); + let args = config.mcp_servers["safeoutputs"].entrypoint_args.as_ref().unwrap(); + assert!(!args.iter().any(|argument| argument.contains("max-patch-size"))); + } + #[test] fn test_generate_mcpg_config_safeoutputs_is_hardened_stdio_container() { let fm = minimal_front_matter(); 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/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..4b40eabd7 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,41 @@ 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") + ) + })?; + let policy_report = crate::compile::codemods::apply_codemods_with( + &mut state.merged, + &[&crate::compile::codemods::PR_POLICY_DEFAULTS], + None, + )?; + if policy_report.changed() { + log::warn!("Imported PR policies use triggering-only defaults when no legacy target provenance is available; sync-stack has no ADO effect and is removed"); + } dedupe_repos(&mut state.merged)?; state.merged.remove(Value::String("imports".to_string())); *consumer_fm = state.merged; @@ -104,10 +146,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 +200,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 +222,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 +524,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 +587,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 +1077,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..952a23338 --- /dev/null +++ b/src/compile/imports/pr_migration_tests.rs @@ -0,0 +1,632 @@ +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 legacy_comment_shorthand_migrates_at_root_and_in_imports() { + for (old, canonical) in [ + ("add-pr-comment", "add-pull-request-comment"), + ("reply-to-pr-comment", "reply-to-pull-request-comment"), + ] { + for shorthand in ["true", "null", "{}"] { + for imported in [false, true] { + let repo = temp_repo(); + let outputs = format!(" {old}: {shorthand}\n"); + let component = format!("---\nsafe-outputs:\n{outputs}---\nComponent body.\n"); + let source = if imported { + local_workflow(repo.path(), &component, " noop:\n") + } else { + let source = repo.path().join("agent.md"); + write(&source, &workflow("", &outputs)); + source + }; + let original = fs::read_to_string(&source).unwrap(); + let (before, _) = compile::build_pipeline_ir(&source).await.unwrap(); + assert_eq!(before.safe_outputs[canonical]["target"], "*"); + assert!(!before.safe_outputs.contains_key(old)); + assert_eq!(fs::read_to_string(&source).unwrap(), original); + assert_eq!(compile_source(&source).await.unwrap(), !imported); + let rewritten = fs::read_to_string(&source).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!(fs::read_to_string(&source).unwrap(), rewritten); + compile::check_pipeline(&source.with_extension("lock.yml").to_string_lossy()) + .await + .unwrap(); + if imported { + assert_eq!(rewritten, original); + assert_eq!( + fs::read_to_string(repo.path().join("component.md")).unwrap(), + component + ); + } + } + } + } +} + +#[tokio::test] +async fn invalid_legacy_comment_shorthand_is_not_rewritten_or_enabled() { + for value in ["false", "\"true\""] { + let repo = temp_repo(); + let source = repo.path().join("agent.md"); + let original = workflow("", &format!(" add-pr-comment: {value}\n")); + write(&source, &original); + assert!(compile_source(&source).await.is_err()); + assert_eq!(fs::read_to_string(source).unwrap(), original); + } +} + +#[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, "target": "*"}) + ); + 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 legacy_consumer_provenance_does_not_grant_new_import_wildcard_authority() { + let repo = temp_repo(); + let component = "---\nsafe-outputs:\n add-pull-request-labels:\n max: 2\n---\nComponent"; + let source = local_workflow(repo.path(), component, " add-pull-request-comment:\n max: 1\n"); + write(&source.with_extension("lock.yml"), + "# @ado-aw source=\"agent.md\" version=0.52.0\n"); + let (fm, _) = compile::build_pipeline_ir(&source).await.unwrap(); + assert_eq!(fm.safe_outputs["add-pull-request-comment"]["target"], "*"); + assert_eq!(fm.safe_outputs["add-pull-request-labels"]["target"], "triggering"); + compile_source(&source).await.unwrap(); + let (again, _) = compile::build_pipeline_ir(&source).await.unwrap(); + assert_eq!(fm.safe_outputs, again.safe_outputs); + assert_eq!(fs::read_to_string(repo.path().join("component.md")).unwrap(), component); +} + +#[tokio::test] +async fn new_policy_header_prevents_development_version_from_restoring_wildcard() { + let repo = temp_repo(); + let source = repo.path().join("agent.md"); + let authored = workflow("", " add-pull-request-labels:\n max: 2\n"); + write(&source, &authored); + compile_source(&source).await.unwrap(); + // Simulate an author removing the explicit target to request the new default. + write(&source, &authored); + let (fm, _) = compile::build_pipeline_ir(&source).await.unwrap(); + assert_eq!(fm.safe_outputs["add-pull-request-labels"]["target"], "triggering"); + assert_eq!( + compile::prepare_source_front_matter(&source).await.unwrap().safe_outputs, + fm.safe_outputs, + ); + compile_source(&source).await.unwrap(); + compile::check_pipeline(&source.with_extension("lock.yml").to_string_lossy()).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..f738cfdae --- /dev/null +++ b/src/compile/imports/pr_policy.rs @@ -0,0 +1,202 @@ +//! 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(mut value) = renamed.remove(*old) { + ensure!( + !renamed.contains_key(*new), + "manual migration required: both {old} and {new} are configured" + ); + if value.is_null() || value == Value::Bool(true) { + value = Value::Mapping(Mapping::new()); + } + if let Some(config) = value.as_mapping_mut() { + config.entry(Value::String("target".into())).or_insert(Value::String("*".into())); + } + 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 f2cf5a62a..bdc31fa1b 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,23 +48,24 @@ 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, reconstruct_source, }; pub use types::{CompileTarget, FrontMatter}; +pub(crate) mod pr_migration; /// Trait for pipeline compilers. /// @@ -170,41 +171,25 @@ async fn compile_pipeline_inner( // changed default need it to tell an old source from a new one, and // it also drives the version upgrade note below. let yaml_output_path = resolve_output_path(input_path, output_path)?; - let existing_version = read_existing_pipeline_version(&yaml_output_path).await; + let (existing_version, current_pr_policy) = read_existing_pipeline_provenance(&yaml_output_path).await; - let parsed = common::parse_markdown_detailed_with_registry( + let mut parsed = common::parse_markdown_detailed_with_policy( &content, registry, existing_version.as_deref(), + current_pr_policy, )?; + 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?; - 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. @@ -651,10 +636,15 @@ pub async fn check_pipeline(pipeline_path: &str) -> Result<()> { ) })?; - let parsed = parse_markdown_detailed_for_source( + let mut parsed = common::parse_markdown_detailed_with_policy( &content, + codemods::CODEMODS, Some(header_meta.version.as_str()).filter(|v| !v.is_empty()), + common::has_current_pr_policy(&existing), )?; + 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 @@ -675,18 +665,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(); @@ -832,14 +810,20 @@ fn format_diff(existing: &str, expected: &str, pipeline_path: &Path) -> String { /// header written by every compilation. Returns the version string when found, /// `None` when the file does not exist, is unreadable, or has no recognisable /// header. -async fn read_existing_pipeline_version(path: &Path) -> Option { - let content = tokio::fs::read_to_string(path).await.ok()?; - content +async fn read_existing_pipeline_provenance(path: &Path) -> (Option, bool) { + let Ok(content) = tokio::fs::read_to_string(path).await else { return (None, false); }; + let version = content .lines() .take(5) .find_map(crate::detect::parse_header_line) .filter(|meta| !meta.version.is_empty()) - .map(|meta| meta.version) + .map(|meta| meta.version); + (version, common::has_current_pr_policy(&content)) +} + +#[cfg(test)] +async fn read_existing_pipeline_version(path: &Path) -> Option { + read_existing_pipeline_provenance(path).await.0 } /// Map a [`CompileTarget`] to the corresponding boxed [`Compiler`] implementation. @@ -1034,6 +1018,68 @@ 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)?; + } + if parsed.front_matter.safe_outputs.get("submit-pull-request-review") + .and_then(|config| config.get("allowed-events")) + .and_then(serde_json::Value::as_array) + .is_some_and(|events| events.iter().any(|event| event == "comment")) + { + log::warn!( + "{}: safe-outputs.submit-pull-request-review: comment is non-voting and never clears an existing vote; configure and request reset explicitly when that is intended", + crate::sanitize::neutralize_pipeline_commands(&source_path.display().to_string()), + ); + } + if let Some(config) = parsed.front_matter.safe_outputs.get("add-pull-request-labels") + && config.get("max-labels").is_none() + { + log::warn!( + "{}: safe-outputs.add-pull-request-labels: max-labels now defaults to 10 per call; configure a larger intentional batch explicitly", + crate::sanitize::neutralize_pipeline_commands(&source_path.display().to_string()), + ); + } + 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, current_pr_policy) = read_existing_pipeline_provenance(&input_path.with_extension("lock.yml")).await; + let mut parsed = common::parse_markdown_detailed_with_policy( + &content, codemods::CODEMODS, version.as_deref(), current_pr_policy, + )?; + 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. /// @@ -1057,22 +1103,19 @@ pub async fn build_pipeline_ir(input_path: &Path) -> Result<(FrontMatter, ir::Pi // Match `compile`'s view of the source: codemods that migrate a changed // default are gated on the version recorded in the committed output, so // 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 (existing_version, current_pr_policy) = + read_existing_pipeline_provenance(&input_path.with_extension("lock.yml")).await; + let mut parsed = common::parse_markdown_detailed_with_policy( + &content, codemods::CODEMODS, existing_version.as_deref(), current_pr_policy, + )?; // 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 new file mode 100644 index 000000000..8e916f464 --- /dev/null +++ b/src/compile/pr_migration.rs @@ -0,0 +1,625 @@ +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(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"), + ("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-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"), +]; + +#[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)) +} + +/// 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); + }; + 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(), + _ => 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, _)| { + (!bare || *operation != "vote") + && (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(); + validate_legacy_votes(&votes)?; + + 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(); + config.insert("target".into(), json!("*")); + 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!( + crate::safe_outputs::pr_common::PR_MUTATION_TOOLS.contains(&tool.as_str()), + "budget group '{name}' may only contain PR mutation 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(()) +} + +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!( + !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!( + crate::safe_outputs::pr_common::PR_MUTATION_TOOLS.contains(&tool.as_str()), + "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" + ); + } + } + Ok(()) +} + +#[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 = serde_json::from_value(serde_json::to_value(&manifest["safe-outputs"])?)?; + } + 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 { + body.lines() + .enumerate() + .filter_map(|(line, text)| { + text.split(|c: char| { + c.is_whitespace() || (!c.is_alphanumeric() && c != '-' && c != '_') + }) + .any(is_deprecated_pr_tool) + .then_some(line + 1) + }) + .collect() +} + +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) { + 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 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 { + 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()); + let mut expected = config; + expected["target"] = json!("*"); + assert_eq!(outputs.get(*new), Some(&expected)); + 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!({ + "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-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"], "*"); + 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-pull-request-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-pull-request-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-pull-request-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-pull-request-reviewers", + json!({"allowed-reviewers":[""]}), + "allowed-reviewers", + ), + ( + "add-pull-request-labels", + json!({"allowed-repositories":[""]}), + "allowed-repositories", + ), + ( + "set-pull-request-auto-complete", + json!({"merge-strategy":"invalid"}), + "merge-strategy", + ), + ( + "submit-pull-request-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 08eb3afe4..2810c267f 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}"))?; @@ -2291,12 +2293,24 @@ impl FrontMatter { self.typed_safe_output_config("close-github-issue") } + pub fn abandon_pull_request_config( + &self, + ) -> anyhow::Result> { + self.typed_safe_output_config("abandon-pull-request") + } + pub fn update_github_issue_config( &self, ) -> anyhow::Result> { 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> { @@ -7143,7 +7157,7 @@ description: "Test" safe-outputs: require-approval: true create-pull-request: {} - add-pr-comment: {} + add-pull-request-comment: {} --- Body @@ -7155,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] @@ -7170,7 +7190,7 @@ safe-outputs: require-approval: true create-pull-request: require-approval: false - add-pr-comment: {} + add-pull-request-comment: {} --- Body @@ -7178,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 0ec321901..05106b754 100644 --- a/src/execute.rs +++ b/src/execute.rs @@ -16,7 +16,7 @@ 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, CommentOnGithubIssueResult, CommentOnWorkItemResult, CreateBranchResult, CreateGitTagResult, CreateGithubIssueResult, CreatePrResult, @@ -25,10 +25,14 @@ use crate::safe_outputs::{ MissingToolResult, NoopResult, QueueBuildResult, RemoveGithubIssueLabelsResult, ReplyToPrCommentResult, ReportIncompleteResult, ResolvePrThreadResult, SetGithubIssueFieldResult, SetGithubIssueTypeResult, SubmitPrReviewResult, ToolResult, - UnassignGithubIssueFromUserResult, UpdateGithubIssueResult, UpdatePrResult, + UnassignGithubIssueFromUserResult, UpdateGithubIssueResult, UpdatePullRequestResult, UpdateWikiPageResult, UpdateWorkItemResult, UploadBuildAttachmentResult, UploadPipelineArtifactResult, UploadWorkitemAttachmentResult, }; +use crate::safe_outputs::{AddPrLabelsResult, AddPrReviewersResult, SetPrAutoCompleteResult}; +use crate::safe_outputs::{RemovePullRequestLabelsResult, ReplacePullRequestLabelResult, MarkPullRequestReadyResult}; +use crate::safe_outputs::UpdatePullRequestCommentResult; +use crate::safe_outputs::PushToPullRequestBranchResult; use crate::sanitize::neutralize_pipeline_commands; // Re-export memory types for use by main.rs @@ -204,6 +208,7 @@ pub async fn execute_safe_outputs( ctx: &ExecutionContext, filter: &ToolFilter, ) -> Result> { + 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); @@ -245,7 +250,15 @@ pub async fn execute_safe_outputs( CreateGitTagResult, AddBuildTagResult, CreateBranchResult, - UpdatePrResult, + AddPrReviewersResult, + AddPrLabelsResult, + RemovePullRequestLabelsResult, + ReplacePullRequestLabelResult, + MarkPullRequestReadyResult, + UpdatePullRequestCommentResult, + PushToPullRequestBranchResult, + SetPrAutoCompleteResult, + AbandonPullRequestResult, UploadBuildAttachmentResult, UploadPipelineArtifactResult, UploadWorkitemAttachmentResult, @@ -259,6 +272,7 @@ pub async fn execute_safe_outputs( AddGithubIssueLabelsResult, RemoveGithubIssueLabelsResult, CloseGithubIssueResult, + UpdatePullRequestResult, UpdateGithubIssueResult, SetGithubIssueFieldResult, AssignGithubIssueMilestoneResult, @@ -267,23 +281,54 @@ pub async fn execute_safe_outputs( LinkGithubSubIssueResult, ); + let mut group_counts = HashMap::::new(); let mut results = Vec::new(); + let mut failed_pr_pushes = std::collections::HashSet::new(); for (i, entry) in entries.iter().enumerate() { + let tool=entry.get("name").and_then(Value::as_str).unwrap_or(""); + let key=pr_push_target_key(entry,ctx); + if filter.allows(tool) + && matches!(tool,"mark-pull-request-as-ready-for-review"|"submit-pull-request-review"|"set-pull-request-auto-complete") + && (failed_pr_pushes.contains("*") || key.as_ref().is_some_and(|key|failed_pr_pushes.contains(key))) + { + let failure=ExecutionResult::failure("Skipped: an earlier code push for this PR failed or was unconfirmed"); + append_execution_record(safe_output_dir,tool,&failure,entry.get("context").and_then(Value::as_str)).await; + results.push(failure); + continue; + } if let Some(result) = process_one_entry( i, entries.len(), entry, &mut budgets, + &mut group_counts, filter, ctx, safe_output_dir, ) .await { + if tool=="push-to-pull-request-branch" && !result.success { + failed_pr_pushes.insert(key.unwrap_or_else(||"*".into())); + } results.push(result); } } + fn pr_push_target_key(entry:&Value,ctx:&ExecutionContext)->Option{ + let tool=entry.get("name")?.as_str()?; + let policy=crate::safe_outputs::pr_common::PrMutationPolicy::parse(ctx.tool_configs.get(tool)?).ok()?; + let reference=entry.get("pull_request_id").filter(|value|!value.is_null()) + .map(|value|serde_json::from_value::(value.clone())) + .transpose().ok()?; + let repository=entry.get("repository").and_then(Value::as_str).or(policy.target_repo.as_deref()); + let (id,target)=crate::safe_outputs::pr_common::resolve_pr_policy_target( + &policy.target_policy().ok()?,reference.as_ref(),repository,&policy.allowed_repositories,ctx, + ).ok()?.ok()?; + Some(format!("{}|{}|{}|{id}",target.organization_url.trim_end_matches('/').to_ascii_lowercase(), + target.project.to_ascii_lowercase(),target.repository.to_ascii_lowercase())) + } + // Log final summary let success_count = results .iter() @@ -332,6 +377,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, @@ -360,7 +406,19 @@ 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 == proposal_tool_name) + && 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, @@ -370,6 +428,11 @@ async fn process_one_entry( .await; return Some(result); } + for (name, group) in &ctx.budget_groups { + if group.tools.iter().any(|tool| tool == proposal_tool_name) { + *group_counts.entry(name.clone()).or_default() += 1; + } + } let result = match execute_safe_output(entry, ctx).await { Ok((tool_name, result)) => { @@ -562,11 +625,7 @@ async fn append_execution_record_impl( name: tool_name.replace('-', "_"), status, context: proposal_context.map(str::to_owned), - result: if matches!(status, "succeeded" | "warning") { - result.data.clone() - } else { - None - }, + result: result.data.clone(), error: if status == "succeeded" { None } else { @@ -622,10 +681,18 @@ async fn dispatch_tool( ctx: &ExecutionContext, ) -> Result where - T: DeserializeOwned + Executor, + T: DeserializeOwned + Executor + ToolResult, { debug!("Parsing {} payload", tool_name); - let mut output: T = serde_json::from_value(entry.clone()) + let mut payload = entry.clone(); + // Write proposals carry execution context in the envelope; diagnostics own + // their context field as tool input and must retain it. + if T::REQUIRES_WRITE + && let Some(object) = payload.as_object_mut() + { + object.remove("context"); + } + let mut output: T = serde_json::from_value(payload) .map_err(|e| anyhow::anyhow!("Failed to parse {}: {}", tool_name, e))?; output.execute_sanitized(ctx).await } @@ -737,11 +804,20 @@ async fn dispatch_pr_tools( ) -> Result> { dispatch_executor_tools!(tool_name, entry, ctx, { "create-pull-request" => CreatePrResult, - "add-pr-comment" => AddPrCommentResult, - "update-pr" => UpdatePrResult, - "submit-pr-review" => SubmitPrReviewResult, - "reply-to-pr-comment" => ReplyToPrCommentResult, - "resolve-pr-thread" => ResolvePrThreadResult, + "add-pull-request-comment" => AddPrCommentResult, + "add-pull-request-reviewers" => AddPrReviewersResult, + "add-pull-request-labels" => AddPrLabelsResult, + "remove-pull-request-labels" => RemovePullRequestLabelsResult, + "replace-pull-request-label" => ReplacePullRequestLabelResult, + "mark-pull-request-as-ready-for-review" => MarkPullRequestReadyResult, + "update-pull-request-comment" => UpdatePullRequestCommentResult, + "push-to-pull-request-branch" => PushToPullRequestBranchResult, + "set-pull-request-auto-complete" => SetPrAutoCompleteResult, + "abandon-pull-request" => AbandonPullRequestResult, + "update-pull-request" => UpdatePullRequestResult, + "submit-pull-request-review" => SubmitPrReviewResult, + "reply-to-pull-request-comment" => ReplyToPrCommentResult, + "resolve-pull-request-thread" => ResolvePrThreadResult, }) } @@ -804,6 +880,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_id") { + return format!(" (pull request {})", safe_json_identifier(pr)); + } if let (Some(parent), Some(sub_issue)) = ( entry.get("parent_issue_number"), entry.get("sub_issue_number"), @@ -904,6 +983,798 @@ mod tests { use std::collections::HashMap; use std::path::PathBuf; + #[tokio::test] + async fn pr_closed_payloads_preserve_execution_context_envelopes() { + let temp = tempfile::tempdir().unwrap(); + let entry = serde_json::json!({ + "name": "submit-pull-request-review", + "pull_request_id": 7, + "event": "comment", + "body": "A review body.", + "context": "proposal-context", + }); + std::fs::write( + temp.path().join(SAFE_OUTPUT_FILENAME), + format!("{entry}\n"), + ).unwrap(); + let ctx = ExecutionContext { + dry_run: true, + ..Default::default() + }; + let results = execute_safe_outputs(temp.path(), &ctx, &ToolFilter::default()) + .await.unwrap(); + assert!(results[0].success); + let records = ndjson::read_ndjson_file(&temp.path().join(EXECUTED_NDJSON_FILENAME)) + .await.unwrap(); + assert_eq!(records[0]["context"], "proposal-context"); + + let mut invalid = entry; + invalid["force"] = serde_json::json!(true); + let error = execute_safe_output(&invalid, &ctx).await.unwrap_err(); + assert!(error.to_string().contains("unknown field `force`"), "{error:#}"); + } + + #[tokio::test] + async fn pr_envelope_handling_does_not_strip_diagnostic_context() { + let (_, result) = execute_safe_output( + &serde_json::json!({"name": "noop", "context": "nothing needs changing"}), + &ExecutionContext::default(), + ).await.unwrap(); + assert_eq!(result.message, "No operation needed: nothing needs changing"); + } + + #[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!({"target":"*"}), + )]), + ..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!({"target":"*"}), + )]), + ..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!({"target":"*","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!({"target":"*","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!({"target":"*"}), + )]), + ..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 abandonment_connection_loss_never_posts_a_followup_comment() { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + for fail_patch in [false, true] { + 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 ")); + if fail_patch { + let body = r#"{"status":"active"}"#; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + lookup.write_all(response.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([( + "abandon-pull-request".into(), + serde_json::json!({"target":"*","include-stats":false}), + )]), + ..Default::default() + }; + let entry = serde_json::json!({ + "name":"abandon-pull-request","pull_request_id":7,"body":"Must not be posted." + }); + let error = tokio::time::timeout( + std::time::Duration::from_secs(5), + execute_safe_output(&entry, &ctx), + ) + .await + .unwrap() + .unwrap_err(); + assert!(error.to_string().contains(if fail_patch { + "Failed to abandon" + } else { + "Failed to fetch" + })); + tokio::time::timeout(std::time::Duration::from_secs(5), server) + .await + .unwrap() + .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_posts_rationale_before_vote_and_records_partial_failures() { + 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": -5}))) + .respond_with(ResponseTemplate::new(vote_status)) + .expect(u64::from(comment_status == 200)) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/P/_apis/git/repositories/repo/pullRequests/7/threads")) + .respond_with(ResponseTemplate::new(comment_status).set_body_json(serde_json::json!({"id": 12}))) + .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([( + "submit-pull-request-review".into(), + serde_json::json!({"allowed-events":["request-changes"],"target":"*"}), + )]), + ..Default::default() + }; + let (_, result) = execute_safe_output( + &serde_json::json!({ + "name":"submit-pull-request-review","pull_request_id":7,"event":"request-changes", + "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 comment_status == 200 { 3 } else { 2 }); + assert_eq!(requests[0].method.as_str(), "GET"); + assert_eq!(requests[1].method.as_str(), "POST"); + let data = result.data.as_ref().unwrap(); + if comment_status == 200 { + assert_eq!(requests[2].method.as_str(), "PUT"); + assert_eq!(data["thread_id"],12); + assert_eq!(data["comment_status"],"posted"); + assert_eq!(data["vote_status"],"failed"); + } else { + assert_eq!(data["comment_status"],"failed"); + assert_eq!(data["vote_status"],"not-attempted"); + } + let directory = tempfile::tempdir().unwrap(); + append_execution_record(directory.path(),"submit-pull-request-review",&result,Some("review")).await; + let record = read_executed_manifest(&directory).await; + assert_eq!(record[0]["status"],"failed"); + assert_eq!(record[0]["result"], *data); + } + } + + #[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!({"target":"*"}), + )]), + ..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 (delivery uncertain)") + ); + 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; + 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", + "body": "Informational review body." + }), &ctx).await.expect_err("trusted invalid legacy policy must fail before requests"); + assert!(format!("{error:#}").contains("unsupported legacy vote"), "{error:#}"); + 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("agent-authored legacy policy is not a proposal field"); + assert!(format!("{error:#}").contains("unknown field `legacy-update-pr`"), "{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 { + 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-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 + .iter() + .map(Value::to_string) + .collect::>() + .join("\n"); + 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, + 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()) + .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 migrated_pr_filters_use_canonical_review_lane() { + let dir = tempfile::tempdir().unwrap(); + tokio::fs::write(dir.path().join(SAFE_OUTPUT_FILENAME), + "{\"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, + budget_groups: crate::compile::pr_migration::budget_groups(&fm).unwrap(), + tool_configs: fm.safe_outputs, + ..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")) @@ -945,15 +1816,15 @@ mod tests { } #[tokio::test] - async fn execution_manifest_failure_preserves_error_without_result() { + async fn execution_manifest_failure_preserves_error_and_partial_result() { let record = append_and_read_execution_record(ExecutionResult::failure_with_data( "permission denied", - serde_json::json!({"ignored": true}), + serde_json::json!({"thread_id": 12, "vote_status": "failed"}), )) .await; assert_eq!(record["status"], "failed"); - assert!(record["result"].is_null()); + assert_eq!(record["result"], serde_json::json!({"thread_id": 12, "vote_status": "failed"})); assert_eq!(record["error"], "permission denied"); } @@ -1002,7 +1873,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 { @@ -1010,7 +1881,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 { @@ -1018,7 +1889,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 { @@ -1409,13 +2280,14 @@ mod tests { #[tokio::test] async fn test_execute_safe_outputs_creates_then_updates_temporary_pr_reference() { use std::process::Command; - use wiremock::matchers::{method, path}; + use wiremock::matchers::{method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; let server = MockServer::start().await; let api_base = "/Target%20Project/_apis/git/repositories/repo-id"; Mock::given(method("GET")) .and(path(format!("{api_base}/refs"))) + .and(query_param("filter", "heads/agent/update-test-file-abc123")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "value": [] }))) @@ -1482,6 +2354,14 @@ mod tests { .unwrap() .trim() .to_string(); + Mock::given(method("GET")) + .and(path(format!("{api_base}/refs"))) + .and(query_param("filter", "heads/main")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "value":[{"name":"refs/heads/main","objectId":base_commit}] + }))) + .expect(1) + .mount(&server).await; let patch_file = safe_outputs_dir.join("change.patch"); std::fs::write(&patch_file, &patch).unwrap(); let patch_sha256 = crate::hash::sha256_hex(&patch); @@ -1499,10 +2379,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", @@ -1518,7 +2397,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()), @@ -2319,6 +3204,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 dbcdf24b6..e7e02aefe 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", @@ -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", @@ -301,15 +302,29 @@ 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", - "update-pr" => "Parameters for updating a pull request", + "submit-pull-request-review" => "Submits non-voting review feedback or an explicitly authorized ADO vote", + "add-pull-request-reviewers" => "Adds policy-permitted Azure DevOps PR reviewers", + "add-pull-request-labels" => { + "Adds labels without replacing existing Azure DevOps PR labels" + } + "remove-pull-request-labels" => "Removes policy-permitted PR labels while preserving unrelated labels", + "replace-pull-request-label" => "Adds and verifies a replacement PR label before removing the old one", + "mark-pull-request-as-ready-for-review" => "Publishes an active draft PR without approving or completing it", + "update-pull-request-comment" => "Edits a verified pipeline-owned PR comment without overwriting human conversations", + "push-to-pull-request-branch" => "Applies code changes to an authorized PR source ref with an exact-head concurrency guard", + "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", "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..1b8c8ec77 100644 --- a/src/main.rs +++ b/src/main.rs @@ -257,9 +257,21 @@ enum Commands { /// definitions to register as dynamic MCP tools. #[arg(long = "custom-tools")] custom_tools: Option, + #[arg(long, hide = true, default_value_t = safe_outputs::pr_patch::PatchSizeKiB::default())] + create_pull_request_max_patch_size: safe_outputs::pr_patch::PatchSizeKiB, + #[arg(long, hide = true, default_value_t = safe_outputs::pr_patch::PatchSizeKiB::default())] + push_to_pull_request_branch_max_patch_size: safe_outputs::pr_patch::PatchSizeKiB, }, /// Run the author-facing MCP server over stdio (IDE/Copilot Chat integration) McpAuthor {}, + /// Trusted pre-agent source snapshot preparation; never performs remote writes. + #[command(hide = true)] + PreparePrPush { + #[arg(long)] + resolved_config: PathBuf, + #[arg(long)] + snapshot_path: PathBuf, + }, /// Execute safe outputs from Stage 1 (Stage 3 of the pipeline) Execute { /// Path to the source markdown file (used by built-in safe-output execution) @@ -669,6 +681,7 @@ impl Commands { Commands::Mcp { .. } => "mcp", Commands::McpAuthor {} => "mcp-author", Commands::Execute { .. } => "execute", + Commands::PreparePrPush { .. } => "prepare-pr-push", Commands::Init { .. } => "init", Commands::Configure { .. } => "configure", Commands::Secrets { .. } => "secrets", @@ -806,6 +819,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 +905,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, @@ -984,27 +1000,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 @@ -1033,7 +1032,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 +1062,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 +1157,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( @@ -1689,7 +1691,7 @@ async fn main() -> Result<()> { // Also skipped in CI environments to avoid unnecessary outbound calls. let is_pipeline_internal = matches!( command, - Commands::Execute { .. } | Commands::Mcp { .. } | Commands::McpAuthor { .. } + Commands::Execute { .. } | Commands::Mcp { .. } | Commands::McpAuthor { .. } | Commands::PreparePrPush { .. } ); let update_handle = if !is_pipeline_internal && std::env::var_os("CI").is_none() { Some(tokio::spawn(update_check::check_for_update())) @@ -1729,6 +1731,8 @@ async fn main() -> Result<()> { self_repository_directory, enabled_tools, custom_tools, + create_pull_request_max_patch_size, + push_to_pull_request_branch_max_patch_size, } => { let filter = if enabled_tools.is_empty() { None @@ -1741,12 +1745,23 @@ async fn main() -> Result<()> { self_repository_directory.as_deref(), filter.as_deref(), custom_tools.as_deref(), + create_pull_request_max_patch_size, + push_to_pull_request_branch_max_patch_size, ) .await?; } Commands::McpAuthor {} => { mcp_author::run_stdio().await?; } + Commands::PreparePrPush { resolved_config, snapshot_path } => { + let config: ResolvedExecutionConfig = serde_json::from_slice( + &tokio::fs::read(&resolved_config).await.context("Failed to read PR source preparation config")? + ).context("Invalid PR source preparation config")?; + let ctx = build_execution_context_from_resolved( + &config, &std::env::current_dir()?, None, None, false, + ).await; + safe_outputs::push_to_pull_request_branch::prepare_agent(&ctx,&snapshot_path).await?; + } Commands::Execute { source, safe_output_dir, @@ -1848,6 +1863,26 @@ async fn main() -> Result<()> { #[cfg(test)] mod tests { use super::is_github_remote; + use clap::Parser; + + #[test] + fn mcp_patch_limits_are_validated_and_preserved_per_tool() { + let args = super::Args::try_parse_from([ + "ado-aw", "mcp", "out", "repo", + "--create-pull-request-max-patch-size", "1", + "--push-to-pull-request-branch-max-patch-size", "10240", + ]).unwrap(); + let Some(super::Commands::Mcp { + create_pull_request_max_patch_size, push_to_pull_request_branch_max_patch_size, .. + }) = args.command else { panic!("expected MCP command"); }; + assert_eq!(create_pull_request_max_patch_size.bytes(), 1024); + assert_eq!(push_to_pull_request_branch_max_patch_size.bytes(), 10 * 1024 * 1024); + for value in ["0", "10241", "true", "1.5", "-1"] { + assert!(super::Args::try_parse_from([ + "ado-aw", "mcp", "out", "repo", "--create-pull-request-max-patch-size", value, + ]).is_err(), "{value}"); + } + } #[test] fn detects_github_https_remote() { diff --git a/src/mcp.rs b/src/mcp.rs index 358f50f03..73df7cb34 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -11,10 +11,11 @@ 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, + 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, @@ -29,11 +30,20 @@ use crate::safe_outputs::{ 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, + 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, + RemovePullRequestLabelsParams, RemovePullRequestLabelsResult, + ReplacePullRequestLabelParams, ReplacePullRequestLabelResult, + MarkPullRequestReadyParams, MarkPullRequestReadyResult, + UpdatePullRequestCommentParams, UpdatePullRequestCommentResult, + PushToPullRequestBranchParams, PushToPullRequestBranchResult, }; use crate::sanitize::{SanitizeContent, sanitize as sanitize_text, sanitize_markdown}; use crate::secure::{PullRequestTemporaryId, WorkItemTemporaryId}; @@ -210,6 +220,8 @@ async fn try_root_commit_fallback(git_dir: &std::path::Path) -> Option { /// fresh on each call, so no shared mutable state exists between clones. #[derive(Clone, Debug)] pub struct SafeOutputs { + create_pr_patch_size: crate::safe_outputs::pr_patch::PatchSizeKiB, + push_pr_patch_size: crate::safe_outputs::pr_patch::PatchSizeKiB, bounding_directory: PathBuf, self_repository_directory: PathBuf, output_directory: PathBuf, @@ -257,138 +269,6 @@ fn resolve_git_dir_for_patch( } } -/// Check whether the working tree has uncommitted changes (staged or unstaged). -async fn check_uncommitted_changes(git_dir: &std::path::Path) -> Result { - use tokio::process::Command; - let status_output = Command::new("git") - .args(["status", "--porcelain"]) - .current_dir(git_dir) - .output() - .await - .map_err(|e| anyhow_to_mcp_error(anyhow::anyhow!("Failed to run git status: {}", e)))?; - if !status_output.status.success() { - return Err(anyhow_to_mcp_error(anyhow::anyhow!( - "git status failed: {}", - String::from_utf8_lossy(&status_output.stderr) - ))); - } - Ok(!String::from_utf8_lossy(&status_output.stdout) - .trim() - .is_empty()) -} - -/// Stage all changes and create a temporary "agent changes" commit so that -/// uncommitted work is captured by `git format-patch`. -/// -/// On any failure the staging area is reset before the error is returned, -/// leaving the working tree in the same state as before the call. -async fn make_synthetic_commit(git_dir: &std::path::Path) -> Result<(), McpError> { - use tokio::process::Command; - - let add_output = Command::new("git") - .args(["add", "-A"]) - .current_dir(git_dir) - .output() - .await - .map_err(|e| anyhow_to_mcp_error(anyhow::anyhow!("Failed to run git add -A: {}", e)))?; - - if !add_output.status.success() { - // Reset index to clean state on failure - let _ = Command::new("git") - .args(["reset", "HEAD", "--quiet"]) - .current_dir(git_dir) - .output() - .await; - return Err(anyhow_to_mcp_error(anyhow::anyhow!( - "git add -A failed: {}", - String::from_utf8_lossy(&add_output.stderr) - ))); - } - - // Create a temporary commit with git identity flags to avoid config dependency - let commit_output = Command::new("git") - .args([ - "-c", - "user.email=agent@ado-aw", - "-c", - "user.name=ADO Agent", - "commit", - "-m", - "agent changes", - "--allow-empty", - "--no-verify", - ]) - .current_dir(git_dir) - .output() - .await - .map_err(|e| { - anyhow_to_mcp_error(anyhow::anyhow!("Failed to create temporary commit: {}", e)) - })?; - - if !commit_output.status.success() { - // Reset staging on failure - let _ = Command::new("git") - .args(["reset", "HEAD", "--quiet"]) - .current_dir(git_dir) - .output() - .await; - return Err(anyhow_to_mcp_error(anyhow::anyhow!( - "Failed to create temporary commit: {}", - String::from_utf8_lossy(&commit_output.stderr) - ))); - } - - Ok(()) -} - -/// Undo the synthetic commit created by [`make_synthetic_commit`], restoring -/// all changes to the working tree. -/// -/// `git reset --mixed HEAD~1` resets the index to the parent tree, leaving -/// modified files as unstaged changes and previously-untracked files as -/// untracked again. -async fn undo_synthetic_commit(git_dir: &std::path::Path) -> Result<(), McpError> { - use tokio::process::Command; - - // Capture the synthetic commit SHA for diagnostics before resetting - let head_sha = Command::new("git") - .args(["rev-parse", "HEAD"]) - .current_dir(git_dir) - .output() - .await - .ok() - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()) - .unwrap_or_else(|| "".to_string()); - - let reset_output = Command::new("git") - .args(["reset", "HEAD~1", "--mixed", "--quiet"]) - .current_dir(git_dir) - .output() - .await - .map_err(|e| { - anyhow_to_mcp_error(anyhow::anyhow!( - "Failed to run git reset (synthetic commit {} may remain): {}", - head_sha, - e - )) - })?; - - if !reset_output.status.success() { - warn!( - "WARNING: synthetic commit {} was not cleaned up; \ - run `git reset HEAD~1` to restore state", - head_sha - ); - return Err(anyhow_to_mcp_error(anyhow::anyhow!( - "git reset HEAD~1 failed (synthetic commit {} may remain): {}", - head_sha, - String::from_utf8_lossy(&reset_output.stderr) - ))); - } - - Ok(()) -} - /// Decide whether a single tool should remain in the router after filtering. /// /// Three categories, evaluated in priority order: @@ -431,6 +311,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 crate::compile::pr_migration::is_deprecated_pr_tool(name) { + warn!("{}", crate::compile::pr_migration::PR_PROMPT_GUIDANCE); + } warn!( "Enabled-tools entry '{}' has no matching route (ignored)", name @@ -631,6 +514,8 @@ impl SafeOutputs { } Ok(Self { + create_pr_patch_size: Default::default(), + push_pr_patch_size: Default::default(), bounding_directory: bounding_dir, self_repository_directory: self_repository_dir, output_directory: output_dir, @@ -644,68 +529,50 @@ impl SafeOutputs { /// Generate a git diff patch from a specific directory /// If `repository` is Some, it's treated as a subdirectory of bounding_directory. /// If `repository` is None or "self", use the explicit self checkout. - async fn generate_patch(&self, repository: Option<&str>) -> Result<(String, String), McpError> { - use tokio::process::Command; - + async fn generate_patch(&self, repository: Option<&str>) -> Result<(Vec, String), McpError> { + use crate::safe_outputs::pr_patch::{bounded_output, git, git_without_filters}; let git_dir = resolve_git_dir_for_patch( - &self.bounding_directory, - &self.self_repository_directory, - repository, + &self.bounding_directory, &self.self_repository_directory, repository, )?; - - // Generate patch using git format-patch for proper commit metadata, - // rename detection, and binary file handling. - // - // Handles both committed and uncommitted changes: - // 1. Find the merge-base with the upstream branch (origin/HEAD or origin/main) - // 2. If there are uncommitted changes, stage and create a temporary commit - // 3. Generate format-patch from merge-base..HEAD to capture ALL changes - // 4. If a temporary commit was created, reset it (preserving working tree) - let merge_base = Self::find_merge_base(&git_dir).await?; - debug!("Using merge base: {}", merge_base); - - let has_uncommitted = check_uncommitted_changes(&git_dir).await?; - if has_uncommitted { - debug!("Uncommitted changes detected, creating synthetic commit"); - make_synthetic_commit(&git_dir).await?; - } else { - debug!("No uncommitted changes — capturing committed changes only"); - } - - // Capture (don't propagate) the format-patch result so the synthetic commit - // is always undone first, even when format-patch fails. - let format_patch_result = Command::new("git") - .args([ - "format-patch", - &format!("{}..HEAD", merge_base), - "--stdout", - "-M", - ]) - .current_dir(&git_dir) - .output() - .await; - - // Always undo the temporary commit before propagating errors. - // `git reset --mixed HEAD~1` undoes the commit and resets the index - // to the parent tree, which leaves modified files as unstaged changes - // and previously-untracked files as untracked again. - if has_uncommitted { - undo_synthetic_commit(&git_dir).await?; - } - - let format_patch_output = format_patch_result.map_err(|e| { - anyhow_to_mcp_error(anyhow::anyhow!("Failed to run git format-patch: {}", e)) - })?; - - if !format_patch_output.status.success() { - return Err(anyhow_to_mcp_error(anyhow::anyhow!( - "git format-patch failed: {}", - String::from_utf8_lossy(&format_patch_output.stderr) - ))); - } - - let patch = String::from_utf8_lossy(&format_patch_output.stdout).to_string(); + let scratch = tempfile::tempdir().map_err(|error| anyhow_to_mcp_error(error.into()))?; + let index = scratch.path().join("index"); + let captured: anyhow::Result> = async { + let original = git(&git_dir, &["rev-parse", "--verify", "HEAD^{commit}"]).await?; + anyhow::ensure!(original.status.success(), "Could not resolve capture HEAD"); + let head = crate::secure::CommitSha::parse(std::str::from_utf8(&original.stdout)?.trim())?; + for args in [vec!["read-tree", head.as_str()], vec!["add", "-A"]] { + let output = bounded_output(git_without_filters(&git_dir).await?.args(args) + .env("GIT_INDEX_FILE", &index), crate::safe_outputs::pr_patch::MAX_SOURCE_BYTES, None).await?; + anyhow::ensure!(output.status.success(), "Could not capture changes in a private Git index"); + } + let tree = bounded_output(git_without_filters(&git_dir).await?.arg("write-tree") + .env("GIT_INDEX_FILE", &index), 1024, None).await?; + anyhow::ensure!(tree.status.success(), "Could not write captured Git tree"); + let tree = std::str::from_utf8(&tree.stdout)?.trim(); + anyhow::ensure!(crate::validate::is_valid_commit_sha(tree), "Git returned an invalid tree ID"); + let original_tree = git(&git_dir, &["rev-parse", &format!("{head}^{{tree}}")]).await?; + anyhow::ensure!(original_tree.status.success(), "Could not resolve original Git tree"); + let tip = if std::str::from_utf8(&original_tree.stdout)?.trim() == tree { + head + } else { + let commit = bounded_output(git_without_filters(&git_dir).await?.args([ + "-c", "user.email=agent@ado-aw", "-c", "user.name=ADO Agent", "-c", "commit.gpgSign=false", + "commit-tree", tree, "-p", head.as_str(), "-m", "agent changes", + ]), 1024, None).await?; + anyhow::ensure!(commit.status.success(), "Could not create detached capture commit"); + crate::secure::CommitSha::parse(std::str::from_utf8(&commit.stdout)?.trim())? + }; + let output = bounded_output(git_without_filters(&git_dir).await?.args([ + "format-patch", &format!("{merge_base}..{tip}"), "--stdout", "-M", "--full-index", + "--binary", "--no-ext-diff", "--no-textconv", + ]), self.create_pr_patch_size.bytes(), None).await?; + anyhow::ensure!(output.status.success(), "git format-patch failed: {}", + crate::sanitize::neutralize_pipeline_commands(&String::from_utf8_lossy(&output.stderr))); + Ok(output.stdout) + }.await; + let patch = crate::safe_outputs::pr_patch::finish_scratch(scratch, captured) + .map_err(anyhow_to_mcp_error)?; Ok((patch, merge_base)) } @@ -947,6 +814,18 @@ issue_number may be a positive number or a temporary_id from create-github-issue self.queue_sanitized_output(result).await } + #[tool( + name = "abandon-pull-request", + description = "Abandon a configured Azure DevOps pull request without merging, optionally with a comment." + )] + async fn abandon_pull_request( + &self, + params: Parameters, + ) -> Result { + let result: AbandonPullRequestResult = 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." @@ -959,6 +838,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 an Azure DevOps pull request title or description. Uses gh-aw-style title/body/operation inputs." + )] + 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." @@ -1081,12 +972,13 @@ 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, params: Parameters, ) -> Result { + let _guard = self.create_pr_proposal_lock.lock().await; info!("Tool called: create-pull-request - '{}'", params.0.title); // Sanitize untrusted agent-provided text fields (IS-01) let mut sanitized = params.0; @@ -1101,7 +993,7 @@ Returns a generated temporary_id that can be passed as pull_request_id to later debug!("Generating patch for repository: {}", repository); let (patch_content, merge_base) = self.generate_patch(Some(repository)).await?; - if patch_content.trim().is_empty() { + if patch_content.iter().all(u8::is_ascii_whitespace) { warn!("No changes detected in repository '{}'", repository); return Err(anyhow_to_mcp_error(anyhow::anyhow!( "No changes detected in repository '{}'. Make code changes before creating a PR.", @@ -1109,6 +1001,7 @@ Returns a generated temporary_id that can be passed as pull_request_id to later ))); } debug!("Patch size: {} bytes", patch_content.len()); + crate::safe_outputs::pr_patch::inspected_paths(&patch_content).map_err(anyhow_to_mcp_error)?; // Generate a unique filename for the patch (include repo for clarity) let patch_filename = self.generate_patch_filename(repository); @@ -1123,7 +1016,7 @@ Returns a generated temporary_id that can be passed as pull_request_id to later })?; // Compute SHA-256 of the patch for cross-stage integrity verification. - let patch_sha256 = crate::hash::sha256_hex(patch_content.as_bytes()); + let patch_sha256 = crate::hash::sha256_hex(&patch_content); // Generate source branch name from sanitized title + short unique suffix let title_slug = slugify_title(&sanitized.title); @@ -1135,7 +1028,6 @@ Returns a generated temporary_id that can be passed as pull_request_id to later }; const MAX_ID_ATTEMPTS: usize = 16; - let _guard = self.create_pr_proposal_lock.lock().await; let existing = self .read_safe_output_file() .await @@ -1179,7 +1071,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!({ @@ -1188,6 +1080,30 @@ Returns a generated temporary_id that can be passed as pull_request_id to later Ok(response) } + #[tool( + name = "push-to-pull-request-branch", + description = "Propose code changes to an existing ADO PR source branch, never an arbitrary branch. Supply its original expected_head_sha from /tmp/ado-aw/pr-source-snapshot.json or an explicitly prepared matching checkout. Captures committed and uncommitted changes without altering HEAD/index. Refuses merge-checkout history, stale heads, forks, protected files and unauthorized branches. Stage 3 never force-pushes." + )] + async fn push_pr_branch(&self, params: Parameters) -> Result { + params.0.validate().map_err(anyhow_to_mcp_error)?; + let _guard = self.create_pr_proposal_lock.lock().await; + let dir = resolve_git_dir_for_patch(&self.bounding_directory,&self.self_repository_directory,Some(params.0.repository.as_str()))?; + let bytes = crate::safe_outputs::push_to_pull_request_branch::capture_patch(&dir,¶ms.0.expected_head_sha,self.push_pr_patch_size) + .await.map_err(anyhow_to_mcp_error)?; + crate::safe_outputs::pr_patch::inspected_paths(&bytes).map_err(anyhow_to_mcp_error)?; + let filename = format!("{}-{}", generate_short_id(), self.generate_patch_filename(params.0.repository.as_str())); + tokio::fs::write(self.output_directory.join(&filename),&bytes).await + .map_err(|error|anyhow_to_mcp_error(anyhow::anyhow!("Failed to stage PR patch: {error}")))?; + let result = PushToPullRequestBranchResult { + name: PushToPullRequestBranchResult::NAME.into(), + pull_request_id: params.0.pull_request_id, repository: params.0.repository, + expected_head_sha: params.0.expected_head_sha, + patch_file: crate::secure::StrictRelativePath::parse(filename).map_err(anyhow_to_mcp_error)?, + patch_sha256: crate::hash::sha256_hex(&bytes), + }; + self.queue_sanitized_output(result).await + } + #[tool( name = "update-wiki-page", description = "Create or update an Azure DevOps wiki page with the provided markdown content. \ @@ -1262,30 +1178,30 @@ structured output that should be visible in the project wiki." } #[tool( - name = "add-pr-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." + name = "add-pull-request-comment", + description = "Propose an independent ad hoc comment thread on an Azure DevOps PR. \ +Supports general and inline feedback. It is never buffered into a later review; use \ +submit-pull-request-review for a complete review. Writes happen only during safe output processing." )] async fn add_pr_comment( &self, params: Parameters, ) -> Result { info!( - "Tool called: add-pr-comment - PR #{}", - params.0.pull_request_id + "Tool called: add-pull-request-comment - {}", + crate::safe_outputs::pr_common::describe_pr_reference(params.0.pull_request_id.as_ref()) ); debug!("Content length: {} chars", params.0.content.len()); let mut sanitized = params.0; - sanitized.content = sanitize_text(&sanitized.content); + sanitized.content = sanitize_markdown(&sanitized.content); let result: AddPrCommentResult = 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)) })?; - info!("PR comment queued for PR #{}", result.pull_request_id); + info!("PR comment queued for {}", crate::safe_outputs::pr_common::describe_pr_reference(result.pull_request_id.as_ref())); Ok(CallToolResult::success(vec![Content::text(format!( - "Comment queued for PR #{}. The comment will be posted during safe output processing.", - result.pull_request_id + "Comment queued for {}. The comment will be posted during safe output processing.", + crate::safe_outputs::pr_common::describe_pr_reference(result.pull_request_id.as_ref()) ))])) } @@ -1408,29 +1324,66 @@ 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-pull-request-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-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( + &self, + params: Parameters, + ) -> Result { + let result: AddPrLabelsResult = params.0.try_into()?; + self.queue_sanitized_output(result).await + } + + #[tool( + name = "remove-pull-request-labels", + description = "Propose removal of policy-permitted labels from an Azure DevOps PR. Missing labels are no-ops. Does not remove other labels; writes happen in safe output processing." + )] + async fn remove_pr_labels(&self, params: Parameters) -> Result { + let result: RemovePullRequestLabelsResult = params.0.try_into()?; + self.queue_sanitized_output(result).await + } + + #[tool( + name = "replace-pull-request-label", + description = "Propose one permitted PR label transition from one label to another. Adds and verifies the new label before removing the old label. This is not an atomic ADO operation; partial outcomes are reported." + )] + async fn replace_pr_label(&self, params: Parameters) -> Result { + let result: ReplacePullRequestLabelResult = params.0.try_into()?; + self.queue_sanitized_output(result).await + } + + #[tool( + name = "mark-pull-request-as-ready-for-review", + description = "Propose publishing an existing active draft PR for review. Does not approve, merge or enable auto-complete. Already-ready PRs are no-ops; persisted publication is verified in Stage 3." + )] + async fn mark_pr_ready(&self, params: Parameters) -> Result { + let result: MarkPullRequestReadyResult = params.0.try_into()?; + self.queue_sanitized_output(result).await + } + + #[tool( + 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( + &self, + params: Parameters, + ) -> Result { + let result: SetPrAutoCompleteResult = params.0.try_into()?; + self.queue_sanitized_output(result).await } #[tool( @@ -1726,33 +1679,44 @@ restrictions may apply per the workflow's safe-outputs config." } #[tool( - name = "submit-pr-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." + name = "submit-pull-request-review", + description = "Propose a complete pull request review. The comment event posts \ +non-voting feedback and preserves any existing vote; reset explicitly clears your vote. \ +Other allowed events cast their documented ADO vote. Standalone comments are independent, \ +never buffered into this review. Requires 'allowed-events'; writes happen only during safe output processing." )] async fn submit_pr_review( &self, params: Parameters, ) -> Result { info!( - "Tool called: submit-pr-review - PR #{} event '{}'", - params.0.pull_request_id, params.0.event + "Tool called: submit-pull-request-review - {} event '{}'", + crate::safe_outputs::pr_common::describe_pr_reference(params.0.pull_request_id.as_ref()), params.0.event ); let mut sanitized = params.0; - sanitized.body = sanitized.body.map(|b| sanitize_text(&b)); + sanitized.body = sanitized.body.map(|b| sanitize_markdown(&b)); + for comment in &mut sanitized.comments { comment.content = sanitize_markdown(&comment.content); } let result: SubmitPrReviewResult = 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 review '{}' queued for PR #{}. The review will be submitted during safe output processing.", - result.event, result.pull_request_id + "PR review '{}' queued for {}. The review will be submitted during safe output processing.", + result.event, crate::safe_outputs::pr_common::describe_pr_reference(result.pull_request_id.as_ref()) ))])) } #[tool( - name = "reply-to-pr-comment", + name = "update-pull-request-comment", + description = "Propose editing a verified root comment owned by this pipeline and actor, with no replies. Requires existing thread_id and comment_id. Refuses conversations, externally edited comments and unowned history. This edits a comment, not the PR description." + )] + async fn update_pr_comment(&self, params: Parameters) -> Result { + let result: UpdatePullRequestCommentResult = params.0.try_into()?; + self.queue_sanitized_output(result).await + } + + #[tool( + 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." )] @@ -1761,23 +1725,23 @@ 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 #{}", - params.0.pull_request_id, params.0.thread_id + "Tool called: reply-to-pull-request-comment - {} thread #{}", + crate::safe_outputs::pr_common::describe_pr_reference(params.0.pull_request_id.as_ref()), params.0.thread_id ); let mut sanitized = params.0; - sanitized.content = sanitize_text(&sanitized.content); + sanitized.content = sanitize_markdown(&sanitized.content); let result: ReplyToPrCommentResult = 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!( - "Reply queued for thread #{} on PR #{}. The reply will be posted during safe output processing.", - result.thread_id, result.pull_request_id + "Reply queued for thread #{} on {}. The reply will be posted during safe output processing.", + result.thread_id, crate::safe_outputs::pr_common::describe_pr_reference(result.pull_request_id.as_ref()) ))])) } #[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." @@ -1787,16 +1751,16 @@ The status change will be applied during safe output processing." params: Parameters, ) -> Result { info!( - "Tool called: resolve-pr-thread - PR #{} thread #{} → '{}'", - params.0.pull_request_id, params.0.thread_id, params.0.status + "Tool called: resolve-pull-request-thread - {} thread #{} → '{}'", + crate::safe_outputs::pr_common::describe_pr_reference(params.0.pull_request_id.as_ref()), params.0.thread_id, params.0.status ); let result: ResolvePrThreadResult = params.0.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!( - "Thread #{} status change to '{}' queued for PR #{}. The change will be applied during safe output processing.", - result.thread_id, result.status, result.pull_request_id + "Thread #{} status change to '{}' queued for {}. The change will be applied during safe output processing.", + result.thread_id, result.status, crate::safe_outputs::pr_common::describe_pr_reference(result.pull_request_id.as_ref()) ))])) } @@ -1837,17 +1801,21 @@ pub async fn run( self_repository_directory: Option<&str>, enabled_tools: Option<&[String]>, custom_tools: Option<&std::path::Path>, + create_pr_patch_size: crate::safe_outputs::pr_patch::PatchSizeKiB, + push_pr_patch_size: crate::safe_outputs::pr_patch::PatchSizeKiB, ) -> Result<()> { // Create and run the server with STDIO transport - let service = SafeOutputs::new_with_self_repository_directory( + let mut service = SafeOutputs::new_with_self_repository_directory( bounding_directory, output_directory, self_repository_directory.map(PathBuf::from), enabled_tools, custom_tools, ) - .await? - .serve(stdio()) + .await?; + service.create_pr_patch_size = create_pr_patch_size; + service.push_pr_patch_size = push_pr_patch_size; + let service = service.serve(stdio()) .await .inspect_err(|e| { error!("Error starting MCP server: {}", e); @@ -2130,6 +2098,36 @@ mod tests { assert_eq!(proposals[0]["temporary_id"], temporary_id); } + #[tokio::test] + async fn creation_capture_preserves_head_index_and_worktree_on_success_and_size_failure() { + for limited in [false, true] { + let repo = tempdir().unwrap(); + let output = tempdir().unwrap(); + initialize_git_repo_with_change(repo.path()); + let command = |args: &[&str]| { + let result = std::process::Command::new("git").args(args).current_dir(repo.path()).output().unwrap(); + assert!(result.status.success(), "{}", String::from_utf8_lossy(&result.stderr)); + result.stdout + }; + command(&["add", "file.txt"]); + std::fs::write(repo.path().join("file.txt"), "unstaged version\n").unwrap(); + std::fs::write(repo.path().join("untracked.txt"), "x".repeat(4096)).unwrap(); + let head = command(&["rev-parse", "HEAD"]); + let index = std::fs::read(repo.path().join(".git").join("index")).unwrap(); + let mut service = SafeOutputs::new(repo.path(), output.path(), None, None).await.unwrap(); + if limited { + service.create_pr_patch_size = crate::safe_outputs::pr_patch::PatchSizeKiB::try_from(1).unwrap(); + } + let result = service.generate_patch(None).await; + assert_eq!(result.is_ok(), !limited); + assert_eq!(command(&["rev-parse", "HEAD"]), head); + assert_eq!(std::fs::read(repo.path().join(".git").join("index")).unwrap(), index); + assert_eq!(std::fs::read(repo.path().join("file.txt")).unwrap(), b"unstaged version\n"); + assert_eq!(std::fs::read(repo.path().join("untracked.txt")).unwrap().len(), 4096); + assert!(service.read_safe_output_file().await.unwrap().is_empty()); + } + } + #[tokio::test] async fn create_work_item_preserves_html_description_in_proposal() { let (safe_outputs, _temp_dir) = create_test_safe_outputs().await; @@ -2619,7 +2617,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(), 24); let temp_dir = tempfile::tempdir().unwrap(); let enabled: Vec = CONFIGURED_ONLY_TOOLS .iter() @@ -2629,6 +2627,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() @@ -2641,6 +2643,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/abandon_pull_request.rs b/src/safe_outputs/abandon_pull_request.rs new file mode 100644 index 000000000..46e3ace8d --- /dev/null +++ b/src/safe_outputs/abandon_pull_request.rs @@ -0,0 +1,1198 @@ +//! `abandon-pull-request` Azure DevOps safe output. + +use super::pr_http::BoundedPrResponse; +use anyhow::ensure; +use log::{debug, info, warn}; +use schemars::JsonSchema; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use super::pr_common::{ + PrTargetPolicy, PullRequestReference, fetch_pr_labels, repository_api_base, resolve_pr_policy_target, + validate_reference, +}; +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; + +const MAX_COMMENT_LEN: usize = 4_000; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum AbandonPullRequestTarget { + #[default] + Triggering, + Any, + Id(u64), +} + +impl Serialize for AbandonPullRequestTarget { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + match self { + Self::Triggering => serializer.serialize_str("triggering"), + Self::Any => serializer.serialize_str("*"), + Self::Id(id) => serializer.serialize_u64(*id), + } + } +} + +impl<'de> Deserialize<'de> for AbandonPullRequestTarget { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct Visitor; + + impl serde::de::Visitor<'_> for Visitor { + 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"#) + } + + fn visit_u64(self, value: u64) -> Result + where + E: serde::de::Error, + { + if value == 0 { + return Err(E::custom("target pull request ID must be positive")); + } + Ok(AbandonPullRequestTarget::Id(value)) + } + + fn visit_i64(self, value: i64) -> Result + where + E: serde::de::Error, + { + if value <= 0 { + return Err(E::custom("target pull request ID must be positive")); + } + Ok(AbandonPullRequestTarget::Id(value as u64)) + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + match value { + "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") + })?; + if id == 0 { + return Err(E::custom("target pull request ID must be positive")); + } + Ok(AbandonPullRequestTarget::Id(id)) + } + } + } + } + + deserializer.deserialize_any(Visitor) + } +} + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AbandonPullRequestParams { + /// 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, + /// Optional abandonment comment. + #[serde(default)] + pub body: Option, + /// Optional repository alias/name. + #[serde(default)] + pub repository: Option, +} + +impl Validate for AbandonPullRequestParams { + fn validate(&self) -> anyhow::Result<()> { + 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.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() { + ensure!( + !repository.trim().is_empty(), + "repository must not be empty" + ); + crate::validate::reject_pipeline_injection(repository, "repository")?; + } + Ok(()) + } +} + +tool_result! { + name = "abandon-pull-request", + write = true, + params = AbandonPullRequestParams, + default_max = 1, + /// Result of abandoning an Azure DevOps pull request. + #[serde(deny_unknown_fields)] + pub struct AbandonPullRequestResult { + #[serde(default, alias = "pull_request_number")] + pull_request_id: Option, + #[serde(default)] + body: Option, + #[serde(default)] + repository: Option, + } +} + +impl SanitizeContent for AbandonPullRequestResult { + fn sanitize_content_fields(&mut self) { + self.body = self.body.as_deref().map(sanitize_markdown); + self.repository = self.repository.as_deref().map(sanitize_config); + } +} + +#[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, + /// 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-repositories", alias = "allowed-repos")] + pub allowed_repositories: 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 AbandonPullRequestConfig { + fn default() -> Self { + Self { + include_stats: true, + target: AbandonPullRequestTarget::Triggering, + target_repo: None, + allowed_repositories: Vec::new(), + required_labels: Vec::new(), + required_title_prefix: None, + max: None, + } + } +} + +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(), + "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(()) +} + +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), + } + } +} + +impl AbandonPullRequestResult { + 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( + &self, + pr: &serde_json::Value, + labels: &[String], + 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(); + 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 missing: Vec<&str> = config + .required_labels + .iter() + .map(String::as_str) + .filter(|required| { + !labels + .iter() + .any(|actual| actual.eq_ignore_ascii_case(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.bounded_json().await.map_err(|error| { + anyhow::anyhow!("Failed to parse pull request response: {error}") + })?)); + } + + let status = response.status(); + let body = response.bounded_text().await.unwrap_or_else(|error| format!("Failed to read PR error response: {error}")); + Ok(Err(ExecutionResult::failure(format!( + "Failed to fetch pull request (HTTP {}): {}", + status, body + )))) +} + +async fn post_comment( + client: &reqwest::Client, + base_url: &str, + pull_request_id: u64, + token: &str, + ctx: &ExecutionContext, + body: Option<&str>, +) -> anyhow::Result> { + let Some(body) = body else { + return Ok(Ok(false)); + }; + let url = format!( + "{}/pullRequests/{}/threads?api-version=7.1", + base_url, 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)); + } + let status = response.status(); + let body = response.bounded_text().await.unwrap_or_else(|error| format!("Failed to read PR error response: {error}")); + Ok(Err(ExecutionResult::failure(format!( + "Failed to add abandonment comment to PR #{} (HTTP {}): {}", + pull_request_id, status, body + )))) +} + +async fn abandon_pr( + client: &reqwest::Client, + url: &str, + pull_request_id: u64, + token: &str, + ctx: &ExecutionContext, +) -> anyhow::Result> { + 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 status = response.status(); + let body = response.bounded_text().await.unwrap_or_else(|error| format!("Failed to read PR error response: {error}")); + Ok(Err(ExecutionResult::failure(format!( + "Failed to abandon PR #{} (HTTP {}): {}", + pull_request_id, status, body + )))) +} + +#[async_trait::async_trait] +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}") + } + + async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + let params = AbandonPullRequestParams { + pull_request_id: self.pull_request_id.clone(), + 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", + )); + } + 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: 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())); + } + + 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, + )? { + Ok(target) => target, + Err(result) => return Ok(result), + }; + let repo_name = target.qualified_repository(); + let client = super::pr_http::client()?; + let base_url = repository_api_base(&target); + let pr_url = format!( + "{}/pullRequests/{}?api-version=7.1", + base_url, pull_request_id, + ); + debug!("abandon-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), + }; + 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); + } + + 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( + format!("Azure DevOps PR #{} was already abandoned", pull_request_id), + serde_json::json!({ + "pull_request_id": pull_request_id, + "repository": repo_name, + "already_abandoned": true, + "abandoned": true, + "comment_posted": false, + "comment_status": "not-attempted", + }), + )); + } + 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 + ))); + } + + 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); + } + + let comment_posted = match post_comment( + &client, + &base_url, + pull_request_id, + token, + ctx, + comment.as_deref(), + ) + .await + { + 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(), + )); + } + }; + + info!("Abandoned Azure DevOps PR #{}", pull_request_id); + Ok(ExecutionResult::success_with_data( + format!("Abandoned Azure DevOps PR #{}", pull_request_id), + serde_json::json!({ + "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::*; + use crate::safe_outputs::ToolResult; + use std::collections::HashMap; + 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("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, + repository_name: Some("repo".to_string()), + allowed_repositories: HashMap::from([("other".to_string(), "other-repo".to_string())]), + ..Default::default() + } + } + + fn pr(status: &str) -> serde_json::Value { + serde_json::json!({ + "pullRequestId": 7, + "title": "[bot] stale PR", + "status": status + }) + } + + #[test] + fn contract_name_and_budget() { + assert_eq!(AbandonPullRequestResult::NAME, "abandon-pull-request"); + assert_eq!(AbandonPullRequestResult::DEFAULT_MAX, 1); + } + + #[test] + fn config_accepts_target_forms() { + let triggering: AbandonPullRequestConfig = + serde_json::from_value(serde_json::json!({"target": "triggering"})).unwrap(); + assert_eq!(triggering.target, AbandonPullRequestTarget::Triggering); + let any: AbandonPullRequestConfig = + serde_json::from_value(serde_json::json!({"target": "*"})).unwrap(); + assert_eq!(any.target, AbandonPullRequestTarget::Any); + let id: AbandonPullRequestConfig = + serde_json::from_value(serde_json::json!({"target": 42})).unwrap(); + assert_eq!(id.target, AbandonPullRequestTarget::Id(42)); + assert!( + serde_json::from_value::(serde_json::json!({ + "target": 0 + })) + .is_err() + ); + } + + #[test] + fn validates_optional_id_body_and_repository() { + assert!( + AbandonPullRequestParams { + pull_request_id: Some(PullRequestReference::Number(42)), + body: Some("Closing as stale.".to_string()), + repository: Some("self".to_string()), + } + .validate() + .is_ok() + ); + assert!( + AbandonPullRequestParams { + pull_request_id: Some(PullRequestReference::Number(0)), + body: None, + repository: None, + } + .validate() + .is_err() + ); + } + + #[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"]}), + ); + 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(), 2); + } + + #[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; + Mock::given(method("GET")) + .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( + "/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("/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) + .await; + let ctx = context( + &server, + serde_json::json!({ + "target": "*", + "include-stats": false, + "required-labels": ["automated", "stale"], + "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()), + 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) + ); + server.verify().await; + } + + #[tokio::test] + async fn triggering_target_uses_context_pull_request_id() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .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/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) + .mount(&server) + .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, + repository: None, + } + .try_into() + .unwrap(); + assert!(result.execute_sanitized(&ctx).await.unwrap().success); + server.verify().await; + } + + #[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")) + .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-labels": ["missing"]}), + ); + let mut result: AbandonPullRequestResult = AbandonPullRequestParams { + pull_request_id: Some(PullRequestReference::Number(7)), + body: None, + repository: None, + } + .try_into() + .unwrap(); + let execution = result.execute_sanitized(&ctx).await.unwrap(); + 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(PullRequestReference::Number(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(PullRequestReference::Number(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(PullRequestReference::Number(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(PullRequestReference::Number(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/add_pr_comment.rs b/src/safe_outputs/add_pr_comment.rs index b3ee5dcb5..968e28df6 100644 --- a/src/safe_outputs/add_pr_comment.rs +++ b/src/safe_outputs/add_pr_comment.rs @@ -1,14 +1,20 @@ //! Add PR comment safe output tool +use super::pr_http::BoundedPrResponse; use log::{debug, info}; -use percent_encoding::utf8_percent_encode; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use std::path::Path; -use super::PATH_SEGMENT; +use super::pr_common::{ + PullRequestReference, describe_pr_reference, repository_api_base, resolve_configured_pr_target, + validate_reference, +}; +use super::pr_inline::{PrCommentSide, PrInlineComment}; +use super::pr_mutations::UpdatePrContext; +use super::{ToolResult, authenticate_ado_request}; use crate::safe_outputs::{ExecutionContext, ExecutionResult, Executor, Validate}; -use crate::sanitize::{SanitizeContent, sanitize as sanitize_text, sanitize_config}; +use crate::sanitize::{SanitizeContent, sanitize_config, sanitize_markdown}; +use crate::secure::{CommitSha, Identifier, RelativeSafePath}; use crate::tool_result; use crate::validate::reject_pipeline_injection; use ado_aw_derive::SanitizeConfig; @@ -16,17 +22,19 @@ use anyhow::{Context, ensure}; /// Parameters for adding a comment thread on a pull request #[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct AddPrCommentParams { /// The pull request ID to comment on - pub pull_request_id: i32, + #[serde(default)] + pub pull_request_id: Option, /// Comment text in markdown format. Ensure adequate content > 10 characters. pub content: String, /// Repository alias: "self" for pipeline repo, or an alias from the checkout list. /// Defaults to "self" if omitted. - #[serde(default = "default_repository")] - pub repository: String, + #[serde(default)] + pub repository: Option, /// File path for an inline comment. When set, the comment is anchored to this file. #[serde(default)] @@ -41,6 +49,12 @@ pub struct AddPrCommentParams { /// Line number for an inline comment. Requires `file_path` to be set. #[serde(default)] pub line: Option, + /// Side of the PR diff; right by default. + #[serde(default)] + pub side: PrCommentSide, + /// Exact reviewed source commit. Required for inline comments. + #[serde(default)] + pub expected_head_sha: Option, /// Thread status: "active" (default), "fixed", "wont-fix", "closed", or "by-design". /// CamelCase forms ("Active", "WontFix", etc.) are also accepted for backwards compatibility. @@ -48,10 +62,6 @@ pub struct AddPrCommentParams { pub status: String, } -fn default_repository() -> String { - "self".to_string() -} - fn default_status() -> String { "active".to_string() } @@ -66,11 +76,14 @@ fn validate_repository_selector(repository: &str) -> anyhow::Result<()> { impl Validate for AddPrCommentParams { fn validate(&self) -> anyhow::Result<()> { - ensure!(self.pull_request_id > 0, "pull_request_id must be positive"); + if let Some(reference) = &self.pull_request_id { + validate_reference(reference)?; + } ensure!( self.content.len() >= 10, "content must be at least 10 characters" ); + super::pr_comments::validate_body(&self.content)?; ensure!( status_to_int(&self.status).is_some(), "status must be one of: {}", @@ -95,32 +108,59 @@ impl Validate for AddPrCommentParams { } if let Some(fp) = &self.file_path { validate_file_path(fp)?; + RelativeSafePath::parse(fp)?; + ensure!( + self.expected_head_sha.is_some(), + "expected_head_sha is required for inline comments" + ); + PrInlineComment { + file_path: RelativeSafePath::parse(fp)?, + side: self.side, + line: u32::try_from(self.line.unwrap_or(1)).context("line must be positive")?, + start_line: self + .start_line + .map(u32::try_from) + .transpose() + .context("start_line must be positive")?, + content: self.content.clone(), + } + .validate()?; + } else { + ensure!(self.side == PrCommentSide::Right, "side requires file_path"); + } + if let Some(repository) = &self.repository { + validate_repository_selector(repository)?; } - validate_repository_selector(&self.repository)?; Ok(()) } } 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 + #[serde(deny_unknown_fields)] pub struct AddPrCommentResult { - pull_request_id: i32, + #[serde(default)] + pull_request_id: Option, content: String, - repository: String, + repository: Option, file_path: Option, start_line: Option, line: Option, + #[serde(default)] + side: PrCommentSide, + #[serde(default)] + expected_head_sha: Option, status: String, } } impl SanitizeContent for AddPrCommentResult { fn sanitize_content_fields(&mut self) { - self.content = sanitize_text(&self.content); - self.repository = sanitize_config(&self.repository); + self.content = sanitize_markdown(&self.content); + self.repository = self.repository.as_deref().map(sanitize_config); // Strip control characters from remaining structural fields for defense-in-depth self.status = self.status.chars().filter(|c| !c.is_control()).collect(); self.file_path = self @@ -130,12 +170,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 @@ -145,7 +185,32 @@ impl SanitizeContent for AddPrCommentResult { /// - Closed /// ``` #[derive(Debug, Clone, SanitizeConfig, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct AddPrCommentConfig { + #[serde(default, rename = "supersede-older-comments")] + #[sanitize_config(skip)] + pub supersede_older_comments: bool, + #[serde( + default = "super::pr_comments::default_comment_key", + rename = "comment-key" + )] + #[sanitize_config(skip)] + pub comment_key: Identifier, + #[serde(default = "default_max_superseded", rename = "max-superseded-comments")] + #[sanitize_config(skip)] + pub max_superseded_comments: usize, + #[serde(default)] + #[sanitize_config(skip)] + pub target: super::update_pull_request::UpdatePullRequestTarget, + #[serde(default, rename = "target-repo")] + pub target_repo: Option, + #[serde(default, rename = "required-labels")] + pub required_labels: Vec, + #[serde(default, rename = "required-title-prefix")] + pub required_title_prefix: Option, + #[serde(default, rename = "allow-temporary-ids")] + #[sanitize_config(skip)] + pub allow_temporary_ids: bool, /// Prefix prepended to all comments (e.g., "[Agent Review] ") #[serde(default, rename = "comment-prefix")] pub comment_prefix: Option, @@ -165,19 +230,47 @@ pub struct AddPrCommentConfig { rename = "include-stats" )] pub include_stats: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[sanitize_config(skip)] + pub max: Option, } impl Default for AddPrCommentConfig { fn default() -> Self { Self { + supersede_older_comments: false, + comment_key: super::pr_comments::default_comment_key(), + max_superseded_comments: default_max_superseded(), + target: Default::default(), + target_repo: None, + required_labels: Vec::new(), + required_title_prefix: None, + allow_temporary_ids: false, comment_prefix: None, allowed_repositories: Vec::new(), allowed_statuses: Vec::new(), include_stats: true, + max: None, } } } +fn default_max_superseded() -> usize { + 20 +} + +pub(crate) fn validate_add_pr_comment_config(config: &AddPrCommentConfig) -> anyhow::Result<()> { + ensure!( + config.max_superseded_comments > 0 && config.max_superseded_comments <= 100, + "max-superseded-comments must be between 1 and 100" + ); + ensure!( + config.comment_key.len() <= 100, + "comment-key must fit 100 bytes" + ); + Ok(()) +} + /// Map a thread status string to the ADO API integer value. /// Accepts both kebab-case (preferred) and CamelCase for backwards compatibility. fn status_to_int(status: &str) -> Option { @@ -208,76 +301,12 @@ fn validate_file_path(path: &str) -> anyhow::Result<()> { Ok(()) } -fn build_inline_thread_context( - workspace_root: &Path, - repo_root: &Path, - file_path: &str, - start_line: i32, - end_line: i32, -) -> anyhow::Result { - ensure!(start_line > 0, "start_line must be positive"); - ensure!(end_line > 0, "end_line must be positive"); - ensure!( - start_line <= end_line, - "start_line ({start_line}) must be less than or equal to line ({end_line})" - ); - - let resolved_path = repo_root.join(file_path); - let canonical = resolved_path.canonicalize().with_context(|| { - format!( - "Failed to canonicalize inline comment file '{}' — file may not exist", - file_path - ) - })?; - let canonical_root = repo_root - .canonicalize() - .context("Failed to canonicalize repository checkout root")?; - ensure!( - canonical.starts_with(&canonical_root), - "Inline comment file '{}' resolves outside the repository checkout", - file_path - ); - let canonical_workspace = workspace_root - .canonicalize() - .context("Failed to canonicalize build workspace root")?; - ensure!( - canonical.starts_with(&canonical_workspace), - "Inline comment file '{}' resolves outside the build workspace", - file_path - ); - - let contents = std::fs::read_to_string(&canonical) - .with_context(|| format!("Failed to read inline comment file '{}'", file_path))?; - let target_line = contents - .lines() - .nth((end_line - 1) as usize) - .with_context(|| format!("Inline comment line {} is out of range", end_line))?; - // Azure DevOps threadContext offsets are 1-based, so the end offset must point - // one UTF-16 code unit past the final character to span the whole target line. - let end_offset = target_line.encode_utf16().count() as i32 + 1; - - Ok(serde_json::json!({ - "filePath": format!("/{}", file_path), - "rightFileStart": { "line": start_line, "offset": 1 }, - "rightFileEnd": { "line": end_line, "offset": end_offset } - })) -} - impl AddPrCommentResult { /// Validates the request against the tool's config-driven policy /// (allowed-repositories, allowed-statuses, known status value, and /// file_path shape). Returns the resolved ADO status integer on success, /// or a human-readable failure message on the first violated rule. fn validate_against_config(&self, config: &AddPrCommentConfig) -> Result { - if !config.allowed_repositories.is_empty() - && !config.allowed_repositories.contains(&self.repository) - { - return Err(format!( - "Repository '{}' is not in the allowed-repositories list", - self.repository - )); - } - if !config.allowed_statuses.is_empty() && !config .allowed_statuses @@ -305,28 +334,6 @@ impl AddPrCommentResult { Ok(status_int) } - /// Resolves the Azure DevOps repository name to comment on, honoring the - /// "self" alias as well as the checkout allowlist. - fn resolve_repo_name(&self, ctx: &ExecutionContext) -> Result { - if self.repository == "self" || self.repository.is_empty() { - ctx.repository_name - .clone() - .ok_or_else(|| "BUILD_REPOSITORY_NAME not set and repository is 'self'".into()) - } else { - crate::safe_outputs::lookup_allowed_repository( - &self.repository, - &ctx.allowed_repositories, - ) - .cloned() - .ok_or_else(|| { - format!( - "Repository alias '{}' not found in allowed repositories", - self.repository - ) - }) - } - } - /// Builds the JSON body for the ADO "create thread" API call, attaching /// `threadContext` for inline (file-anchored) comments. fn build_thread_body( @@ -341,10 +348,11 @@ impl AddPrCommentResult { }; let comment_body = crate::agent_stats::append_stats_to_body(&comment_body, ctx, config.include_stats); + super::pr_comments::validate_body(&comment_body).map_err(|error| error.to_string())?; let comment_obj = serde_json::json!({ "parentCommentId": 0, - "content": comment_body, + "content": &comment_body, "commentType": 1 }); @@ -352,35 +360,10 @@ impl AddPrCommentResult { "comments": [comment_obj], "status": status_int }); - - if let Some(ref fp) = self.file_path { - let end_line = self.line.unwrap_or(1); - let start_line = self.start_line.unwrap_or(end_line); - let repo_root = - crate::safe_outputs::resolve_repository_checkout_dir(&self.repository, ctx) - .and_then(|path| { - crate::validate::ensure_path_within_base( - &path, - &ctx.source_directory, - "Repository checkout root", - ) - }) - .map_err(|err| { - format!( - "Failed to resolve repository checkout for '{}': {}", - self.repository, err - ) - })?; - let thread_context = build_inline_thread_context( - &ctx.source_directory, - &repo_root, - fp, - start_line, - end_line, - ) - .map_err(|err| format!("Failed to anchor inline comment for '{}': {}", fp, err))?; - thread_body["threadContext"] = thread_context; - } + let owner = super::pr_comments::owner(ctx, "comment", &config.comment_key) + .map_err(|error| format!("Comment ownership: {error:#}"))?; + super::pr_comments::stamp(&mut thread_body, owner.as_ref(), ctx, &comment_body) + .map_err(|error| format!("Comment ownership: {error:#}"))?; Ok(thread_body) } @@ -389,36 +372,35 @@ impl AddPrCommentResult { #[async_trait::async_trait] impl Executor for AddPrCommentResult { fn dry_run_summary(&self) -> String { - format!("add comment to PR #{}", self.pull_request_id) + format!( + "add comment to {}", + describe_pr_reference(self.pull_request_id.as_ref()) + ) } async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { - info!( - "Adding comment to PR #{}: {} chars", - self.pull_request_id, - self.content.len() - ); - debug!( - "add-pr-comment: pr_id={}, content length={}", - self.pull_request_id, - self.content.len() - ); - - 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) = (AddPrCommentParams { + pull_request_id: self.pull_request_id.clone(), + repository: self.repository.clone(), + content: self.content.clone(), + file_path: self.file_path.clone(), + line: self.line, + start_line: self.start_line, + side: self.side, + expected_head_sha: self.expected_head_sha.clone(), + status: self.status.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: AddPrCommentConfig = ctx.get_tool_config("add-pr-comment")?; + let config: AddPrCommentConfig = ctx.get_tool_config("add-pull-request-comment")?; + validate_add_pr_comment_config(&config)?; debug!("Config: {:?}", config); let status_int = match self.validate_against_config(&config) { @@ -426,73 +408,164 @@ impl Executor for AddPrCommentResult { Err(msg) => return Ok(ExecutionResult::failure(msg)), }; - let repo_name = match self.resolve_repo_name(ctx) { - Ok(name) => name, - Err(msg) => return Ok(ExecutionResult::failure(msg)), + super::pr_common::validate_temporary_opt_in( + self.pull_request_id.as_ref(), + config.allow_temporary_ids, + )?; + let client = super::pr_http::client()?; + let (pull_request_id, target) = match resolve_configured_pr_target( + Self::NAME, + self.pull_request_id.as_ref(), + self.repository.as_deref(), + ctx, + &client, + ) + .await? + { + Ok(target) => target, + Err(failure) => return Ok(failure), }; + let repo_name = target.qualified_repository(); + let project = &target.project; - let thread_body = match self.build_thread_body(ctx, &config, status_int) { + let mut thread_body = match self.build_thread_body(ctx, &config, status_int) { Ok(body) => body, Err(msg) => return Ok(ExecutionResult::failure(msg)), }; let url = format!( - "{}/{}/_apis/git/repositories/{}/pullRequests/{}/threads?api-version=7.1", - org_url.trim_end_matches('/'), - utf8_percent_encode(project, PATH_SEGMENT), - utf8_percent_encode(&repo_name, PATH_SEGMENT), - self.pull_request_id, + "{}/pullRequests/{}/threads?api-version=7.1", + repository_api_base(&target), + pull_request_id, ); debug!("API URL: {}", url); - let client = reqwest::Client::new(); + let operation = UpdatePrContext { + client: &client, + target: target.clone(), + pr_id: pull_request_id, + token, + connection_type: ctx.write_connection_type, + }; + if let Some(file_path) = &self.file_path { + let head = self + .expected_head_sha + .as_ref() + .context("Inline comments require expected_head_sha")?; + let inline = PrInlineComment { + file_path: RelativeSafePath::parse(file_path)?, + side: self.side, + line: u32::try_from(self.line.unwrap_or(1))?, + start_line: self.start_line.map(u32::try_from).transpose()?, + content: self.content.clone(), + }; + let prepared = super::pr_inline::prepare(&operation, head, &[inline]).await?; + let context = prepared + .first() + .context("Inline comment context was not prepared")?; + thread_body["threadContext"] = context["threadContext"].clone(); + thread_body["pullRequestThreadContext"] = context["pullRequestThreadContext"].clone(); + } + let supersession = if config.supersede_older_comments { + let owner = super::pr_comments::owner(ctx, "comment", &config.comment_key)? + .context("Supersession requires a complete trusted pipeline identity")?; + let actor = super::pr_comments::actor(&operation).await?; + let (candidates, skipped) = super::pr_comments::older_threads( + &operation, + &owner, + &actor, + ctx.build_id.context("Supersession requires a build ID")?, + config.max_superseded_comments, + ) + .await?; + Some((owner, actor, candidates, skipped)) + } else { + None + }; - info!("Sending comment thread to PR #{}", self.pull_request_id); - let response = client - .post(&url) - .header("Content-Type", "application/json") - .basic_auth("", Some(token)) - .json(&thread_body) - .send() - .await - .context("Failed to send request to Azure DevOps")?; + info!("Sending comment thread to PR #{}", pull_request_id); + if let Some(head) = &self.expected_head_sha { + super::pr_inline::verify_head(&operation, head).await?; + } + let response = + authenticate_ado_request(client.post(&url), token, ctx.write_connection_type) + .header("Content-Type", "application/json") + .json(&thread_body) + .send() + .await + .context("Failed to send PR comment; delivery is uncertain")?; if response.status().is_success() { let body: serde_json::Value = response - .json() + .bounded_json() .await - .context("Failed to parse response JSON")?; + .context("Failed to parse PR comment response JSON; delivery is uncertain")?; - let thread_id = body.get("id").and_then(|v| v.as_i64()).unwrap_or(0); + let thread_id = body + .get("id") + .and_then(|v| v.as_i64()) + .filter(|id| *id > 0) + .context("Comment response missing a positive thread ID; delivery is uncertain")?; info!( "Comment thread added to PR #{}: thread #{}", - self.pull_request_id, thread_id + pull_request_id, thread_id ); + let mut data = serde_json::json!({ + "thread_id": thread_id, + "pull_request_id": pull_request_id, + "repository": repo_name, + "project": project, + "status": self.status, + }); + if let Some((owner, actor, candidates, skipped)) = supersession { + match super::pr_comments::supersede( + &operation, + &owner, + &actor, + &candidates, + i32::try_from(thread_id).context("Thread ID is outside the ADO range")?, + skipped, + ) + .await + { + Ok(details) => { + let failed = details["failures"] + .as_u64() + .context("Missing supersession outcome count")? + > 0; + data["supersession"] = details; + if failed { + return Ok(ExecutionResult::warning_with_data( + "New comment posted, but some older comments could not be superseded", + data, + )); + } + } + Err(error) => { + data["supersession_error"] = serde_json::json!(format!("{error:#}")); + return Ok(ExecutionResult::warning_with_data( + "New comment posted, but supersession failed", + data, + )); + } + } + } Ok(ExecutionResult::success_with_data( - format!( - "Added comment thread #{} to PR #{}", - thread_id, self.pull_request_id - ), - serde_json::json!({ - "thread_id": thread_id, - "pull_request_id": self.pull_request_id, - "repository": repo_name, - "project": project, - "status": self.status, - }), + format!("Added comment thread #{thread_id} to PR #{pull_request_id}"), + data, )) } else { let status = response.status(); let error_body = response - .text() + .bounded_text() .await - .unwrap_or_else(|_| "Unknown error".to_string()); + .unwrap_or_else(|error| format!("Failed to read PR error response: {error}")); Ok(ExecutionResult::failure(format!( "Failed to add comment to PR #{} (HTTP {}): {}", - self.pull_request_id, status, error_body + pull_request_id, status, error_body ))) } } @@ -506,16 +579,19 @@ 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] fn test_params_deserializes() { let json = r#"{"pull_request_id": 42, "content": "This is a review comment on the PR."}"#; let params: AddPrCommentParams = serde_json::from_str(json).unwrap(); - assert_eq!(params.pull_request_id, 42); + assert_eq!( + params.pull_request_id, + Some(PullRequestReference::Number(42)) + ); assert!(params.content.contains("review comment")); - assert_eq!(params.repository, "self"); + assert_eq!(params.repository, None); assert!(params.file_path.is_none()); assert!(params.line.is_none()); assert_eq!(params.status, "active"); @@ -524,35 +600,42 @@ mod tests { #[test] fn test_params_converts_to_result() { let params = AddPrCommentParams { - pull_request_id: 42, + pull_request_id: Some(PullRequestReference::Number(42)), content: "This is a test comment with enough characters.".to_string(), - repository: "self".to_string(), + repository: Some("self".to_string()), file_path: None, start_line: None, line: None, + side: PrCommentSide::Right, + expected_head_sha: None, status: "active".to_string(), }; let result: AddPrCommentResult = params.try_into().unwrap(); - assert_eq!(result.name, "add-pr-comment"); - assert_eq!(result.pull_request_id, 42); + assert_eq!(result.name, "add-pull-request-comment"); + assert_eq!( + result.pull_request_id, + Some(PullRequestReference::Number(42)) + ); assert!(result.content.contains("test comment")); } #[test] fn test_validation_rejects_zero_pr_id() { let params = AddPrCommentParams { - pull_request_id: 0, + pull_request_id: Some(PullRequestReference::Number(0)), content: "This is a valid comment body text.".to_string(), - repository: "self".to_string(), + repository: Some("self".to_string()), file_path: None, start_line: None, line: None, + side: PrCommentSide::Right, + expected_head_sha: None, status: "active".to_string(), }; let err: Result = params.try_into(); let err = err.unwrap_err().to_string(); assert!( - err.contains("pull_request_id must be positive"), + err.contains("pull_request_id must be a positive integer"), "got: {err}" ); } @@ -560,12 +643,14 @@ mod tests { #[test] fn test_validation_rejects_short_content() { let params = AddPrCommentParams { - pull_request_id: 42, + pull_request_id: Some(PullRequestReference::Number(42)), content: "Too short".to_string(), - repository: "self".to_string(), + repository: Some("self".to_string()), file_path: None, start_line: None, line: None, + side: PrCommentSide::Right, + expected_head_sha: None, status: "active".to_string(), }; let err: Result = params.try_into(); @@ -579,12 +664,14 @@ mod tests { #[test] fn test_validation_rejects_repository_pipeline_command() { let params = AddPrCommentParams { - pull_request_id: 42, + pull_request_id: Some(PullRequestReference::Number(42)), content: "This is a valid comment body text.".to_string(), - repository: "##vso[task.setvariable variable=x]y".to_string(), + repository: Some("##vso[task.setvariable variable=x]y".to_string()), file_path: None, start_line: None, line: None, + side: PrCommentSide::Right, + expected_head_sha: None, status: "active".to_string(), }; let err: Result = params.try_into(); @@ -595,12 +682,14 @@ mod tests { #[test] fn test_validation_rejects_repository_traversal_selector() { let params = AddPrCommentParams { - pull_request_id: 42, + pull_request_id: Some(PullRequestReference::Number(42)), content: "This is a valid comment body text.".to_string(), - repository: "../sibling-repo".to_string(), + repository: Some("../sibling-repo".to_string()), file_path: None, start_line: None, line: None, + side: PrCommentSide::Right, + expected_head_sha: None, status: "active".to_string(), }; let result: Result = params.try_into(); @@ -610,12 +699,14 @@ mod tests { #[test] fn test_validation_accepts_project_scoped_repository_selector() { let params = AddPrCommentParams { - pull_request_id: 42, + pull_request_id: Some(PullRequestReference::Number(42)), content: "This is a valid comment body text.".to_string(), - repository: "4x4/sdk-FtdiDeviceControl".to_string(), + repository: Some("4x4/sdk-FtdiDeviceControl".to_string()), file_path: None, start_line: None, line: None, + side: PrCommentSide::Right, + expected_head_sha: None, status: "active".to_string(), }; let result: Result = params.try_into(); @@ -625,12 +716,14 @@ mod tests { #[test] fn test_validation_rejects_line_without_file_path() { let params = AddPrCommentParams { - pull_request_id: 42, + pull_request_id: Some(PullRequestReference::Number(42)), content: "This is a valid comment body text.".to_string(), - repository: "self".to_string(), + repository: Some("self".to_string()), file_path: None, start_line: None, line: Some(10), + side: PrCommentSide::Right, + expected_head_sha: None, status: "active".to_string(), }; let err: Result = params.try_into(); @@ -641,18 +734,20 @@ mod tests { #[test] fn test_result_serializes_correctly() { let params = AddPrCommentParams { - pull_request_id: 42, + pull_request_id: Some(PullRequestReference::Number(42)), content: "A comment body that is definitely longer than ten characters.".to_string(), - repository: "self".to_string(), + repository: Some("self".to_string()), file_path: Some("src/main.rs".to_string()), start_line: None, line: Some(10), + side: PrCommentSide::Right, + expected_head_sha: Some(CommitSha::parse("a".repeat(40)).unwrap()), status: "active".to_string(), }; 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"#)); } @@ -721,12 +816,14 @@ allowed-statuses: #[test] fn test_validation_rejects_invalid_status() { let params = AddPrCommentParams { - pull_request_id: 42, + pull_request_id: Some(PullRequestReference::Number(42)), content: "This is a valid comment body text.".to_string(), - repository: "self".to_string(), + repository: Some("self".to_string()), file_path: None, start_line: None, line: None, + side: PrCommentSide::Right, + expected_head_sha: None, status: "unknown".to_string(), }; let result: Result = params.try_into(); @@ -747,12 +844,14 @@ allowed-statuses: "WontFix", ] { let params = AddPrCommentParams { - pull_request_id: 42, + pull_request_id: Some(PullRequestReference::Number(42)), content: "This is a valid comment body text.".to_string(), - repository: "self".to_string(), + repository: Some("self".to_string()), file_path: None, start_line: None, line: None, + side: PrCommentSide::Right, + expected_head_sha: None, status: s.to_string(), }; let result: Result = params.try_into(); @@ -768,6 +867,8 @@ allowed-statuses: allowed_repositories: Vec::new(), allowed_statuses: vec!["Active".to_string(), "Closed".to_string()], include_stats: true, + max: None, + ..Default::default() }; // Test the exact comparison logic extracted from execute_impl let status = "active"; @@ -784,82 +885,36 @@ allowed-statuses: #[test] fn test_sanitize_content_neutralizes_repository_pipeline_command() { let params = AddPrCommentParams { - pull_request_id: 42, + pull_request_id: Some(PullRequestReference::Number(42)), content: "This is a valid comment body text.".to_string(), - repository: "##vso[task.setvariable variable=x]y".to_string(), + repository: Some("##vso[task.setvariable variable=x]y".to_string()), file_path: None, start_line: None, line: None, + side: PrCommentSide::Right, + expected_head_sha: None, 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, file_path: params.file_path, start_line: params.start_line, line: params.line, + side: params.side, + expected_head_sha: params.expected_head_sha, status: params.status, }; result.sanitize_content_fields(); assert!( - result.repository.contains("`##vso[`"), - "repository pipeline command should be neutralized with backticks: {}", + result.repository.as_deref().unwrap().contains("`##vso[`"), + "repository pipeline command should be neutralized with backticks: {:?}", result.repository ); } - #[test] - fn test_build_inline_thread_context_uses_utf16_end_offset() { - let dir = tempdir().unwrap(); - std::fs::write(dir.path().join("suggestion.rs"), "prefix\nab😀\n").unwrap(); - - let thread_context = - build_inline_thread_context(dir.path(), dir.path(), "suggestion.rs", 2, 2).unwrap(); - - assert_eq!(thread_context["rightFileStart"]["line"], 2); - assert_eq!(thread_context["rightFileStart"]["offset"], 1); - assert_eq!(thread_context["rightFileEnd"]["line"], 2); - assert_eq!(thread_context["rightFileEnd"]["offset"], 5); - } - - #[test] - fn test_build_inline_thread_context_uses_last_line_for_multiline_span() { - let dir = tempdir().unwrap(); - std::fs::write( - dir.path().join("suggestion.rs"), - "first line\nab😀\nthird\n", - ) - .unwrap(); - - let thread_context = - build_inline_thread_context(dir.path(), dir.path(), "suggestion.rs", 1, 2).unwrap(); - - assert_eq!(thread_context["rightFileStart"]["line"], 1); - assert_eq!(thread_context["rightFileEnd"]["line"], 2); - assert_eq!(thread_context["rightFileEnd"]["offset"], 5); - } - - #[test] - fn test_build_inline_thread_context_rejects_repo_root_outside_workspace() { - let workspace = tempdir().unwrap(); - let outside_repo = tempdir().unwrap(); - std::fs::write(outside_repo.path().join("suggestion.rs"), "line 1\n").unwrap(); - - let err = build_inline_thread_context( - workspace.path(), - outside_repo.path(), - "suggestion.rs", - 1, - 1, - ) - .unwrap_err() - .to_string(); - - assert!(err.contains("outside the build workspace"), "got: {err}"); - } - #[test] fn test_repository_checkout_dir_resolves_full_repository_name_to_alias_path() { let workspace = tempdir().unwrap(); diff --git a/src/safe_outputs/add_pr_labels.rs b/src/safe_outputs/add_pr_labels.rs new file mode 100644 index 000000000..a565e6d42 --- /dev/null +++ b/src/safe_outputs/add_pr_labels.rs @@ -0,0 +1,309 @@ +//! Add labels without replacing or removing existing Azure DevOps PR labels. + +use super::pr_common::{ + PullRequestReference, legacy_policy, 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 crate::secure::PrLabelName; +use super::ToolResult; +use ado_aw_derive::SanitizeConfig; +use anyhow::{Context, ensure}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AddPrLabelsParams { + #[serde(default)] + pub pull_request_id: Option, + #[serde(default)] + pub repository: Option, + /// Label names to add. The configured max-labels defaults to 10 unique names. + pub labels: Vec, +} + +impl Validate for AddPrLabelsParams { + fn validate(&self) -> anyhow::Result<()> { + if let Some(reference) = &self.pull_request_id { + validate_reference(reference)?; + } + validate_label_input(&self.labels)?; + if let Some(repository) = &self.repository { + crate::validate::reject_pipeline_injection(repository, "repository")?; + } + Ok(()) + } +} + +tool_result! { + name = "add-pull-request-labels", + write = true, + params = AddPrLabelsParams, + #[serde(deny_unknown_fields)] + pub struct AddPrLabelsResult { + #[serde(default)] + pull_request_id: Option, + #[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); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, SanitizeConfig)] +#[serde(deny_unknown_fields)] +pub struct AddPrLabelsConfig { + #[serde(default)] + #[sanitize_config(skip)] + pub target: super::update_pull_request::UpdatePullRequestTarget, + #[serde(default, rename = "target-repo")] + pub target_repo: Option, + #[serde(default, rename = "required-labels")] + pub required_labels: Vec, + #[serde(default, rename = "required-title-prefix")] + pub required_title_prefix: Option, + #[serde(default, rename = "allowed-repositories")] + pub allowed_repositories: Vec, + #[serde(default, rename = "allowed-labels")] + pub allowed_labels: Vec, + #[serde(default, rename = "blocked-labels")] + pub blocked_labels: Vec, + #[serde(default = "default_max_labels", rename = "max-labels")] + #[sanitize_config(skip)] + pub max_labels: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[sanitize_config(skip)] + pub max: Option, +} + +fn default_max_labels() -> usize { 10 } + +impl Default for AddPrLabelsConfig { + fn default() -> Self { + Self { + target: Default::default(), target_repo: None, + required_labels: Vec::new(), required_title_prefix: None, + allowed_repositories: Vec::new(), allowed_labels: Vec::new(), + blocked_labels: Vec::new(), max_labels: default_max_labels(), max: None, + } + } +} + +pub(crate) fn validate_label_input>(labels: &[T]) -> anyhow::Result<()> { + ensure!(!labels.is_empty(), "labels list must not be empty"); + ensure!(labels.len() <= 1_000, "labels list must contain at most 1000 raw entries"); + for label in labels { + crate::secure::PrLabelName::parse(label.as_ref())?; + } + Ok(()) +} + +pub(crate) fn normalize_label_batch>( + labels: &[T], allowed: &[String], blocked: &[String], max_labels: usize, +) -> anyhow::Result> { + validate_label_input(labels)?; + ensure!(max_labels > 0 && max_labels <= 1_000, "max-labels must be between 1 and 1000"); + let mut normalized = Vec::::new(); + for label in labels { + let label = label.as_ref().trim(); + ensure!(!blocked.iter().any(|item| item.trim().eq_ignore_ascii_case(label)), + "Label '{label}' is blocked by blocked-labels"); + ensure!(allowed.is_empty() || allowed.iter().any(|item| item.trim().eq_ignore_ascii_case(label)), + "Label '{label}' is not in allowed-labels"); + if !normalized.iter().any(|item| item.eq_ignore_ascii_case(label)) { + normalized.push(label.to_string()); + } + } + ensure!(normalized.len() <= max_labels, "label batch exceeds max-labels: {max_labels}"); + Ok(normalized) +} + +pub(crate) fn validate_add_pr_labels_config(config: &AddPrLabelsConfig) -> anyhow::Result<()> { + ensure!(config.max_labels > 0 && config.max_labels <= 1_000, "max-labels must be between 1 and 1000"); + for label in config.allowed_labels.iter().chain(&config.blocked_labels) { + crate::secure::PrLabelName::parse(label)?; + } + 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 {}", super::pr_common::describe_pr_reference(self.pull_request_id.as_ref())) + } + 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-pull-request-labels"), + "add-pull-request-labels is not configured" + ); + let config: AddPrLabelsConfig = ctx.get_tool_config("add-pull-request-labels")?; + validate_add_pr_labels_config(&config)?; + let labels = match normalize_label_batch( + &self.labels, &config.allowed_labels, &config.blocked_labels, config.max_labels, + ) { + Ok(labels) => labels, + Err(error) => return Ok(ExecutionResult::failure(error.to_string())), + }; + let client = super::pr_http::client()?; + let (pr_id, target) = match super::pr_common::resolve_configured_pr_target( + Self::NAME, self.pull_request_id.as_ref(), self.repository.as_deref(), ctx, &client, + ).await? { + Ok(target) => target, + Err(failure) => return Ok(failure), + }; + if let Some(legacy) = legacy_policy(ctx, "add-pull-request-labels", "add-labels")? + && let Err(failure) = super::pr_common::validate_pr_repository_policy( + &target, &legacy.allowed_repositories, ctx, + ) + { + return Ok(failure); + } + 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, + }, + &labels, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{body_json, method, path}, + }; + + #[test] + fn label_policy_limits_are_exact_and_blocking_wins() { + for count in [0,1,10,11] { + let labels=(0..count).map(|i|format!("label-{i}")).collect::>(); + assert_eq!(normalize_label_batch(&labels,&[],&[],10).is_ok(),(1..=10).contains(&count)); + } + assert!(normalize_label_batch(&vec!["label".to_string();1_001],&[],&[],10).is_err()); + assert_eq!(normalize_label_batch(&[" Label ".to_string(),"label".to_string()],&["LABEL".into()],&[],1).unwrap(),vec!["Label"]); + assert!(normalize_label_batch(&["label"],&["label".into()],&["LABEL".into()],10).is_err()); + assert!(normalize_label_batch(&["other"],&["label".into()],&[],10).is_err()); + let labels=(0..11).map(|i|format!("label-{i}")).collect::>(); + assert!(normalize_label_batch(&labels,&[],&[],11).is_ok()); + for name in [""," ","bad\nlabel","##vso[task.complete]x"] { + assert!(validate_label_input(&[name]).is_err()); + } + } + + #[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-pull-request-labels".into(), serde_json::json!({"target":"*"})); + let result: AddPrLabelsResult = serde_json::from_value(serde_json::json!({ + "name": "add-pull-request-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-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-pull-request-labels", "pull_request_id": "#aw_pr123", "labels": ["ready"] + })) + .unwrap(); + assert!(result.execute_sanitized(&ctx).await.unwrap().success); + ctx.tool_configs.insert( + "add-pull-request-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..6825f5869 --- /dev/null +++ b/src/safe_outputs/add_pr_reviewers.rs @@ -0,0 +1,304 @@ +//! 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}; +use super::pr_mutations::{ + UpdatePrContext, execute_add_reviewers, validate_and_normalize_reviewers, +}; +use super::update_pr::UpdatePrConfig; +use super::{ExecutionContext, ExecutionResult, Executor, Validate}; +use crate::sanitize::{SanitizeContent, sanitize_config}; +use crate::tool_result; +use super::ToolResult; + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AddPrReviewersParams { + #[serde(default)] + pub pull_request_id: Option, + #[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<()> { + if let Some(reference) = &self.pull_request_id { + super::pr_common::validate_reference(reference)?; + } + if let Some(repository) = &self.repository { + crate::validate::reject_pipeline_injection(repository, "repository")?; + } + super::pr_mutations::validate_reviewer_inputs(&self.reviewers) + } +} + +tool_result! { + name = "add-pull-request-reviewers", + write = true, + params = AddPrReviewersParams, + #[serde(deny_unknown_fields)] + pub struct AddPrReviewersResult { + #[serde(default)] + pull_request_id: Option, + #[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)] + #[sanitize_config(skip)] + pub target: super::update_pull_request::UpdatePullRequestTarget, + #[serde(default, rename = "target-repo")] + pub target_repo: Option, + #[serde(default, rename = "required-labels")] + pub required_labels: Vec, + #[serde(default, rename = "required-title-prefix")] + pub required_title_prefix: Option, + #[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 { + target: Default::default(), + target_repo: None, + required_labels: Vec::new(), + required_title_prefix: None, + 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-pull-request-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 {}", super::pr_common::describe_pr_reference(self.pull_request_id.as_ref())) + } + + 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-pull-request-reviewers"), + "add-pull-request-reviewers is not configured" + ); + 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, + 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 client = super::pr_http::client()?; + let (pr_id, target) = match super::pr_common::resolve_configured_pr_target( + Self::NAME, self.pull_request_id.as_ref(), self.repository.as_deref(), ctx, &client, + ).await? { + Ok(target) => target, + Err(failure) => return Ok(failure), + }; + let legacy = legacy_policy(ctx, "add-pull-request-reviewers", "add-reviewers")?; + if let Some(legacy) = &legacy + && let Err(failure) = super::pr_common::validate_pr_repository_policy( + &target, &legacy.allowed_repositories, ctx, + ) + { + return Ok(failure); + } + 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()], + vec!["##vso[task.setvariable variable=REVIEWER]attacker@example.com".into()], + ] { + assert!( + AddPrReviewersParams { + pull_request_id: Some(PullRequestReference::Number(1)), + repository: None, + reviewers, + } + .validate() + .is_err() + ); + } + assert!(serde_json::from_value::( + serde_json::json!({"pull_request_id":1}), + ).is_err()); + assert!(AddPrReviewersParams { + pull_request_id: Some(PullRequestReference::Number(1)), + repository: None, + reviewers: vec!["reviewer@example.com".into(); 100], + }.validate().is_ok()); + } + + #[tokio::test] + async fn historical_metadata_cannot_widen_reviewer_allowlist() { + let mut ctx = ExecutionContext::default(); + ctx.tool_configs.insert( + "add-pull-request-reviewers".into(), + serde_json::json!({ + "allowed-reviewers": ["permitted"], "legacy-update-pr": {"allowed-reviewers": ["*"]} + }), + ); + let result: AddPrReviewersResult = AddPrReviewersParams { + pull_request_id: Some(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-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-pull-request-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..6444c8b8e 100644 --- a/src/safe_outputs/create_pull_request.rs +++ b/src/safe_outputs/create_pull_request.rs @@ -1,5 +1,6 @@ //! Create pull request safe output tool +use super::pr_http::BoundedPrResponse; use log::{debug, info, warn}; use percent_encoding::utf8_percent_encode; use schemars::JsonSchema; @@ -14,8 +15,6 @@ use crate::validate::reject_pipeline_injection; use ado_aw_derive::SanitizeConfig; use anyhow::{Context, ensure}; -/// Maximum allowed patch file size (5 MB) -const MAX_PATCH_SIZE_BYTES: u64 = 5 * 1024 * 1024; /// Default maximum files allowed in a single PR const DEFAULT_MAX_FILES: usize = 100; @@ -205,7 +204,7 @@ async fn resolve_reviewer_identity( return None; } - match resp.json::().await { + match resp.bounded_json::().await { Ok(data) => { let result = find_identity_in_response(&data, reviewer); if result.is_none() { @@ -300,6 +299,7 @@ tool_result! { write = true, params = CreatePrResultFields, /// Result of creating a pull request - stored as safe output + #[serde(deny_unknown_fields)] pub struct CreatePrResult { /// Title for the pull request title: String, @@ -399,7 +399,15 @@ pub enum ProtectedFiles { /// - "agent-created" /// ``` #[derive(Debug, Clone, SanitizeConfig, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct CreatePrConfig { + /// Maximum patch and expanded selected content, in KiB (default 4096). + #[serde(default, rename = "max-patch-size")] + #[sanitize_config(skip)] + pub max_patch_size: super::pr_patch::PatchSizeKiB, + /// Optional restriction within the compiler-authorized checkout destinations. + #[serde(default, rename = "allowed-repositories")] + pub allowed_repositories: Vec, /// Target branch to merge into (default: "main"). This is the literal /// fallback applied to every repo unless overridden by `target_branches` /// or `infer_target_from_checkout_ref`. It is always a plain branch name — @@ -487,6 +495,9 @@ pub struct CreatePrConfig { /// Whether to include agent execution stats in the PR description (default: true). #[serde(default = "default_true", rename = "include-stats")] pub include_stats: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[sanitize_config(skip)] + pub max: Option, } fn default_target_branch() -> String { @@ -567,6 +578,8 @@ fn repository_api_base(target: &crate::safe_outputs::result::AdoRepositoryTarget impl Default for CreatePrConfig { fn default() -> Self { Self { + max_patch_size: Default::default(), + allowed_repositories: Vec::new(), target_branch: default_target_branch(), target_branches: std::collections::HashMap::new(), infer_target_from_checkout_ref: false, @@ -585,6 +598,7 @@ impl Default for CreatePrConfig { work_items: Vec::new(), fallback_record_branch: true, include_stats: true, + max: None, } } } @@ -593,20 +607,29 @@ impl Default for CreatePrConfig { struct WorktreeGuard { repo_dir: std::path::PathBuf, worktree_path: std::path::PathBuf, + removed: bool, +} + +impl WorktreeGuard { + fn remove(&mut self) -> anyhow::Result<()> { + let output = std::process::Command::new("git") + .args(["worktree", "remove", "--force"]) + .arg(super::pr_patch::git_path(&self.worktree_path).as_ref()) + .current_dir(&self.repo_dir) + .output().context("Failed to remove private PR worktree")?; + anyhow::ensure!(output.status.success(), "Private PR worktree cleanup failed: {}", + String::from_utf8_lossy(&output.stderr)); + self.removed = true; + Ok(()) + } } impl Drop for WorktreeGuard { fn drop(&mut self) { - // Best effort cleanup - ignore errors - let _ = std::process::Command::new("git") - .args([ - "worktree", - "remove", - "--force", - &self.worktree_path.to_string_lossy(), - ]) - .current_dir(&self.repo_dir) - .output(); + if !self.removed + && let Err(error) = self.remove() { + warn!("Private PR worktree cleanup failed: {error:#}"); + } } } @@ -700,6 +723,11 @@ impl Executor for CreatePrResult { Err(failure) => return Ok(failure), }; debug!("Resolved repository ID: {}", target.repository_locator()); + if let Err(failure) = super::pr_common::validate_pr_repository_policy( + &target, &config.allowed_repositories, ctx, + ) { + return Ok(failure); + } if ctx.has_resolved_pull_request(&self.temporary_id)? { return Ok(ExecutionResult::failure(format!( "temporary_id '{}' was already used in this run", @@ -741,107 +769,14 @@ impl Executor for CreatePrResult { ))); } - // Security: Enforce patch file size limit - let metadata = tokio::fs::metadata(&patch_path) - .await - .context("Failed to get patch file metadata")?; - if metadata.len() > MAX_PATCH_SIZE_BYTES { - return Ok(ExecutionResult::failure(format!( - "Patch file exceeds maximum size of {} bytes (got {} bytes)", - MAX_PATCH_SIZE_BYTES, - metadata.len() - ))); - } - - // Read patch content for validation - debug!("Reading patch file content"); - let patch_content = tokio::fs::read_to_string(&patch_path) - .await - .context("Failed to read patch file")?; - debug!("Patch content size: {} bytes", patch_content.len()); - - // SHA-256 integrity check: verify the patch file hasn't been tampered - // with between Stage 1 and Stage 3. - let live_hash = crate::hash::sha256_hex(patch_content.as_bytes()); + let patch_path = crate::validate::ensure_path_within_base( + &patch_path, &ctx.working_directory, "PR patch", + )?; + let patch_bytes = super::pr_patch::read_patch(&patch_path, config.max_patch_size).await?; + let live_hash = crate::hash::sha256_hex(&patch_bytes); if live_hash != self.patch_sha256 { return Ok(ExecutionResult::failure(format!( - "Patch file SHA-256 mismatch: expected {}, got {} — \ - the file may have been tampered with between stages", - self.patch_sha256, live_hash - ))); - } - debug!("Patch file SHA-256 verified: {}", live_hash); - - // Excluded files are handled via --exclude flags on git am / git apply, - // which filters them at the git level rather than post-processing patch content. - // This is the same approach used by gh-aw (via :(exclude) pathspecs). - // Note: Exclusion happens during patch application (before the protection check). - // If a protected file matches an excluded-files pattern, it is silently dropped - // from the patch rather than triggering a protection error. - let exclude_args: Vec = config - .excluded_files - .iter() - .map(|p| format!("--exclude={}", p)) - .collect(); - if !exclude_args.is_empty() { - debug!( - "Will apply {} excluded-files patterns via --exclude flags", - exclude_args.len() - ); - } - - // Security: Validate patch paths before applying - debug!("Validating patch paths for security"); - if let Err(e) = validate_patch_paths(&patch_content) { - warn!("Patch path validation failed: {}", e); - return Ok(ExecutionResult::failure(format!( - "Patch validation failed: {}", - e - ))); - } - debug!("Patch path validation passed"); - - // Extract file paths from patch for validation. - // Filter out excluded files before the protection check — if a protected file - // matches an excluded-files pattern, it will be excluded from the patch by - // git am/apply --exclude and should not trigger a protection error. - let patch_paths: Vec = extract_paths_from_patch(&patch_content) - .into_iter() - .filter(|p| { - !config - .excluded_files - .iter() - .any(|pat| glob_match_simple(pat, p)) - }) - .collect(); - - // Security: File protection check - if config.protected_files != ProtectedFiles::Allowed { - let protected = find_protected_files(&patch_paths); - if !protected.is_empty() { - warn!( - "Patch modifies {} protected file(s): {:?}", - protected.len(), - protected - ); - return Ok(ExecutionResult::failure(format!( - "Patch modifies protected files (set protected-files: allowed to override): {}", - protected.join(", ") - ))); - } - } - - // Security: Max files per PR check (count diff blocks, not paths, to avoid - // double-counting renames which appear in both --- and +++ lines) - let file_count = count_patch_files(&patch_content); - if file_count > config.max_files { - warn!( - "Patch contains {} files, exceeding max of {}", - file_count, config.max_files - ); - return Ok(ExecutionResult::failure(format!( - "Patch contains {} files, exceeding maximum of {} files per PR", - file_count, config.max_files + "Patch file SHA-256 mismatch: expected {}, got {}", self.patch_sha256, live_hash ))); } @@ -880,233 +815,108 @@ impl Executor for CreatePrResult { } debug!("Git repository verified"); - // Create a temporary directory for the worktree - let temp_dir = tempfile::tempdir().context("Failed to create temp directory")?; - let worktree_path = temp_dir.path().join("worktree"); - debug!("Creating worktree at: {}", worktree_path.display()); - - // Create a worktree at the target branch - let worktree_output = Command::new("git") - .args([ - "worktree", - "add", - &worktree_path.to_string_lossy(), - &format!("origin/{}", target_branch), - ]) - .current_dir(&repo_git_dir) - .output() - .await - .context("Failed to create git worktree")?; - - if !worktree_output.status.success() { - debug!( - "Worktree creation with origin/ prefix failed, trying without: {}", - String::from_utf8_lossy(&worktree_output.stderr) - ); - // Try with just the branch name if origin/ prefix fails - let worktree_output = Command::new("git") - .args([ - "worktree", - "add", - &worktree_path.to_string_lossy(), - target_branch, - ]) - .current_dir(&repo_git_dir) - .output() - .await - .context("Failed to create git worktree")?; - - if !worktree_output.status.success() { - warn!( - "Failed to create worktree: {}", - String::from_utf8_lossy(&worktree_output.stderr) - ); - return Ok(ExecutionResult::failure(format!( - "Failed to create worktree: {}", - String::from_utf8_lossy(&worktree_output.stderr) - ))); + let client = super::pr_http::client()?; + #[derive(Deserialize)] + struct RefTip { + name: String, + #[serde(rename = "objectId")] + oid: crate::secure::CommitSha, + } + #[derive(Deserialize)] + struct Refs { value: Vec } + let response = crate::safe_outputs::authenticate_ado_request( + client.get(format!("{}/refs", repository_api_base(&target))).query(&[ + ("filter", format!("heads/{target_branch}")), ("api-version", "7.1".into()), + ]), token, ctx.write_connection_type, + ).send().await.context("Failed to resolve PR target ref")?; + anyhow::ensure!(response.status().is_success(), "Failed to resolve PR target ref (HTTP {})", response.status()); + let refs: Refs = response.bounded_json().await?; + let mut matches = refs.value.into_iter().filter(|reference| reference.name == target_ref); + let current = matches.next().context("PR target ref is missing")?.oid; + anyhow::ensure!(matches.next().is_none(), "PR target ref is ambiguous"); + let base_sha = self.base_commit.as_deref().map(crate::secure::CommitSha::parse) + .transpose().context("Invalid base_commit SHA from Stage 1")?.unwrap_or_else(|| current.clone()); + if !base_sha.eq_ignore_ascii_case(current.as_str()) { + #[derive(Deserialize)] + struct Ancestry { + #[serde(rename = "baseCommit")] + base: crate::secure::CommitSha, + #[serde(rename = "targetCommit")] + target: crate::secure::CommitSha, + #[serde(rename = "commonCommit")] + common: crate::secure::CommitSha, } + let response = crate::safe_outputs::authenticate_ado_request( + client.get(format!("{}/diffs/commits", repository_api_base(&target))).query(&[ + ("baseVersion", base_sha.as_str()), ("baseVersionType", "commit"), + ("targetVersion", current.as_str()), ("targetVersionType", "commit"), + ("diffCommonCommit", "true"), ("$top", "1"), ("api-version", "7.1"), + ]), token, ctx.write_connection_type, + ).send().await.context("Failed to verify captured PR base ancestry")?; + anyhow::ensure!(response.status().is_success(), "Failed to verify captured PR base ancestry (HTTP {})", response.status()); + let ancestry: Ancestry = response.bounded_json().await?; + anyhow::ensure!(ancestry.base.eq_ignore_ascii_case(base_sha.as_str()) + && ancestry.target.eq_ignore_ascii_case(current.as_str()) + && ancestry.common.eq_ignore_ascii_case(base_sha.as_str()), + "Captured PR base is not a verified ancestor of the configured target"); + } + super::pr_patch::ensure_commit(&repo_git_dir, &base_sha, &target, &client, token, ctx.write_connection_type).await?; + let prepared = super::pr_patch::prepare(&repo_git_dir, &base_sha, &patch_bytes, &super::pr_patch::PatchPolicy { + limit: config.max_patch_size, max_files: config.max_files, + excluded_files: &config.excluded_files, protected_files: config.protected_files, exact: false, + }).await?; + if prepared.is_empty() { + let mut result = handle_no_changes(&config, &[]); + result.data = Some(serde_json::json!({"omitted_operations":prepared.omitted})); + return Ok(result); } - debug!("Worktree created successfully"); + let temp_dir = tempfile::tempdir().context("Failed to create temp directory")?; + let worktree_path = temp_dir.path().join("worktree"); + let verified_patch = temp_dir.path().join("verified.patch"); + tokio::fs::write(&verified_patch, &prepared.bytes).await?; + let worktree_output = super::pr_patch::bounded_output( + super::pr_patch::git_without_filters(&repo_git_dir).await? + .args(["worktree", "add", "--detach", &worktree_path.to_string_lossy(), base_sha.as_str()]), + super::pr_patch::MAX_SOURCE_BYTES, None, + ).await?; + anyhow::ensure!(worktree_output.status.success(), "Failed to create PR worktree at its verified base"); // Ensure worktree cleanup on exit - let _worktree_guard = WorktreeGuard { + let mut worktree_guard = WorktreeGuard { repo_dir: repo_git_dir.clone(), worktree_path: worktree_path.clone(), + removed: false, }; - // Create and checkout a local branch in the worktree for patch application. - // Note: this local branch name may differ from the final remote branch name - // if a collision is detected later — the ADO push is REST-only, so the local - // branch name is not used for the remote ref. - debug!("Creating source branch: {}", source_branch); - let checkout_output = Command::new("git") - .args(["checkout", "-b", &source_branch]) - .current_dir(&worktree_path) - .output() - .await - .context("Failed to create source branch")?; - - if !checkout_output.status.success() { - warn!( - "Failed to create source branch: {}", - String::from_utf8_lossy(&checkout_output.stderr) - ); - return Ok(ExecutionResult::failure(format!( - "Failed to create source branch: {}", - String::from_utf8_lossy(&checkout_output.stderr) - ))); - } - debug!("Source branch created"); - - // Record the worktree HEAD before applying the patch so we can diff against - // it later. For multi-commit patches, git am creates N commits and diff-tree HEAD - // alone only shows the last commit's changes — we need base_sha..HEAD. - let base_sha_output = Command::new("git") - .args(["rev-parse", "HEAD"]) - .current_dir(&worktree_path) - .output() - .await - .context("Failed to get worktree HEAD SHA")?; - let base_sha = String::from_utf8_lossy(&base_sha_output.stdout) - .trim() - .to_string(); - debug!("Worktree base SHA before patch: {}", base_sha); - - // Apply the patch. Strategy depends on whether excluded-files are configured: - // - Without exclusions: prefer git am --3way (preserves commit metadata) - // with git apply --3way as fallback - // - With exclusions: use git apply --3way directly (git am does not support - // --exclude flags; git apply does) - let patch_committed = - match apply_patch_to_worktree(&worktree_path, &patch_path, &exclude_args).await? { + let application = async { + let patch_committed = match apply_patch_to_worktree( + &worktree_path, &verified_patch, &prepared.batches, !config.excluded_files.is_empty(), + ).await? { Ok(committed) => committed, - Err(result) => return Ok(result), + Err(result) => return Ok(Err(result)), }; - - // Collect changed files. The method depends on how the patch was applied: - // - git am: changes are committed → use git diff-tree to compare base_sha..HEAD - // (covers all commits in multi-commit patches, not just the last one) - // - git apply: changes are in working tree → use git status --porcelain - debug!("Getting list of changed files"); - let (status_str, use_diff_tree) = if patch_committed { - let diff_tree_output = Command::new("git") - .args(["diff-tree", "-r", "--name-status", &base_sha, "HEAD"]) - .current_dir(&worktree_path) - .output() - .await - .context("Failed to run git diff-tree")?; - - if !diff_tree_output.status.success() { - warn!( - "Failed to get diff-tree: {}", - String::from_utf8_lossy(&diff_tree_output.stderr) - ); - return Ok(ExecutionResult::failure(format!( - "Failed to get diff-tree: {}", - String::from_utf8_lossy(&diff_tree_output.stderr) - ))); - } - ( - String::from_utf8_lossy(&diff_tree_output.stdout).to_string(), - true, - ) - } else { - let status_output = Command::new("git") - .args(["status", "--porcelain"]) - .current_dir(&worktree_path) - .output() - .await - .context("Failed to run git status")?; - - if !status_output.status.success() { - warn!( - "Failed to get git status: {}", - String::from_utf8_lossy(&status_output.stderr) - ); - return Ok(ExecutionResult::failure(format!( - "Failed to get git status: {}", - String::from_utf8_lossy(&status_output.stderr) - ))); - } - ( - String::from_utf8_lossy(&status_output.stdout).to_string(), - false, - ) + prepared.collect(&worktree_path, &base_sha, None, + if patch_committed { Some("HEAD") } else { None }).await.map(Ok) + }.await; + let application = match (application, worktree_guard.remove()) { + (result, Ok(())) => result, + (Ok(_), Err(cleanup)) => Err(cleanup), + (Err(error), Err(cleanup)) => Err(error.context(format!("PR worktree cleanup also failed: {cleanup:#}"))), }; - - debug!("Change detection output:\n{}", status_str); - let (changes, skipped_symlinks) = if use_diff_tree { - collect_changes_from_diff_tree(&worktree_path, &status_str).await? - } else { - collect_changes_from_worktree(&worktree_path, &status_str).await? + let applied = match super::pr_patch::finish_scratch(temp_dir, application)? { + Ok(applied) => applied, + Err(result) => return Ok(result), }; - debug!("Collected {} file changes for push", changes.len()); - if !skipped_symlinks.is_empty() { - warn!( - "Skipped {} symlink(s) when collecting PR file changes: {}", - skipped_symlinks.len(), - skipped_symlinks.join(", ") - ); - } - + let changes = applied.changes; + let skipped_symlinks = applied.skipped_symlinks; + let omitted_paths = applied.omitted; if changes.is_empty() { - return Ok(handle_no_changes(&config, &skipped_symlinks)); + let mut result = handle_no_changes(&config, &skipped_symlinks); + result.data = Some(serde_json::json!({"omitted_operations":omitted_paths})); + return Ok(result); } - // Use ADO REST API to create branch and push changes - let client = reqwest::Client::new(); - - // Get the target branch ref to find the base commit - debug!("Getting target branch ref from ADO"); - let refs_url = format!("{}/refs", repository_api_base(&target)); - debug!("Refs URL: {}", refs_url); - - // Resolve the base commit for the push. - // Prefer the merge-base SHA recorded at patch generation time (Stage 1) so the - // patch is applied against the exact commit it was created from. Fall back to - // querying the ADO refs API when the field is absent (backward compat with old - // NDJSON entries). - let base_commit: String = if let Some(ref recorded) = self.base_commit { - // Validate SHA format before trusting Stage 1 data - if recorded.len() != 40 || !recorded.chars().all(|c| c.is_ascii_hexdigit()) { - anyhow::bail!( - "Invalid base_commit SHA from Stage 1 NDJSON: {:?}", - recorded - ); - } - info!("Using recorded base_commit from Stage 1: {}", recorded); - recorded.clone() - } else { - debug!("No recorded base_commit — resolving from ADO refs API"); - let refs_response = crate::safe_outputs::authenticate_ado_request( - client.get(&refs_url).query(&[ - ("filter", format!("heads/{target_branch}")), - ("api-version", "7.1".to_string()), - ]), - token, - ctx.write_connection_type, - ) - .send() - .await - .context("Failed to get target branch ref")?; - - if !refs_response.status().is_success() { - let status = refs_response.status(); - let body = refs_response.text().await.unwrap_or_default(); - warn!("Failed to get target branch ref: {} - {}", status, body); - return Ok(ExecutionResult::failure(format!( - "Failed to get target branch ref: {} - {}", - status, body - ))); - } - - let refs_data: serde_json::Value = refs_response.json().await?; - let resolved = refs_data["value"][0]["objectId"] - .as_str() - .context("Could not find target branch commit")?; - resolved.to_string() - }; + let base_commit = base_sha.to_string(); debug!("Base commit: {}", base_commit); info!( @@ -1136,10 +946,18 @@ impl Executor for CreatePrResult { .await .context("Failed to check source branch existence")?; - if check_ref_response.status().is_success() { - let check_data: serde_json::Value = check_ref_response.json().await?; - let refs = check_data["value"].as_array(); - if refs.is_some_and(|r| !r.is_empty()) { + anyhow::ensure!(check_ref_response.status().is_success(), + "Failed to check source branch existence (HTTP {})", check_ref_response.status()); + anyhow::ensure!(check_ref_response.headers().get("x-ms-continuationtoken") + .is_none_or(|value| value.as_bytes().is_empty()), "Source branch discovery is incomplete"); + #[derive(Deserialize)] + struct RefEntry { name: String, #[serde(rename = "objectId")] _sha: crate::secure::CommitSha } + #[derive(Deserialize)] + struct RefList { value: Vec } + let refs: RefList = check_ref_response.bounded_json().await?; + let exact = refs.value.iter().filter(|entry| entry.name == source_ref).count(); + anyhow::ensure!(exact <= 1, "Source branch discovery is ambiguous"); + if exact == 1 { warn!( "Branch '{}' already exists, generating new suffix (attempt {})", source_branch, @@ -1150,7 +968,6 @@ impl Executor for CreatePrResult { info!("Renamed source branch to '{}'", source_branch); continue; } - } break; } @@ -1233,11 +1050,11 @@ impl Executor for CreatePrResult { .json(&pr_body) .send() .await - .context("Failed to create pull request")?; + .context("Failed to create pull request; delivery is uncertain and must not be replayed blindly")?; if !pr_response.status().is_success() { let status = pr_response.status(); - let body = pr_response.text().await.unwrap_or_default(); + let body = pr_response.bounded_text().await.unwrap_or_else(|error| format!("Failed to read PR error response: {error}")); warn!("Failed to create pull request: {} - {}", status, body); // Record branch info for manual recovery if enabled @@ -1286,7 +1103,8 @@ impl Executor for CreatePrResult { ))); } - let pr_data: serde_json::Value = pr_response.json().await?; + let pr_data: serde_json::Value = pr_response.bounded_json().await + .context("PR creation response is unavailable or invalid; the branch was pushed and PR delivery is uncertain")?; let pr_id = pr_data["pullRequestId"].as_u64().unwrap_or(0); let pr_web_url = pr_data["url"].as_str().unwrap_or(""); info!("Pull request created: #{} - {}", pr_id, pr_web_url); @@ -1350,6 +1168,7 @@ impl Executor for CreatePrResult { "target_branch": target_branch, "draft": config.draft, "temporary_id": self.temporary_id.canonical(), + "omitted_operations": omitted_paths, }), )) } @@ -1363,11 +1182,9 @@ impl Executor for CreatePrResult { async fn check_for_conflict_markers( worktree_path: &std::path::Path, ) -> anyhow::Result> { - let conflict_check = Command::new("git") - .args(["grep", "-l", "-E", r"^(<<<<<<<\s|>>>>>>>\s)"]) - .current_dir(worktree_path) - .output() - .await + let conflict_check = super::pr_patch::git( + worktree_path, &["grep", "--cached", "-l", "-E", r"^(<<<<<<<\s|>>>>>>>\s)"], + ).await .context("Failed to run git grep for conflict markers")?; if conflict_check.status.success() { @@ -1381,28 +1198,21 @@ async fn check_for_conflict_markers( warn!("{}", err_msg); return Ok(Some(ExecutionResult::failure(err_msg))); } + anyhow::ensure!(conflict_check.status.code() == Some(1), "Could not inspect patch conflict markers"); Ok(None) } -/// Apply a patch to a git worktree using `git apply --3way` with `--exclude` flags. -/// -/// Used when `excluded-files` are configured (git am does not support `--exclude`). -/// Returns `Ok(false)` on success (`false` = changes are staged, not committed). +/// Apply already-selected operations to the index; never reinterpret policy globs. async fn apply_patch_with_exclusions( worktree_path: &std::path::Path, - patch_path: &std::path::Path, - exclude_args: &[String], + batches: &[Vec], ) -> anyhow::Result> { - debug!("Using git apply --3way (excluded-files configured)"); - let mut apply_args: Vec = vec!["apply".into(), "--3way".into()]; - apply_args.extend(exclude_args.iter().cloned()); - apply_args.push(patch_path.to_string_lossy().into_owned()); - - let apply_output = Command::new("git") - .args(&apply_args) - .current_dir(worktree_path) - .output() - .await + for batch in batches { + let apply_output = super::pr_patch::bounded_output( + super::pr_patch::git_without_filters(worktree_path).await? + .args(["apply", "--cached", "--3way", "--whitespace=nowarn"]), + super::pr_patch::MAX_SOURCE_BYTES, Some(batch), + ).await .context("Failed to run git apply --3way")?; if !apply_output.status.success() { @@ -1413,6 +1223,7 @@ async fn apply_patch_with_exclusions( warn!("{}", err_msg); return Ok(Err(ExecutionResult::failure(err_msg))); } + } debug!("Patch applied with git apply --3way"); if let Some(conflict_result) = check_for_conflict_markers(worktree_path).await? { @@ -1430,14 +1241,16 @@ async fn apply_patch_with_exclusions( async fn apply_patch_without_exclusions( worktree_path: &std::path::Path, patch_path: &std::path::Path, + batches: &[Vec], ) -> anyhow::Result> { // No exclusions — use git am --3way for proper commit metadata preservation debug!("Applying patch with git am --3way"); - let am_output = Command::new("git") - .args(["am", "--3way", &patch_path.to_string_lossy()]) - .current_dir(worktree_path) - .output() - .await + let am_output = super::pr_patch::bounded_output( + super::pr_patch::git_without_filters(worktree_path).await? + .args(["am", "--3way", "--keep-cr", "--whitespace=nowarn", + super::pr_patch::git_path(patch_path).as_ref()]), + super::pr_patch::MAX_SOURCE_BYTES, None, + ).await .context("Failed to run git am")?; if am_output.status.success() { @@ -1449,41 +1262,24 @@ async fn apply_patch_without_exclusions( debug!("git am --3way failed: {}", stderr); // Abort the failed am to leave worktree clean - let _ = Command::new("git") - .args(["am", "--abort"]) - .current_dir(worktree_path) - .output() - .await; + let abort = super::pr_patch::git(worktree_path, &["am", "--abort"]).await?; + if !abort.status.success() { + // A raw diff is not a mailbox and never starts an am session. + let state = super::pr_patch::git(worktree_path, &["rev-parse", "--git-path", "rebase-apply"]).await?; + anyhow::ensure!(state.status.success(), "Could not inspect failed git am state"); + let path = worktree_path.join(std::str::from_utf8(&state.stdout)?.trim()); + anyhow::ensure!(!path.exists(), "Failed to abort partially applied git am session"); + } // Fallback: try git apply --3way debug!("Falling back to git apply --3way"); - let apply_output = Command::new("git") - .args(["apply", "--3way", &patch_path.to_string_lossy()]) - .current_dir(worktree_path) - .output() - .await - .context("Failed to run git apply --3way")?; - - if !apply_output.status.success() { - let err_msg = format!( - "Patch could not be applied (conflicts): {}", - String::from_utf8_lossy(&apply_output.stderr) - ); - warn!("{}", err_msg); - return Ok(Err(ExecutionResult::failure(err_msg))); - } - debug!("Patch applied with git apply --3way fallback"); - - if let Some(conflict_result) = check_for_conflict_markers(worktree_path).await? { - return Ok(Err(conflict_result)); - } - Ok(Ok(false)) + apply_patch_with_exclusions(worktree_path, batches).await } /// Apply a patch to a git worktree, choosing the right strategy automatically. /// -/// Delegates to [`apply_patch_with_exclusions`] when `exclude_args` is non-empty -/// (because `git am` doesn't support `--exclude`), otherwise delegates to +/// Delegates to [`apply_patch_with_exclusions`] for filtered patches, +/// otherwise delegates to /// [`apply_patch_without_exclusions`] which prefers `git am` for commit metadata. /// /// Returns `Ok(patch_committed)` on success (`true` = changes are committed via @@ -1492,12 +1288,13 @@ async fn apply_patch_without_exclusions( async fn apply_patch_to_worktree( worktree_path: &std::path::Path, patch_path: &std::path::Path, - exclude_args: &[String], + batches: &[Vec], + filtered: bool, ) -> anyhow::Result> { - if !exclude_args.is_empty() { - apply_patch_with_exclusions(worktree_path, patch_path, exclude_args).await + if filtered { + apply_patch_with_exclusions(worktree_path, batches).await } else { - apply_patch_without_exclusions(worktree_path, patch_path).await + apply_patch_without_exclusions(worktree_path, patch_path, batches).await } } @@ -1581,7 +1378,8 @@ async fn push_new_branch( token, connection_type, ) - .json(&push_body) + .header("Content-Type", "application/json") + .body(super::pr_patch::request_bytes(&push_body)?) .send() .await .context("Failed to push changes")?; @@ -1591,7 +1389,7 @@ async fn push_new_branch( } let status = push_response.status(); - let body = push_response.text().await.unwrap_or_default(); + let body = push_response.bounded_text().await.unwrap_or_else(|error| format!("Failed to read PR error response: {error}")); // Handle TOCTOU branch collision: retry once with a new random suffix if status.as_u16() == 409 || (status.as_u16() == 400 && body.contains("already exists")) { @@ -1609,14 +1407,15 @@ async fn push_new_branch( token, connection_type, ) - .json(&retry_body) + .header("Content-Type", "application/json") + .body(super::pr_patch::request_bytes(&retry_body)?) .send() .await .context("Failed to push changes (retry)")?; if !retry_response.status().is_success() { let retry_status = retry_response.status(); - let retry_body_text = retry_response.text().await.unwrap_or_default(); + let retry_body_text = retry_response.bounded_text().await.unwrap_or_else(|error| format!("Failed to read PR error response: {error}")); warn!( "Retry push also failed: {} - {}", retry_status, retry_body_text @@ -1877,283 +1676,6 @@ async fn add_reviewers_to_pr(ctx: &PrContext<'_>) { } } -/// Collect file changes from a worktree based on git status output -/// -/// Parses git status --porcelain output and reads file contents to build -/// ADO Push API change objects with full file content. -async fn collect_changes_from_worktree( - worktree_path: &std::path::Path, - status_output: &str, -) -> anyhow::Result<(Vec, Vec)> { - let mut changes = Vec::new(); - let mut skipped_symlinks: Vec = Vec::new(); - - for line in status_output.lines() { - if line.len() < 3 { - continue; - } - - let status_code = &line[0..2]; - let file_path = line[3..].trim(); - - // Skip empty paths - if file_path.is_empty() { - continue; - } - - // Validate path for security - validate_single_path(file_path)?; - - let full_path = worktree_path.join(file_path); - - match status_code { - // Deleted files - " D" | "D " | "DD" => { - changes.push(serde_json::json!({ - "changeType": "delete", - "item": { - "path": format!("/{}", file_path) - } - })); - } - // New/untracked files - "??" | "A " | " A" | "AM" => { - push_file_change_skipping_symlinks( - &mut changes, - &mut skipped_symlinks, - "add", - file_path, - &full_path, - ) - .await?; - } - // Modified files - " M" | "M " | "MM" => { - push_file_change_skipping_symlinks( - &mut changes, - &mut skipped_symlinks, - "edit", - file_path, - &full_path, - ) - .await?; - } - // Renamed files - format is "R old_path -> new_path" - // For "RM" (renamed + modified), we emit both a rename and an edit change. - // The ADO Pushes API processes changes sequentially within a single push, - // so the rename establishes the file at the new path, then the edit updates - // its content — this is the correct way to express rename-with-modification. - "R " | " R" | "RM" => { - if let Some((old_path, new_path)) = file_path.split_once(" -> ") { - validate_single_path(old_path.trim())?; - validate_single_path(new_path.trim())?; - - changes.push(serde_json::json!({ - "changeType": "rename", - "sourceServerItem": format!("/{}", old_path.trim()), - "item": { - "path": format!("/{}", new_path.trim()) - } - })); - - // If status is "RM" (renamed + modified), also emit content - if status_code == "RM" { - let new_path_trimmed = new_path.trim(); - let new_full_path = worktree_path.join(new_path_trimmed); - push_file_change_skipping_symlinks( - &mut changes, - &mut skipped_symlinks, - "edit", - new_path_trimmed, - &new_full_path, - ) - .await?; - } - } - } - // Other statuses - try to handle as edit if file exists - _ => { - push_file_change_skipping_symlinks( - &mut changes, - &mut skipped_symlinks, - "edit", - file_path, - &full_path, - ) - .await?; - } - } - } - - Ok((changes, skipped_symlinks)) -} - -/// Collect file changes from a git diff-tree --name-status output. -/// -/// Used when git am has already committed the changes. Parses the output format: -/// `M\tpath`, `A\tpath`, `D\tpath`, `R100\told_path\tnew_path` -async fn collect_changes_from_diff_tree( - worktree_path: &std::path::Path, - diff_tree_output: &str, -) -> anyhow::Result<(Vec, Vec)> { - let mut changes = Vec::new(); - let mut skipped_symlinks: Vec = Vec::new(); - - for line in diff_tree_output.lines() { - let line = line.trim(); - if line.is_empty() { - continue; - } - - let parts: Vec<&str> = line.split('\t').collect(); - if parts.len() < 2 { - continue; - } - - let status_code = parts[0]; - let file_path = parts[1]; - - // Validate path for security - validate_single_path(file_path)?; - - let full_path = worktree_path.join(file_path); - - if status_code == "D" { - // Deleted file - changes.push(serde_json::json!({ - "changeType": "delete", - "item": { - "path": format!("/{}", file_path) - } - })); - } else if status_code == "A" { - // Added file - push_file_change_skipping_symlinks( - &mut changes, - &mut skipped_symlinks, - "add", - file_path, - &full_path, - ) - .await?; - } else if status_code.starts_with('R') && parts.len() >= 3 { - // Renamed file: R100\told_path\tnew_path - let old_path = file_path; - let new_path = parts[2]; - // old_path (= file_path) is already validated above - validate_single_path(new_path)?; - - // Emit the rename - changes.push(serde_json::json!({ - "changeType": "rename", - "sourceServerItem": format!("/{}", old_path), - "item": { - "path": format!("/{}", new_path) - } - })); - - // If the file was also modified (similarity < 100), emit an edit with content - let new_full_path = worktree_path.join(new_path); - if status_code != "R100" { - push_file_change_skipping_symlinks( - &mut changes, - &mut skipped_symlinks, - "edit", - new_path, - &new_full_path, - ) - .await?; - } - } else if status_code.starts_with('C') && parts.len() >= 3 { - // Copied file: C100\tsrc_path\tdest_path - let dest_path = parts[2]; - validate_single_path(dest_path)?; - - let dest_full_path = worktree_path.join(dest_path); - push_file_change_skipping_symlinks( - &mut changes, - &mut skipped_symlinks, - "add", - dest_path, - &dest_full_path, - ) - .await?; - } else { - // Modified or other — read current content - push_file_change_skipping_symlinks( - &mut changes, - &mut skipped_symlinks, - "edit", - file_path, - &full_path, - ) - .await?; - } - } - - Ok((changes, skipped_symlinks)) -} - -/// Push a file change into `changes`, skipping symlinks with a warning. -/// -/// Centralizes the "regular file → read & push; symlink → warn & skip; other → ignore" -/// logic used in multiple places when collecting changes from a worktree or diff tree. -/// Uses `symlink_metadata` so symlinks are detected without being followed — this is -/// the primary defense against symlink-following exfiltration attacks in Stage 3. -/// -/// Skipped symlink paths are appended to `skipped_symlinks` so the caller can surface -/// them in the PR description (the agent that produced the PR would otherwise have no -/// way to see that some of its intended file content was dropped). -async fn push_file_change_skipping_symlinks( - changes: &mut Vec, - skipped_symlinks: &mut Vec, - change_type: &str, - file_path: &str, - full_path: &std::path::Path, -) -> anyhow::Result<()> { - // Note: there is a theoretical TOCTOU window between the symlink_metadata - // (lstat) call below and the subsequent tokio::fs::read inside - // read_file_change (which follows symlinks). A concurrent rename(2) could - // swap a regular file for a symlink between the two syscalls. This is not - // exploitable in Stage 3's deployment model: the worktree has no concurrent - // writer (the agent's patch has already been applied; only this serial - // collector reads from it). Closing the window fully would require platform- - // specific O_NOFOLLOW open syscalls, which is overkill given the threat - // model. If that ever changes, switch to opening the fd here with - // O_NOFOLLOW and reading from the fd inside read_file_change. - match tokio::fs::symlink_metadata(full_path).await { - Ok(meta) if meta.file_type().is_file() => { - changes.push(read_file_change(change_type, file_path, full_path).await?); - } - Ok(meta) if meta.file_type().is_symlink() => { - warn!( - "Skipping symlink in worktree: {} (symlink-following attack prevention)", - file_path - ); - skipped_symlinks.push(file_path.to_string()); - } - Ok(_) => { - // Not a regular file (e.g. directory, fifo, socket) — silently skip. - } - Err(e) => { - // NotFound is a normal transient condition (worktree mid-rebase, file - // already pruned by git apply, etc.). Anything else — most notably - // PermissionDenied — is unusual and worth flagging at warn for triage. - if e.kind() == std::io::ErrorKind::NotFound { - debug!("File no longer present for {} — skipping", file_path); - } else { - warn!( - "Failed to read metadata for {}: {} (kind={:?}) — skipping", - file_path, - e, - e.kind() - ); - } - } - } - Ok(()) -} - /// If any symlinks were skipped during PR file collection, append a clearly /// marked notice to the PR description so the agent/author can see that some /// intended content was deliberately dropped. @@ -2220,126 +1742,6 @@ fn sanitize_path_for_markdown(path: &str) -> String { .collect() } -/// Read a file and produce an ADO push change entry. -/// Handles both text (rawtext) and binary (base64encoded) content. -async fn read_file_change( - change_type: &str, - file_path: &str, - full_path: &std::path::Path, -) -> anyhow::Result { - let bytes = tokio::fs::read(full_path) - .await - .with_context(|| format!("Failed to read file: {}", file_path))?; - - // Try UTF-8 first; fall back to base64 for binary files - match String::from_utf8(bytes.clone()) { - Ok(content) => Ok(serde_json::json!({ - "changeType": change_type, - "item": { - "path": format!("/{}", file_path) - }, - "newContent": { - "content": content, - "contentType": "rawtext" - } - })), - Err(_) => { - use base64::Engine; - let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); - Ok(serde_json::json!({ - "changeType": change_type, - "item": { - "path": format!("/{}", file_path) - }, - "newContent": { - "content": encoded, - "contentType": "base64encoded" - } - })) - } - } -} -/// -/// Security checks: -/// - No path traversal (..) -/// - No .git directory modifications -/// - No absolute paths -/// - No null bytes -/// - No symlink entries (mode 120000) -fn validate_patch_paths(patch_content: &str) -> anyhow::Result<()> { - let mut in_diff = false; - for line in patch_content.lines() { - // Only validate paths within diff blocks, not commit message bodies. - // format-patch output includes commit messages before each diff section. - if line.starts_with("diff --git") { - in_diff = true; - // Extract paths using strip_prefix for correct handling of spaces - if let Some(rest) = line.strip_prefix("diff --git a/") { - // The b/ path starts after the last " b/" — but for simple validation, - // validate the a/ path (everything before " b/") and the b/ path - if let Some((a_path, b_path)) = rest.rsplit_once(" b/") { - validate_single_path(a_path.trim_matches('"'))?; - validate_single_path(b_path.trim_matches('"'))?; - } - } - continue; - } - // Reset on commit envelope boundaries - if line.starts_with("From ") && in_diff { - in_diff = false; - continue; - } - if !in_diff { - continue; - } - if let Some(rest) = line.strip_prefix("--- a/") { - let path = rest.trim().trim_matches('"'); - validate_single_path(path)?; - } else if let Some(rest) = line.strip_prefix("+++ b/") { - let path = rest.trim().trim_matches('"'); - validate_single_path(path)?; - } else if line.starts_with("--- /dev/null") || line.starts_with("+++ /dev/null") { - // New or deleted files — no path to validate - } else if line.starts_with("rename from ") - || line.starts_with("rename to ") - || line.starts_with("copy from ") - || line.starts_with("copy to ") - { - let path = line.splitn(3, ' ').nth(2).unwrap_or("").trim_matches('"'); - validate_single_path(path)?; - } - // Only consider this a real header line if it has no leading - // whitespace — diff context lines are space-prefixed, so a line - // body of "new file mode 120000" inside a hunk would look identical - // to a real mode header after trim(). Real git header lines never - // start with whitespace; we require the same here to avoid false - // positives on adversarial-but-benign diff content. Added (+)/ - // removed (-) lines start with a non-whitespace prefix that - // survives trim() and so cannot match the exact mode-line strings. - let is_symlink_mode_header = { - let starts_with_ws = line.chars().next().is_some_and(|c| c.is_whitespace()); - let trimmed = line.trim(); - !starts_with_ws && (trimmed == "new file mode 120000" || trimmed == "new mode 120000") - }; - if is_symlink_mode_header { - // Reject patch lines that INTRODUCE a symlink (git mode 120000). - // Either of these lines means the resulting tree contains a symlink: - // - "new file mode 120000" — a freshly added symlink - // - "new mode 120000" — an existing file converted to a symlink - // A symlink in the worktree could make Stage 3 follow it to arbitrary - // filesystem paths (e.g. /proc/self/environ) when collecting file - // changes to upload to ADO. - // - // We deliberately do NOT reject "old mode 120000" on its own: a patch - // with "old mode 120000" + "new mode 100644" converts a symlink into a - // regular file, which is a legitimate cleanup operation and produces a - // safe worktree. - anyhow::bail!("Patch introduces a symlink (mode 120000), which is not allowed"); - } - } - Ok(()) -} - /// Truncate an error response body to avoid embedding large or sensitive content. fn truncate_error_body(body: &str, max_len: usize) -> &str { match body.char_indices().nth(max_len) { @@ -2356,7 +1758,7 @@ fn truncate_error_body(body: &str, max_len: usize) -> &str { /// Patterns without `/` are treated as basename matches (e.g., `*.lock` matches /// `subdir/Cargo.lock`). Patterns with `**/` prefix match at any depth. /// Uses the `glob-match` crate for correct glob semantics (`*` does not cross `/`). -fn glob_match_simple(pattern: &str, path: &str) -> bool { +pub(crate) fn glob_match_simple(pattern: &str, path: &str) -> bool { if !pattern.contains('/') { // Basename-only pattern: auto-prefix with **/ for any-depth matching let full_pattern = format!("**/{}", pattern); @@ -2365,71 +1767,6 @@ fn glob_match_simple(pattern: &str, path: &str) -> bool { glob_match::glob_match(pattern, path) } -/// Validate a single file path for security issues. -/// -/// Thin wrapper over the canonical [`crate::validate::validate_relative_safe_path`] -/// primitive so all path-traversal / absolute / `.git` / null-byte / pipeline -/// command checks live in one place. -fn validate_single_path(path: &str) -> anyhow::Result<()> { - crate::validate::validate_relative_safe_path(path, "path") -} - -/// Extract all file paths from a patch/diff content. -/// Returns deduplicated list of file paths referenced in the patch (both source and destination). -/// Uses `--- a/` and `+++ b/` lines for robust parsing (handles quoted paths -/// with spaces that break `diff --git` header parsing via split_whitespace). -fn extract_paths_from_patch(patch_content: &str) -> Vec { - let mut paths = Vec::new(); - let mut in_diff = false; - for line in patch_content.lines() { - // Only extract paths after the first diff --git header to avoid - // false positives from commit messages that quote patch fragments - if line.starts_with("diff --git") { - in_diff = true; - continue; - } - if !in_diff { - continue; - } - // A new commit envelope resets — skip until next diff --git - if line.starts_with("From ") { - in_diff = false; - continue; - } - if let Some(rest) = line.strip_prefix("--- a/") { - let path = rest.trim().trim_matches('"'); - if !path.is_empty() { - paths.push(path.to_string()); - } - } else if let Some(rest) = line.strip_prefix("+++ b/") { - let path = rest.trim().trim_matches('"'); - if !path.is_empty() { - paths.push(path.to_string()); - } - } else if line.starts_with("rename from ") - || line.starts_with("rename to ") - || line.starts_with("copy from ") - || line.starts_with("copy to ") - { - // "rename from " / "rename to " - let path = line.splitn(3, ' ').nth(2).unwrap_or("").trim_matches('"'); - if !path.is_empty() { - paths.push(path.to_string()); - } - } - } - paths.sort(); - paths.dedup(); - paths -} - -/// Count the number of distinct files changed in a patch. -/// Reuses `extract_paths_from_patch` which correctly handles quoted paths, -/// renames, copies, and multi-commit deduplication. -fn count_patch_files(patch_content: &str) -> usize { - extract_paths_from_patch(patch_content).len() -} - /// Check if any file paths in the patch are protected. /// /// Protected files include: @@ -2439,7 +1776,7 @@ fn count_patch_files(patch_content: &str) -> usize { /// - Access control files (CODEOWNERS) /// /// Returns a list of protected file paths found, or empty vec if none. -fn find_protected_files(paths: &[String]) -> Vec { +pub(crate) fn find_protected_files(paths: &[String]) -> Vec { let mut protected = Vec::new(); for path in paths { let lower_path = path.to_lowercase(); @@ -2482,6 +1819,120 @@ mod tests { use super::*; use crate::safe_outputs::ToolResult; + #[tokio::test] + async fn creation_uses_shared_patch_selection_blobs_and_verified_parent() { + use crate::safe_outputs::pr_patch::tests::{command, movement, repository}; + use std::sync::{Arc, Mutex}; + use wiremock::{Mock, MockServer, ResponseTemplate, matchers::{method, path, query_param}}; + for case in ["copy", "rename-edit", "crlf", "excluded-copy", "series", "filtered-series", "base-drift", "unrelated-base", "prefix-ref"] { + let (repo, base) = repository(&[("old.txt", b"base\n"), ("nested/secret.txt", b"excluded\n"), ("keep.txt", b"old\n")]); + let mut current = base.clone(); + let mut text = match case { + "copy" => movement("copy", "old.txt", "new.txt"), + "rename-edit" => movement("rename", "old.txt", "new.txt") + + "--- a/old.txt\n+++ b/new.txt\n@@ -1 +1 @@\n-base\n+changed\n", + "excluded-copy" => movement("copy", "nested/secret.txt", "public.txt") + + "diff --git a/old.txt b/old.txt\n--- a/old.txt\n+++ b/old.txt\n@@ -1 +1 @@\n-base\n+changed\n", + _ => "diff --git a/old.txt b/old.txt\n--- a/old.txt\n+++ b/old.txt\n@@ -1 +1 @@\n-base\n+changed\n".into(), + }; + if case == "crlf" { command(repo.path(), &["config", "core.autocrlf", "true"]); } + if case == "series" || case == "filtered-series" { + for content in ["first\n", "changed\n"] { + std::fs::write(repo.path().join("old.txt"), content).unwrap(); + command(repo.path(), &["add", "old.txt"]); + command(repo.path(), &["commit", "--quiet", "-m", content.trim()]); + } + let output = std::process::Command::new("git") + .args(["format-patch", "--full-index", "--stdout", &format!("{base}..HEAD")]) + .current_dir(repo.path()).output().unwrap(); + assert!(output.status.success()); + text = String::from_utf8(output.stdout).unwrap(); + command(repo.path(), &["checkout", "--detach", base.as_str()]); + } + if case == "base-drift" { + std::fs::write(repo.path().join("keep.txt"), "target changed\n").unwrap(); + command(repo.path(), &["add", "keep.txt"]); + command(repo.path(), &["commit", "--quiet", "-m", "advance target"]); + current = crate::secure::CommitSha::parse(command(repo.path(), &["rev-parse", "HEAD"])).unwrap(); + } + let recorded = if case == "unrelated-base" { "f".repeat(40) } else { base.to_string() }; + let original_refs = command(repo.path(), &["show-ref"]); + let original_index = std::fs::read(repo.path().join(".git").join("index")).unwrap(); + let original_status = command(repo.path(), &["status", "--porcelain"]); + let server = MockServer::start().await; + let api = "/P/_apis/git/repositories/repo"; + Mock::given(method("GET")).and(path(format!("{api}/refs"))).and(query_param("filter", "heads/main")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "value":[{"name":"refs/heads/main","objectId":current}] + }))).mount(&server).await; + Mock::given(method("GET")).and(path(format!("{api}/refs"))) + .and(query_param("filter", format!("heads/agent/{case}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "value": if case == "prefix-ref" { + vec![serde_json::json!({"name":"refs/heads/agent/prefix-ref-target", "objectId":base})] + } else { vec![] } + }))) + .mount(&server).await; + Mock::given(method("GET")).and(path(format!("{api}/diffs/commits"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "baseCommit":recorded,"targetCommit":current,"commonCommit":base + }))).mount(&server).await; + let pushes = Arc::new(Mutex::new(Vec::::new())); + let observed = pushes.clone(); + Mock::given(method("POST")).and(path(format!("{api}/pushes"))) + .respond_with(move |request: &wiremock::Request| { + observed.lock().unwrap().push(serde_json::from_slice(&request.body).unwrap()); + ResponseTemplate::new(201).set_body_json(serde_json::json!({"pushId":1})) + }).mount(&server).await; + Mock::given(method("POST")).and(path(format!("{api}/pullrequests"))) + .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({ + "pullRequestId":7,"url":"https://example.test/pr/7" + }))).mount(&server).await; + let output = tempfile::tempdir().unwrap(); + std::fs::write(output.path().join("changes.patch"), &text).unwrap(); + let mut ctx = ExecutionContext { + ado_org_url: Some(server.uri()), ado_organization: Some("org".into()), + ado_project: Some("P".into()), repository_name: Some("repo".into()), + repository_provider: Some("TfsGit".into()), access_token: Some("token".into()), + source_directory: repo.path().into(), self_repository_directory: repo.path().into(), + working_directory: output.path().into(), ..Default::default() + }; + ctx.tool_configs.insert("create-pull-request".into(), serde_json::json!({ + "include-stats":false, "excluded-files":if case == "excluded-copy" || case == "filtered-series" { vec!["secret.txt"] } else { vec![] } + })); + let result = crate::execute::execute_safe_output(&serde_json::json!({ + "name":"create-pull-request", "title":"Patch fixture", "description":"A validated code change.", + "source_branch":format!("agent/{case}"), "repository":"self", "temporary_id":"#aw_fixture", + "patch_file":"changes.patch", "patch_sha256":crate::hash::sha256_hex(text.as_bytes()), + "base_commit":recorded + }), &ctx).await; + assert_eq!(command(repo.path(), &["show-ref"]), original_refs, "{case}"); + assert_eq!(std::fs::read(repo.path().join(".git").join("index")).unwrap(), original_index, "{case}"); + assert_eq!(command(repo.path(), &["status", "--porcelain"]), original_status, "{case}"); + if case == "unrelated-base" { + assert!(result.is_err()); + assert!(pushes.lock().unwrap().is_empty()); + assert!(server.received_requests().await.unwrap().iter().all(|request| request.method.as_str() == "GET")); + continue; + } + let (_, result) = result.unwrap(); + assert!(result.success, "{case}: {}", result.message); + let pushes = pushes.lock().unwrap(); + assert_eq!(pushes.len(), 1, "{case}"); + assert_eq!(pushes[0]["refUpdates"][0]["name"], format!("refs/heads/agent/{case}")); + assert_eq!(pushes[0]["commits"][0]["parents"], serde_json::json!([base])); + let changes = pushes[0]["commits"][0]["changes"].as_array().unwrap(); + let unique = changes.iter().map(|change| change["item"]["path"].as_str().unwrap()).collect::>(); + assert_eq!(unique.len(), changes.len(), "ADO allows one operation per path: {case}"); + let content = changes.iter().find(|change| change.get("newContent").is_some()).unwrap(); + assert_eq!(content["newContent"]["content"], if case == "copy" { "base\n" } else { "changed\n" }, "{case}"); + if case == "excluded-copy" { + assert!(changes.iter().all(|change| change["item"]["path"] == "/old.txt")); + assert_eq!(result.data.unwrap()["omitted_operations"][0]["operation"], "copy"); + } + } + } + #[test] fn test_validate_params_valid() { let params = CreatePrParams { @@ -2913,7 +2364,7 @@ mod tests { #[test] fn test_validate_patch_paths_valid() { let patch = r#"diff --git a/src/main.rs b/src/main.rs -index 1234567..abcdefg 100644 +index 1234567..abcdef0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ @@ -2922,7 +2373,7 @@ index 1234567..abcdefg 100644 println!("World"); } "#; - assert!(validate_patch_paths(patch).is_ok()); + assert!(super::super::pr_patch::inspected_paths((patch).as_bytes()).is_ok()); } #[test] @@ -2936,7 +2387,7 @@ index 0000000..1234567 +Hello +World "#; - assert!(validate_patch_paths(patch).is_ok()); + assert!(super::super::pr_patch::inspected_paths((patch).as_bytes()).is_ok()); } #[test] @@ -2945,7 +2396,7 @@ index 0000000..1234567 --- a/../../../etc/passwd +++ b/../../../etc/passwd "#; - assert!(validate_patch_paths(patch).is_err()); + assert!(super::super::pr_patch::inspected_paths((patch).as_bytes()).is_err()); } #[test] @@ -2957,7 +2408,7 @@ new file mode 100755 @@ -0,0 +1 @@ +#!/bin/bash "#; - assert!(validate_patch_paths(patch).is_err()); + assert!(super::super::pr_patch::inspected_paths((patch).as_bytes()).is_err()); } #[test] @@ -2966,7 +2417,7 @@ new file mode 100755 --- a//etc/passwd +++ b//etc/passwd "#; - assert!(validate_patch_paths(patch).is_err()); + assert!(super::super::pr_patch::inspected_paths((patch).as_bytes()).is_err()); } #[test] @@ -2977,7 +2428,7 @@ new file mode 100755 let patch = "diff --git a/old b/new\n\ rename from some dir/../.git/config\n\ rename to new name\n"; - let result = validate_patch_paths(patch); + let result = super::super::pr_patch::inspected_paths((patch).as_bytes()); assert!( result.is_err(), "rename with spaces and traversal should be rejected" @@ -2987,7 +2438,7 @@ new file mode 100755 let patch_copy = "diff --git a/old b/new\n\ copy from some dir/../.git/config\n\ copy to new name\n"; - let result_copy = validate_patch_paths(patch_copy); + let result_copy = super::super::pr_patch::inspected_paths((patch_copy).as_bytes()); assert!( result_copy.is_err(), "copy with spaces and traversal should be rejected" @@ -3000,7 +2451,7 @@ new file mode 100755 // This is the primary attack vector for symlink exfiltration of Stage 3 secrets. let patch = r#"diff --git a/secrets.txt b/secrets.txt new file mode 120000 -index 0000000..abcdefg +index 0000000..abcdef0 --- /dev/null +++ b/secrets.txt @@ -0,0 +1 @@ @@ -3008,7 +2459,7 @@ index 0000000..abcdefg \ No newline at end of file "#; assert!( - validate_patch_paths(patch).is_err(), + super::super::pr_patch::inspected_paths((patch).as_bytes()).is_err(), "patch with symlink mode 120000 should be rejected" ); } @@ -3020,7 +2471,7 @@ index 0000000..abcdefg old mode 100644\n\ new mode 120000\n"; assert!( - validate_patch_paths(patch).is_err(), + super::super::pr_patch::inspected_paths((patch).as_bytes()).is_err(), "patch that introduces symlink via mode change should be rejected" ); } @@ -3040,7 +2491,7 @@ index 0000000..abcdefg -/etc/passwd\n\ +hello world\n"; assert!( - validate_patch_paths(patch).is_ok(), + super::super::pr_patch::inspected_paths((patch).as_bytes()).is_ok(), "patch converting symlink → regular file should be allowed" ); } @@ -3054,7 +2505,7 @@ index 0000000..abcdefg --- a/link\n\ +++ /dev/null\n"; assert!( - validate_patch_paths(patch).is_ok(), + super::super::pr_patch::inspected_paths((patch).as_bytes()).is_ok(), "patch deleting an existing symlink should be allowed" ); } @@ -3068,7 +2519,7 @@ index 0000000..abcdefg // padding. let patch_trailing_ws = "diff --git a/x b/x\nnew file mode 120000 \n"; assert!( - validate_patch_paths(patch_trailing_ws).is_err(), + super::super::pr_patch::inspected_paths((patch_trailing_ws).as_bytes()).is_err(), "trailing whitespace must not let a symlink mode line bypass the check" ); @@ -3076,7 +2527,7 @@ index 0000000..abcdefg // not bypass. let patch_crlf = "diff --git a/x b/x\nnew file mode 120000\r\n"; assert!( - validate_patch_paths(patch_crlf).is_err(), + super::super::pr_patch::inspected_paths((patch_crlf).as_bytes()).is_err(), "trailing \\r must not let a symlink mode line bypass the check" ); @@ -3098,7 +2549,7 @@ index 0000000..abcdefg + " new file mode 120000\n" + " keep line\n"; assert!( - validate_patch_paths(&patch_context_line).is_ok(), + super::super::pr_patch::inspected_paths(patch_context_line.as_bytes()).is_ok(), "diff context line containing the mode string as data must not be rejected" ); @@ -3113,7 +2564,7 @@ index 0000000..abcdefg -count: 1200000\n\ +count: 1200001\n"; assert!( - validate_patch_paths(patch_benign).is_ok(), + super::super::pr_patch::inspected_paths((patch_benign).as_bytes()).is_ok(), "patch body lines containing '120000' as data must not be rejected" ); } @@ -3214,36 +2665,36 @@ index 0000000..abcdefg #[test] fn test_validate_single_path_valid() { - assert!(validate_single_path("src/main.rs").is_ok()); - assert!(validate_single_path("deeply/nested/path/file.txt").is_ok()); - assert!(validate_single_path("file.txt").is_ok()); + assert!(crate::secure::RelativeSafePath::parse("src/main.rs").is_ok()); + assert!(crate::secure::RelativeSafePath::parse("deeply/nested/path/file.txt").is_ok()); + assert!(crate::secure::RelativeSafePath::parse("file.txt").is_ok()); } #[test] fn test_validate_single_path_traversal() { - assert!(validate_single_path("../secret.txt").is_err()); - assert!(validate_single_path("foo/../bar").is_err()); - assert!(validate_single_path("foo/bar/../../baz").is_err()); + assert!(crate::secure::RelativeSafePath::parse("../secret.txt").is_err()); + assert!(crate::secure::RelativeSafePath::parse("foo/../bar").is_err()); + assert!(crate::secure::RelativeSafePath::parse("foo/bar/../../baz").is_err()); } #[test] fn test_validate_single_path_git_dir() { - assert!(validate_single_path(".git").is_err()); - assert!(validate_single_path(".git/config").is_err()); - assert!(validate_single_path(".git/hooks/pre-commit").is_err()); - assert!(validate_single_path(".GIT/config").is_err()); // case insensitive + assert!(crate::secure::RelativeSafePath::parse(".git").is_err()); + assert!(crate::secure::RelativeSafePath::parse(".git/config").is_err()); + assert!(crate::secure::RelativeSafePath::parse(".git/hooks/pre-commit").is_err()); + assert!(crate::secure::RelativeSafePath::parse(".GIT/config").is_err()); // case insensitive } #[test] fn test_validate_single_path_absolute() { - assert!(validate_single_path("/etc/passwd").is_err()); - assert!(validate_single_path("\\Windows\\System32").is_err()); - assert!(validate_single_path("C:\\Windows").is_err()); + assert!(crate::secure::RelativeSafePath::parse("/etc/passwd").is_err()); + assert!(crate::secure::RelativeSafePath::parse("\\Windows\\System32").is_err()); + assert!(crate::secure::RelativeSafePath::parse("C:\\Windows").is_err()); } #[test] fn test_validate_single_path_null_byte() { - assert!(validate_single_path("file\0.txt").is_err()); + assert!(crate::secure::RelativeSafePath::parse("file\0.txt").is_err()); } // ─── Protected files detection ────────────────────────────────────────── @@ -3369,27 +2820,27 @@ index 0000000..abcdefg #[test] fn test_extract_paths_from_patch() { - let patch = "diff --git a/src/main.rs b/src/main.rs\nindex abc..def\n--- a/src/main.rs\n+++ b/src/main.rs\ndiff --git a/README.md b/README.md\n--- a/README.md\n+++ b/README.md\n"; - let paths = extract_paths_from_patch(patch); - assert!(paths.contains(&"src/main.rs".to_string())); - assert!(paths.contains(&"README.md".to_string())); + let patch = "diff --git a/src/main.rs b/src/main.rs\nindex abc1234..def1234\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1 +1 @@\n-old\n+new\ndiff --git a/README.md b/README.md\n--- a/README.md\n+++ b/README.md\n@@ -1 +1 @@\n-old\n+new\n"; + let paths = super::super::pr_patch::inspected_paths((patch).as_bytes()).unwrap(); + assert!(paths.contains("src/main.rs")); + assert!(paths.contains("README.md")); } #[test] fn test_extract_paths_from_patch_with_spaces() { - let patch = "diff --git \"a/path with spaces/file.txt\" \"b/path with spaces/file.txt\"\n--- a/path with spaces/file.txt\n+++ b/path with spaces/file.txt\n"; - let paths = extract_paths_from_patch(patch); - assert!(paths.contains(&"path with spaces/file.txt".to_string())); + let patch = "diff --git \"a/path with spaces/file.txt\" \"b/path with spaces/file.txt\"\n--- a/path with spaces/file.txt\n+++ b/path with spaces/file.txt\n@@ -1 +1 @@\n-old\n+new\n"; + let paths = super::super::pr_patch::inspected_paths((patch).as_bytes()).unwrap(); + assert!(paths.contains("path with spaces/file.txt")); } #[test] fn test_extract_paths_new_file() { let patch = "diff --git a/new.txt b/new.txt\nnew file mode 100644\n--- /dev/null\n+++ b/new.txt\n"; - let paths = extract_paths_from_patch(patch); - assert!(paths.contains(&"new.txt".to_string())); + let paths = super::super::pr_patch::inspected_paths((patch).as_bytes()).unwrap(); + assert!(paths.contains("new.txt")); // /dev/null from --- should not be included (no a/ prefix) - assert!(!paths.contains(&"/dev/null".to_string())); + assert!(!paths.contains("/dev/null")); } #[test] @@ -3529,6 +2980,7 @@ index 0000000..abcdefg ado_organization: Some("test".to_string()), ado_project: Some("TestProject".to_string()), ado_project_id: None, + pipeline_collection_uri: None, access_token: Some("fake-token".to_string()), github_token: None, github_actor_login: None, @@ -3565,6 +3017,8 @@ index 0000000..abcdefg resolved_pull_requests: std::sync::Arc::new(std::sync::Mutex::new( 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/mark_pull_request_as_ready_for_review.rs b/src/safe_outputs/mark_pull_request_as_ready_for_review.rs new file mode 100644 index 000000000..e0b88c6eb --- /dev/null +++ b/src/safe_outputs/mark_pull_request_as_ready_for_review.rs @@ -0,0 +1,305 @@ +//! Publish an existing active draft PR without changing its vote or merge policy. +use super::pr_http::BoundedPrResponse; +use ado_aw_derive::SanitizeConfig; +use anyhow::{Context, ensure}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::pr_common::{ + PullRequestReference, describe_pr_reference, repository_api_base, resolve_configured_pr_target, + validate_reference, +}; +use super::{ + ExecutionContext, ExecutionResult, Executor, ToolResult, Validate, authenticate_ado_request, +}; +use crate::sanitize::{SanitizeContent, sanitize_config}; +use crate::tool_result; + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct MarkPullRequestReadyParams { + /// Omit only for a configured fixed or trusted triggering target. + #[serde(default)] + pub pull_request_id: Option, + #[serde(default)] + pub repository: Option, +} + +impl Validate for MarkPullRequestReadyParams { + fn validate(&self) -> anyhow::Result<()> { + if let Some(reference) = &self.pull_request_id { + validate_reference(reference)?; + } + if let Some(repository) = &self.repository { + crate::validate::reject_pipeline_injection(repository, "repository")?; + } + Ok(()) + } +} + +tool_result! { + name = "mark-pull-request-as-ready-for-review", + write = true, + params = MarkPullRequestReadyParams, + #[serde(deny_unknown_fields)] + pub struct MarkPullRequestReadyResult { + #[serde(default)] + pull_request_id:Option, + #[serde(default)] + repository:Option, + } +} + +impl SanitizeContent for MarkPullRequestReadyResult { + fn sanitize_content_fields(&mut self) { + self.repository = self.repository.as_deref().map(sanitize_config); + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, SanitizeConfig)] +#[serde(deny_unknown_fields)] +pub struct MarkPullRequestReadyConfig { + #[serde(default)] + #[sanitize_config(skip)] + pub target: super::update_pull_request::UpdatePullRequestTarget, + #[serde(default, rename = "target-repo")] + pub target_repo: Option, + #[serde(default, rename = "allowed-repositories")] + pub allowed_repositories: 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, +} + +#[derive(Deserialize)] +struct PrState { + #[serde(rename = "pullRequestId")] + id: u64, + status: String, + #[serde(rename = "isDraft")] + draft: bool, +} + +async fn read_pr( + client: &reqwest::Client, + url: &str, + token: &str, + ctx: &ExecutionContext, + id: u64, +) -> anyhow::Result { + let response = authenticate_ado_request(client.get(url), token, ctx.write_connection_type) + .send() + .await + .context("Failed to fetch PR publication state")?; + ensure!( + response.status().is_success(), + "Failed to fetch PR publication state (HTTP {})", + response.status() + ); + let state: PrState = response + .bounded_json() + .await + .context("Malformed PR publication state")?; + ensure!( + state.id == id, + "PR publication response identified a different PR" + ); + Ok(state) +} + +#[async_trait::async_trait] +impl Executor for MarkPullRequestReadyResult { + fn dry_run_summary(&self) -> String { + format!( + "mark {} ready for review", + describe_pr_reference(self.pull_request_id.as_ref()) + ) + } + async fn execute_impl(&self, ctx: &ExecutionContext) -> anyhow::Result { + MarkPullRequestReadyParams { + pull_request_id: self.pull_request_id.clone(), + repository: self.repository.clone(), + } + .validate()?; + let _: MarkPullRequestReadyConfig = ctx.get_tool_config(Self::NAME)?; + let client = super::pr_http::client()?; + let (id, target) = match resolve_configured_pr_target( + Self::NAME, + self.pull_request_id.as_ref(), + self.repository.as_deref(), + ctx, + &client, + ) + .await? + { + Ok(target) => target, + Err(failure) => return Ok(failure), + }; + let token = ctx + .access_token + .as_deref() + .context("No access token available")?; + let url = format!( + "{}/pullRequests/{id}?api-version=7.1", + repository_api_base(&target) + ); + let before = read_pr(&client, &url, token, ctx, id).await?; + ensure!( + before.status.eq_ignore_ascii_case("active"), + "Only an active PR can be marked ready for review" + ); + let mut data = + serde_json::json!({"pull_request_id":id,"repository":target.qualified_repository()}); + if !before.draft { + data["already_ready"] = serde_json::json!(true); + return Ok(ExecutionResult::success_with_data( + "PR is already ready for review", + data, + )); + } + let response = + authenticate_ado_request(client.patch(&url), token, ctx.write_connection_type) + .json(&serde_json::json!({"isDraft":false})) + .send() + .await; + match response { + Ok(response) if response.status().is_success() => {} + Ok(response) => { + data["publication_status"] = serde_json::json!("failed"); + return Ok(ExecutionResult::failure_with_data( + format!("PR publication failed (HTTP {})", response.status()), + data, + )); + } + Err(error) => { + data["publication_status"] = serde_json::json!("uncertain"); + return Ok(ExecutionResult::failure_with_data( + format!("PR publication delivery is uncertain: {error}"), + data, + )); + } + } + match read_pr(&client, &url, token, ctx, id).await { + Ok(after) if !after.draft && after.status.eq_ignore_ascii_case("active") => { + data["publication_status"] = serde_json::json!("confirmed"); + Ok(ExecutionResult::success_with_data( + "PR publication confirmed", + data, + )) + } + result => { + data["publication_status"] = serde_json::json!("unconfirmed"); + let reason = match result { + Ok(_) => "PR is still draft or no longer active".to_string(), + Err(error) => format!("{error:#}"), + }; + Ok(ExecutionResult::failure_with_data( + format!("Could not confirm PR publication: {reason}"), + data, + )) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{body_json, method}, + }; + + #[tokio::test] + async fn publishes_only_is_draft_and_requires_authoritative_readback() { + use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }; + for persists in [true, false] { + let server = MockServer::start().await; + let draft = Arc::new(AtomicBool::new(true)); + let read = draft.clone(); + Mock::given(method("GET")).respond_with(move |_:&wiremock::Request|{ + ResponseTemplate::new(200).set_body_json(json!({"pullRequestId":42,"status":"active","isDraft":read.load(Ordering::SeqCst)})) + }).expect(2).mount(&server).await; + Mock::given(method("PATCH")) + .and(body_json(json!({"isDraft":false}))) + .respond_with(move |_: &wiremock::Request| { + if persists { + draft.store(false, Ordering::SeqCst); + } + 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("P".into()), + repository_name: Some("repo".into()), + access_token: Some("token".into()), + ..Default::default() + }; + ctx.tool_configs.insert( + MarkPullRequestReadyResult::NAME.into(), + json!({"target":"*"}), + ); + let result = crate::execute::execute_safe_output( + &json!({"name":MarkPullRequestReadyResult::NAME,"pull_request_id":42}), + &ctx, + ) + .await + .unwrap() + .1; + assert_eq!(result.success, persists); + } + } + + #[tokio::test] + async fn ready_is_a_noop_and_terminal_states_never_write() { + for (status, draft, success) in [ + ("active", false, true), + ("completed", false, false), + ("abandoned", true, false), + ] { + let server = MockServer::start().await; + Mock::given(method("GET")) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({"pullRequestId":42,"status":status,"isDraft":draft})), + ) + .expect(1) + .mount(&server) + .await; + let mut 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()), + ..Default::default() + }; + ctx.tool_configs.insert( + MarkPullRequestReadyResult::NAME.into(), + json!({"target":"*"}), + ); + let result = crate::execute::execute_safe_output( + &json!({"name":MarkPullRequestReadyResult::NAME,"pull_request_id":42}), + &ctx, + ) + .await; + assert_eq!( + result.as_ref().is_ok_and(|(_, result)| result.success), + success + ); + assert_eq!(server.received_requests().await.unwrap().len(), 1); + } + } +} diff --git a/src/safe_outputs/mod.rs b/src/safe_outputs/mod.rs index 2dc4a49d2..337303eef 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,16 @@ 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, + RemovePullRequestLabelsResult, + ReplacePullRequestLabelResult, + MarkPullRequestReadyResult, + UpdatePullRequestCommentResult, + PushToPullRequestBranchResult, + SetPrAutoCompleteResult, + UpdatePullRequestResult, + AbandonPullRequestResult, AssignWorkItemResult, CreateGithubIssueResult, SetGithubIssueTypeResult, @@ -84,7 +95,16 @@ pub const ALL_KNOWN_SAFE_OUTPUTS: &[&str] = all_safe_output_names![ CreateGitTagResult, AddBuildTagResult, CreateBranchResult, - UpdatePrResult, + AddPrReviewersResult, + AddPrLabelsResult, + RemovePullRequestLabelsResult, + ReplacePullRequestLabelResult, + MarkPullRequestReadyResult, + UpdatePullRequestCommentResult, + PushToPullRequestBranchResult, + SetPrAutoCompleteResult, + AbandonPullRequestResult, + UpdatePullRequestResult, UploadBuildAttachmentResult, UploadPipelineArtifactResult, UploadWorkitemAttachmentResult, @@ -214,6 +234,7 @@ pub(crate) async fn resolve_wiki_branch( /// Azure DevOps repository names are case-insensitive, so the name-based /// fallbacks match case-insensitively. Returns the resolved alias key only when /// the match is unique; ambiguous names are rejected. +#[cfg(test)] pub(crate) fn lookup_allowed_repository_alias<'a>( input: &str, allowed_repositories: &'a std::collections::HashMap, @@ -264,6 +285,7 @@ pub(crate) fn lookup_allowed_repository_alias<'a>( /// Azure DevOps repository names are case-insensitive, so the trailing-name fallback /// matches case-insensitively. Returns the resolved ADO repo name (the map value) on /// success, or `None` if no entry matches. +#[cfg(test)] pub(crate) fn lookup_allowed_repository<'a>( input: &str, allowed_repositories: &'a std::collections::HashMap, @@ -302,7 +324,7 @@ pub(crate) fn input_refers_to_self(input: &str, ctx: &ExecutionContext) -> bool /// /// **Idempotent**: passing an already-canonical alias returns it unchanged /// (`"self"` short-circuits on [`input_refers_to_self`]; an alias key hits the -/// exact-key arm of [`lookup_allowed_repository_alias`]), so callers may +/// exact-key lookup), so callers may /// canonicalize defensively without changing the result. /// /// **Precedence**: literal `"self"`/empty selects self; an exact checkout alias @@ -558,7 +580,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 @@ -587,6 +609,7 @@ pub(crate) fn resolve_repository_checkout_dir( /// against the trailing repo-name part of either `ctx.repository_name` or any /// configured allowed repository. See [`lookup_allowed_repository`] for the /// matching rules used against `ctx.allowed_repositories`. +#[cfg(test)] pub(crate) fn resolve_repo_name( repo_alias: Option<&str>, ctx: &ExecutionContext, @@ -763,9 +786,22 @@ macro_rules! impl_temporary_reference_deserialize { }; } +mod abandon_pull_request; mod add_build_tag; mod add_github_issue_labels; mod add_pr_comment; +mod add_pr_labels; +mod pr_labels; +pub(crate) mod pr_comments; +pub(crate) mod pr_http; +pub(crate) mod pr_patch; +pub(crate) mod pr_inline; +mod remove_pull_request_labels; +mod replace_pull_request_label; +mod mark_pull_request_as_ready_for_review; +mod update_pull_request_comment; +pub(crate) mod push_to_pull_request_branch; +mod add_pr_reviewers; mod assign_github_issue_milestone; mod assign_github_issue_to_user; mod assign_work_item; @@ -786,6 +822,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; @@ -794,19 +832,29 @@ 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; mod update_pr; +mod update_pull_request; mod update_wiki_page; mod update_work_item; 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::*; +pub use add_pr_labels::*; +pub use remove_pull_request_labels::*; +pub use replace_pull_request_label::*; +pub use mark_pull_request_as_ready_for_review::*; +pub use update_pull_request_comment::*; +pub use push_to_pull_request_branch::*; +pub use add_pr_reviewers::*; pub use assign_github_issue_milestone::*; pub use assign_github_issue_to_user::*; pub use assign_work_item::*; @@ -838,10 +886,11 @@ 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::*; -pub use update_pr::*; +pub use update_pull_request::*; pub use update_wiki_page::*; pub use update_work_item::*; pub use upload_build_attachment::*; @@ -900,6 +949,9 @@ mod tests { const { assert!(CreatePrResult::REQUIRES_WRITE); } + const { + assert!(AbandonPullRequestResult::REQUIRES_WRITE); + } const { assert!(CreateWikiPageResult::REQUIRES_WRITE); } @@ -925,7 +977,12 @@ mod tests { assert!(CreateBranchResult::REQUIRES_WRITE); } const { - assert!(UpdatePrResult::REQUIRES_WRITE); + assert!(AddPrReviewersResult::REQUIRES_WRITE); + assert!(AddPrLabelsResult::REQUIRES_WRITE); + assert!(SetPrAutoCompleteResult::REQUIRES_WRITE); + } + const { + assert!(UpdatePullRequestResult::REQUIRES_WRITE); } const { assert!(UploadBuildAttachmentResult::REQUIRES_WRITE); diff --git a/src/safe_outputs/pr_comments.rs b/src/safe_outputs/pr_comments.rs new file mode 100644 index 000000000..6fa8d5bdb --- /dev/null +++ b/src/safe_outputs/pr_comments.rs @@ -0,0 +1,783 @@ +//! Owned comment metadata and non-destructive lifecycle operations. +use anyhow::{Context, ensure}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +use super::pr_common::collection_identity; +use super::pr_http::get_json; +use super::pr_mutations::UpdatePrContext; +use super::{ExecutionContext, ExecutionResult, authenticate_ado_request}; +use crate::secure::{Guid, Identifier}; + +const OWNER: &str = "ado-aw.owner"; +const BODY_HASH: &str = "ado-aw.content-sha256"; +const RUN: &str = "ado-aw.run-id"; +const CONTENT_PROOF: &str = "\n\n")?; + (hash.len() == 64 + && hash.bytes().all(|byte| byte.is_ascii_hexdigit()) + && hash == crate::hash::sha256_hex(body.as_bytes())) + .then_some(body) + }); + proof.context( + "Owned comment no longer matches its confirmed content hash; refusing to overwrite it", + ) +} + +fn updated_content(content: &str) -> anyhow::Result { + validate_body(content)?; + let content = format!( + "{content}{CONTENT_PROOF}{} -->", + crate::hash::sha256_hex(content.as_bytes()) + ); + validate_body(&content)?; + Ok(content) +} + +pub(crate) fn thread_url(ctx: &UpdatePrContext<'_>, id: i32) -> String { + format!( + "{}/pullRequests/{}/threads/{id}?api-version=7.1", + ctx.repository_api_base(), + ctx.pr_id + ) +} + +pub(crate) async fn read_thread(ctx: &UpdatePrContext<'_>, id: i32) -> anyhow::Result { + ensure!(id > 0, "thread_id must be positive"); + let thread: Thread = get_json(ctx, &thread_url(ctx, id)).await?; + ensure!( + thread.id == id, + "Comment metadata returned a different thread" + ); + ensure!( + thread.comments.len() <= 2_000, + "Comment conversation exceeds the discovery bound" + ); + Ok(thread) +} + +pub(crate) fn owner_for_thread( + ctx: &ExecutionContext, + key: &Identifier, + thread: &Thread, +) -> anyhow::Result { + let stored: Owner = serde_json::from_str( + property(&thread.properties, OWNER).context("Thread has no workflow ownership metadata")?, + )?; + ensure!( + matches!(stored.purpose.as_str(), "comment" | "review"), + "Unsupported owned-comment purpose" + ); + let expected = owner(ctx, &stored.purpose, key)? + .context("Owned updates require a complete trusted pipeline identity")?; + ensure!( + stored == expected, + "Thread belongs to a different workflow or report" + ); + Ok(expected) +} + +pub(crate) async fn older_threads( + ctx: &UpdatePrContext<'_>, + owner: &Owner, + actor: &str, + run: u64, + max: usize, +) -> anyhow::Result<(Vec, Vec)> { + ensure!( + run > 0, + "A positive build ID is required for comment supersession" + ); + ensure!( + max > 0 && max <= 100, + "max-superseded-comments must be between 1 and 100" + ); + #[derive(Deserialize)] + struct Threads { + value: Vec, + } + let threads: Threads = get_json( + ctx, + &format!( + "{}/pullRequests/{}/threads?api-version=7.1", + ctx.repository_api_base(), + ctx.pr_id + ), + ) + .await?; + ensure!( + threads.value.len() <= 2_000, + "PR thread discovery exceeds the 2000-thread bound" + ); + let mut candidates = Vec::new(); + let mut skipped = Vec::new(); + for thread in threads.value { + if property(&thread.properties, OWNER).is_none() { + continue; + } + let stored = match serde_json::from_str::( + property(&thread.properties, OWNER).expect("property checked"), + ) { + Ok(stored) => stored, + Err(error) => { + skipped.push(json!({"thread_id":thread.id,"reason":format!("invalid ownership metadata: {error}")})); + continue; + } + }; + if &stored != owner { + continue; + } + if thread.status != Some(json!(1)) && thread.status != Some(json!("active")) { + skipped.push(json!({"thread_id":thread.id,"reason":"thread is already resolved or its status is unknown"})); + continue; + } + let older = property(&thread.properties, RUN) + .and_then(|value| value.parse::().ok()) + .is_some_and(|value| value > 0 && value < run); + if !older { + skipped.push(json!({"thread_id":thread.id,"reason":"not a proven older run"})); + continue; + } + if let Err(error) = owned_root(&thread, owner, actor) { + skipped.push(json!({"thread_id":thread.id,"reason":format!("{error:#}")})); + continue; + } + candidates.push(thread); + } + ensure!( + candidates.len() <= max, + "Eligible comments exceed max-superseded-comments; nothing was superseded" + ); + Ok((candidates, skipped)) +} + +async fn patch(ctx: &UpdatePrContext<'_>, url: &str, body: &Value) -> anyhow::Result<()> { + let response = authenticate_ado_request(ctx.client.patch(url), ctx.token, ctx.connection_type) + .json(body) + .send() + .await + .context("Comment mutation delivery is uncertain")?; + ensure!( + response.status().is_success(), + "Comment mutation failed (HTTP {})", + response.status() + ); + Ok(()) +} + +pub(crate) async fn update_owned( + ctx: &UpdatePrContext<'_>, + owner: &Owner, + actor: &str, + thread: &Thread, + comment_id: i32, + content: &str, + superseded_by: Option, +) -> anyhow::Result { + validate_body(content)?; + let root = owned_root(thread, owner, actor)?; + ensure!( + comment_id == root.id, + "Only the verified owned root comment may be updated" + ); + let fresh = read_thread(ctx, thread.id).await?; + ensure!( + &fresh == thread, + "Comment conversation changed during preflight; no update attempted" + ); + owned_root(&fresh, owner, actor)?; + let encoded_content = updated_content(content)?; + let mut data = json!({"pull_request_id":ctx.pr_id,"thread_id":thread.id,"comment_id":comment_id, + "content_status":"not-attempted","metadata_status":"not-attempted"}); + let comment_url = format!( + "{}/pullRequests/{}/threads/{}/comments/{comment_id}?api-version=7.1", + ctx.repository_api_base(), + ctx.pr_id, + thread.id + ); + if let Err(error) = patch(ctx, &comment_url, &json!({"content":encoded_content})).await { + data["content_status"] = json!("uncertain"); + return Ok(ExecutionResult::failure_with_data( + format!("{error:#}"), + data, + )); + } + data["content_status"] = json!("applied"); + let after = match read_thread(ctx, thread.id).await { + Ok(after) => after, + Err(error) => { + return Ok(ExecutionResult::failure_with_data( + format!("Comment changed but verification failed: {error:#}"), + data, + )); + } + }; + let expected_comments = thread + .comments + .iter() + .map(|comment| { + let mut copy = comment.clone(); + if copy.id == comment_id { + copy.content = Some(encoded_content.clone()); + } + copy.updated = None; + copy + }) + .collect::>(); + let actual_comments = after + .comments + .iter() + .map(|comment| { + let mut copy = comment.clone(); + copy.updated = None; + copy + }) + .collect::>(); + if after.properties != thread.properties + || after.status != thread.status + || actual_comments != expected_comments + { + return Ok(ExecutionResult::failure_with_data( + "Conversation changed concurrently; ownership refresh and thread closure were not attempted", + data, + )); + } + // ADO thread properties are immutable after creation. Content and its + // optimistic-concurrency hash move together; immutable ownership is still required. + if superseded_by.is_some() { + if let Err(error) = patch(ctx, &thread_url(ctx, thread.id), &json!({"status":4})).await { + data["thread_status"] = json!("uncertain"); + return Ok(ExecutionResult::failure_with_data( + format!("Comment content changed but thread closure failed: {error:#}"), + data, + )); + } + data["thread_status"] = json!("closed"); + } + let final_state = match read_thread(ctx, thread.id).await { + Ok(state) => state, + Err(error) => { + return Ok(ExecutionResult::failure_with_data( + format!("Comment writes completed but final verification failed: {error:#}"), + data, + )); + } + }; + let verified = owned_root(&final_state, owner, actor); + if !verified + .is_ok_and(|root| visible_content(&final_state, root).is_ok_and(|actual| actual == content)) + || (superseded_by.is_some() + && !matches!(final_state.status.as_ref(),Some(Value::String(status)) if status=="closed") + && final_state.status != Some(json!(4))) + { + return Ok(ExecutionResult::failure_with_data( + "Comment writes could not be confirmed; concurrent changes require attention", + data, + )); + } + data["metadata_status"] = json!("confirmed"); + Ok(ExecutionResult::success_with_data( + "Owned comment update confirmed", + data, + )) +} + +pub(crate) async fn supersede( + ctx: &UpdatePrContext<'_>, + owner: &Owner, + actor: &str, + candidates: &[Thread], + replacement: i32, + mut skipped: Vec, +) -> anyhow::Result { + let mut completed = Vec::new(); + let mut failures = 0; + for thread in candidates { + let root = owned_root(thread, owner, actor)?; + let old = visible_content(thread, root)?; + let content = format!( + "{old}\n\n_Superseded by the newer automated report in thread #{replacement}._" + ); + match update_owned( + ctx, + owner, + actor, + thread, + root.id, + &content, + Some(replacement), + ) + .await + { + Ok(result) if result.success => completed.push(thread.id), + Ok(result) => { + failures += 1; + skipped.push( + json!({"thread_id":thread.id,"reason":result.message,"partial":result.data}), + ); + } + Err(error) => { + failures += 1; + skipped.push(json!({"thread_id":thread.id,"reason":format!("{error:#}")})); + } + } + } + Ok(json!({"superseded":completed,"not_superseded":skipped,"failures":failures})) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::{Arc, Mutex}; + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{method, path}, + }; + + const ACTOR: &str = "33333333-3333-3333-3333-333333333333"; + const ORIGINAL: &str = "Original owned report with `code`."; + + fn context(server: &MockServer) -> ExecutionContext { + ExecutionContext { + ado_org_url: Some(server.uri()), + ado_organization: Some("org".into()), + ado_project: Some("P".into()), + ado_project_id: Some("11111111-1111-1111-1111-111111111111".into()), + pipeline_collection_uri: Some("https://dev.azure.com/source".into()), + definition_id: Some(7), + build_id: Some(100), + repository_name: Some("repo".into()), + access_token: Some("token".into()), + ..Default::default() + } + } + + fn fixture(ctx: &ExecutionContext, id: i32, run: u64) -> Value { + let owner = owner(ctx, "comment", &default_comment_key()) + .unwrap() + .unwrap(); + let mut thread = json!({"id":id,"status":"active","comments":[ + {"id":1,"parentCommentId":0,"content":ORIGINAL,"author":{"id":ACTOR}} + ]}); + stamp(&mut thread, Some(&owner), ctx, ORIGINAL).unwrap(); + thread["properties"][RUN] = string_property(run.to_string()); + thread + } + + #[tokio::test] + async fn markers_without_actor_namespace_and_content_proof_cannot_authorize_updates() { + let server = MockServer::start().await; + let ctx = context(&server); + let expected = owner(&ctx, "comment", &default_comment_key()) + .unwrap() + .unwrap(); + let base = fixture(&ctx, 3, 99); + let good: Thread = serde_json::from_value(base.clone()).unwrap(); + assert!(owned_root(&good, &expected, ACTOR).is_ok()); + for failure in [ + "actor", + "namespace", + "hash", + "unmarked", + "same-actor-reply", + "human-reply", + ] { + let mut value = base.clone(); + match failure { + "actor"=>value["comments"][0]["author"]["id"]=json!("44444444-4444-4444-4444-444444444444"), + "namespace"=>{ + let mut other=expected.clone();other.definition+=1; + value["properties"][OWNER]=string_property(serde_json::to_string(&other).unwrap()); + }, + "hash"=>value["comments"][0]["content"]=json!("Human edited the old bot comment."), + "unmarked"=>value["properties"]=json!({}), + kind=>value["comments"].as_array_mut().unwrap().push(json!({ + "id":2,"parentCommentId":1,"content":"A conversation reply.", + "author":{"id":if kind=="same-actor-reply" {ACTOR}else{"44444444-4444-4444-4444-444444444444"}} + })), + } + let thread: Thread = serde_json::from_value(value).unwrap(); + assert!(owned_root(&thread, &expected, ACTOR).is_err(), "{failure}"); + } + let mut edited=base.clone(); + edited["comments"][0]["content"]=json!(updated_content("A newer owned report.").unwrap()); + let verified:Thread=serde_json::from_value(edited.clone()).unwrap(); + let root=owned_root(&verified,&expected,ACTOR).unwrap(); + assert_eq!(visible_content(&verified,root).unwrap(),"A newer owned report."); + edited["comments"][0]["content"]=json!(edited["comments"][0]["content"].as_str().unwrap().replace("newer","human-edited")); + assert!(owned_root(&serde_json::from_value(edited).unwrap(),&expected,ACTOR).is_err()); + let mut unowned=base; + unowned["properties"]=json!({}); + unowned["comments"][0]["content"]=json!(updated_content("A forged but correctly hashed footer.").unwrap()); + assert!(owned_root(&serde_json::from_value(unowned).unwrap(),&expected,ACTOR).is_err()); + } + + #[tokio::test] + async fn discovery_protects_current_runs_and_replies_and_enforces_complete_bounds() { + let server = MockServer::start().await; + let ctx = context(&server); + let expected = owner(&ctx, "comment", &default_comment_key()) + .unwrap() + .unwrap(); + let mut replied = fixture(&ctx, 5, 99); + replied["comments"].as_array_mut().unwrap().push(json!({ + "id":2,"content":"A reply from the same account is not provenance.","author":{"id":ACTOR} + })); + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "value":[fixture(&ctx,3,99),fixture(&ctx,4,100),replied] + }))) + .mount(&server) + .await; + let client = reqwest::Client::new(); + let op = UpdatePrContext { + client: &client, + target: super::super::resolve_repository_write_target(None, &ctx).unwrap(), + pr_id: 42, + token: "token", + connection_type: None, + }; + let (candidates, skipped) = older_threads(&op, &expected, ACTOR, 100, 1).await.unwrap(); + assert_eq!(candidates.len(), 1); + assert_eq!(candidates[0].id, 3); + assert_eq!(skipped.len(), 2); + assert!(older_threads(&op, &expected, ACTOR, 100, 0).await.is_err()); + assert!( + server + .received_requests() + .await + .unwrap() + .iter() + .all(|request| request.method.as_str() == "GET") + ); + } + + #[tokio::test] + async fn updates_preserve_ownership_and_supersession_preserves_original_text() { + for superseded_by in [None, Some(9)] { + let server = MockServer::start().await; + let ctx = context(&server); + let expected = owner(&ctx, "comment", &default_comment_key()) + .unwrap() + .unwrap(); + let initial = fixture(&ctx, 3, 99); + let state = Arc::new(Mutex::new(initial.clone())); + let reads = state.clone(); + let route = "/P/_apis/git/repositories/repo/pullRequests/42/threads/3"; + Mock::given(method("GET")) + .and(path(route)) + .respond_with(move |_: &wiremock::Request| { + ResponseTemplate::new(200).set_body_json(reads.lock().unwrap().clone()) + }) + .mount(&server) + .await; + let comments = state.clone(); + Mock::given(method("PATCH")) + .and(path(format!("{route}/comments/1"))) + .respond_with(move |request: &wiremock::Request| { + let body: Value = serde_json::from_slice(&request.body).unwrap(); + comments.lock().unwrap()["comments"][0]["content"] = body["content"].clone(); + ResponseTemplate::new(200).set_body_json(json!({"id":1})) + }) + .expect(1) + .mount(&server) + .await; + let metadata = state.clone(); + Mock::given(method("PATCH")) + .and(path(route)) + .respond_with(move |request: &wiremock::Request| { + let body: Value = serde_json::from_slice(&request.body).unwrap(); + let mut value = metadata.lock().unwrap(); + assert!( + body.get("properties").is_none(), + "ADO thread properties are immutable" + ); + if let Some(status) = body.get("status") { + value["status"] = status.clone(); + } + ResponseTemplate::new(200).set_body_json(value.clone()) + }) + .expect(u64::from(superseded_by.is_some())) + .mount(&server) + .await; + let client = reqwest::Client::new(); + let op = UpdatePrContext { + client: &client, + target: super::super::resolve_repository_write_target(None, &ctx).unwrap(), + pr_id: 42, + token: "token", + connection_type: None, + }; + let thread: Thread = serde_json::from_value(initial.clone()).unwrap(); + if superseded_by.is_some() { + let outcome = supersede(&op, &expected, ACTOR, &[thread], 9, vec![]) + .await + .unwrap(); + assert_eq!(outcome["superseded"], json!([3])); + assert_eq!(outcome["failures"], 0); + let value = state.lock().unwrap(); + assert!( + value["comments"][0]["content"] + .as_str() + .unwrap() + .starts_with(ORIGINAL) + ); + assert_eq!(value["status"], 4); + } else { + let result = update_owned( + &op, + &expected, + ACTOR, + &thread, + 1, + "Replacement owned content.", + None, + ) + .await + .unwrap(); + assert!(result.success, "{}", result.message); + assert_eq!(state.lock().unwrap()["status"], "active"); + } + let value = state.lock().unwrap(); + assert_eq!(value["properties"][OWNER], initial["properties"][OWNER]); + } + } + + #[tokio::test] + async fn concurrent_edits_stop_before_the_first_write() { + let server = MockServer::start().await; + let ctx = context(&server); + let expected = owner(&ctx, "comment", &default_comment_key()) + .unwrap() + .unwrap(); + let before = fixture(&ctx, 3, 99); + let mut changed = before.clone(); + changed["comments"][0]["content"] = json!("Concurrent human edit."); + Mock::given(method("GET")) + .respond_with(ResponseTemplate::new(200).set_body_json(changed)) + .expect(1) + .mount(&server) + .await; + let client = reqwest::Client::new(); + let op = UpdatePrContext { + client: &client, + target: super::super::resolve_repository_write_target(None, &ctx).unwrap(), + pr_id: 42, + token: "token", + connection_type: None, + }; + let result = update_owned( + &op, + &expected, + ACTOR, + &serde_json::from_value(before).unwrap(), + 1, + "New owned content.", + None, + ) + .await; + assert!( + result + .unwrap_err() + .to_string() + .contains("changed during preflight") + ); + assert!( + server + .received_requests() + .await + .unwrap() + .iter() + .all(|request| request.method.as_str() == "GET") + ); + } +} diff --git a/src/safe_outputs/pr_common.rs b/src/safe_outputs/pr_common.rs new file mode 100644 index 000000000..8411fe95d --- /dev/null +++ b/src/safe_outputs/pr_common.rs @@ -0,0 +1,1383 @@ +//! 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::pr_http::BoundedPrResponse; +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; + +pub(crate) const PR_MUTATION_TOOLS: &[&str] = &[ + "update-pull-request", "abandon-pull-request", "add-pull-request-reviewers", + "add-pull-request-labels", "set-pull-request-auto-complete", "submit-pull-request-review", + "remove-pull-request-labels", "replace-pull-request-label", + "mark-pull-request-as-ready-for-review", + "update-pull-request-comment", + "push-to-pull-request-branch", + "add-pull-request-comment", "reply-to-pull-request-comment", "resolve-pull-request-thread", +]; + +/// Shared projection of an already closed, tool-specific configuration. +#[derive(Debug, Clone, Default, Deserialize)] +pub(crate) struct PrMutationPolicy { + #[serde(default)] + pub target: super::update_pull_request::UpdatePullRequestTarget, + #[serde(default, rename = "target-repo")] + pub target_repo: Option, + #[serde(default, rename = "allowed-repositories")] + pub allowed_repositories: Vec, + #[serde(default, rename = "required-labels")] + pub required_labels: Vec, + #[serde(default, rename = "required-title-prefix")] + pub required_title_prefix: Option, +} + +impl PrMutationPolicy { + pub(crate) fn parse(value: &serde_json::Value) -> anyhow::Result { + let policy: Self = if value.is_null() { + Self::default() + } else { + serde_json::from_value(value.clone()).context("invalid PR target policy")? + }; + policy.target_policy()?; + for (field, values) in [ + ("target-repo", policy.target_repo.iter().collect::>()), + ("allowed-repositories", policy.allowed_repositories.iter().collect()), + ("required-labels", policy.required_labels.iter().collect()), + ("required-title-prefix", policy.required_title_prefix.iter().collect()), + ] { + for value in values { + ensure!(!value.trim().is_empty(), "{field} must not be empty"); + crate::validate::reject_pipeline_injection(value, field)?; + } + } + Ok(policy) + } + + pub(crate) fn target_policy(&self) -> anyhow::Result { + match &self.target { + super::update_pull_request::UpdatePullRequestTarget::Id(id) => PrTargetPolicy::fixed(*id), + super::update_pull_request::UpdatePullRequestTarget::Named(value) => PrTargetPolicy::named(value), + } + } +} + +pub(crate) fn describe_pr_reference(reference: Option<&PullRequestReference>) -> String { + reference.map(|id| format!("#{id}")).unwrap_or_else(|| "the configured PR".into()) +} + +pub(crate) fn validate_temporary_opt_in(reference: Option<&PullRequestReference>, allowed: bool) -> anyhow::Result<()> { + ensure!( + allowed || !matches!(reference, Some(PullRequestReference::Temporary(_))), + "temporary PR references require allow-temporary-ids: true", + ); + Ok(()) +} + +pub(crate) async fn resolve_configured_pr_target( + tool: &str, + reference: Option<&PullRequestReference>, + repository: Option<&str>, + ctx: &ExecutionContext, + client: &reqwest::Client, +) -> anyhow::Result> { + let raw = ctx.tool_configs.get(tool).with_context(|| format!("{tool} is not configured"))?; + let policy = PrMutationPolicy::parse(raw)?; + let resolved = match resolve_pr_policy_target( + &policy.target_policy()?, reference, repository.or(policy.target_repo.as_deref()), + &policy.allowed_repositories, ctx, + )? { + Ok(resolved) => resolved, + Err(failure) => return Ok(Err(failure)), + }; + let (id, target) = &resolved; + if !policy.required_labels.is_empty() || policy.required_title_prefix.is_some() { + let token = ctx.access_token.as_deref().context("No access token available")?; + let base = repository_api_base(target); + if !policy.required_labels.is_empty() { + let labels = match fetch_pr_labels(client, &base, *id, token, ctx).await? { + Ok(labels) => labels, + Err(failure) => return Ok(Err(failure)), + }; + if let Some(missing) = policy.required_labels.iter() + .find(|required| !labels.iter().any(|actual| actual.eq_ignore_ascii_case(required))) + { + return Ok(Err(ExecutionResult::failure(format!("PR #{id} is missing required label '{missing}'")))); + } + } + if let Some(prefix) = &policy.required_title_prefix { + let response = super::authenticate_ado_request( + client.get(format!("{base}/pullRequests/{id}?api-version=7.1")), token, ctx.write_connection_type, + ).send().await.context("Failed to fetch PR title policy metadata")?; + if !response.status().is_success() { + return Ok(Err(ExecutionResult::failure(format!( + "Failed to fetch PR #{id} title (HTTP {})", response.status(), + )))); + } + #[derive(Deserialize)] + struct Metadata { title: String } + let metadata: Metadata = response.bounded_json().await.context("Invalid PR title policy metadata")?; + if !metadata.title.starts_with(prefix) { + return Ok(Err(ExecutionResult::failure(format!("PR #{id} does not match required-title-prefix")))); + } + } + } + Ok(Ok(resolved)) +} + +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 { + count: Option, + value: Vec