Skip to content

Make on: pr a first-class trigger by deriving PR context from the ADO REST API on CI-triggered builds #916

@jamesadevine

Description

@jamesadevine

Summary

ado-aw agents that use on.pr cannot actually be triggered by pull-request activity in Azure Repos today, because Azure DevOps Services ignores the YAML pr: block unless a Build Validation branch policy is registered server-side for the target branch. Without that policy, a git push to a feature branch fires the compiled pipeline as Build.Reason = IndividualCI even when an open PR exists, so:

  • scripts/ado-script/src/gate/bypass.ts takes the "Not a {bypass_label} build — gate passes automatically" path and never gets PR identifiers.
  • scripts/ado-script/src/exec-context-pr/index.ts is conditioned eq(variables['Build.Reason'], 'PullRequest') and is skipped entirely, so aw-context/pr/{base.sha,head.sha} and the agent-prompt PR-context fragment are never staged.
  • Any agent that depends on PR context (e.g. a PR Reviewer) cannot run unless an operator manually adds a per-pipeline, per-target-branch Build Validation policy in the ADO UI.

This defeats the "one markdown file is the whole workflow" model ado-aw is built around: registering an ado-aw agent should never require a separate ADO-UI click for every target branch.

Goal

When an ado-aw-compiled pipeline is triggered by CI on a branch that has an open PR matching the pr: filters in the agent markdown, the runtime should behave as if Build.Reason = PullRequest — discover the PR via the ADO REST API, expose its identifiers to gate.js and exec-context-pr.js, gate normally, and stage aw-context/pr/. No branch policy required.

When no matching PR exists (or path filters reject the diff), the build self-cancels cleanly with a single info log line — never noisy, never red.

Design

Locked decisions

Decision Value
Match rule Exactly one open PR whose sourceRefName == Build.SourceBranch and whose targetRefName matches the agent's pr.branches.include (respecting exclude). Zero or >1 → clean skip.
Path filtering When a single PR matches, also enforce pr.paths (include/exclude) against the merge-base..HEAD diff. ADO's CI trigger ignores these.
Default On whenever on.pr is configured. Opt-out via a new front-matter knob (proposed: on.pr.synthetic-from-ci: false — final name TBD in review).
Prerequisite #915 (colon-in-build-tag fix) must land first; every gate path currently dies on addBuildTag("pr-gate:passed").

Out of scope

  • Real PR builds (Build.Reason == PullRequest) when a branch policy is configured — that path must remain bit-identical.
  • Multi-PR fanout (one CI build → N synthetic PR runs).
  • GitHub-typed repo resources in ADO — those already have working pr: semantics; the new step must detect and no-op.

Implementation playbook

A coding agent can pick this up turn-by-turn. The steps below are ordered and each is independently testable.

Step 1 — Schema knob

  • src/compile/types.rs + src/compile/pr_filters.rs: add synthetic-from-ci: bool (default true) to the on.pr front-matter schema. Thread the flag through so it can suppress all wiring changes when set to false.

Step 2 — PR_SYNTH_SPEC serialiser

  • src/compile/filter_ir.rs: build a base64-encoded JSON spec carrying:
    {
      "branches": { "include": [...], "exclude": [...] },
      "paths":    { "include": [...], "exclude": [...] }
    }
    Mirror GATE_SPEC mechanics — same MAX_SPEC_DECODED_BYTES cap, same decoding helper pattern.

Step 3 — New runtime script

  • New module scripts/ado-script/src/exec-context-pr-synth/:

    • index.ts entry point.
    • match.ts — glob matching for branches and paths (reuse picomatch if already in the bundle, otherwise add minimal glob helper).
    • __tests__/ covering: exactly-one-match happy path, zero matches, >1 matches, branch include/exclude filtering, path include/exclude filtering, Build.Reason == PullRequest no-op, Build.Repository.Provider == GitHub no-op.

    Runtime behaviour:

    1. If process.env.BUILD_REASON == "PullRequest" → emit nothing, exit 0 (real SYSTEM_PULLREQUEST_* vars are authoritative).
    2. If process.env.BUILD_REPOSITORY_PROVIDER == "GitHub" → emit nothing, exit 0 (GitHub-typed repos already work).
    3. Decode PR_SYNTH_SPEC. Filter Build.SourceBranch against branches.include/exclude; if it doesn't match, emit ##vso[task.setvariable variable=AW_SYNTHETIC_PR_SKIP;isOutput=true]true and one logInfo line, exit 0.
    4. Call GET /{project}/_apis/git/repositories/{repoId}/pullrequests?searchCriteria.sourceRefName=$(Build.SourceBranch)&searchCriteria.status=active via shared/ado-client.ts. Reuse withRetry + the existing auth path.
    5. Keep PRs whose targetRefName matches branches.include/exclude. If count != 1 → set AW_SYNTHETIC_PR_SKIP=true with reason in the log, exit 0.
    6. For the one match: resolve merge-base via getPullRequestIterations + getIterationChanges (already in ado-client.ts — no new SDK surface). Apply paths.include/exclude to the changed-file list. If empty → AW_SYNTHETIC_PR_SKIP=true, exit 0.
    7. Otherwise emit (with isOutput=true so dependent jobs can read them):
      • AW_SYNTHETIC_PR=true
      • AW_SYNTHETIC_PR_ID=<id>
      • AW_SYNTHETIC_PR_TARGETBRANCH=refs/heads/<target>
      • AW_SYNTHETIC_PR_SOURCEBRANCH=refs/heads/<source>
      • AW_SYNTHETIC_PR_IS_DRAFT=<bool>

Step 4 — Gate bypass update

  • scripts/ado-script/src/gate/bypass.ts: change the bypass check from buildReason !== spec.context.build_reason to buildReason !== spec.context.build_reason && process.env.AW_SYNTHETIC_PR !== "true". Update bypass.test.ts to cover the new path. ADO_PR_ID is already read from env, and the compiler will route the synthetic value into it (next step).

Step 5 — Compile-side YAML wiring

  • src/compile/common.rs:
    1. Insert the new step into the Setup job before prGate:
      - bash: node '/tmp/ado-aw-scripts/ado-script/exec-context-pr-synth.js'
        name: synthPr
        displayName: "Resolve synthetic PR context"
        condition: ne(variables['Build.Reason'], 'PullRequest')
        env:
          SYSTEM_ACCESSTOKEN: $(System.AccessToken)
          ADO_COLLECTION_URI: $(System.CollectionUri)
          ADO_PROJECT: $(System.TeamProject)
          ADO_REPO_ID: $(Build.Repository.ID)
          BUILD_REASON: $(Build.Reason)
          BUILD_REPOSITORY_PROVIDER: $(Build.Repository.Provider)
          BUILD_SOURCEBRANCH: $(Build.SourceBranch)
          PR_SYNTH_SPEC: "<base64>"
    2. Route coalesce() of real + synthetic values into both gate.js and exec-context-pr.js env blocks:
      ADO_PR_ID: $[ coalesce(variables['System.PullRequest.PullRequestId'], dependencies.Setup.outputs['synthPr.AW_SYNTHETIC_PR_ID']) ]
      SYSTEM_PULLREQUEST_PULLREQUESTID: $[ coalesce(variables['System.PullRequest.PullRequestId'], dependencies.Setup.outputs['synthPr.AW_SYNTHETIC_PR_ID']) ]
      SYSTEM_PULLREQUEST_TARGETBRANCH:  $[ coalesce(variables['System.PullRequest.TargetBranch'], dependencies.Setup.outputs['synthPr.AW_SYNTHETIC_PR_TARGETBRANCH']) ]
      SYSTEM_PULLREQUEST_SOURCEBRANCH:  $[ coalesce(variables['System.PullRequest.SourceBranch'], dependencies.Setup.outputs['synthPr.AW_SYNTHETIC_PR_SOURCEBRANCH']) ]
    3. Update the exec-context-pr.js step condition from eq(variables['Build.Reason'], 'PullRequest') to:
      condition: or(
        eq(variables['Build.Reason'], 'PullRequest'),
        eq(dependencies.Setup.outputs['synthPr.AW_SYNTHETIC_PR'], 'true')
      )
    4. Update the Agent-job condition (currently around common.rs line 2228) from:
      or(ne(variables['Build.Reason'], 'PullRequest'), eq(prGate.SHOULD_RUN, 'true'))
      
      to additionally skip when AW_SYNTHETIC_PR_SKIP == 'true':
      and(
        ne(dependencies.Setup.outputs['synthPr.AW_SYNTHETIC_PR_SKIP'], 'true'),
        or(
          eq(variables['Build.Reason'], 'PullRequest'),
          eq(dependencies.Setup.outputs['synthPr.AW_SYNTHETIC_PR'], 'true'),
          eq(dependencies.Setup.outputs['prGate.SHOULD_RUN'], 'true')
        )
      )
      
    5. When synthetic-from-ci is on, emit a default trigger: block mirroring pr.branches.include for source-branch CI. Today no trigger: is emitted so ADO falls back to all-branches CI; narrowing this keeps unrelated branches from queuing builds. When the agent declares its own trigger: block, defer to it.

Step 6 — Compile-side snapshot tests

src/compile/tests.rs:

  • Fixture Aon.pr with default synthetic-from-ci: true: snapshot the compiled YAML proving the new Setup step, env coalescing, exec-context-pr.js condition, Agent-job condition, and trigger: block all appear.
  • Fixture Bon.pr with synthetic-from-ci: false: snapshot proves the YAML is byte-identical to the pre-change output for back-compat.
  • Fixture C — same agent with a type: GitHub repo resource: snapshot proves the new step is omitted (or that its condition is augmented to no-op on GitHub).

Step 7 — Docs

  • prompts/create-ado-agentic-workflow.md — add a "PR triggering in Azure Repos" section explaining the no-branch-policy promise, the match rule, the path-filter enforcement, and how to opt out.
  • docs/ page on on: pr — add "How it works under the hood": the synthetic-CI path, exactly-one-PR rule, what happens on zero/multi-match, GitHub-resource no-op.
  • CHANGELOG.md.

Acceptance criteria

  1. cargo test + pnpm test (script suite) all green.
  2. Fixture A produces a pipeline that, when triggered by CI on a branch with one matching open PR, reaches the Agent job and stages aw-context/pr/{base.sha,head.sha} with the merge-base.
  3. Fixture B is byte-identical to the pre-change output.
  4. End-to-end demo: against the test PR in msazuresphere/4x4/azure-devops-agentic-pipelines (PR #38551, fires the ado-aw reviewer pipeline), the reviewer must produce review comments with no Build Validation branch policy on the target repo.

Hard prerequisite

#915 (addBuildTag colon bug) must land in a release first. Without it, even the successful synthetic-PR gate path dies on addBuildTag("pr-gate:passed").

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions