Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 2 additions & 2 deletions .github/workflows/ado-script.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,9 +54,9 @@ jobs:

- name: Verify generated TypeScript is up to date
run: |
if ! git diff --exit-code -- scripts/ado-script/src/shared/types.gen.ts; then
if ! git diff --exit-code -- scripts/ado-script/src/shared/types.gen.ts scripts/ado-script/src/trigger-e2e/fact-catalog.gen.json; then
echo ""
echo "::error::types.gen.ts is out of date with the Rust IR."
echo "::error::Generated files are out of date with the Rust IR (types.gen.ts and/or fact-catalog.gen.json)."
echo "Run 'cd scripts/ado-script && npm run codegen' and commit the result."
exit 1
fi
Expand Down
3 changes: 2 additions & 1 deletion scripts/ado-script/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@
"build:github-app-token": "ncc build src/github-app-token/index.ts -o .ado-build/github-app-token -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/github-app-token/index.js','github-app-token.js'); fs.rmSync('.ado-build/github-app-token',{recursive:true,force:true});\"",
"build:prepare-pr-base": "ncc build src/prepare-pr-base/index.ts -o .ado-build/prepare-pr-base -m -t && node -e \"const fs=require('node:fs'); fs.copyFileSync('.ado-build/prepare-pr-base/index.js','prepare-pr-base.js'); fs.rmSync('.ado-build/prepare-pr-base',{recursive:true,force:true});\"",
"build:executor-e2e": "ncc build src/executor-e2e/index.ts -o .ado-build/executor-e2e -m -t && node -e \"const fs=require('node:fs'); fs.mkdirSync('test-bin',{recursive:true}); fs.copyFileSync('.ado-build/executor-e2e/index.js','test-bin/executor-e2e.js'); fs.rmSync('.ado-build/executor-e2e',{recursive:true,force:true});\"",
"build:trigger-e2e": "ncc build src/trigger-e2e/index.ts -o .ado-build/trigger-e2e -m -t && node -e \"const fs=require('node:fs'); fs.mkdirSync('test-bin',{recursive:true}); fs.copyFileSync('.ado-build/trigger-e2e/index.js','test-bin/trigger-e2e.js'); fs.rmSync('.ado-build/trigger-e2e',{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.\"",
"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.\" && cargo run --quiet --manifest-path ../../Cargo.toml -- export-fact-catalog --output src/trigger-e2e/fact-catalog.gen.json",
"test": "vitest run",
"test:smoke": "npm run build:gate && npm run build:import && npm run build:exec-context-pr && npm run build:exec-context-pr-synth && npm run build:exec-context-manual && npm run build:exec-context-pipeline && npm run build:exec-context-ci-push && npm run build:exec-context-workitem && npm run build:exec-context-schedule && npm run build:exec-context-pr-checks && npm run build:exec-context-repo && npm run build:conclusion && npm run build:approval-summary && npm run build:github-app-token && npm run build:prepare-pr-base && vitest run -c vitest.config.smoke.ts",
"lint": "echo TODO",
Expand Down
6 changes: 5 additions & 1 deletion scripts/ado-script/src/__tests__/bundle-coverage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,12 @@ const packageJsonPath = join(here, "..", "..", "package.json");
* so the release glob (`ado-script/*.js`) never packages it — it is a test
* harness run only by the executor-e2e pipeline, never downloaded by compiled
* agentic pipelines at runtime.
*
* `trigger-e2e` is the analogous deterministic trigger-condition (gate /
* synth-PR) E2E harness. Same treatment: its own `build:trigger-e2e` emits to
* `test-bin/`, so it is never packaged in `ado-script.zip`.
*/
const NON_BUNDLE_DIRS = new Set(["shared", "__tests__", "executor-e2e"]);
const NON_BUNDLE_DIRS = new Set(["shared", "__tests__", "executor-e2e", "trigger-e2e"]);

function listBundleDirs(): string[] {
return readdirSync(srcDir)
Expand Down
146 changes: 140 additions & 6 deletions scripts/ado-script/src/executor-e2e/ado-rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,55 @@ export class AdoRest {
return commitId;
}

/**
* Create a NEW branch and a single commit adding one OR MORE files in one
* push. Returns the new commit id.
*
* Prefer this over calling {@link pushAddFileBranch} in a loop: that helper
* always uses new-branch semantics (`oldObjectId` = zeros), so a second call
* against the now-existing branch would be rejected by ADO with a ref
* conflict. Batching every file into a single commit sidesteps that entirely
* and still produces a real diff vs. the base for PR scenarios.
*/
async pushAddFilesBranch(
repo: string,
branchName: string,
baseCommitId: string,
files: Record<string, string>,
comment: string,
): Promise<string> {
const entries = Object.entries(files);
if (entries.length === 0) throw new Error("pushAddFilesBranch requires at least one file");
const path = this.projPath(
`_apis/git/repositories/${AdoRest.seg(repo)}/pushes?api-version=7.1`,
);
const res = await this.request<{ commits?: { commitId: string }[] }>(path, {
method: "POST",
body: {
refUpdates: [
{
name: branchName.startsWith("refs/") ? branchName : `refs/heads/${branchName}`,
oldObjectId: "0000000000000000000000000000000000000000",
},
],
commits: [
{
comment,
parents: [baseCommitId],
changes: entries.map(([filePath, content]) => ({
changeType: "add",
item: { path: filePath.startsWith("/") ? filePath : `/${filePath}` },
newContent: { content, contentType: "rawtext" },
})),
},
],
},
});
const commitId = res?.commits?.[0]?.commitId;
if (!commitId) throw new Error("pushAddFilesBranch returned no commit id");
return commitId;
}

// ---- Git: pull requests & threads ------------------------------------

async createPullRequest(
Expand All @@ -264,18 +313,21 @@ export class AdoRest {
targetRef: string,
title: string,
description: string,
isDraft?: boolean,
): Promise<{ pullRequestId: number }> {
const path = this.projPath(
`_apis/git/repositories/${AdoRest.seg(repo)}/pullrequests?api-version=7.1`,
);
const body: Record<string, unknown> = {
sourceRefName: sourceRef.startsWith("refs/") ? sourceRef : `refs/heads/${sourceRef}`,
targetRefName: targetRef.startsWith("refs/") ? targetRef : `refs/heads/${targetRef}`,
title,
description,
};
if (isDraft !== undefined) body.isDraft = isDraft;
const res = await this.request<{ pullRequestId: number }>(path, {
method: "POST",
body: {
sourceRefName: sourceRef.startsWith("refs/") ? sourceRef : `refs/heads/${sourceRef}`,
targetRefName: targetRef.startsWith("refs/") ? targetRef : `refs/heads/${targetRef}`,
title,
description,
},
body,
});
if (!res) throw new Error("createPullRequest returned no body");
return res;
Expand Down Expand Up @@ -365,6 +417,27 @@ export class AdoRest {
await this.request(path, { method: "PATCH", body: { status: "abandoned" }, allow404: true });
}

/**
* Attach one or more labels (tags) to a PR. ADO's label endpoint takes a
* single `{ name }` per POST, so this loops. Used by the trigger E2E harness
* to exercise the gate's `label_set_match` predicate against real PR labels.
*/
async setPullRequestLabels(repo: string, prId: number, labels: string[]): Promise<void> {
for (const name of labels) {
const path = this.projPath(
`_apis/git/repositories/${AdoRest.seg(repo)}/pullRequests/${prId}/labels?api-version=7.1`,
);
try {
await this.request(path, { method: "POST", body: { name } });
} catch (err) {
// Name the specific label that failed so a partial-attach surfaces as a
// clear setup error rather than a confusing downstream gate mismatch.
const message = err instanceof Error ? err.message : String(err);
throw new Error(`setPullRequestLabels: failed to attach label '${name}' to PR ${prId}: ${message}`);
}
}
}

// ---- Wiki -------------------------------------------------------------

async listWikis(): Promise<{ name: string; id: string; type?: string }[]> {
Expand Down Expand Up @@ -451,4 +524,65 @@ export class AdoRest {
const path = this.projPath(`_apis/build/builds/${buildId}?api-version=7.1`);
await this.request(path, { method: "PATCH", body: { status: "cancelling" }, allow404: true });
}

/**
* Queue a new build of a registered definition. `sourceBranch` selects the
* branch to build (used by the trigger E2E harness to point a queued build
* at a PR's source branch so `exec-context-pr-synth` can discover the open
* PR). `templateParameters` supplies the victim pipeline's runtime
* parameters (e.g. the base64 GATE_SPEC / PR_SYNTH_SPEC under test).
*
* Returns the new build id. Note: a build queued via this REST call has
* `Build.Reason = Manual`; the victim relies on the synthetic-PR flag (set
* by `exec-context-pr-synth` from a real open PR) — not the build reason —
* to drive full PR-gate evaluation.
*/
async queueBuild(
definitionId: number,
opts: { sourceBranch?: string; templateParameters?: Record<string, string> } = {},
): Promise<{ id: number }> {
const path = this.projPath(`_apis/build/builds?api-version=7.1`);
const body: Record<string, unknown> = { definition: { id: definitionId } };
if (opts.sourceBranch) {
body.sourceBranch = opts.sourceBranch.startsWith("refs/")
? opts.sourceBranch
: `refs/heads/${opts.sourceBranch}`;
}
if (opts.templateParameters && Object.keys(opts.templateParameters).length > 0) {
body.templateParameters = opts.templateParameters;
}
const res = await this.request<{ id: number }>(path, { method: "POST", body });
if (!res) throw new Error(`queueBuild(${definitionId}) returned no body`);
return res;
}

/**
* Poll a build until it reaches `status === "completed"` (or the timeout
* elapses). Returns the terminal `{ status, result }`. `result` is one of
* `succeeded` | `partiallySucceeded` | `failed` | `canceled`.
*
* Timeout/poll defaults are generic (15 min / 10 s). Callers that need
* suite-specific tuning pass explicit `opts` — env-var knobs belong in the
* caller, not this shared client.
*/
async waitForBuild(
buildId: number,
opts: { timeoutMs?: number; pollMs?: number } = {},
): Promise<{ status: string; result?: string }> {
const timeoutMs = opts.timeoutMs ?? 900_000;
const pollMs = opts.pollMs ?? 10_000;
const deadline = Date.now() + timeoutMs;
for (;;) {
const build = await this.getBuild(buildId);
if (build.status === "completed") {
return { status: build.status, result: build.result };
}
if (Date.now() >= deadline) {
throw new Error(
`waitForBuild(${buildId}) timed out after ${timeoutMs}ms (last status='${build.status ?? "?"}')`,
);
}
await new Promise((r) => setTimeout(r, pollMs));
}
}
}
132 changes: 132 additions & 0 deletions scripts/ado-script/src/trigger-e2e/__tests__/gate-spec.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { readFileSync } from "node:fs";

import { describe, expect, it } from "vitest";

import {
buildGateSpec,
changeCountCheck,
changedFilesCheck,
draftCheck,
encodeGateSpec,
encodePrSynthSpec,
factMetaCatalog,
labelsCheck,
targetBranchCheck,
titleCheck,
} from "../gate-spec.js";

const factKinds = (checks: Parameters<typeof buildGateSpec>[1]) =>
buildGateSpec("pull-request", checks).facts.map((f) => f.kind);

describe("buildGateSpec fact derivation", () => {
it("emits no facts and no checks for an empty gate", () => {
const spec = buildGateSpec("pull-request", []);
expect(spec.facts).toEqual([]);
expect(spec.checks).toEqual([]);
expect(spec.context.tag_prefix).toBe("pr-gate");
expect(spec.context.build_reason).toBe("PullRequest");
expect(spec.context.bypass_label).toBe("PR");
});

it("derives pr_metadata (skip_dependents) before pr_labels (fail_open) for a labels check", () => {
const spec = buildGateSpec("pull-request", [labelsCheck({ anyOf: ["run-agent"] })]);
const kinds = spec.facts.map((f) => f.kind);
expect(kinds).toEqual(["pr_metadata", "pr_labels"]);
const meta = Object.fromEntries(spec.facts.map((f) => [f.kind, f]));
expect(meta.pr_metadata!.failure_policy).toBe("skip_dependents");
expect(meta.pr_metadata!.dependencies).toEqual([]);
expect(meta.pr_labels!.failure_policy).toBe("fail_open");
expect(meta.pr_labels!.dependencies).toEqual(["pr_metadata"]);
});

it("derives pr_metadata before pr_is_draft (fail_closed) for a draft check", () => {
expect(factKinds([draftCheck(true)])).toEqual(["pr_metadata", "pr_is_draft"]);
});

it("derives changed_files before changed_file_count for a change-count check", () => {
expect(factKinds([changeCountCheck({ min: 5 })])).toEqual([
"changed_files",
"changed_file_count",
]);
});

it("derives only changed_files for a changed-files check", () => {
expect(factKinds([changedFilesCheck({ include: ["src/**"] })])).toEqual(["changed_files"]);
});

it("derives a single fail_closed pipeline-var fact for a title check", () => {
const spec = buildGateSpec("pull-request", [titleCheck("release *")]);
expect(spec.facts).toEqual([
{ kind: "pr_title", failure_policy: "fail_closed", dependencies: [] },
]);
});

it("dedupes facts shared across multiple checks", () => {
const kinds = factKinds([draftCheck(true), labelsCheck({ anyOf: ["x"] })]);
// pr_metadata appears once, before both dependents, in enum order.
expect(kinds).toEqual(["pr_metadata", "pr_is_draft", "pr_labels"]);
});
});

describe("check tag suffixes match the Rust FilterCheck::build_tag_suffix", () => {
it("emits the expected suffixes", () => {
const cases: [Parameters<typeof buildGateSpec>[1][number], string][] = [
[titleCheck("x"), "title-mismatch"],
[labelsCheck({ anyOf: ["x"] }), "labels-mismatch"],
[changedFilesCheck({ include: ["src/**"] }), "changed-files-mismatch"],
[draftCheck(true), "draft-mismatch"],
[targetBranchCheck("main"), "target-branch-mismatch"],
[changeCountCheck({ min: 1 }), "changes-mismatch"],
];
for (const [check, suffix] of cases) {
const spec = buildGateSpec("pull-request", [check]);
expect(spec.checks[0]!.tag_suffix).toBe(suffix);
}
});
});

describe("encoding", () => {
it("round-trips a gate spec through base64", () => {
const spec = buildGateSpec("pull-request", [labelsCheck({ anyOf: ["run-agent"] })]);
const decoded = JSON.parse(Buffer.from(encodeGateSpec(spec), "base64").toString("utf8"));
expect(decoded).toEqual(spec);
});

it("produces the canonical empty PR_SYNTH_SPEC base64", () => {
expect(encodePrSynthSpec()).toBe(
"eyJicmFuY2hlcyI6eyJpbmNsdWRlIjpbXSwiZXhjbHVkZSI6W119LCJwYXRocyI6eyJpbmNsdWRlIjpbXSwiZXhjbHVkZSI6W119fQ==",
);
});

it("encodes provided branch/path filters", () => {
const decoded = JSON.parse(
Buffer.from(
encodePrSynthSpec({ branches: { include: ["main"] }, paths: { exclude: ["docs/**"] } }),
"base64",
).toString("utf8"),
);
expect(decoded).toEqual({
branches: { include: ["main"], exclude: [] },
paths: { include: [], exclude: ["docs/**"] },
});
});
});

describe("FACT_META drift guard vs Rust-generated catalog", () => {
// Deep-compares the hand-maintained FACT_META mirror against the committed
// fact-catalog.gen.json, which `npm run codegen` regenerates from
// filter_ir.rs::generate_fact_catalog and CI drift-checks (ado-script.yml).
//
// How this closes the drift hole the types.gen.ts import cannot:
// - A Rust change to a fact's failure_policy/dependencies, or a new/removed
// Fact variant, regenerates fact-catalog.gen.json → CI git-diff fails
// until committed → committing makes this test fail until FACT_META is
// updated to match. Neither the policy nor dependency VALUES are visible
// to the types.gen.ts structural codegen, so this is the only guard.
it("matches fact-catalog.gen.json exactly (kind, failure_policy, dependencies, order)", () => {
const catalog = JSON.parse(
readFileSync(new URL("../fact-catalog.gen.json", import.meta.url), "utf8"),
);
expect(factMetaCatalog()).toEqual(catalog);
});
});
Loading
Loading