diff --git a/actions-expressions/src/completion.test.ts b/actions-expressions/src/completion.test.ts index 0cadab9..0549e2b 100644 --- a/actions-expressions/src/completion.test.ts +++ b/actions-expressions/src/completion.test.ts @@ -35,6 +35,13 @@ const testContext = new Dictionary( value: new BooleanData(true) }) }, + { + key: "vars", + value: new Dictionary({ + key: "VAR_NAME", + value: new BooleanData(true) + }) + }, { key: "hashFiles(1,255)", value: new Dictionary() @@ -110,6 +117,14 @@ describe("auto-complete", () => { expect(testComplete("toJSON(secrets.")).toEqual(expected); }); + it("provides suggestions for variables", () => { + const expected = completionItems("VAR_NAME"); + + expect(testComplete("vars.V")).toEqual(expected); + expect(testComplete("1 == vars.F")).toEqual(expected); + expect(testComplete("toJSON(vars.")).toEqual(expected); + }); + it("provides suggestions for contexts in function call", () => { expect(testComplete("toJSON(env.|)")).toEqual(completionItems("BAR_TEST", "FOO")); }); diff --git a/actions-languageserver/src/context-providers.ts b/actions-languageserver/src/context-providers.ts index c0824ce..5d1f151 100644 --- a/actions-languageserver/src/context-providers.ts +++ b/actions-languageserver/src/context-providers.ts @@ -4,6 +4,7 @@ import {WorkflowContext} from "@github/actions-languageservice/context/workflow- import {Octokit} from "@octokit/rest"; import {getSecrets} from "./context-providers/secrets"; import {getStepsContext} from "./context-providers/steps"; +import {getVariables} from "./context-providers/variables"; import {RepositoryContext} from "./initializationOptions"; import {TTLCache} from "./utils/cache"; @@ -28,7 +29,8 @@ export function contextProviders( switch (name) { case "secrets": return await getSecrets(workflowContext, octokit, cache, repo, defaultContext); - + case "vars": + return await getVariables(workflowContext, octokit, cache, repo, defaultContext); case "steps": return await getStepsContext(octokit, cache, defaultContext, workflowContext); } diff --git a/actions-languageserver/src/context-providers/variables.ts b/actions-languageserver/src/context-providers/variables.ts new file mode 100644 index 0000000..5481bc4 --- /dev/null +++ b/actions-languageserver/src/context-providers/variables.ts @@ -0,0 +1,144 @@ +import {data, DescriptionDictionary} from "@github/actions-expressions"; +import {WorkflowContext} from "@github/actions-languageservice/context/workflow-context"; +import {isMapping, isString} from "@github/actions-workflow-parser"; +import {Octokit} from "@octokit/rest"; +import {Pair} from "@github/actions-expressions/data/expressiondata"; +import {RepositoryContext} from "../initializationOptions"; +import {StringData} from "@github/actions-expressions/data/index"; +import {TTLCache} from "../utils/cache"; + +export async function getVariables( + workflowContext: WorkflowContext, + octokit: Octokit, + cache: TTLCache, + repo: RepositoryContext, + defaultContext: DescriptionDictionary | undefined +): Promise { + let environmentName: string | undefined; + if (workflowContext?.job?.environment) { + if (isString(workflowContext.job.environment)) { + environmentName = workflowContext.job.environment.value; + } else if (isMapping(workflowContext.job.environment)) { + for (const x of workflowContext.job.environment) { + if (isString(x.key) && x.key.value === "name") { + if (isString(x.value)) { + environmentName = x.value.value; + } + break; + } + } + } + } + + const variables = await getRemoteVariables(octokit, cache, repo, environmentName); + + // Build combined map of variables + const variablesMap = new Map< + string, + { + key: string; + value: data.StringData; + description?: string; + } + >(); + + variables.repoVariables.forEach(variable => + variablesMap.set(variable.key.toLowerCase(), { + key: variable.key, + value: new data.StringData(variable.value.coerceString()), + description: `${variable.value.coerceString()} - Repository variable` + }) + ); + + // Override repo variables with environment veriables (if defined) + variables.environmentVariables.forEach(variable => + variablesMap.set(variable.key.toLowerCase(), { + key: variable.key, + value: new data.StringData(variable.value.coerceString()), + description: `${variable.value.coerceString()} - Variable for environment \`${environmentName}\`` + }) + ); + + const variablesContext = defaultContext || new DescriptionDictionary(); + + // Sort variables by key and add to context + Array.from(variablesMap.values()) + .sort((a, b) => a.key.localeCompare(b.key)) + .forEach(variable => variablesContext?.add(variable.key, variable.value, variable.description)); + + return variablesContext; +} + +export async function getRemoteVariables( + octokit: Octokit, + cache: TTLCache, + repo: RepositoryContext, + environmentName?: string +): Promise<{ + repoVariables: Pair[]; + environmentVariables: Pair[]; +}> { + // Repo variables + return { + repoVariables: await cache.get(`${repo.owner}/${repo.name}/vars`, undefined, () => + fetchVariables(octokit, repo.owner, repo.name) + ), + environmentVariables: + (environmentName && + (await cache.get(`${repo.owner}/${repo.name}/vars/environment/${environmentName}`, undefined, () => + fetchEnvironmentVariables(octokit, repo.id, environmentName) + ))) || + [] + }; +} + +async function fetchVariables(octokit: Octokit, owner: string, name: string): Promise { + try { + const response = (await octokit.paginate("GET /repos/{owner}/{repo}/actions/variables{?per_page}", { + owner: owner, + repo: name + })) as { + name: string; + value: string; + created_at: string; + updated_at: string; + }[]; + + return response.map(variable => { + return {key: variable.name, value: new StringData(variable.value)}; + }); + } catch (e) { + console.log("Failure to retrieve variables: ", e); + } + + return []; +} + +async function fetchEnvironmentVariables( + octokit: Octokit, + repositoryId: number, + environmentName: string +): Promise { + try { + const response = (await octokit.paginate( + "GET /repositories/{repository_id}/environments/{environment_name}/variables{?per_page}", + { + repository_id: repositoryId, + environment_name: environmentName + } + )) as { + name: string; + value: string; + created_at: string; + updated_at: string; + }[]; + + return response.map(variable => { + return {key: variable.name, value: new StringData(variable.value)}; + }); + } catch (e) { + console.log("Failure to retrieve environment variables: ", e); + } + + return []; +}