diff --git a/actions-languageservice/src/complete.expressions.test.ts b/actions-languageservice/src/complete.expressions.test.ts index 6b5e41b..299ba69 100644 --- a/actions-languageservice/src/complete.expressions.test.ts +++ b/actions-languageservice/src/complete.expressions.test.ts @@ -674,6 +674,86 @@ jobs: }); }); + describe("job context", () => { + it("job context is suggested within a job", async () => { + const input = ` +on: push + +jobs: + test: + runs-on: ubuntu-latest + container: + image: node:14.16 + services: + nginx: + image: node:14.16 + steps: + - run: echo \${{ job.| }} +`; + + const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig); + expect(result.map(x => x.label)).toEqual(["container", "services", "status"]); + }); + + it("container context is suggested within a job container", async () => { + const input = ` +on: push + +jobs: + test: + runs-on: ubuntu-latest + container: + image: node:14.16 + steps: + - run: echo \${{ job.container.| }} +`; + + const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig); + expect(result.map(x => x.label)).toEqual(["id", "network"]); + }); + + it("services are suggested within a job services list", async () => { + const input = ` +on: push + +jobs: + test: + runs-on: ubuntu-latest + services: + nginx: + image: node:14.16 + redis: + image: redis + steps: + - run: echo \${{ job.services.| }} +`; + + const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig); + expect(result.map(x => x.label)).toEqual(["nginx", "redis"]); + }); + + it("services context is suggested within a job service", async () => { + const input = ` +on: push + +jobs: + test: + runs-on: ubuntu-latest + services: + nginx: + image: node:14.16 + ports: + - 80:8080 + - 90 + steps: + - run: echo \${{ job.services.nginx.| }} +`; + + const result = await complete(...getPositionFromCursor(input), undefined, contextProviderConfig); + expect(result.map(x => x.label)).toEqual(["id", "network", "ports"]); + }); + }); + it("context completion items include kind and insert text", async () => { const input = ` on: push diff --git a/actions-languageservice/src/context-providers/default.ts b/actions-languageservice/src/context-providers/default.ts index 5893c49..e80cd45 100644 --- a/actions-languageservice/src/context-providers/default.ts +++ b/actions-languageservice/src/context-providers/default.ts @@ -4,6 +4,7 @@ import {WorkflowContext} from "../context/workflow-context"; import {ContextProviderConfig} from "./config"; import {getGithubContext} from "./github"; import {getInputsContext} from "./inputs"; +import {getJobContext} from "./job"; import {getMatrixContext} from "./matrix"; import {getNeedsContext} from "./needs"; import {getStepsContext} from "./steps"; @@ -74,6 +75,9 @@ function getDefaultContext(name: string, workflowContext: WorkflowContext, mode: case "strategy": return getStrategyContext(workflowContext); + + case "job": + return getJobContext(workflowContext); } return undefined; diff --git a/actions-languageservice/src/context-providers/job.ts b/actions-languageservice/src/context-providers/job.ts new file mode 100644 index 0000000..89d31c2 --- /dev/null +++ b/actions-languageservice/src/context-providers/job.ts @@ -0,0 +1,67 @@ +import {data} from "@github/actions-expressions"; +import {isMapping, isSequence} from "@github/actions-workflow-parser"; +import {MappingToken} from "@github/actions-workflow-parser/templates/tokens/mapping-token"; +import {WorkflowContext} from "../context/workflow-context"; + +export function getJobContext(workflowContext: WorkflowContext): data.Dictionary { + // https://docs.github.com/en/actions/learn-github-actions/contexts#job-context + const jobContext = new data.Dictionary(); + const job = workflowContext.job; + if (!job) { + return jobContext; + } + + // Container + const jobContainer = job.container; + if (jobContainer && isMapping(jobContainer)) { + const containerContext = createContainerContext(jobContainer, false); + jobContext.add("container", containerContext); + } + + // Services + const jobServices = job.services; + if (jobServices && isMapping(jobServices)) { + const servicesContext = new data.Dictionary(); + for (const service of jobServices) { + if (!isMapping(service.value)) { + continue; + } + const serviceContext = createContainerContext(service.value, true); + servicesContext.add(service.key.toString(), serviceContext); + } + jobContext.add("services", servicesContext); + } + + // Status + jobContext.add("status", new data.Null()); + + return jobContext; +} + +function createContainerContext(container: MappingToken, isServices: boolean): data.Dictionary { + const containerContext = new data.Dictionary(); + for (const {key, value} of container) { + if (isSequence(value)) { + // service ports are the only thing that is part of the job context + if (key.toString() !== "ports") { + continue; + } + const ports = new data.Dictionary(); + for (const item of value) { + // We can determine the context mapping fully only if the port is defined + // as a mapping (i.e. :), single ports are assigned randomly + const portParts = item.toString().split(":"); + if (isServices && portParts.length === 2) { + ports.add(portParts[1], new data.StringData(portParts[0])); + } else { + // If the port isn't a mapping, just use null + ports.add(portParts[0], new data.Null()); + } + } + containerContext.add(key.toString(), ports); + } + } + containerContext.add("id", new data.Null()); + containerContext.add("network", new data.Null()); + return containerContext; +} diff --git a/actions-languageservice/src/validate.expressions.test.ts b/actions-languageservice/src/validate.expressions.test.ts index 9157a0b..ace4d85 100644 --- a/actions-languageservice/src/validate.expressions.test.ts +++ b/actions-languageservice/src/validate.expressions.test.ts @@ -224,6 +224,99 @@ jobs: }); }); + describe("job context", () => { + it("job.status", async () => { + const input = ` +on: push + +jobs: + test: + runs-on: ubuntu-latest + container: + image: node:14.16 + steps: + - run: echo \${{ job.container }} + - run: echo \${{ job.container.id }} + - run: echo \${{ job.container.network }} +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toEqual([]); + }); + + it("job.status", async () => { + const input = ` +on: push + +jobs: + test: + runs-on: ubuntu-latest + steps: + - run: echo \${{ job.status }} +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toEqual([]); + }); + + it("job.services.", async () => { + const input = ` +on: push + +jobs: + test: + runs-on: ubuntu-latest + services: + nginx: + image: node:14.16 + volumes: + - my_docker_volume:/volume_mount + ports: + - 80:8080 + steps: + - run: echo \${{ job.services.nginx }} + - run: echo \${{ job.services.nginx.id }} + - run: echo \${{ job.services.nginx.network }} + - run: echo \${{ job.services.nginx.ports }} +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toEqual([]); + }); + + it("job.services.", async () => { + const input = ` +on: push + +jobs: + test: + runs-on: ubuntu-latest + container: + image: node:14.16 + steps: + - run: echo \${{ job.container.tupperware }} +`; + const result = await validate(createDocument("wf.yaml", input)); + + expect(result).toEqual([ + { + message: "Context access might be invalid: tupperware", + range: { + end: { + character: 49, + line: 9 + }, + start: { + character: 18, + line: 9 + } + }, + severity: DiagnosticSeverity.Warning + } + ]); + }); + }); + describe("strategy context", () => { it("reference within a matrix job", async () => { const input = ` diff --git a/package-lock.json b/package-lock.json index fd48861..2fcc155 100644 --- a/package-lock.json +++ b/package-lock.json @@ -701,9 +701,9 @@ "link": true }, "node_modules/@github/actions-workflow-parser": { - "version": "0.0.35", - "resolved": "https://npm.pkg.github.com/download/@github/actions-workflow-parser/0.0.35/0e88d541486f7c8772c5640fc13fac630cfaeaa8", - "integrity": "sha512-OuNEuqUH4AOfWd2xb8VRl7KwJrtiZu1ic0b27lmadwvK8qgRhPHxvS+QICEc+JtFbbVWpAA2ymh8I1U35639mA==", + "version": "0.0.36", + "resolved": "https://npm.pkg.github.com/download/@github/actions-workflow-parser/0.0.36/f27b908936efa949a105d7192dd3787a15094d71", + "integrity": "sha512-ATResf0PCT/bftJgs2ROAo0gDMIKQ1X0HcB4GejuYTcyHxWfvC44EqvFZmMpurV4Camht20bqK3LSY4NoJ0aVQ==", "license": "MIT", "dependencies": { "@github/actions-expressions": "*", @@ -13539,9 +13539,9 @@ } }, "@github/actions-workflow-parser": { - "version": "0.0.35", - "resolved": "https://npm.pkg.github.com/download/@github/actions-workflow-parser/0.0.35/0e88d541486f7c8772c5640fc13fac630cfaeaa8", - "integrity": "sha512-OuNEuqUH4AOfWd2xb8VRl7KwJrtiZu1ic0b27lmadwvK8qgRhPHxvS+QICEc+JtFbbVWpAA2ymh8I1U35639mA==", + "version": "0.0.36", + "resolved": "https://npm.pkg.github.com/download/@github/actions-workflow-parser/0.0.36/f27b908936efa949a105d7192dd3787a15094d71", + "integrity": "sha512-ATResf0PCT/bftJgs2ROAo0gDMIKQ1X0HcB4GejuYTcyHxWfvC44EqvFZmMpurV4Camht20bqK3LSY4NoJ0aVQ==", "requires": { "@github/actions-expressions": "*", "yaml": "^2.0.0-8"