From f518fd2ad6d66211dd76fed6af2c18d0e38f2e0e Mon Sep 17 00:00:00 2001 From: Josh Gross Date: Wed, 7 Dec 2022 19:08:44 -0500 Subject: [PATCH 1/2] Support `matrix` context auto-completion --- .../src/complete.expressions.test.ts | 172 +++++++++- .../src/context-providers/default.ts | 13 +- .../src/context-providers/matrix.test.ts | 319 ++++++++++++++++++ .../src/context-providers/matrix.ts | 184 ++++++++++ .../src/context-providers/strategy.ts | 4 - 5 files changed, 681 insertions(+), 11 deletions(-) create mode 100644 actions-languageservice/src/context-providers/matrix.test.ts create mode 100644 actions-languageservice/src/context-providers/matrix.ts diff --git a/actions-languageservice/src/complete.expressions.test.ts b/actions-languageservice/src/complete.expressions.test.ts index 255a95a..9087375 100644 --- a/actions-languageservice/src/complete.expressions.test.ts +++ b/actions-languageservice/src/complete.expressions.test.ts @@ -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"]); + }); }); }); diff --git a/actions-languageservice/src/context-providers/default.ts b/actions-languageservice/src/context-providers/default.ts index 809ea2f..4b35267 100644 --- a/actions-languageservice/src/context-providers/default.ts +++ b/actions-languageservice/src/context-providers/default.ts @@ -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; +} diff --git a/actions-languageservice/src/context-providers/matrix.test.ts b/actions-languageservice/src/context-providers/matrix.test.ts new file mode 100644 index 0000000..b1a0d48 --- /dev/null +++ b/actions-languageservice/src/context-providers/matrix.test.ts @@ -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 | 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()); + }); + }); +}); diff --git a/actions-languageservice/src/context-providers/matrix.ts b/actions-languageservice/src/context-providers/matrix.ts new file mode 100644 index 0000000..654c3f7 --- /dev/null +++ b/actions-languageservice/src/context-providers/matrix.ts @@ -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 propeties 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 | undefined> | undefined { + const properties = new Map | 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(); + 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 | 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([value])); + return; + } + + if (value === undefined) { + return; + } + + const property = properties.get(key); + if (property !== undefined) { + property.add(value); + return; + } + + properties.set(key, new Set([value])); +} diff --git a/actions-languageservice/src/context-providers/strategy.ts b/actions-languageservice/src/context-providers/strategy.ts index 4ca0377..8160859 100644 --- a/actions-languageservice/src/context-providers/strategy.ts +++ b/actions-languageservice/src/context-providers/strategy.ts @@ -38,7 +38,3 @@ export function getStrategyContext(workflowContext: WorkflowContext): data.Dicti return strategyContext; } - -export function allowStrategyContext(workflowContext: WorkflowContext): boolean { - return workflowContext.job?.strategy !== undefined; -} From b6fc90171cf21732a79ae93dba114a36642ac92d Mon Sep 17 00:00:00 2001 From: Josh Gross Date: Thu, 8 Dec 2022 10:23:52 -0500 Subject: [PATCH 2/2] Fix "properties" typo Co-authored-by: Christopher Schleiden --- actions-languageservice/src/context-providers/matrix.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions-languageservice/src/context-providers/matrix.ts b/actions-languageservice/src/context-providers/matrix.ts index 654c3f7..c13f6ac 100644 --- a/actions-languageservice/src/context-providers/matrix.ts +++ b/actions-languageservice/src/context-providers/matrix.ts @@ -48,7 +48,7 @@ export function getMatrixContext(workflowContext: WorkflowContext): data.Diction * - A configuration included via the "include" property * * By definition, "exclude" can't add new keys to the matrix. - * Additionally, "include" and "exclude are not propeties of 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: