Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
841f938
feat(compile): add synthetic-from-ci front-matter knob to on.pr
jamesadevine Jun 8, 2026
76356d3
feat(compile): add build_pr_synth_spec for PR_SYNTH_SPEC env var
jamesadevine Jun 8, 2026
dca03ba
feat(scripts): scaffold exec-context-pr-synth ado-script bundle
jamesadevine Jun 8, 2026
104de5f
feat(scripts): implement exec-context-pr-synth runtime contract
jamesadevine Jun 8, 2026
d5bb0b0
test(scripts): vitest coverage for exec-context-pr-synth bundle
jamesadevine Jun 8, 2026
6fcc9a8
fix(gate): honour AW_SYNTHETIC_PR so synthetic PR builds skip the bypass
jamesadevine Jun 8, 2026
7bce7f3
feat(compile): emit synthPr Setup step when synthetic-from-ci active
jamesadevine Jun 8, 2026
7e9cae0
feat(compile): coalesce real + synthetic PR env vars in gate and exec…
jamesadevine Jun 8, 2026
4c56f38
feat(compile): update Agent-job dependsOn condition for synthetic PR
jamesadevine Jun 8, 2026
a217df1
feat(compile): auto-emit narrowed CI trigger when synthetic-from-ci i…
jamesadevine Jun 8, 2026
8df384b
test(compile): snapshot fixtures for synthetic-from-ci default-on and…
jamesadevine Jun 8, 2026
8d23497
docs: document on.pr.synthetic-from-ci across front-matter and prompt…
jamesadevine Jun 8, 2026
9f643fe
style: cargo fmt on touched files
jamesadevine Jun 9, 2026
127c786
fix(compile): drop synthetic-from-ci CI trigger narrowing
jamesadevine Jun 9, 2026
ae6e29a
feat(compile)!: replace synthetic-from-ci bool with on.pr.mode enum
jamesadevine Jun 9, 2026
41a9436
fix(compile): gate-step same-job synthPr ref and agent-condition gate…
jamesadevine Jun 9, 2026
6ccb132
fix: address Rust PR Reviewer feedback round 4 (#922)
jamesadevine Jun 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ jobs:
run: |
set -euo pipefail
cd scripts
zip -r ../ado-script.zip ado-script/gate.js ado-script/import.js ado-script/exec-context-pr.js
zip -r ../ado-script.zip ado-script/gate.js ado-script/import.js ado-script/exec-context-pr.js ado-script/exec-context-pr-synth.js

- name: Upload release assets
env:
Expand Down
93 changes: 93 additions & 0 deletions docs/front-matter.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,24 @@ on: # trigger configuration (unified under on: key)
include: [main]
paths:
include: [src/*]
mode: synthetic # synthetic (default) | policy. Controls how
# `on.pr` builds reach the pipeline.
# - synthetic: a Setup-job script calls the
# ADO REST API on every CI build, finds the
# open PR for `Build.SourceBranch`, and
# promotes the build to PR semantics if it
# matches `branches`/`paths`. No Build
# Validation branch policy required. Zero
# or multiple matches → Agent job
# self-skips cleanly. CI trigger stays at
# the ADO default (all branches).
# - policy: the operator has installed a
# Build Validation branch policy. Compiler
# omits all synth wiring AND emits
# `trigger: none` so feature-branch pushes
# do not queue duplicate CI builds. Real
# PR-typed builds drive everything.
# See "PR Triggering in Azure Repos" below.
filters: # runtime PR filters (compiled to gate step)
title: "*[review]*"
author:
Expand Down Expand Up @@ -328,3 +346,78 @@ The filter gate step uses `System.AccessToken` for self-cancellation
If the token is unavailable, the gate step logs a warning and the build
completes as "Succeeded" (with the agent job skipped via condition)
rather than "Cancelled".

## PR Triggering in Azure Repos

Azure DevOps Services **ignores the YAML `pr:` block unless a per-branch
Build Validation branch policy is registered server-side**. Without that
policy, a `git push` to a feature branch fires the compiled pipeline as
`Build.Reason = IndividualCI` even when an open PR exists — the gate
evaluator's "not a PR build" bypass triggers and `exec-context-pr.js`
is skipped. PR-aware agents (e.g. PR reviewers) silently degrade.

`ado-aw` lets the agent author pick one of two coherent strategies via
`on.pr.mode`:

| `on.pr.mode` | Synthesis wiring | Top-level `trigger:` | Use when |
|---|---|---|---|
| `synthetic` (default) | emitted (synthPr Setup step, coalesced env, broadened conditions) | ADO default (all branches) | No branch policy. **The vast majority of agents.** |
| `policy` | omitted | `trigger: none` | Operator has installed a Build Validation branch policy and wants real PR-typed builds only, no duplicate CI builds. |

### `mode: synthetic` — how it works under the hood

On every CI build:

1. **Real PR build?** If `Build.Reason == PullRequest` (a branch policy
is configured), the synth step no-ops and the existing PR path
handles everything.
2. **GitHub-typed repo resource?** GitHub repos already get correct
`pr:` semantics from ADO. The synth step no-ops.
3. **Look up the PR.** Otherwise, the script calls
`GET /{project}/_apis/git/repositories/{repoId}/pullrequests`
filtered by `sourceRefName == Build.SourceBranch` and
`status = active`.
4. **Filter by target branch.** PRs whose `targetRefName` does not match
`on.pr.branches.include` (respecting `exclude`) are dropped.
5. **Exactly one match.** Zero or multiple matches → emit
`AW_SYNTHETIC_PR_SKIP=true`; the Agent job self-skips cleanly with a
single info log line. Never noisy, never red.
6. **Path filter.** If `on.pr.paths` is configured, the script enforces
it against the PR's changed-file list (which ADO's CI trigger
ignores). Empty intersection → skip.
7. **Promote.** Otherwise, emit `AW_SYNTHETIC_PR=true` plus the PR
identifiers as Setup-job outputs. Downstream `gate.js` and
`exec-context-pr.js` env blocks coalesce these with the real
`System.PullRequest.*` variables, so the gate evaluator runs the
full PR-spec predicates and `aw-context/pr/{base.sha,head.sha}` is
staged for the agent.

### Why the CI trigger is not auto-narrowed in `mode: synthetic`

`pr.branches.include` lists PR **target** branches (e.g. `main`), but
ADO `trigger:` fires on pushes **to** the listed branches. Narrowing
`trigger:` to `pr.branches.include` would suppress CI on the feature
branches synthPr actually needs to react to (pushing to `feature/x`
with an open PR `feature/x → main` would never queue a build). The
compiler therefore leaves the top-level `trigger:` at the ADO default
("trigger on every branch") in synth mode, and relies on the synthPr
Setup step's fast-exit for cost control: a single
`listActivePullRequestsBySourceRef` call returns `[]` on branches
without a matching PR and the Agent job self-skips cleanly via
`AW_SYNTHETIC_PR_SKIP=true`.

### `mode: policy` — when to choose it

Choose `mode: policy` when the operator has explicitly installed an
Azure DevOps Build Validation branch policy targeting the compiled
pipeline. In this mode the compiler:

- Omits all synth wiring (`synthPr` step, `PR_SYNTH_SPEC` env,
`AW_SYNTHETIC_PR_SKIP` guard, coalesced env macros, broadened
`exec-context-pr.js` condition).
- Emits `trigger: none` so feature-branch pushes do not queue
duplicate CI builds alongside the policy-driven PR build.

Result: every PR update fires exactly one PR-typed build (`Build.Reason
== PullRequest`); commit-driven CI is fully silenced.

2 changes: 2 additions & 0 deletions prompts/create-ado-agentic-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,8 @@ on:

When `on.pr` is set: the native ADO `pr:` trigger block is generated from `branches:` and `paths:`. Runtime `filters:` compile to a gate step in the Setup job that self-cancels the build when they do not match.

**`on.pr` triggering works without a Build Validation branch policy.** By default (`mode: synthetic`), the compiler emits a Setup-job script that, on CI-triggered builds, looks up the open PR for `Build.SourceBranch` via the ADO REST API and promotes the build to PR semantics if exactly one matches `pr.branches` (and `pr.paths` if configured). Zero or multiple matches → the Agent job self-skips cleanly. Set `on.pr.mode: policy` when an operator-installed Build Validation branch policy is in place — that mode omits all synth wiring AND emits `trigger: none` so feature-branch pushes do not queue duplicate CI builds alongside the policy-driven PR build. Note that in `mode: synthetic` the top-level CI `trigger:` is **not** auto-narrowed to `pr.branches.include`: those are PR target branches, and ADO `trigger:` fires on pushes *to* listed branches, so narrowing would suppress CI on the feature branches synthPr must react to. Full reference: ["PR Triggering in Azure Repos" in `docs/front-matter.md`](../docs/front-matter.md#pr-triggering-in-azure-repos).

**PR-reviewer agents — DO NOT write your own precompute step.** When `on.pr` is set, the compiler automatically (1) fetches the PR target branch with progressive deepening, (2) resolves and stages `aw-context/pr/base.sha` + `aw-context/pr/head.sha`, (3) appends a prompt fragment listing common `git diff`/`git show`/`git log` commands and example Azure DevOps MCP tool calls (`repo_get_pull_request_by_id`, `repo_list_pull_request_threads`, `repo_create_pull_request_thread`) with the PR id / project / repo pre-filled, and (4) adds `git`, `git diff`, `git log`, `git show`, `git status`, `git rev-parse`, `git symbolic-ref` to the agent's bash allow-list. The agent runs `git diff $BASE..$HEAD` itself inside the AWF sandbox (objects are already fetched into the workspace). On failure (e.g. merge-base could not be resolved), the failure fragment tells the agent to surface the error rather than produce an empty review. Opt out via `execution-context.pr.enabled: false`. Full reference: [`docs/execution-context.md`](../docs/execution-context.md).

#### Pipeline Triggers (`on.pipeline`)
Expand Down
2 changes: 1 addition & 1 deletion prompts/debug-ado-agentic-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ Agent → Detection → SafeOutputs
| **SafeOutputs** | Executes approved safe outputs (create PRs, work items, wiki pages, etc.) | Write (`permissions.write`) | Standard ADO agent |

Additional optional jobs:
- **Setup** — runs before `Agent` (from `setup:` front matter)
- **Setup** — runs before `Agent` (from `setup:` front matter). When `on.pr` is set with the default `mode: synthetic`, this job also runs a `synthPr` step that calls the ADO REST API to promote CI-triggered builds to PR semantics when an open PR matches; if it sets `AW_SYNTHETIC_PR_SKIP=true`, the Agent job is skipped cleanly. With `mode: policy` there is no synthPr step (and `trigger: none` is emitted so the operator's Build Validation branch policy is the sole source of PR builds). See [`docs/front-matter.md#pr-triggering-in-azure-repos`](../docs/front-matter.md#pr-triggering-in-azure-repos).
- **Teardown** — runs after `SafeOutputs` (from `teardown:` front matter)

---
Expand Down
10 changes: 10 additions & 0 deletions prompts/update-ado-agentic-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,16 @@ triggers → steps → post-steps → setup → teardown → network →
permissions → parameters
```

> **`on.pr` knob update**: when changing `on.pr.branches` or
> `on.pr.paths`, also confirm whether `mode` (default `synthetic`) is
> appropriate. In `synthetic` mode the compiler emits a Setup-job ADO
> REST call to discover the open PR for `Build.SourceBranch` and
> leaves the top-level `trigger:` at the ADO default. Switch to
> `mode: policy` only if the operator has explicitly installed a
> Build Validation branch policy — that mode emits `trigger: none`
> and drops the synth wiring. Reference:
> [`docs/front-matter.md#pr-triggering-in-azure-repos`](../docs/front-matter.md#pr-triggering-in-azure-repos).

### Step 3 — Validate the Changes

Run through the validation checklist (see below) before finalizing. Fix any issues and inform the user of corrections made.
Expand Down
1 change: 1 addition & 0 deletions scripts/ado-script/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ node_modules
gate.js
import.js
exec-context-pr.js
exec-context-pr-synth.js
schema
*.tsbuildinfo
12 changes: 10 additions & 2 deletions scripts/ado-script/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 7 additions & 4 deletions scripts/ado-script/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,26 @@
"node": ">=20.0.0"
},
"scripts": {
"build": "npm run codegen && npm run clean && npm run build:gate && npm run build:import && npm run build:exec-context-pr",
"clean": "node -e \"const fs=require('node:fs'); fs.rmSync('.ado-build',{recursive:true,force:true}); fs.rmSync('gate.js',{force:true}); fs.rmSync('import.js',{force:true}); fs.rmSync('exec-context-pr.js',{force:true});\"",
"build": "npm run codegen && npm run clean && npm run build:gate && npm run build:import && npm run build:exec-context-pr && npm run build:exec-context-pr-synth",
"clean": "node -e \"const fs=require('node:fs'); fs.rmSync('.ado-build',{recursive:true,force:true}); fs.rmSync('gate.js',{force:true}); fs.rmSync('import.js',{force:true}); fs.rmSync('exec-context-pr.js',{force:true}); fs.rmSync('exec-context-pr-synth.js',{force:true});\"",
"build:gate": "ncc build src/gate/index.ts -o .ado-build/gate -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/gate/index.js','gate.js'); fs.rmSync('.ado-build/gate',{recursive:true,force:true});\"",
"build:import": "ncc build src/import/index.ts -o .ado-build/import -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/import/index.js','import.js'); fs.rmSync('.ado-build/import',{recursive:true,force:true});\"",
"build:exec-context-pr": "ncc build src/exec-context-pr/index.ts -o .ado-build/exec-context-pr -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/exec-context-pr/index.js','exec-context-pr.js'); fs.rmSync('.ado-build/exec-context-pr',{recursive:true,force:true});\"",
"build:exec-context-pr-synth": "ncc build src/exec-context-pr-synth/index.ts -o .ado-build/exec-context-pr-synth -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/exec-context-pr-synth/index.js','exec-context-pr-synth.js'); fs.rmSync('.ado-build/exec-context-pr-synth',{recursive:true,force:true});\"",
"build:check": "ls -lh gate.js && wc -c gate.js",
"codegen": "node -e \"require('node:fs').mkdirSync('schema', { recursive: true })\" && cargo run --quiet --manifest-path ../../Cargo.toml -- export-gate-schema --output schema/gate-spec.schema.json && npx json2ts schema/gate-spec.schema.json -o src/shared/types.gen.ts --bannerComment \"// AUTO-GENERATED from Rust IR via cargo run -- export-gate-schema. Do not edit; run npm run codegen.\"",
"test": "vitest run",
"test:smoke": "npm run build:gate && npm run build:import && npm run build:exec-context-pr && vitest run -c vitest.config.smoke.ts",
"test:smoke": "npm run build:gate && npm run build:import && npm run build:exec-context-pr && npm run build:exec-context-pr-synth && vitest run -c vitest.config.smoke.ts",
"lint": "echo TODO",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"azure-devops-node-api": "^14.1.0"
"azure-devops-node-api": "^14.1.0",
"picomatch": "^4.0.4"
},
"devDependencies": {
"@types/node": "^20.19.39",
"@types/picomatch": "^4.0.3",
"@vercel/ncc": "^0.38.4",
"json-schema-to-typescript": "^15.0.4",
"typescript": "^5.9.3",
Expand Down
61 changes: 61 additions & 0 deletions scripts/ado-script/src/exec-context-pr-synth/__tests__/harness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Test harness for the exec-context-pr-synth bundle.
*
* Provides:
* - `runMain(env)` — invokes the bundle's main() with a captured
* stdout buffer.
* - `makeEnv(overrides)` — returns a minimal env block populated
* with the required vars, easy to override per case.
* - `build_pr_synth_spec(spec)` — base64-encodes a PrSynthSpec JSON
* for the PR_SYNTH_SPEC env var.
*/
import { vi } from "vitest";

import { main } from "../index.js";
import { _resetCompletedForTesting } from "../../shared/vso-logger.js";

export interface RunResult {
code: number;
output: string;
}

export async function runMain(env: NodeJS.ProcessEnv): Promise<RunResult> {
_resetCompletedForTesting();
const chunks: string[] = [];
const writeSpy = vi
.spyOn(process.stdout, "write")
.mockImplementation((c: any) => {
chunks.push(typeof c === "string" ? c : c.toString());
return true;
});
try {
const code = await main(env);
return { code, output: chunks.join("") };
} finally {
writeSpy.mockRestore();
}
}

export function makeEnv(overrides: Record<string, string>): NodeJS.ProcessEnv {
return {
BUILD_REASON: "IndividualCI",
BUILD_REPOSITORY_PROVIDER: "TfsGit",
BUILD_SOURCEBRANCH: "refs/heads/feature/x",
ADO_PROJECT: "MyProject",
ADO_REPO_ID: "00000000-0000-0000-0000-000000000000",
...overrides,
};
}

export function build_pr_synth_spec(
spec: {
branches?: { include: string[]; exclude: string[] };
paths?: { include: string[]; exclude: string[] };
} = {},
): string {
const full = {
branches: spec.branches ?? { include: ["main"], exclude: [] },
paths: spec.paths ?? { include: [], exclude: [] },
};
return Buffer.from(JSON.stringify(full), "utf8").toString("base64");
}
Loading
Loading