Merge pull request #42 from github/joshmgross/matrix-context-completion

Support `matrix` context auto-completion
This commit is contained in:
Josh Gross
2022-12-08 10:27:42 -05:00
committed by GitHub
5 changed files with 681 additions and 11 deletions
@@ -374,10 +374,9 @@ jobs:
expect(result.map(x => x.label)).toContain("strategy");
});
});
it("includes expected keys", async () => {
const input = `
it("includes expected keys", async () => {
const input = `
on: push
jobs:
@@ -390,10 +389,173 @@ jobs:
steps:
- uses: actions/checkout@v3
- run: npm test > test-job-\${{ strategy.| }}.txt
`;
const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig);
expect(result.map(x => x.label)).toEqual(["fail-fast", "job-index", "job-total", "max-parallel"]);
});
});
describe("matrix context", () => {
it("matrix is not suggested when outside of a matrix job", async () => {
const input = `
on: push
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- run: npm test > test-job-\${{ | }}.txt
`;
const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig);
const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig);
expect(result.map(x => x.label)).toEqual(["fail-fast", "job-index", "job-total", "max-parallel"]);
expect(result.map(x => x.label)).not.toContain("strategy");
});
it("matrix is suggested within a matrix job", async () => {
const input = `
on: push
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
test-group: [1, 2]
node: [14, 16]
steps:
- uses: actions/checkout@v3
- run: npm test > test-job-\${{ | }}.txt
`;
const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig);
expect(result.map(x => x.label)).toContain("strategy");
});
it("basic matrix job", async () => {
const input = `
on: push
jobs:
test:
runs-on: \${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [14, 16]
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: \${{ matrix.| }}
`;
const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig);
expect(result.map(x => x.label)).toEqual(["node", "os"]);
});
it("matrix with include", async () => {
const input = `
on: push
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
fruit: [apple, pear]
animal: [cat, dog]
include:
- color: green
- color: pink
animal: cat
- fruit: apple
shape: circle
- fruit: banana
- fruit: banana
animal: cat
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: \${{ matrix.| }}
`;
const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig);
expect(result.map(x => x.label)).toEqual(["animal", "color", "fruit", "shape"]);
});
it("matrix from expression", async () => {
const input = `
on: push
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix: \${{ fromJSON('{"color":["green","blue"]}') }}
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: \${{ matrix.| }}
`;
const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig);
expect(result.map(x => x.label)).toEqual([]);
});
it("matrix with include expression", async () => {
const input = `
on: push
jobs:
test:
runs-on: \${{ matrix.os }}
strategy:
matrix:
fruit: [apple, pear]
animal: [cat, dog]
include: \${{ fromJSON('{"color":"green"}') }}
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: \${{ matrix.| }}
`;
const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig);
expect(result.map(x => x.label)).toEqual([]);
});
it("matrix with expression in property", async () => {
const input = `
on: push
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
color: \${{ fromJSON('["green","blue"]') }}
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: \${{ matrix.| }}
`;
const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig);
expect(result.map(x => x.label)).toEqual(["color"]);
});
});
});
@@ -2,9 +2,10 @@ import {data} from "@github/actions-expressions";
import {WorkflowContext} from "../context/workflow-context";
import {ContextProviderConfig} from "./config";
import {getInputsContext} from "./inputs";
import {getMatrixContext} from "./matrix";
import {getNeedsContext} from "./needs";
import {getStepsContext} from "./steps";
import {allowStrategyContext, getStrategyContext} from "./strategy";
import {getStrategyContext} from "./strategy";
export async function getContext(
names: string[],
@@ -55,6 +56,9 @@ async function getDefaultContext(name: string, workflowContext: WorkflowContext)
case "strategy":
return getStrategyContext(workflowContext);
case "matrix":
return getMatrixContext(workflowContext);
}
return undefined;
@@ -72,9 +76,14 @@ function objectToDictionary(object: {[key: string]: string}): data.Dictionary {
function filterContextNames(contextNames: string[], workflowContext: WorkflowContext): string[] {
return contextNames.filter(name => {
switch (name) {
case "matrix":
case "strategy":
return allowStrategyContext(workflowContext);
return hasStrategy(workflowContext);
}
return true;
});
}
function hasStrategy(workflowContext: WorkflowContext): boolean {
return workflowContext.job?.strategy !== undefined;
}
@@ -0,0 +1,319 @@
import {data} from "@github/actions-expressions";
import {Job} from "@github/actions-workflow-parser/model/workflow-template";
import {BasicExpressionToken} from "@github/actions-workflow-parser/templates/tokens/basic-expression-token";
import {ExpressionToken} from "@github/actions-workflow-parser/templates/tokens/expression-token";
import {MappingToken} from "@github/actions-workflow-parser/templates/tokens/mapping-token";
import {SequenceToken} from "@github/actions-workflow-parser/templates/tokens/sequence-token";
import {StringToken} from "@github/actions-workflow-parser/templates/tokens/string-token";
import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token";
import {WorkflowContext} from "../context/workflow-context";
import {getMatrixContext} from "./matrix";
type MatrixMap = {
[key: string]: Array<string> | Array<{[key: string]: string}>;
};
function createMatrix(map: MatrixMap): WorkflowContext {
const strategy = new MappingToken(undefined, undefined, undefined);
strategy.add(stringToToken("matrix"), mapToToken(map));
return contextFromStrategy(strategy);
}
function mapToToken(map: MatrixMap) {
const token = new MappingToken(undefined, undefined, undefined);
for (const key in map) {
const arr = map[key];
const seqToken = new SequenceToken(undefined, undefined, undefined);
for (const item of arr) {
if (typeof item === "string") {
seqToken.add(new StringToken(undefined, undefined, item, undefined));
} else {
const mapToken = new MappingToken(undefined, undefined, undefined);
for (const key in item) {
mapToken.add(stringToToken(key), stringToToken(item[key]));
}
seqToken.add(mapToken);
}
}
token.add(stringToToken(key), seqToken);
}
return token;
}
function stringToToken(value: string) {
return new StringToken(undefined, undefined, value, undefined);
}
function expressionToToken(expr: string) {
return new BasicExpressionToken(undefined, undefined, expr, undefined, undefined);
}
function contextFromStrategy(strategy?: TemplateToken) {
return {
job: {
strategy: strategy
}
} as WorkflowContext;
}
describe("matrix context", () => {
describe("invalid workflow context", () => {
it("job not defined", () => {
const workflowContext = {} as WorkflowContext;
expect(workflowContext.job).toBeUndefined();
const context = getMatrixContext(workflowContext);
expect(context).toEqual(new data.Dictionary());
});
it("strategy not defined", () => {
const job = {} as Job;
const workflowContext = {job} as WorkflowContext;
expect(workflowContext.job!.strategy).toBeUndefined();
const context = getMatrixContext(workflowContext);
expect(context).toEqual(new data.Dictionary());
});
it("strategy is not a mapping token", () => {
const workflowContext = contextFromStrategy(stringToToken("hello"));
expect(workflowContext.job!.strategy).toBeDefined();
const context = getMatrixContext(workflowContext);
expect(context).toEqual(new data.Dictionary());
});
it("matrix is not defined", () => {
const strategy = new MappingToken(undefined, undefined, undefined);
const workflowContext = contextFromStrategy(strategy);
const context = getMatrixContext(workflowContext);
expect(context).toEqual(new data.Dictionary());
});
it("matrix is not a mapping token", () => {
const strategy = new MappingToken(undefined, undefined, undefined);
strategy.add(stringToToken("matrix"), stringToToken("hello"));
const workflowContext = contextFromStrategy(strategy);
const context = getMatrixContext(workflowContext);
expect(context).toEqual(new data.Dictionary());
});
it("empty matrix", () => {
const strategy = new MappingToken(undefined, undefined, undefined);
strategy.add(stringToToken("matrix"), new MappingToken(undefined, undefined, undefined));
const workflowContext = contextFromStrategy(strategy);
const context = getMatrixContext(workflowContext);
expect(context).toEqual(new data.Dictionary());
});
});
describe("matrix with expressions", () => {
it("matrix from expression", () => {
const strategy = new MappingToken(undefined, undefined, undefined);
strategy.add(stringToToken("matrix"), expressionToToken("${{ fromJSON(needs.job1.outputs.matrix) }}"));
const workflowContext = contextFromStrategy(strategy);
const context = getMatrixContext(workflowContext);
expect(context).toEqual(new data.Dictionary());
});
it("matrix with include expression", () => {
const include = expressionToToken("${{ fromJSON(needs.job1.outputs.matrix) }}");
const nodeSequence = new SequenceToken(undefined, undefined, undefined);
nodeSequence.add(stringToToken("12"));
nodeSequence.add(stringToToken("14"));
const matrix = new MappingToken(undefined, undefined, undefined);
matrix.add(stringToToken("node"), nodeSequence);
matrix.add(stringToToken("include"), include);
const strategy = new MappingToken(undefined, undefined, undefined);
strategy.add(stringToToken("matrix"), matrix);
const workflowContext = contextFromStrategy(strategy);
const context = getMatrixContext(workflowContext);
expect(context).toEqual(new data.Dictionary());
});
it("matrix with expression within property", () => {
const version = expressionToToken("${{ github.event.client_payload.versions }}");
const matrix = new MappingToken(undefined, undefined, undefined);
matrix.add(stringToToken("version"), version);
const strategy = new MappingToken(undefined, undefined, undefined);
strategy.add(stringToToken("matrix"), matrix);
const workflowContext = contextFromStrategy(strategy);
const context = getMatrixContext(workflowContext);
expect(context).toEqual(
new data.Dictionary({
key: "version",
value: new data.Null()
})
);
});
});
describe("valid matrices", () => {
it("basic matrix", () => {
const workflowContext = createMatrix({os: ["ubuntu-latest", "windows-latest"]});
const context = getMatrixContext(workflowContext);
expect(context).toEqual(
new data.Dictionary({
key: "os",
value: new data.Array(new data.StringData("ubuntu-latest"), new data.StringData("windows-latest"))
})
);
});
it("matrix with multiple properties", () => {
const workflowContext = createMatrix({
os: ["ubuntu-latest", "windows-latest"],
node: ["12", "14"]
});
const context = getMatrixContext(workflowContext);
expect(context).toEqual(
new data.Dictionary(
{
key: "os",
value: new data.Array(new data.StringData("ubuntu-latest"), new data.StringData("windows-latest"))
},
{
key: "node",
value: new data.Array(new data.StringData("12"), new data.StringData("14"))
}
)
);
});
it("matrix with include", () => {
const workflowContext = createMatrix({
os: ["ubuntu-latest", "windows-latest"],
node: ["12", "14"],
include: [
{
os: "macos-latest",
node: "12"
}
]
});
const context = getMatrixContext(workflowContext);
expect(context).toEqual(
new data.Dictionary(
{
key: "os",
value: new data.Array(
new data.StringData("ubuntu-latest"),
new data.StringData("windows-latest"),
new data.StringData("macos-latest")
)
},
{
key: "node",
value: new data.Array(new data.StringData("12"), new data.StringData("14"))
}
)
);
});
it("matrix with only include", () => {
const workflowContext = createMatrix({
include: [
{
site: "production",
datacenter: "site-a"
},
{
site: "staging",
datacenter: "site-b"
}
]
});
const context = getMatrixContext(workflowContext);
expect(context).toEqual(
new data.Dictionary(
{
key: "site",
value: new data.Array(new data.StringData("production"), new data.StringData("staging"))
},
{
key: "datacenter",
value: new data.Array(new data.StringData("site-a"), new data.StringData("site-b"))
}
)
);
});
it("matrix with exclude", () => {
const workflowContext = createMatrix({
os: ["macos-latest", "windows-latest"],
node: ["12", "14", "16"],
environment: ["staging", "production"],
exclude: [
{
os: "macos-latest",
node: "12",
environment: "production"
},
{
os: "windows-latest",
node: "16"
}
]
});
const context = getMatrixContext(workflowContext);
expect(context).toEqual(
new data.Dictionary(
{
key: "os",
value: new data.Array(new data.StringData("macos-latest"), new data.StringData("windows-latest"))
},
{
key: "node",
value: new data.Array(new data.StringData("12"), new data.StringData("14"), new data.StringData("16"))
},
{
key: "environment",
value: new data.Array(new data.StringData("staging"), new data.StringData("production"))
}
)
);
});
it("matrix with only exclude", () => {
const workflowContext = createMatrix({
exclude: [
{
os: "macos-latest",
node: "12",
environment: "production"
},
{
os: "windows-latest",
node: "16"
}
]
});
const context = getMatrixContext(workflowContext);
expect(context).toEqual(new data.Dictionary());
});
});
});
@@ -0,0 +1,184 @@
import {data} from "@github/actions-expressions";
import {isBasicExpression, isMapping, isSequence, isString} from "@github/actions-workflow-parser";
import {KeyValuePair} from "@github/actions-workflow-parser/templates/tokens/key-value-pair";
import {MappingToken} from "@github/actions-workflow-parser/templates/tokens/mapping-token";
import {SequenceToken} from "@github/actions-workflow-parser/templates/tokens/sequence-token";
import {WorkflowContext} from "../context/workflow-context";
export function getMatrixContext(workflowContext: WorkflowContext): data.Dictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#matrix-context
const strategy = workflowContext.job?.strategy;
if (!strategy || !isMapping(strategy)) {
return new data.Dictionary();
}
const matrix = strategy.find("matrix");
if (!matrix || !isMapping(matrix)) {
// Matrix could be an expression, so there's no context we can provide
return new data.Dictionary();
}
const properties = matrixProperties(matrix);
if (!properties) {
// Matrix included an expression, so there's no context we can provide
return new data.Dictionary();
}
const d = new data.Dictionary();
for (const [key, value] of properties) {
if (value === undefined) {
d.add(key, new data.Null());
continue;
}
const a = new data.Array();
for (const v of value) {
a.add(new data.StringData(v));
}
d.add(key, a);
}
return d;
}
/**
* https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstrategymatrix
* A matrix property can come from:
* - An explicit matrix property key
* - A configuration included via the "include" property
*
* By definition, "exclude" can't add new keys to the matrix.
* Additionally, "include" and "exclude are not properties of the matrix
* If the matrix or "include" is an expression, we can't know the keys
*
* Examples:
* 1. Basic matrix
* matrix:
* version: [10, 12, 14]
* os: [ubuntu-latest, windows-latest]
*
* Keys: version, os
*
* 2. Matrix with "include"
* matrix:
* version: [10, 12, 14]
* os: [ubuntu-latest, windows-latest]
* include:
* - version: 10
* os: macos-latest
*
* Keys: version, os
*
* 3. Matrix with new properties in "include"
* matrix:
* include:
* - site: "production"
* datacenter: "site-a"
* - site: "staging"
* datacenter: "site-b"
*
* Keys: site, datacenter
*
* 4. Matrix with "exclude"
* matrix:
* os: [macos-latest, windows-latest]
* version: [12, 14, 16]
* environment: [staging, production]
* exclude:
* - os: macos-latest
* version: 12
* environment: production
* - os: windows-latest
* version: 16
*
* Keys: os, version, environment
*/
function matrixProperties(matrix: MappingToken): Map<string, Set<string> | undefined> | undefined {
const properties = new Map<string, Set<string> | undefined>();
let include: SequenceToken | undefined;
for (let i = 0; i < matrix.count; i++) {
const pair = matrix.get(i);
if (!isString(pair.key)) {
continue;
}
const key = pair.key.value;
switch (key) {
case "include":
// If "include" is an expression, we can't know the properties of the matrix
if (isBasicExpression(pair.value) || !isSequence(pair.value)) {
return;
}
include = pair.value;
break;
case "exclude":
break;
default:
if (!isSequence(pair.value)) {
properties.set(key, undefined);
continue;
}
const values = new Set<string>();
for (let j = 0; j < pair.value.count; j++) {
const value = pair.value.get(j);
// The parser should coerce matrix values to strings, ignore expressions
if (isString(value)) {
values.add(value.value);
}
}
properties.set(key, values);
break;
}
}
if (include) {
for (let i = 0; i < include.count; i++) {
const item = include.get(i);
if (!isMapping(item)) {
continue;
}
for (let j = 0; j < item.count; j++) {
const pair = item.get(j);
addValueToProperties(properties, pair);
}
}
}
return properties;
}
function addValueToProperties(properties: Map<string, Set<string> | undefined>, pair: KeyValuePair): void {
if (!isString(pair.key)) {
return;
}
const key = pair.key.value;
const value = isString(pair.value) ? pair.value.value : undefined;
if (!properties.has(key)) {
if (value === undefined) {
properties.set(key, undefined);
return;
}
properties.set(key, new Set<string>([value]));
return;
}
if (value === undefined) {
return;
}
const property = properties.get(key);
if (property !== undefined) {
property.add(value);
return;
}
properties.set(key, new Set<string>([value]));
}
@@ -38,7 +38,3 @@ export function getStrategyContext(workflowContext: WorkflowContext): data.Dicti
return strategyContext;
}
export function allowStrategyContext(workflowContext: WorkflowContext): boolean {
return workflowContext.job?.strategy !== undefined;
}