Skip to content

Commit 1130d2a

Browse files
feat(compile): make on.pr a first-class trigger via synthetic CI-derived PR context (#922)
* feat(compile): add synthetic-from-ci front-matter knob to on.pr Default true. Plumbs the schema through PrTriggerConfig with a serde-renamed field; existing struct-literal call sites use ..Default::default() for forward-compat. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(compile): add build_pr_synth_spec for PR_SYNTH_SPEC env var Serializes on.pr branches/paths to base64-encoded JSON consumed by the upcoming exec-context-pr-synth.js bundle. Mirrors GATE_SPEC encoding and adds an 8 KiB defence-in-depth cap. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(scripts): scaffold exec-context-pr-synth ado-script bundle Adds skeleton index.ts (no-op main) and match.ts (picomatch-based branch/path glob helpers with refs/heads/ and leading-slash normalisation). Wires the bundle into the npm build/clean/test:smoke chain and the release.yml ado-script.zip packager. Adds picomatch as a direct dependency so ncc bundles it deterministically. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(scripts): implement exec-context-pr-synth runtime contract Decodes PR_SYNTH_SPEC, no-ops on real PR builds and GitHub-typed repos, queries ADO REST for active PRs by sourceRefName, enforces exactly-one-match + target-branch filter + path filter, emits AW_SYNTHETIC_PR* outputs consumed by gate.js and exec-context-pr.js. Adds listActivePullRequestsBySourceRef to shared/ado-client.ts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(scripts): vitest coverage for exec-context-pr-synth bundle 26 new tests across match.ts (glob normalisation + include/exclude semantics) and index.ts (real-PR no-op, GitHub no-op, spec decode errors, zero/multi-match skips, path filter rejection, happy path with all five AW_SYNTHETIC_PR* outputs). Adds an ESM entry-point guard to index.ts so importing main() does not trigger top-level process.exit. Removes the source-branch pre-filter; pr.branches filters the TARGET ref per the issue spec, not the build source. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(gate): honour AW_SYNTHETIC_PR so synthetic PR builds skip the bypass When the upstream synthPr Setup-job step has elected a CI build for PR treatment, the gate must run the full PR-spec predicates instead of auto-passing via the 'not a PullRequest build' bypass. Only PullRequest specs honour the override; PipelineCompletion specs are unaffected. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(compile): emit synthPr Setup step when synthetic-from-ci active Adds EXEC_CONTEXT_PR_SYNTH_PATH constant, synthetic_pr_active + pr_trigger_for_synth fields to AdoScriptExtension, synthetic_pr_step() generator, and wires collect_extensions to populate the flags from on.pr.synthetic-from-ci. Setup-job emits the synthPr step BEFORE prGate so downstream env coalescing can read the dependencies.Setup.outputs['synthPr.*'] values. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(compile): coalesce real + synthetic PR env vars in gate and exec-context-pr Extends compile_gate_step_external with a synthetic_pr_active flag. When true and ctx == PullRequest, ADO_PR_ID / ADO_SOURCE_BRANCH / ADO_TARGET_BRANCH are emitted as coalesce(variables.System.PullRequest.*, dependencies.Setup.outputs.synthPr.*) so the gate evaluator picks up either the real PR variables or the synthPr Setup-job outputs. Also exports AW_SYNTHETIC_PR so gate/bypass.ts can detect the synthetic-promotion case. PrContextContributor gains the same synthetic_pr_active flag and switches SYSTEM_PULLREQUEST_PULLREQUESTID + SYSTEM_PULLREQUEST_TARGETBRANCH to the coalesced form, with the step condition broadened to accept either real or synthetic PR. This also closes compile-exec-context-cond. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(compile): update Agent-job dependsOn condition for synthetic PR Extends generate_agentic_depends_on with a synthetic_pr_active flag. When true the condition gains a leading ne(synthPr.AW_SYNTHETIC_PR_SKIP, true) guard plus a broadened PR clause accepting real PR builds, synthPr promotion, or gate-passed. Threads the flag from compile_shared via front_matter.pr_trigger().synthetic_from_ci. Existing callers default to false (no behavioural change). Adds two unit tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(compile): auto-emit narrowed CI trigger when synthetic-from-ci is on When on.pr.synthetic-from-ci is on (default) and on.pr.branches.include is non-empty, emit a top-level trigger: block mirroring those branches so CI fires only on the configured set. Without this, ADO would queue a build for every push and most would be wasted compute (synthPr would skip them). Pipeline/schedule suppression still wins, synthetic-from-ci: false preserves the previous default, and an empty include list disables the narrowing. Five new unit tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(compile): snapshot fixtures for synthetic-from-ci default-on and opt-out Adds two fixtures and two integration tests. Fixture A asserts the full synth wiring is emitted (synthPr step, PR_SYNTH_SPEC env, broadened exec-context-pr.js condition, AW_SYNTHETIC_PR_SKIP guard, narrowed trigger block). Fixture B asserts ALL synth artefacts are absent under synthetic-from-ci: false (substring-negation back-compat guard, no stored baseline needed). The GitHub-resource case planned as fixture C is omitted because it produces the same YAML as A; the runtime no-op is covered by the bundle's vitest suite. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: document on.pr.synthetic-from-ci across front-matter and prompt files front-matter.md gains the new knob in the example block and a 'PR Triggering in Azure Repos' section walking through why the feature exists, the 7-step runtime contract, the auto-narrowed CI trigger, and how to opt out. The three prompt files (create/update/debug) each cross-link to the new section with a one-paragraph note tailored to their context. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * style: cargo fmt on touched files Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(compile): drop synthetic-from-ci CI trigger narrowing The narrowed trigger emitted by branch 2 of generate_ci_trigger used pr.branches.include as the trigger include list. Those are PR target branches (e.g. 'main'), but ADO trigger: fires on pushes TO listed branches, so narrowing to [main] suppressed CI on the feature branches synthPr actually needs to react to: a push to feature/x with an open PR feature/x -> main would never queue a build, defeating the entire synthetic-from-ci feature. Remove branch 2 of generate_ci_trigger, the four narrowing-shape unit tests, and the narrowed-trigger assertion in test_synthetic_pr_default_emits_full_synth_wiring. Add a positive 'does not narrow' unit test, a negative integration assertion, and keep the pipeline-trigger priority test. Update docs/front-matter.md and prompts/create-ado-agentic-workflow.md to explain why narrowing is intentionally absent. Cost concern from the original commit (a217df1) is addressed by the synthPr Setup step's existing fast-exit: a single listActivePullRequestsBySourceRef call returns [] for branches without a matching PR and the Agent job self-skips via AW_SYNTHETIC_PR_SKIP. Addresses Rust PR Reviewer feedback on #922. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(compile)!: replace synthetic-from-ci bool with on.pr.mode enum Replaces the unshipped on.pr.synthetic-from-ci: true|false boolean with a two-value enum on.pr.mode: synthetic|policy (default synthetic). The two modes give the agent author a single coherent choice between the no-policy-required path and the operator-installed-branch-policy path. Mode semantics: * synthetic (default) — emit synthPr Setup-job step + downstream env coalescing + broadened conditions. CI trigger left at ADO default (all branches). Synth promotes CI builds with a matching open PR; non-matching CI builds self-skip cleanly via AW_SYNTHETIC_PR_SKIP. No Build Validation branch policy required. * policy — omit all synth wiring AND emit rigger: none. Branch-policy-driven PR builds are the sole source of pipeline runs; feature-branch pushes no longer queue duplicate CI builds. Choose this when an operator has explicitly installed a Build Validation branch policy. Previously, synthetic-from-ci: false omitted the synth wiring but did NOT suppress the CI trigger, so feature-branch pushes still queued CI builds that immediately bypassed the gate as 'not a PR build'. The new mode: policy closes that gap by emitting rigger: none, so every PR update fires exactly one PR-typed build. Implementation: * New PrMode { Synthetic, Policy } enum (Default = Synthetic) in src/compile/types.rs, replacing PrTriggerConfig.synthetic_from_ci: bool. PrTriggerConfig now derives Default. * generate_ci_trigger gains a mode: policy → trigger: none branch after the existing pipeline/schedule suppression branch. * All internal call sites (extensions/mod.rs, extensions/exec_context/mod.rs, common.rs::generate_agentic_depends_on derivation) replace p.synthetic_from_ci with matches!(p.mode, PrMode::Synthetic). The internal synthetic_pr_active: bool flag is preserved — it remains the right semantic abstraction. * Fixtures: synthetic-pr-opt-out.md renamed to pr-mode-policy.md with mode: policy; synthetic-pr-default.md description cleaned (no longer references the removed narrowing). The policy fixture's integration test now asserts rigger: none is emitted. * Unit tests rewritten around the two-mode contract: synth mode keeps the ADO default, policy mode emits trigger:none, pipeline-completion trigger still wins on priority. Schema tests cover all four cases (omitted, synthetic, policy, invalid value). * Docs (front-matter.md), and prompts (create/update/debug) rewritten to present the two modes as a table and explain the policy mode's rigger: none emission. No back-compat alias for synthetic-from-ci since the knob never shipped (still on the feature branch). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(compile): gate-step same-job synthPr ref and agent-condition gate enforcement Addresses two real correctness bugs and one stale comment from the Rust PR Reviewer feedback on #922. (1) Gate-step same-job synthPr reference (filter_ir.rs::compile_gate_step_external): The gate step is emitted into the Setup job (same job as `synthPr`), so the env-block references `dependencies.Setup.outputs['synthPr.X']` were silently wrong — that is cross-job syntax and resolves to null inside the producing job, making `coalesce(...)` always return the empty string. On synth-promoted CI builds this left `AW_SYNTHETIC_PR=''`, so `gate/bypass.ts` took the "not a PR build" auto-pass and the agent ran without `pr.filters` being evaluated at all. Fixed by switching the gate-step env coalesce to the same-job runtime expression `variables['synthPr.X']`, which resolves step output variables added to the producing job's variable scope. The Agent-job env (in `exec_context/pr.rs`) keeps `dependencies.Setup.outputs[...]` — that step runs cross-job where the dependencies form is the correct one. (2) Agent-job condition gate enforcement (common.rs::generate_agentic_depends_on): With `mode: synthetic` + `pr.filters`, the synth branch emitted `or(eq(Build.Reason, 'PullRequest'), eq(synthPr.AW_SYNTHETIC_PR, 'true'), eq(prGate.SHOULD_RUN, 'true'))`. The first two arms make any PR build (real or synth) run the agent UNCONDITIONALLY — silently bypassing the gate that `pr.filters` exists to enforce. Replaced with `or(and(ne(Build.Reason, 'PullRequest'), ne(synthPr.AW_SYNTHETIC_PR, 'true')), eq(prGate.SHOULD_RUN, 'true'))` — non-PR / non-synth builds run unconditionally; real-PR and synth-PR builds must pass the gate. (3) Removed stale `compile-coalesce-env todo` comment in `exec_context/pr.rs`; the work referenced is now implemented in the same function. Findings 2 and 3 from the same review were false alarms: `$[ coalesce(...) ]` IS documented as valid in step-level `env:` blocks, and `System.PullRequest.TargetBranch` is documented as the full `refs/heads/<name>` form, matching `pr.targetRefName`. Both clarified inline with MS-docs cross-references. Test changes: * `test_agentic_depends_on_synthetic_pr_active_emits_skip_guard_and_gate_enforced_pr_clause` (renamed from `_and_broader_pr_clause`) — pins the new AND-NOT shape and asserts the old permissive bypass arms are gone. * `test_pr_filter_synth_mode_agent_condition_enforces_gate` — integration test that exercises the `pr-filter-tier1-agent.md` fixture (mode: synthetic + pr.filters) and asserts the Agent-job dependsOn condition contains both AND-NOT arms and none of the buggy bypass arms. * `test_pr_filter_synth_mode_gate_step_uses_same_job_synth_ref` — integration test asserting the gate-step env uses `variables['synthPr.X']` (same-job) and contains no `dependencies.Setup.outputs['synthPr.X']` cross-job references inside the producing job. * `test_synthetic_pr_default_emits_full_synth_wiring` — dropped the misleading `eq(synthPr.AW_SYNTHETIC_PR, 'true')` assertion (the fixture has no filters, so that string came from the exec-context-pr step, not the Agent-job condition). All `cargo test` (1811 lib + 131 compiler-integration + others) and `cargo clippy --all-targets --all-features` pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address Rust PR Reviewer feedback round 4 (#922) Four findings from the latest review on this PR: (1) `exec-context-pr-synth/index.ts` runtime-contract comment was misleading: step 4 said `branches.include/exclude miss on BUILD_SOURCEBRANCH → skip`, but the bundle never filters `on.pr.branches` against the source branch (that would be wrong — `on.pr.branches` lists PR *target* branches per ADO semantics). The actual flow fetches PRs by `sourceRefName == BUILD_SOURCEBRANCH`, then filters the matched PRs by their `targetRefName` against `spec.branches`. Updated the contract comment to reflect this. (2) `ado-client.ts` comment claimed "first page (200 PRs)" — ADO's `getPullRequests` default page size without an explicit `$top` is 100, not 200. Updated to match the SDK default. (3) `AdoScriptExtension` previously had two coupled fields (`synthetic_pr_active: bool` + `pr_trigger_for_synth: Option<PrTriggerConfig>`) whose pairing was enforced only by a runtime `anyhow!` guard in `setup_steps`. Refactored to a single field `pr_trigger_for_synth: Option<...>` whose `is_some()` IS the activation predicate, exposed as `synthetic_pr_active()` for callers that read it. The invariant is now unrepresentable-wrong at the type level; the runtime guard is gone. Tests and `collect_extensions` updated accordingly. (4) Wrapped `$[ ... ]` runtime expressions in YAML double quotes in the step `env:` blocks emitted by `filter_ir.rs::compile_gate_step_external` and `exec_context/pr.rs::prepare_step`. The values contain single quotes (`variables['System.PullRequest.X']`) and although ADO's YAML parser accepts them unquoted in practice, double-quoting is the form shown in ADO docs and is strictly conformant to the YAML spec (which reserves `'` as a scalar indicator). Adjusted the corresponding integration-test assertions to match. Validation: - `cargo build` clean - `cargo test` (1811 lib + 131 compiler-integration + others) 0 failures - `cargo clippy --all-targets --all-features` clean - `npm test` in scripts/ado-script/ — 281/281 vitest passes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address Rust PR Reviewer round-5 suggestions (#922) Three minor suggestions from the latest review. (1) `ado-client.ts::listActivePullRequestsBySourceRef` — added `?? []` guard on the SDK call. azure-devops-node-api's `getPullRequests` can return `null` instead of `[]` on empty REST bodies; the bundle's `.filter(...)` on the result would have thrown at runtime. Matches the established pattern (`getIterationChanges` uses `result.changeEntries ?? []`). (2) `exec_context/pr.rs` — added a `#[cfg(test)] mod tests` block with two targeted unit tests pinning the emitted YAML for `PrContextContributor::prepare_step`: * `prepare_step_synth_active_emits_coalesced_env_and_broadened_condition` — asserts the synth-mode env coalesces (PR id + target branch, YAML double-quoted runtime expressions) and the broadened `or(eq(Build.Reason,'PullRequest'), eq(synthPr.AW_SYNTHETIC_PR, 'true'))` condition. * `prepare_step_synth_inactive_emits_plain_macros_and_narrow_condition` — asserts plain `$(System.PullRequest.*)` macros + narrow `eq(Build.Reason,'PullRequest')` condition + defensive "no synthPr references in synth-inactive output". Previously these were only covered indirectly via the `synthetic-pr-default.md` snapshot fixture. (3) `types.rs::SanitizeConfigTrait for PrTriggerConfig` — added a one-line comment explaining why `mode` is intentionally absent from `sanitize_config_fields` (it's a `Copy` enum with no string content and any malformed input is rejected at deserialisation). Validation: - `cargo build` clean - `cargo test` 1813 lib + 131 compiler-integration + others, 0 failures - `cargo clippy --all-targets --all-features` clean - `npm test` in scripts/ado-script/ — 281/281 vitest passes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address Rust PR Reviewer round-6 suggestions (#922) Three items from the latest review. (1) Stale test name (`tests/compiler_tests.rs` ~L4239) — `test_exec_context_pr_only_downloads_bundle_in_agent_job_not_setup` was the original intent, but the body was later updated to assert the Setup job DOES carry the bundle download (the synthPr step is a Setup-job bundle consumer). Renamed to `test_exec_context_pr_downloads_bundle_in_both_jobs_with_synth_mode` and updated the doc-comment to describe the two consumers. (2) `is_synthetic_pr()` helper on `FrontMatter` — `front_matter.pr_trigger().is_some_and(|p| matches!(p.mode, PrMode::Synthetic))` was duplicated verbatim in three places (`compile_shared`, `ExecContextExtension::new`, `collect_extensions`). Extracted to `FrontMatter::is_synthetic_pr()` in `src/compile/types.rs` so a future `PrMode` variant can't drift across the three sites. All three call sites now use the helper. (3) Truncated doc-comment on `PrTriggerConfig.mode` — the previous text ("Whether to synthesise PullRequest semantics on CI builds when an / PR-trigger mode. Drives whether ...") read as a cut-off sentence. Rewrote to "Determines how `on.pr` builds reach the pipeline; see [`PrMode`] for the two supported strategies (`synthetic`, `policy`). Defaults to [`PrMode::Synthetic`]." Validation: - `cargo build` clean - `cargo test` 1813 lib + 131 compiler-integration + others, 0 failures - `cargo clippy --all-targets --all-features` clean Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent ce617a7 commit 1130d2a

28 files changed

Lines changed: 2442 additions & 220 deletions

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ jobs:
7373
run: |
7474
set -euo pipefail
7575
cd scripts
76-
zip -r ../ado-script.zip ado-script/gate.js ado-script/import.js ado-script/exec-context-pr.js
76+
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
7777
7878
- name: Upload release assets
7979
env:

docs/front-matter.md

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,24 @@ on: # trigger configuration (unified under on: key)
104104
include: [main]
105105
paths:
106106
include: [src/*]
107+
mode: synthetic # synthetic (default) | policy. Controls how
108+
# `on.pr` builds reach the pipeline.
109+
# - synthetic: a Setup-job script calls the
110+
# ADO REST API on every CI build, finds the
111+
# open PR for `Build.SourceBranch`, and
112+
# promotes the build to PR semantics if it
113+
# matches `branches`/`paths`. No Build
114+
# Validation branch policy required. Zero
115+
# or multiple matches → Agent job
116+
# self-skips cleanly. CI trigger stays at
117+
# the ADO default (all branches).
118+
# - policy: the operator has installed a
119+
# Build Validation branch policy. Compiler
120+
# omits all synth wiring AND emits
121+
# `trigger: none` so feature-branch pushes
122+
# do not queue duplicate CI builds. Real
123+
# PR-typed builds drive everything.
124+
# See "PR Triggering in Azure Repos" below.
107125
filters: # runtime PR filters (compiled to gate step)
108126
title: "*[review]*"
109127
author:
@@ -328,3 +346,78 @@ The filter gate step uses `System.AccessToken` for self-cancellation
328346
If the token is unavailable, the gate step logs a warning and the build
329347
completes as "Succeeded" (with the agent job skipped via condition)
330348
rather than "Cancelled".
349+
350+
## PR Triggering in Azure Repos
351+
352+
Azure DevOps Services **ignores the YAML `pr:` block unless a per-branch
353+
Build Validation branch policy is registered server-side**. Without that
354+
policy, a `git push` to a feature branch fires the compiled pipeline as
355+
`Build.Reason = IndividualCI` even when an open PR exists — the gate
356+
evaluator's "not a PR build" bypass triggers and `exec-context-pr.js`
357+
is skipped. PR-aware agents (e.g. PR reviewers) silently degrade.
358+
359+
`ado-aw` lets the agent author pick one of two coherent strategies via
360+
`on.pr.mode`:
361+
362+
| `on.pr.mode` | Synthesis wiring | Top-level `trigger:` | Use when |
363+
|---|---|---|---|
364+
| `synthetic` (default) | emitted (synthPr Setup step, coalesced env, broadened conditions) | ADO default (all branches) | No branch policy. **The vast majority of agents.** |
365+
| `policy` | omitted | `trigger: none` | Operator has installed a Build Validation branch policy and wants real PR-typed builds only, no duplicate CI builds. |
366+
367+
### `mode: synthetic` — how it works under the hood
368+
369+
On every CI build:
370+
371+
1. **Real PR build?** If `Build.Reason == PullRequest` (a branch policy
372+
is configured), the synth step no-ops and the existing PR path
373+
handles everything.
374+
2. **GitHub-typed repo resource?** GitHub repos already get correct
375+
`pr:` semantics from ADO. The synth step no-ops.
376+
3. **Look up the PR.** Otherwise, the script calls
377+
`GET /{project}/_apis/git/repositories/{repoId}/pullrequests`
378+
filtered by `sourceRefName == Build.SourceBranch` and
379+
`status = active`.
380+
4. **Filter by target branch.** PRs whose `targetRefName` does not match
381+
`on.pr.branches.include` (respecting `exclude`) are dropped.
382+
5. **Exactly one match.** Zero or multiple matches → emit
383+
`AW_SYNTHETIC_PR_SKIP=true`; the Agent job self-skips cleanly with a
384+
single info log line. Never noisy, never red.
385+
6. **Path filter.** If `on.pr.paths` is configured, the script enforces
386+
it against the PR's changed-file list (which ADO's CI trigger
387+
ignores). Empty intersection → skip.
388+
7. **Promote.** Otherwise, emit `AW_SYNTHETIC_PR=true` plus the PR
389+
identifiers as Setup-job outputs. Downstream `gate.js` and
390+
`exec-context-pr.js` env blocks coalesce these with the real
391+
`System.PullRequest.*` variables, so the gate evaluator runs the
392+
full PR-spec predicates and `aw-context/pr/{base.sha,head.sha}` is
393+
staged for the agent.
394+
395+
### Why the CI trigger is not auto-narrowed in `mode: synthetic`
396+
397+
`pr.branches.include` lists PR **target** branches (e.g. `main`), but
398+
ADO `trigger:` fires on pushes **to** the listed branches. Narrowing
399+
`trigger:` to `pr.branches.include` would suppress CI on the feature
400+
branches synthPr actually needs to react to (pushing to `feature/x`
401+
with an open PR `feature/x → main` would never queue a build). The
402+
compiler therefore leaves the top-level `trigger:` at the ADO default
403+
("trigger on every branch") in synth mode, and relies on the synthPr
404+
Setup step's fast-exit for cost control: a single
405+
`listActivePullRequestsBySourceRef` call returns `[]` on branches
406+
without a matching PR and the Agent job self-skips cleanly via
407+
`AW_SYNTHETIC_PR_SKIP=true`.
408+
409+
### `mode: policy` — when to choose it
410+
411+
Choose `mode: policy` when the operator has explicitly installed an
412+
Azure DevOps Build Validation branch policy targeting the compiled
413+
pipeline. In this mode the compiler:
414+
415+
- Omits all synth wiring (`synthPr` step, `PR_SYNTH_SPEC` env,
416+
`AW_SYNTHETIC_PR_SKIP` guard, coalesced env macros, broadened
417+
`exec-context-pr.js` condition).
418+
- Emits `trigger: none` so feature-branch pushes do not queue
419+
duplicate CI builds alongside the policy-driven PR build.
420+
421+
Result: every PR update fires exactly one PR-typed build (`Build.Reason
422+
== PullRequest`); commit-driven CI is fully silenced.
423+

prompts/create-ado-agentic-workflow.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,8 @@ on:
429429

430430
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.
431431

432+
**`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).
433+
432434
**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).
433435

434436
#### Pipeline Triggers (`on.pipeline`)

prompts/debug-ado-agentic-workflow.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ Agent → Detection → SafeOutputs
3232
| **SafeOutputs** | Executes approved safe outputs (create PRs, work items, wiki pages, etc.) | Write (`permissions.write`) | Standard ADO agent |
3333

3434
Additional optional jobs:
35-
- **Setup** — runs before `Agent` (from `setup:` front matter)
35+
- **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).
3636
- **Teardown** — runs after `SafeOutputs` (from `teardown:` front matter)
3737

3838
---

prompts/update-ado-agentic-workflow.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,16 @@ triggers → steps → post-steps → setup → teardown → network →
4444
permissions → parameters
4545
```
4646

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

4959
Run through the validation checklist (see below) before finalizing. Fix any issues and inform the user of corrections made.

scripts/ado-script/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@ node_modules
33
gate.js
44
import.js
55
exec-context-pr.js
6+
exec-context-pr-synth.js
67
schema
78
*.tsbuildinfo

scripts/ado-script/package-lock.json

Lines changed: 10 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

scripts/ado-script/package.json

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,26 @@
77
"node": ">=20.0.0"
88
},
99
"scripts": {
10-
"build": "npm run codegen && npm run clean && npm run build:gate && npm run build:import && npm run build:exec-context-pr",
11-
"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});\"",
10+
"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",
11+
"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});\"",
1212
"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});\"",
1313
"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});\"",
1414
"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});\"",
15+
"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});\"",
1516
"build:check": "ls -lh gate.js && wc -c gate.js",
1617
"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.\"",
1718
"test": "vitest run",
18-
"test:smoke": "npm run build:gate && npm run build:import && npm run build:exec-context-pr && vitest run -c vitest.config.smoke.ts",
19+
"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",
1920
"lint": "echo TODO",
2021
"typecheck": "tsc --noEmit"
2122
},
2223
"dependencies": {
23-
"azure-devops-node-api": "^14.1.0"
24+
"azure-devops-node-api": "^14.1.0",
25+
"picomatch": "^4.0.4"
2426
},
2527
"devDependencies": {
2628
"@types/node": "^20.19.39",
29+
"@types/picomatch": "^4.0.3",
2730
"@vercel/ncc": "^0.38.4",
2831
"json-schema-to-typescript": "^15.0.4",
2932
"typescript": "^5.9.3",
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
/**
2+
* Test harness for the exec-context-pr-synth bundle.
3+
*
4+
* Provides:
5+
* - `runMain(env)` — invokes the bundle's main() with a captured
6+
* stdout buffer.
7+
* - `makeEnv(overrides)` — returns a minimal env block populated
8+
* with the required vars, easy to override per case.
9+
* - `build_pr_synth_spec(spec)` — base64-encodes a PrSynthSpec JSON
10+
* for the PR_SYNTH_SPEC env var.
11+
*/
12+
import { vi } from "vitest";
13+
14+
import { main } from "../index.js";
15+
import { _resetCompletedForTesting } from "../../shared/vso-logger.js";
16+
17+
export interface RunResult {
18+
code: number;
19+
output: string;
20+
}
21+
22+
export async function runMain(env: NodeJS.ProcessEnv): Promise<RunResult> {
23+
_resetCompletedForTesting();
24+
const chunks: string[] = [];
25+
const writeSpy = vi
26+
.spyOn(process.stdout, "write")
27+
.mockImplementation((c: any) => {
28+
chunks.push(typeof c === "string" ? c : c.toString());
29+
return true;
30+
});
31+
try {
32+
const code = await main(env);
33+
return { code, output: chunks.join("") };
34+
} finally {
35+
writeSpy.mockRestore();
36+
}
37+
}
38+
39+
export function makeEnv(overrides: Record<string, string>): NodeJS.ProcessEnv {
40+
return {
41+
BUILD_REASON: "IndividualCI",
42+
BUILD_REPOSITORY_PROVIDER: "TfsGit",
43+
BUILD_SOURCEBRANCH: "refs/heads/feature/x",
44+
ADO_PROJECT: "MyProject",
45+
ADO_REPO_ID: "00000000-0000-0000-0000-000000000000",
46+
...overrides,
47+
};
48+
}
49+
50+
export function build_pr_synth_spec(
51+
spec: {
52+
branches?: { include: string[]; exclude: string[] };
53+
paths?: { include: string[]; exclude: string[] };
54+
} = {},
55+
): string {
56+
const full = {
57+
branches: spec.branches ?? { include: ["main"], exclude: [] },
58+
paths: spec.paths ?? { include: [], exclude: [] },
59+
};
60+
return Buffer.from(JSON.stringify(full), "utf8").toString("base64");
61+
}

0 commit comments

Comments
 (0)