Rename folders
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
import {Octokit} from "@octokit/rest";
|
||||
|
||||
export function getClient(token: string, userAgent?: string): Octokit {
|
||||
return new Octokit({
|
||||
auth: token,
|
||||
userAgent: userAgent || `GitHub Actions Language Server`
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export enum Commands {
|
||||
ClearCache = "cacheClear"
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import {documentLinks, hover, validate, ValidationConfig} from "@github/actions-languageservice";
|
||||
import {registerLogger, setLogLevel} from "@github/actions-languageservice/log";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {
|
||||
CompletionItem,
|
||||
Connection,
|
||||
DocumentLink,
|
||||
DocumentLinkParams,
|
||||
ExecuteCommandParams,
|
||||
Hover,
|
||||
HoverParams,
|
||||
InitializeParams,
|
||||
InitializeResult,
|
||||
TextDocumentPositionParams,
|
||||
TextDocuments,
|
||||
TextDocumentSyncKind
|
||||
} from "vscode-languageserver";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {getClient} from "./client";
|
||||
import {Commands} from "./commands";
|
||||
import {contextProviders} from "./context-providers";
|
||||
import {descriptionProvider} from "./description-provider";
|
||||
import {getFileProvider} from "./file-provider";
|
||||
import {InitializationOptions, RepositoryContext} from "./initializationOptions";
|
||||
import {onCompletion} from "./on-completion";
|
||||
import {ReadFileRequest, Requests} from "./request";
|
||||
import {fetchActionMetadata} from "./utils/action-metadata";
|
||||
import {TTLCache} from "./utils/cache";
|
||||
import {timeOperation} from "./utils/timer";
|
||||
import {valueProviders} from "./value-providers";
|
||||
|
||||
export function initConnection(connection: Connection) {
|
||||
const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
|
||||
|
||||
let client: Octokit | undefined;
|
||||
let repos: RepositoryContext[] = [];
|
||||
const cache = new TTLCache();
|
||||
|
||||
let hasWorkspaceFolderCapability = false;
|
||||
let hasDiagnosticRelatedInformationCapability = false;
|
||||
|
||||
// Register remote console logger with language service
|
||||
registerLogger(connection.console);
|
||||
|
||||
connection.onInitialize((params: InitializeParams) => {
|
||||
const capabilities = params.capabilities;
|
||||
|
||||
hasWorkspaceFolderCapability = !!(capabilities.workspace && !!capabilities.workspace.workspaceFolders);
|
||||
hasDiagnosticRelatedInformationCapability = !!(
|
||||
capabilities.textDocument &&
|
||||
capabilities.textDocument.publishDiagnostics &&
|
||||
capabilities.textDocument.publishDiagnostics.relatedInformation
|
||||
);
|
||||
|
||||
const options: InitializationOptions = params.initializationOptions;
|
||||
|
||||
if (options.sessionToken) {
|
||||
client = getClient(options.sessionToken, options.userAgent);
|
||||
}
|
||||
|
||||
if (options.repos) {
|
||||
repos = options.repos;
|
||||
}
|
||||
|
||||
if (options.logLevel !== undefined) {
|
||||
setLogLevel(options.logLevel);
|
||||
}
|
||||
|
||||
const result: InitializeResult = {
|
||||
capabilities: {
|
||||
textDocumentSync: TextDocumentSyncKind.Full,
|
||||
completionProvider: {
|
||||
resolveProvider: false,
|
||||
triggerCharacters: [":", "."]
|
||||
},
|
||||
hoverProvider: true,
|
||||
documentLinkProvider: {
|
||||
resolveProvider: false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (hasWorkspaceFolderCapability) {
|
||||
result.capabilities.workspace = {
|
||||
workspaceFolders: {
|
||||
supported: true
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
// The content of a text document has changed. This event is emitted
|
||||
// when the text document first opened or when its content has changed.
|
||||
documents.onDidChangeContent(change => {
|
||||
return timeOperation("validation", async () => await validateTextDocument(change.document));
|
||||
});
|
||||
|
||||
async function validateTextDocument(textDocument: TextDocument): Promise<void> {
|
||||
const repoContext = repos.find(repo => textDocument.uri.startsWith(repo.workspaceUri));
|
||||
|
||||
const config: ValidationConfig = {
|
||||
valueProviderConfig: valueProviders(client, repoContext, cache),
|
||||
contextProviderConfig: contextProviders(client, repoContext, cache),
|
||||
fetchActionMetadata: async action => {
|
||||
if (client) {
|
||||
return await fetchActionMetadata(client, cache, action);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
},
|
||||
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
|
||||
return await connection.sendRequest(Requests.ReadFile, {path} satisfies ReadFileRequest);
|
||||
})
|
||||
};
|
||||
|
||||
const result = await validate(textDocument, config);
|
||||
await connection.sendDiagnostics({uri: textDocument.uri, diagnostics: result});
|
||||
}
|
||||
|
||||
connection.onCompletion(async ({position, textDocument}: TextDocumentPositionParams): Promise<CompletionItem[]> => {
|
||||
return timeOperation(
|
||||
"completion",
|
||||
async () =>
|
||||
await onCompletion(
|
||||
connection,
|
||||
position,
|
||||
documents.get(textDocument.uri)!,
|
||||
client,
|
||||
repos.find(repo => textDocument.uri.startsWith(repo.workspaceUri)),
|
||||
cache
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
connection.onHover(async ({position, textDocument}: HoverParams): Promise<Hover | null> => {
|
||||
return timeOperation("hover", async () => {
|
||||
const repoContext = repos.find(repo => textDocument.uri.startsWith(repo.workspaceUri));
|
||||
return await hover(documents.get(textDocument.uri)!, position, {
|
||||
descriptionProvider: descriptionProvider(client, cache),
|
||||
contextProviderConfig: repoContext && contextProviders(client, repoContext, cache),
|
||||
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
|
||||
return await connection.sendRequest(Requests.ReadFile, {path});
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
connection.onRequest("workspace/executeCommand", (params: ExecuteCommandParams) => {
|
||||
if (params.command === Commands.ClearCache) {
|
||||
cache.clear();
|
||||
documents.all().forEach(validateTextDocument);
|
||||
}
|
||||
});
|
||||
|
||||
connection.onDocumentLinks(async ({textDocument}: DocumentLinkParams): Promise<DocumentLink[] | null> => {
|
||||
return documentLinks(documents.get(textDocument.uri)!);
|
||||
});
|
||||
|
||||
// Make the text document manager listen on the connection
|
||||
// for open, change and close text document events
|
||||
documents.listen(connection);
|
||||
|
||||
// Listen on the connection
|
||||
connection.listen();
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import {DescriptionDictionary} from "@github/actions-expressions";
|
||||
import {ContextProviderConfig} from "@github/actions-languageservice";
|
||||
import {WorkflowContext} from "@github/actions-languageservice/context/workflow-context";
|
||||
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";
|
||||
|
||||
export function contextProviders(
|
||||
client: Octokit | undefined,
|
||||
repo: RepositoryContext | undefined,
|
||||
cache: TTLCache
|
||||
): ContextProviderConfig {
|
||||
if (!repo || !client) {
|
||||
return {getContext: (_: string) => Promise.resolve(undefined)};
|
||||
}
|
||||
|
||||
const getContext = async (
|
||||
name: string,
|
||||
defaultContext: DescriptionDictionary | undefined,
|
||||
workflowContext: WorkflowContext
|
||||
) => {
|
||||
switch (name) {
|
||||
case "secrets":
|
||||
return await getSecrets(workflowContext, client, cache, repo, defaultContext);
|
||||
case "vars":
|
||||
return await getVariables(workflowContext, client, cache, repo, defaultContext);
|
||||
case "steps":
|
||||
return await getStepsContext(client, cache, defaultContext, workflowContext);
|
||||
}
|
||||
};
|
||||
|
||||
return {getContext};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import {ActionReference, ActionOutputs} from "@github/actions-languageservice/action";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {fetchActionMetadata} from "../utils/action-metadata";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
|
||||
export async function getActionOutputs(
|
||||
octokit: Octokit,
|
||||
cache: TTLCache,
|
||||
action: ActionReference
|
||||
): Promise<ActionOutputs | undefined> {
|
||||
return (await fetchActionMetadata(octokit, cache, action))?.outputs;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import {data, DescriptionDictionary} from "@github/actions-expressions";
|
||||
import {StringData} from "@github/actions-expressions/data/string";
|
||||
import {WorkflowContext} from "@github/actions-languageservice/context/workflow-context";
|
||||
import {isMapping, isString} from "@github/actions-workflow-parser";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {RepositoryContext} from "../initializationOptions";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
|
||||
export async function getSecrets(
|
||||
workflowContext: WorkflowContext,
|
||||
octokit: Octokit,
|
||||
cache: TTLCache,
|
||||
repo: RepositoryContext,
|
||||
defaultContext: DescriptionDictionary | undefined
|
||||
): Promise<DescriptionDictionary> {
|
||||
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 secretsContext = defaultContext || new DescriptionDictionary();
|
||||
try {
|
||||
const secrets = await getRemoteSecrets(octokit, cache, repo, environmentName);
|
||||
|
||||
// Build combined map of secrets
|
||||
const secretsMap = new Map<
|
||||
string,
|
||||
{
|
||||
key: string;
|
||||
value: data.StringData;
|
||||
description?: string;
|
||||
}
|
||||
>();
|
||||
|
||||
secrets.orgSecrets.forEach(secret =>
|
||||
secretsMap.set(secret.value.toLowerCase(), {
|
||||
key: secret.value,
|
||||
value: new data.StringData("***"),
|
||||
description: "Organization secret"
|
||||
})
|
||||
);
|
||||
|
||||
// Override org secrets with repo secrets
|
||||
secrets.repoSecrets.forEach(secret =>
|
||||
secretsMap.set(secret.value.toLowerCase(), {
|
||||
key: secret.value,
|
||||
value: new data.StringData("***"),
|
||||
description: "Repository secret"
|
||||
})
|
||||
);
|
||||
|
||||
// Override repo secrets with environment secrets (if defined)
|
||||
secrets.environmentSecrets.forEach(secret =>
|
||||
secretsMap.set(secret.value.toLowerCase(), {
|
||||
key: secret.value,
|
||||
value: new data.StringData("***"),
|
||||
description: `Secret for environment \`${environmentName}\``
|
||||
})
|
||||
);
|
||||
|
||||
// Sort secrets by key and add to context
|
||||
Array.from(secretsMap.values())
|
||||
.sort((a, b) => a.key.localeCompare(b.key))
|
||||
.forEach(secret => secretsContext?.add(secret.key, secret.value, secret.description));
|
||||
} catch (e: any) {
|
||||
if (e.status === 403 || e.status === 404) {
|
||||
secretsContext.complete = false;
|
||||
}
|
||||
}
|
||||
return secretsContext;
|
||||
}
|
||||
|
||||
async function getRemoteSecrets(
|
||||
octokit: Octokit,
|
||||
cache: TTLCache,
|
||||
repo: RepositoryContext,
|
||||
environmentName?: string
|
||||
): Promise<{
|
||||
repoSecrets: StringData[];
|
||||
environmentSecrets: StringData[];
|
||||
orgSecrets: StringData[];
|
||||
}> {
|
||||
return {
|
||||
repoSecrets: await cache.get(`${repo.owner}/${repo.name}/secrets`, undefined, () =>
|
||||
fetchSecrets(octokit, repo.owner, repo.name)
|
||||
),
|
||||
environmentSecrets:
|
||||
(environmentName &&
|
||||
(await cache.get(`${repo.owner}/${repo.name}/secrets/environment/${environmentName}`, undefined, () =>
|
||||
fetchEnvironmentSecrets(octokit, repo.id, environmentName)
|
||||
))) ||
|
||||
[],
|
||||
orgSecrets: await cache.get(`${repo.owner}/secrets`, undefined, () => fetchOrganizationSecrets(octokit, repo))
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchSecrets(octokit: Octokit, owner: string, name: string): Promise<StringData[]> {
|
||||
try {
|
||||
return await octokit.paginate(
|
||||
octokit.actions.listRepoSecrets,
|
||||
{
|
||||
owner,
|
||||
repo: name,
|
||||
per_page: 100
|
||||
},
|
||||
response => response.data.map(secret => new StringData(secret.name))
|
||||
);
|
||||
} catch (e) {
|
||||
console.log("Failure to retrieve secrets: ", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEnvironmentSecrets(
|
||||
octokit: Octokit,
|
||||
repositoryId: number,
|
||||
environmentName: string
|
||||
): Promise<StringData[]> {
|
||||
try {
|
||||
return await octokit.paginate(
|
||||
octokit.actions.listEnvironmentSecrets,
|
||||
{
|
||||
repository_id: repositoryId,
|
||||
environment_name: environmentName,
|
||||
per_page: 100
|
||||
},
|
||||
response => response.data.map(secret => new StringData(secret.name))
|
||||
);
|
||||
} catch (e) {
|
||||
console.log("Failure to retrieve environment secrets: ", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOrganizationSecrets(octokit: Octokit, repo: RepositoryContext): Promise<StringData[]> {
|
||||
if (!repo.organizationOwned) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const secrets: {name: string}[] = await octokit.paginate("GET /repos/{owner}/{repo}/actions/organization-secrets", {
|
||||
owner: repo.owner,
|
||||
repo: repo.name,
|
||||
per_page: 100
|
||||
});
|
||||
return secrets.map(secret => new StringData(secret.name));
|
||||
} catch (e) {
|
||||
console.log("Failure to retrieve organization secrets: ", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import {data, DescriptionDictionary} from "@github/actions-expressions";
|
||||
import {getStepsContext as getDefaultStepsContext} from "@github/actions-languageservice/context-providers/steps";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import {createWorkflowContext} from "../test-utils/workflow-context";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
import {getStepsContext} from "./steps";
|
||||
|
||||
const workflow = `
|
||||
name: Caching Primes
|
||||
|
||||
on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Cache Primes
|
||||
id: cache-primes
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: prime-numbers
|
||||
key: \${{ runner.os }}-primes
|
||||
|
||||
- name: Generate Prime Numbers
|
||||
if: steps.cache-primes.outputs.cache-hit != 'true'
|
||||
run: /generate-primes.sh -d prime-numbers
|
||||
|
||||
- name: Use Prime Numbers
|
||||
run: /primes.sh -d prime-numbers
|
||||
`;
|
||||
|
||||
// https://api.github.com/repos/actions/cache/contents/action.yml?ref=v3
|
||||
const actionMetadata = {
|
||||
name: "action.yml",
|
||||
path: "action.yml",
|
||||
sha: "3e158e3ee33f7e597ccf45c369a6e830dc8d43e3",
|
||||
size: 946,
|
||||
url: "https://api.github.com/repos/actions/cache/contents/action.yml?ref=v3",
|
||||
html_url: "https://github.com/actions/cache/blob/v3/action.yml",
|
||||
git_url: "https://api.github.com/repos/actions/cache/git/blobs/3e158e3ee33f7e597ccf45c369a6e830dc8d43e3",
|
||||
download_url: "https://raw.githubusercontent.com/actions/cache/v3/action.yml",
|
||||
type: "file",
|
||||
content:
|
||||
"bmFtZTogJ0NhY2hlJwpkZXNjcmlwdGlvbjogJ0NhY2hlIGFydGlmYWN0cyBs\naWtlIGRlcGVuZGVuY2llcyBhbmQgYnVpbGQgb3V0cHV0cyB0byBpbXByb3Zl\nIHdvcmtmbG93IGV4ZWN1dGlvbiB0aW1lJwphdXRob3I6ICdHaXRIdWInCmlu\ncHV0czoKICBwYXRoOgogICAgZGVzY3JpcHRpb246ICdBIGxpc3Qgb2YgZmls\nZXMsIGRpcmVjdG9yaWVzLCBhbmQgd2lsZGNhcmQgcGF0dGVybnMgdG8gY2Fj\naGUgYW5kIHJlc3RvcmUnCiAgICByZXF1aXJlZDogdHJ1ZQogIGtleToKICAg\nIGRlc2NyaXB0aW9uOiAnQW4gZXhwbGljaXQga2V5IGZvciByZXN0b3Jpbmcg\nYW5kIHNhdmluZyB0aGUgY2FjaGUnCiAgICByZXF1aXJlZDogdHJ1ZQogIHJl\nc3RvcmUta2V5czoKICAgIGRlc2NyaXB0aW9uOiAnQW4gb3JkZXJlZCBsaXN0\nIG9mIGtleXMgdG8gdXNlIGZvciByZXN0b3Jpbmcgc3RhbGUgY2FjaGUgaWYg\nbm8gY2FjaGUgaGl0IG9jY3VycmVkIGZvciBrZXkuIE5vdGUgYGNhY2hlLWhp\ndGAgcmV0dXJucyBmYWxzZSBpbiB0aGlzIGNhc2UuJwogICAgcmVxdWlyZWQ6\nIGZhbHNlCiAgdXBsb2FkLWNodW5rLXNpemU6CiAgICBkZXNjcmlwdGlvbjog\nJ1RoZSBjaHVuayBzaXplIHVzZWQgdG8gc3BsaXQgdXAgbGFyZ2UgZmlsZXMg\nZHVyaW5nIHVwbG9hZCwgaW4gYnl0ZXMnCiAgICByZXF1aXJlZDogZmFsc2UK\nb3V0cHV0czoKICBjYWNoZS1oaXQ6CiAgICBkZXNjcmlwdGlvbjogJ0EgYm9v\nbGVhbiB2YWx1ZSB0byBpbmRpY2F0ZSBhbiBleGFjdCBtYXRjaCB3YXMgZm91\nbmQgZm9yIHRoZSBwcmltYXJ5IGtleScKcnVuczoKICB1c2luZzogJ25vZGUx\nNicKICBtYWluOiAnZGlzdC9yZXN0b3JlL2luZGV4LmpzJwogIHBvc3Q6ICdk\naXN0L3NhdmUvaW5kZXguanMnCiAgcG9zdC1pZjogJ3N1Y2Nlc3MoKScKYnJh\nbmRpbmc6CiAgaWNvbjogJ2FyY2hpdmUnCiAgY29sb3I6ICdncmF5LWRhcmsn\nCg==\n",
|
||||
encoding: "base64",
|
||||
_links: {
|
||||
self: "https://api.github.com/repos/actions/cache/contents/action.yml?ref=v3",
|
||||
git: "https://api.github.com/repos/actions/cache/git/blobs/3e158e3ee33f7e597ccf45c369a6e830dc8d43e3",
|
||||
html: "https://github.com/actions/cache/blob/v3/action.yml"
|
||||
}
|
||||
};
|
||||
|
||||
it("returns default context when job is undefined", async () => {
|
||||
const workflowContext = await createWorkflowContext(workflow, undefined);
|
||||
const defaultContext = getDefaultStepsContext(workflowContext);
|
||||
|
||||
const stepsContext = await getStepsContext(new Octokit(), new TTLCache(), defaultContext, workflowContext);
|
||||
expect(stepsContext).toEqual(defaultContext);
|
||||
});
|
||||
|
||||
it("adds action outputs", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/cache/contents/action.yml?ref=v3", actionMetadata);
|
||||
|
||||
const workflowContext = await createWorkflowContext(workflow, "build");
|
||||
const defaultContext = getDefaultStepsContext(workflowContext);
|
||||
|
||||
const stepsContext = await getStepsContext(
|
||||
new Octokit({
|
||||
request: {
|
||||
fetch: mock
|
||||
}
|
||||
}),
|
||||
new TTLCache(),
|
||||
defaultContext,
|
||||
workflowContext
|
||||
);
|
||||
expect(stepsContext).toBeDefined();
|
||||
|
||||
expect(stepsContext).toEqual(
|
||||
new DescriptionDictionary({
|
||||
key: "cache-primes",
|
||||
value: new DescriptionDictionary(
|
||||
{
|
||||
key: "outputs",
|
||||
value: new DescriptionDictionary({
|
||||
key: "cache-hit",
|
||||
value: new data.StringData("A boolean value to indicate an exact match was found for the primary key"),
|
||||
description: "A boolean value to indicate an exact match was found for the primary key"
|
||||
})
|
||||
},
|
||||
{
|
||||
key: "conclusion",
|
||||
value: new data.Null(),
|
||||
description:
|
||||
"The result of a completed step after `continue-on-error` is applied. Possible values are `success`, `failure`, `cancelled`, or `skipped`. When a `continue-on-error` step fails, the `outcome` is `failure`, but the final conclusion is `success`."
|
||||
},
|
||||
{
|
||||
key: "outcome",
|
||||
value: new data.Null(),
|
||||
description:
|
||||
"The result of a completed step before `continue-on-error` is applied. Possible values are `success`, `failure`, `cancelled`, or `skipped`. When a `continue-on-error` step fails, the `outcome` is `failure`, but the final conclusion is `success`."
|
||||
}
|
||||
)
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import {data, DescriptionDictionary, isDescriptionDictionary} from "@github/actions-expressions";
|
||||
import {parseActionReference} from "@github/actions-languageservice/action";
|
||||
import {WorkflowContext} from "@github/actions-languageservice/context/workflow-context";
|
||||
import {isActionStep} from "@github/actions-workflow-parser/model/type-guards";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
import {getActionOutputs} from "./action-outputs";
|
||||
|
||||
export async function getStepsContext(
|
||||
octokit: Octokit,
|
||||
cache: TTLCache,
|
||||
defaultContext: DescriptionDictionary | undefined,
|
||||
workflowContext: WorkflowContext
|
||||
): Promise<DescriptionDictionary | undefined> {
|
||||
if (!defaultContext || !workflowContext.job) {
|
||||
return defaultContext;
|
||||
}
|
||||
|
||||
// The default context includes the set of valid
|
||||
// step ids that can be used in expressions
|
||||
const contextSteps = new Set<string>();
|
||||
for (const {key} of defaultContext.pairs()) {
|
||||
contextSteps.add(key);
|
||||
}
|
||||
|
||||
// Copy the default context for each step
|
||||
// If the step is an action, add the action outputs to the context
|
||||
const stepsContext = new DescriptionDictionary();
|
||||
for (const step of workflowContext.job.steps) {
|
||||
if (!contextSteps.has(step.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const defaultStepContext = defaultContext.get(step.id);
|
||||
if (!defaultStepContext) {
|
||||
stepsContext.add(step.id, new data.Null());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!isActionStep(step) || !isDescriptionDictionary(defaultStepContext)) {
|
||||
stepsContext.add(step.id, defaultStepContext);
|
||||
continue;
|
||||
}
|
||||
|
||||
const action = parseActionReference(step.uses.value);
|
||||
if (!action) {
|
||||
stepsContext.add(step.id, defaultStepContext);
|
||||
continue;
|
||||
}
|
||||
|
||||
const stepContext = new DescriptionDictionary();
|
||||
for (const {key, value, description} of defaultStepContext.pairs()) {
|
||||
switch (key) {
|
||||
case "outputs":
|
||||
const outputs = await getActionOutputs(octokit, cache, action);
|
||||
if (!outputs) {
|
||||
stepContext.add(key, value, description);
|
||||
continue;
|
||||
}
|
||||
const outputsDict = new DescriptionDictionary();
|
||||
for (const [key, value] of Object.entries(outputs)) {
|
||||
outputsDict.add(key, new data.StringData(value.description), value.description);
|
||||
}
|
||||
stepContext.add("outputs", outputsDict);
|
||||
break;
|
||||
default:
|
||||
stepContext.add(key, value, description);
|
||||
}
|
||||
}
|
||||
stepsContext.add(step.id, stepContext);
|
||||
}
|
||||
|
||||
return stepsContext;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
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<DescriptionDictionary | undefined> {
|
||||
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 variablesContext = defaultContext || new DescriptionDictionary();
|
||||
try {
|
||||
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.organizationVariables.forEach(variable =>
|
||||
variablesMap.set(variable.key.toLowerCase(), {
|
||||
key: variable.key,
|
||||
value: new data.StringData(variable.value.coerceString()),
|
||||
description: `${variable.value.coerceString()} - Organization variable`
|
||||
})
|
||||
);
|
||||
|
||||
// Override org variables with repo variables
|
||||
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}\``
|
||||
})
|
||||
);
|
||||
|
||||
// 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));
|
||||
} catch (e: any) {
|
||||
if (e.status === 403 || e.status === 404) {
|
||||
variablesContext.complete = false;
|
||||
}
|
||||
}
|
||||
return variablesContext;
|
||||
}
|
||||
|
||||
export async function getRemoteVariables(
|
||||
octokit: Octokit,
|
||||
cache: TTLCache,
|
||||
repo: RepositoryContext,
|
||||
environmentName?: string
|
||||
): Promise<{
|
||||
repoVariables: Pair[];
|
||||
environmentVariables: Pair[];
|
||||
organizationVariables: 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)
|
||||
))) ||
|
||||
[],
|
||||
organizationVariables: await cache.get(`${repo.owner}/vars`, undefined, () =>
|
||||
fetchOrganizationVariables(octokit, repo)
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchVariables(octokit: Octokit, owner: string, name: string): Promise<Pair[]> {
|
||||
try {
|
||||
return await octokit.paginate(
|
||||
octokit.actions.listRepoVariables,
|
||||
{
|
||||
owner: owner,
|
||||
repo: name,
|
||||
per_page: 100
|
||||
},
|
||||
response =>
|
||||
response.data.map(variable => {
|
||||
return {key: variable.name, value: new StringData(variable.value)};
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
console.log("Failure to retrieve variables: ", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchEnvironmentVariables(
|
||||
octokit: Octokit,
|
||||
repositoryId: number,
|
||||
environmentName: string
|
||||
): Promise<Pair[]> {
|
||||
try {
|
||||
return await octokit.paginate(
|
||||
octokit.actions.listEnvironmentVariables,
|
||||
{
|
||||
repository_id: repositoryId,
|
||||
environment_name: environmentName,
|
||||
per_page: 100
|
||||
},
|
||||
response =>
|
||||
response.data.map(variable => {
|
||||
return {key: variable.name, value: new StringData(variable.value)};
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
console.log("Failure to retrieve environment variables: ", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOrganizationVariables(octokit: Octokit, repo: RepositoryContext): Promise<Pair[]> {
|
||||
if (!repo.organizationOwned) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const variables: {name: string; value: string}[] = await octokit.paginate(
|
||||
"GET /repos/{owner}/{repo}/actions/organization-variables",
|
||||
{
|
||||
owner: repo.owner,
|
||||
repo: repo.name,
|
||||
per_page: 100
|
||||
}
|
||||
);
|
||||
return variables.map(variable => {
|
||||
return {key: variable.name, value: new StringData(variable.value)};
|
||||
});
|
||||
} catch (e) {
|
||||
console.log("Failure to retrieve organization variables: ", e);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {DescriptionProvider} from "@github/actions-languageservice/hover";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {getActionInputDescription} from "./description-providers/action-input";
|
||||
import {TTLCache} from "./utils/cache";
|
||||
|
||||
export function descriptionProvider(client: Octokit | undefined, cache: TTLCache): DescriptionProvider {
|
||||
const getDescription: DescriptionProvider["getDescription"] = async (context, token, path) => {
|
||||
if (!client) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parent = path[path.length - 1];
|
||||
if (context.step && parent.definition?.key === "step-with") {
|
||||
return await getActionInputDescription(client, cache, context.step, token);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
getDescription
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import {StringToken} from "@github/actions-workflow-parser/templates/tokens/string-token";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import fetchMock from "fetch-mock";
|
||||
|
||||
import {createWorkflowContext} from "../test-utils/workflow-context";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
import {getActionInputDescription} from "./action-input";
|
||||
|
||||
const workflow = `
|
||||
name: Hello World
|
||||
on: workflow_dispatch
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
`;
|
||||
|
||||
// A simplified version of the action.yml file from actions/checkout
|
||||
const actionMetadataContent = `
|
||||
name: 'Checkout'
|
||||
description: 'Checkout a Git repository at a particular version'
|
||||
inputs:
|
||||
repository:
|
||||
description: Repository name with owner. For example, actions/checkout
|
||||
default: \${{ github.repository }}
|
||||
ref:
|
||||
description: The branch, tag or SHA to checkout.
|
||||
required: true
|
||||
token:
|
||||
description: Personal access token (PAT) used to fetch the repository.
|
||||
default: \${{ github.token }}
|
||||
repo:
|
||||
description: 'Repository name with owner. For example, actions/checkout'
|
||||
deprecationMessage: 'Use repository instead'
|
||||
runs:
|
||||
using: node16
|
||||
main: dist/index.js
|
||||
post: dist/index.js
|
||||
`;
|
||||
|
||||
// Based on https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3
|
||||
const actionMetadata = {
|
||||
name: "action.yml",
|
||||
path: "action.yml",
|
||||
sha: "cab09ebd3a964aba67b57f9727f5f6fff1372b04",
|
||||
size: 3649,
|
||||
url: "https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3",
|
||||
html_url: "https://github.com/actions/checkout/blob/v3/action.yml",
|
||||
git_url: "https://api.github.com/repos/actions/checkout/git/blobs/cab09ebd3a964aba67b57f9727f5f6fff1372b04",
|
||||
download_url: "https://raw.githubusercontent.com/actions/checkout/v3/action.yml",
|
||||
type: "file",
|
||||
content: Buffer.from(actionMetadataContent).toString("base64"),
|
||||
encoding: "base64",
|
||||
_links: {
|
||||
self: "https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3",
|
||||
git: "https://api.github.com/repos/actions/checkout/git/blobs/cab09ebd3a964aba67b57f9727f5f6fff1372b04",
|
||||
html: "https://github.com/actions/checkout/blob/v3/action.yml"
|
||||
}
|
||||
};
|
||||
|
||||
async function getDescription(input: string, mock: fetchMock.FetchMockSandbox) {
|
||||
const workflowContext = await createWorkflowContext(workflow, "build", 0);
|
||||
|
||||
return await getActionInputDescription(
|
||||
new Octokit({
|
||||
request: {
|
||||
fetch: mock
|
||||
}
|
||||
}),
|
||||
new TTLCache(),
|
||||
workflowContext.step!,
|
||||
new StringToken(undefined, undefined, input, undefined)
|
||||
);
|
||||
}
|
||||
|
||||
describe("action descriptions", () => {
|
||||
it("optional input", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", actionMetadata);
|
||||
|
||||
expect(await getDescription("repository", mock)).toEqual(
|
||||
"Repository name with owner. For example, actions/checkout"
|
||||
);
|
||||
});
|
||||
|
||||
it("required input", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", actionMetadata);
|
||||
|
||||
expect(await getDescription("ref", mock)).toEqual("The branch, tag or SHA to checkout.\n\n**Required**");
|
||||
});
|
||||
|
||||
it("deprecated input", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", actionMetadata);
|
||||
|
||||
expect(await getDescription("repo", mock)).toEqual(
|
||||
"Repository name with owner. For example, actions/checkout\n\n**Deprecated**"
|
||||
);
|
||||
});
|
||||
|
||||
it("invalid input", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", actionMetadata);
|
||||
|
||||
expect(await getDescription("typo", mock)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("action does not exist", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", 404)
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yaml?ref=v3", 404);
|
||||
|
||||
expect(await getDescription("repository", mock)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("invalid permissions", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", 403);
|
||||
|
||||
expect(await getDescription("repository", mock)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import {parseActionReference} from "@github/actions-languageservice/action";
|
||||
import {isString} from "@github/actions-workflow-parser";
|
||||
import {isActionStep} from "@github/actions-workflow-parser/model/type-guards";
|
||||
import {Step} from "@github/actions-workflow-parser/model/workflow-template";
|
||||
import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {fetchActionMetadata} from "../utils/action-metadata";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
|
||||
export async function getActionInputDescription(
|
||||
client: Octokit,
|
||||
cache: TTLCache,
|
||||
step: Step,
|
||||
token: TemplateToken
|
||||
): Promise<string | undefined> {
|
||||
if (!isActionStep(step)) {
|
||||
return undefined;
|
||||
}
|
||||
const action = parseActionReference(step.uses.value);
|
||||
if (!action) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const inputName = isString(token) && token.value;
|
||||
if (!inputName) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const metadata = await fetchActionMetadata(client, cache, action);
|
||||
if (!metadata?.inputs) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const input = metadata.inputs[inputName];
|
||||
if (!input) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let description = input.description;
|
||||
|
||||
const deprecated = input.deprecationMessage !== undefined;
|
||||
|
||||
if (deprecated) {
|
||||
// Validation will include the deprecation message, so don't duplicate it here
|
||||
description += `\n\n**Deprecated**`;
|
||||
}
|
||||
|
||||
if (input.required) {
|
||||
description += "\n\n**Required**";
|
||||
}
|
||||
|
||||
return description;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {File} from "@github/actions-workflow-parser/workflows/file";
|
||||
import {FileProvider} from "@github/actions-workflow-parser/workflows/file-provider";
|
||||
import {fileIdentifier} from "@github/actions-workflow-parser/workflows/file-reference";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import path from "path";
|
||||
import {TTLCache} from "./utils/cache";
|
||||
|
||||
export function getFileProvider(
|
||||
client: Octokit | undefined,
|
||||
cache: TTLCache,
|
||||
workspace: string | undefined,
|
||||
readFile: (path: string) => Promise<string>
|
||||
): FileProvider | undefined {
|
||||
if (!client && !workspace) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
getFileContent: async (ref): Promise<File> => {
|
||||
if ("repository" in ref) {
|
||||
if (!client) {
|
||||
throw new Error("Remote file references are not supported with this configuration");
|
||||
}
|
||||
|
||||
return await cache.get(`file-content-${fileIdentifier(ref)}`, undefined, () =>
|
||||
fetchWorkflowFile(client, ref.owner, ref.repository, ref.path, ref.version)
|
||||
);
|
||||
}
|
||||
|
||||
if (!workspace) {
|
||||
throw new Error("Local file references are not supported with this configuration");
|
||||
}
|
||||
|
||||
const file = await readFile(path.join(workspace, ref.path));
|
||||
if (!file) {
|
||||
throw new Error(`File not found: ${ref.path}`);
|
||||
}
|
||||
return {
|
||||
name: ref.path,
|
||||
content: file
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWorkflowFile(
|
||||
client: Octokit,
|
||||
owner: string,
|
||||
repo: string,
|
||||
path: string,
|
||||
version: string
|
||||
): Promise<File> {
|
||||
const resp = await client.repos.getContent({
|
||||
owner,
|
||||
repo,
|
||||
path,
|
||||
ref: version
|
||||
});
|
||||
|
||||
// https://docs.github.com/rest/repos/contents?apiVersion=2022-11-28
|
||||
// Ignore directories (array of files) and non-file content
|
||||
if (
|
||||
resp.data === undefined ||
|
||||
Array.isArray(resp.data) ||
|
||||
resp.data.type !== "file" ||
|
||||
resp.data.content === undefined
|
||||
) {
|
||||
throw new Error("Not a file");
|
||||
}
|
||||
|
||||
return {
|
||||
name: path,
|
||||
content: Buffer.from(resp.data.content, "base64").toString("utf8")
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import {validate} from "@github/actions-languageservice";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
|
||||
describe("simple test", () => {
|
||||
it("should work", async () => {
|
||||
const doc = TextDocument.create("uri", "workflow", 1, "on: push");
|
||||
|
||||
const r = await validate(doc);
|
||||
expect(r).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import {Connection} from "vscode-languageserver";
|
||||
import {
|
||||
BrowserMessageReader,
|
||||
BrowserMessageWriter,
|
||||
createConnection as createBrowserConnection
|
||||
} from "vscode-languageserver/browser";
|
||||
import {createConnection as createNodeConnection} from "vscode-languageserver/node";
|
||||
|
||||
import {initConnection} from "./connection";
|
||||
|
||||
/** Helper function determining whether we are executing with node runtime */
|
||||
function isNode(): boolean {
|
||||
return typeof process !== "undefined" && process.versions?.node != null;
|
||||
}
|
||||
|
||||
function getConnection(): Connection {
|
||||
if (isNode()) {
|
||||
return createNodeConnection();
|
||||
} else {
|
||||
const messageReader = new BrowserMessageReader(self);
|
||||
const messageWriter = new BrowserMessageWriter(self);
|
||||
return createBrowserConnection(messageReader, messageWriter);
|
||||
}
|
||||
}
|
||||
|
||||
initConnection(getConnection());
|
||||
@@ -0,0 +1,53 @@
|
||||
import {LogLevel} from "@github/actions-languageservice/log";
|
||||
export {LogLevel} from "@github/actions-languageservice/log";
|
||||
|
||||
export interface InitializationOptions {
|
||||
/**
|
||||
* GitHub token that will be used to retrieve additional information from github.com
|
||||
*
|
||||
* Requires the `repo` and `workflow` scopes
|
||||
*/
|
||||
sessionToken?: string;
|
||||
|
||||
/**
|
||||
* Optional user agent to use when making calls to github.com
|
||||
*/
|
||||
userAgent?: string;
|
||||
|
||||
/**
|
||||
* List of repositories that the language server should be aware of
|
||||
*/
|
||||
repos?: RepositoryContext[];
|
||||
|
||||
/**
|
||||
* Desired log level
|
||||
*/
|
||||
logLevel?: LogLevel;
|
||||
}
|
||||
|
||||
export interface RepositoryContext {
|
||||
/**
|
||||
* Repository ID
|
||||
*/
|
||||
id: number;
|
||||
|
||||
/**
|
||||
* Repository owner
|
||||
*/
|
||||
owner: string;
|
||||
|
||||
/**
|
||||
* Indicates if the repository is owned by an organization
|
||||
*/
|
||||
organizationOwned: boolean;
|
||||
|
||||
/**
|
||||
* Repository name
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* Local workspace uri
|
||||
*/
|
||||
workspaceUri: string;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {complete} from "@github/actions-languageservice/complete";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {CompletionItem, Connection, Position} from "vscode-languageserver";
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {contextProviders} from "./context-providers";
|
||||
import {getFileProvider} from "./file-provider";
|
||||
import {RepositoryContext} from "./initializationOptions";
|
||||
import {Requests} from "./request";
|
||||
import {TTLCache} from "./utils/cache";
|
||||
import {valueProviders} from "./value-providers";
|
||||
|
||||
export async function onCompletion(
|
||||
connection: Connection,
|
||||
position: Position,
|
||||
document: TextDocument,
|
||||
client: Octokit | undefined,
|
||||
repoContext: RepositoryContext | undefined,
|
||||
cache: TTLCache
|
||||
): Promise<CompletionItem[]> {
|
||||
return await complete(document, position, {
|
||||
valueProviderConfig: repoContext && valueProviders(client, repoContext, cache),
|
||||
contextProviderConfig: repoContext && contextProviders(client, repoContext, cache),
|
||||
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
|
||||
return await connection.sendRequest(Requests.ReadFile, {path});
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export const Requests = {
|
||||
ReadFile: "actions/readFile"
|
||||
} as const;
|
||||
|
||||
export type ReadFileRequest = {
|
||||
path: string;
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import {WorkflowContext} from "@github/actions-languageservice/context/workflow-context";
|
||||
import {convertWorkflowTemplate, parseWorkflow, TraceWriter} from "@github/actions-workflow-parser";
|
||||
import {isJob} from "@github/actions-workflow-parser/model/type-guards";
|
||||
|
||||
const nullTrace: TraceWriter = {
|
||||
info: x => {},
|
||||
verbose: x => {},
|
||||
error: x => {}
|
||||
};
|
||||
|
||||
export async function createWorkflowContext(
|
||||
workflow: string,
|
||||
job?: string,
|
||||
stepIndex?: number
|
||||
): Promise<WorkflowContext> {
|
||||
const parsed = parseWorkflow({name: "test.yaml", content: workflow}, nullTrace);
|
||||
if (!parsed.value) {
|
||||
throw new Error("Failed to parse workflow");
|
||||
}
|
||||
const template = await convertWorkflowTemplate(parsed.context, parsed.value);
|
||||
const context: WorkflowContext = {uri: "test.yaml", template};
|
||||
|
||||
if (job) {
|
||||
const workflowJob = template.jobs.find(j => j.id.value === job);
|
||||
if (workflowJob) {
|
||||
if (isJob(workflowJob)) {
|
||||
context.job = workflowJob;
|
||||
} else {
|
||||
context.reusableWorkflowJob = workflowJob;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (stepIndex !== undefined) {
|
||||
context.step = context.job?.steps[stepIndex];
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import fetchMock from "fetch-mock";
|
||||
import {fetchActionMetadata} from "./action-metadata";
|
||||
import {TTLCache} from "./cache";
|
||||
|
||||
// A simplified version of the action.yml file from actions/checkout
|
||||
const actionMetadataContent = `
|
||||
name: 'Checkout'
|
||||
description: 'Checkout a Git repository at a particular version'
|
||||
inputs:
|
||||
repository:
|
||||
description: Repository name with owner. For example, actions/checkout
|
||||
default: \${{ github.repository }}
|
||||
runs:
|
||||
using: node16
|
||||
main: dist/index.js
|
||||
post: dist/index.js
|
||||
`;
|
||||
|
||||
// Based on https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3
|
||||
const actionMetadata = {
|
||||
name: "action.yml",
|
||||
path: "action.yml",
|
||||
sha: "cab09ebd3a964aba67b57f9727f5f6fff1372b04",
|
||||
size: 3649,
|
||||
url: "https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3",
|
||||
html_url: "https://github.com/actions/checkout/blob/v3/action.yml",
|
||||
git_url: "https://api.github.com/repos/actions/checkout/git/blobs/cab09ebd3a964aba67b57f9727f5f6fff1372b04",
|
||||
download_url: "https://raw.githubusercontent.com/actions/checkout/v3/action.yml",
|
||||
type: "file",
|
||||
content: Buffer.from(actionMetadataContent).toString("base64"),
|
||||
encoding: "base64",
|
||||
_links: {
|
||||
self: "https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3",
|
||||
git: "https://api.github.com/repos/actions/checkout/git/blobs/cab09ebd3a964aba67b57f9727f5f6fff1372b04",
|
||||
html: "https://github.com/actions/checkout/blob/v3/action.yml"
|
||||
}
|
||||
};
|
||||
|
||||
async function fetchActionWithMock(mock: fetchMock.FetchMockSandbox, cache?: TTLCache) {
|
||||
return await fetchActionMetadata(
|
||||
new Octokit({
|
||||
request: {
|
||||
fetch: mock
|
||||
}
|
||||
}),
|
||||
cache || new TTLCache(),
|
||||
{
|
||||
owner: "actions",
|
||||
name: "checkout",
|
||||
ref: "v3"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
describe("fetchActionMetadata", () => {
|
||||
it("fetches action metadata", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", actionMetadata);
|
||||
|
||||
const metadata = await fetchActionWithMock(mock);
|
||||
|
||||
expect(metadata?.inputs?.repository?.description).toEqual(
|
||||
"Repository name with owner. For example, actions/checkout"
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches action metadata at a path", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/some-path%2Faction.yml?ref=v3", actionMetadata);
|
||||
|
||||
const metadata = await fetchActionMetadata(
|
||||
new Octokit({
|
||||
request: {
|
||||
fetch: mock
|
||||
}
|
||||
}),
|
||||
new TTLCache(),
|
||||
{
|
||||
owner: "actions",
|
||||
name: "checkout",
|
||||
ref: "v3",
|
||||
path: "some-path"
|
||||
}
|
||||
);
|
||||
|
||||
expect(metadata?.inputs?.repository?.description).toEqual(
|
||||
"Repository name with owner. For example, actions/checkout"
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to .yaml extension on 404", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", 404)
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yaml?ref=v3", actionMetadata);
|
||||
|
||||
const metadata = await fetchActionWithMock(mock);
|
||||
|
||||
expect(metadata?.inputs?.repository?.description).toEqual(
|
||||
"Repository name with owner. For example, actions/checkout"
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches action metadata at a path with a .yaml extension", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/some-path%2Faction.yml?ref=v3", 404)
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/some-path%2Faction.yaml?ref=v3", actionMetadata);
|
||||
|
||||
const metadata = await fetchActionMetadata(
|
||||
new Octokit({
|
||||
request: {
|
||||
fetch: mock
|
||||
}
|
||||
}),
|
||||
new TTLCache(),
|
||||
{
|
||||
owner: "actions",
|
||||
name: "checkout",
|
||||
ref: "v3",
|
||||
path: "some-path"
|
||||
}
|
||||
);
|
||||
|
||||
expect(metadata?.inputs?.repository?.description).toEqual(
|
||||
"Repository name with owner. For example, actions/checkout"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fall back for other errors", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", 403);
|
||||
|
||||
const metadata = await fetchActionWithMock(mock);
|
||||
|
||||
expect(metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles invalid actions", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", 404)
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yaml?ref=v3", 404);
|
||||
|
||||
const metadata = await fetchActionWithMock(mock);
|
||||
|
||||
expect(metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it("caches action metadata", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", actionMetadata);
|
||||
|
||||
const cache = new TTLCache();
|
||||
|
||||
const metadata = await fetchActionWithMock(mock, cache);
|
||||
expect(metadata?.inputs?.repository?.description).toEqual(
|
||||
"Repository name with owner. For example, actions/checkout"
|
||||
);
|
||||
|
||||
const cachedMetadata = await fetchActionWithMock(mock, cache);
|
||||
expect(cachedMetadata?.inputs?.repository?.description).toEqual(
|
||||
"Repository name with owner. For example, actions/checkout"
|
||||
);
|
||||
});
|
||||
|
||||
it("caches action metadata", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", actionMetadata);
|
||||
|
||||
const cache = new TTLCache();
|
||||
|
||||
const metadata = await fetchActionWithMock(mock, cache);
|
||||
expect(metadata?.inputs?.repository?.description).toEqual(
|
||||
"Repository name with owner. For example, actions/checkout"
|
||||
);
|
||||
|
||||
const cachedMetadata = await fetchActionWithMock(mock, cache);
|
||||
expect(cachedMetadata?.inputs?.repository?.description).toEqual(
|
||||
"Repository name with owner. For example, actions/checkout"
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores directories", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", [actionMetadata]);
|
||||
|
||||
const metadata = await fetchActionWithMock(mock);
|
||||
|
||||
expect(metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores non-files", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", {
|
||||
type: "not-a-file",
|
||||
content: Buffer.from(actionMetadataContent).toString("base64")
|
||||
});
|
||||
|
||||
const metadata = await fetchActionWithMock(mock);
|
||||
|
||||
expect(metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores responses without content", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", {
|
||||
type: "file"
|
||||
});
|
||||
|
||||
const metadata = await fetchActionWithMock(mock);
|
||||
|
||||
expect(metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles emojis in action descriptions", async () => {
|
||||
const actionMetadataContent = `
|
||||
name: 'Checkout'
|
||||
description: 'Checkout a Git repository at a particular version'
|
||||
inputs:
|
||||
repository:
|
||||
description: 📦 Repository 📦 name with owner. For example, actions/checkout
|
||||
default: \${{ github.repository }}
|
||||
runs:
|
||||
using: node16
|
||||
main: dist/index.js
|
||||
post: dist/index.js
|
||||
`;
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", {
|
||||
type: "file",
|
||||
content: Buffer.from(actionMetadataContent).toString("base64")
|
||||
});
|
||||
|
||||
const metadata = await fetchActionWithMock(mock);
|
||||
|
||||
expect(metadata?.inputs?.repository?.description).toEqual(
|
||||
"📦 Repository 📦 name with owner. For example, actions/checkout"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import {ActionReference, ActionMetadata, actionIdentifier} from "@github/actions-languageservice/action";
|
||||
import {error} from "@github/actions-languageservice/log";
|
||||
import {Octokit, RestEndpointMethodTypes} from "@octokit/rest";
|
||||
import {parse} from "yaml";
|
||||
import {TTLCache} from "./cache";
|
||||
|
||||
export async function fetchActionMetadata(
|
||||
client: Octokit,
|
||||
cache: TTLCache,
|
||||
action: ActionReference
|
||||
): Promise<ActionMetadata | undefined> {
|
||||
const metadata = await cache.get(`${actionIdentifier(action)}/action-metadata`, undefined, () =>
|
||||
getActionMetadata(client, action)
|
||||
);
|
||||
if (!metadata) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions
|
||||
return parse(metadata);
|
||||
}
|
||||
|
||||
async function getActionMetadata(client: Octokit, action: ActionReference): Promise<string | undefined> {
|
||||
let resp: RestEndpointMethodTypes["repos"]["getContent"]["response"];
|
||||
try {
|
||||
resp = await fetchAction(client, action);
|
||||
} catch (e: any) {
|
||||
error(`Failed to fetch action metadata for ${actionIdentifier(action)}: '${e?.message || "<no details>"}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
// https://docs.github.com/rest/repos/contents?apiVersion=2022-11-28
|
||||
// Ignore directories (array of files) and non-file content
|
||||
if (resp.data === undefined || Array.isArray(resp.data) || resp.data.type !== "file") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (resp.data.content === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Buffer.from(resp.data.content, "base64").toString("utf8");
|
||||
}
|
||||
|
||||
async function fetchAction(client: Octokit, action: ActionReference) {
|
||||
try {
|
||||
return await client.repos.getContent({
|
||||
owner: action.owner,
|
||||
repo: action.name,
|
||||
ref: action.ref,
|
||||
path: action.path ? `${action.path}/action.yml` : "action.yml"
|
||||
});
|
||||
} catch (e: any) {
|
||||
// If action.yml doesn't exist, try action.yaml
|
||||
if (e.status === 404) {
|
||||
return await client.repos.getContent({
|
||||
owner: action.owner,
|
||||
repo: action.name,
|
||||
ref: action.ref,
|
||||
path: action.path ? `${action.path}/action.yaml` : "action.yaml"
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// From https://github.com/cschleiden/github-actions-parser/blob/a81dec9b7462dbcff08fbad0792f5ad549d9de7d/src/lib/workflowschema/workflowSchema.ts
|
||||
interface CacheEntry<T> {
|
||||
cachedAt: number;
|
||||
content: T;
|
||||
}
|
||||
|
||||
export class TTLCache {
|
||||
private cache = new Map<string, CacheEntry<unknown>>();
|
||||
|
||||
constructor(private defaultTTLinMS: number = 10 * 60 * 1000) {}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param key Key to cache value under
|
||||
* @param ttlInMS How long is the content valid. If optional, default value will be used
|
||||
* @param getter Function to retrieve content if not in cache
|
||||
*/
|
||||
async get<T>(key: string, ttlInMS: number | undefined, getter: () => Promise<T>): Promise<T> {
|
||||
const hasEntry = this.cache.has(key);
|
||||
const e = hasEntry && this.cache.get(key);
|
||||
if (hasEntry && e && e.cachedAt > Date.now() - (ttlInMS || this.defaultTTLinMS)) {
|
||||
return e.content as T;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await getter();
|
||||
|
||||
this.cache.set(key, {
|
||||
cachedAt: Date.now(),
|
||||
content
|
||||
});
|
||||
|
||||
return content;
|
||||
} catch (e) {
|
||||
this.cache.delete(key);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import {log} from "@github/actions-languageservice/log";
|
||||
|
||||
export async function timeOperation<T>(name: string, f: () => T): Promise<T> {
|
||||
const start = Date.now();
|
||||
const result = f();
|
||||
if (result instanceof Promise) {
|
||||
await result;
|
||||
}
|
||||
|
||||
const end = Date.now();
|
||||
|
||||
log(`${name} took ${end - start}ms`);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {ValueProviderConfig} from "@github/actions-languageservice";
|
||||
import {WorkflowContext} from "@github/actions-languageservice/context/workflow-context";
|
||||
import {ValueProviderKind} from "@github/actions-languageservice/value-providers/config";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {RepositoryContext} from "./initializationOptions";
|
||||
import {TTLCache} from "./utils/cache";
|
||||
import {getActionInputValues} from "./value-providers/action-inputs";
|
||||
import {getEnvironments} from "./value-providers/job-environment";
|
||||
import {getRunnerLabels} from "./value-providers/runs-on";
|
||||
|
||||
export function valueProviders(
|
||||
client: Octokit | undefined,
|
||||
repo: RepositoryContext | undefined,
|
||||
cache: TTLCache
|
||||
): ValueProviderConfig {
|
||||
if (!repo || !client) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {
|
||||
"job-environment": {
|
||||
kind: ValueProviderKind.AllowedValues,
|
||||
get: (_: WorkflowContext) => getEnvironments(client, cache, repo.owner, repo.name)
|
||||
},
|
||||
"job-environment-name": {
|
||||
kind: ValueProviderKind.AllowedValues,
|
||||
get: (_: WorkflowContext) => getEnvironments(client, cache, repo.owner, repo.name)
|
||||
},
|
||||
"runs-on": {
|
||||
kind: ValueProviderKind.SuggestedValues,
|
||||
get: (_: WorkflowContext) => getRunnerLabels(client, cache, repo.owner, repo.name)
|
||||
},
|
||||
"step-with": {
|
||||
kind: ValueProviderKind.AllowedValues,
|
||||
get: (context: WorkflowContext) => getActionInputValues(client, cache, context)
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {ActionInputs, ActionReference, parseActionReference} from "@github/actions-languageservice/action";
|
||||
import {WorkflowContext} from "@github/actions-languageservice/context/workflow-context";
|
||||
import {Value} from "@github/actions-languageservice/value-providers/config";
|
||||
import {isActionStep} from "@github/actions-workflow-parser/model/type-guards";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {fetchActionMetadata} from "../utils/action-metadata";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
|
||||
export async function getActionInputs(
|
||||
client: Octokit,
|
||||
cache: TTLCache,
|
||||
action: ActionReference
|
||||
): Promise<ActionInputs | undefined> {
|
||||
return (await fetchActionMetadata(client, cache, action))?.inputs;
|
||||
}
|
||||
|
||||
export async function getActionInputValues(
|
||||
client: Octokit,
|
||||
cache: TTLCache,
|
||||
context: WorkflowContext
|
||||
): Promise<Value[]> {
|
||||
if (!context.step || !isActionStep(context.step)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const action = parseActionReference(context.step.uses.value);
|
||||
if (!action) {
|
||||
return [];
|
||||
}
|
||||
const inputs = await getActionInputs(client, cache, action);
|
||||
if (!inputs) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Object.entries(inputs).map(([inputName, input]) => {
|
||||
return {
|
||||
label: inputName,
|
||||
description: input.description,
|
||||
insertText: `${inputName}: `,
|
||||
deprecated: input.deprecationMessage !== undefined
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import {Value} from "@github/actions-languageservice/value-providers/config";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
|
||||
export async function getEnvironments(client: Octokit, cache: TTLCache, owner: string, name: string): Promise<Value[]> {
|
||||
const environments = await cache.get(`${owner}/${name}/environments`, undefined, () =>
|
||||
fetchEnvironments(client, owner, name)
|
||||
);
|
||||
return Array.from(environments).map(env => ({label: env}));
|
||||
}
|
||||
|
||||
async function fetchEnvironments(client: Octokit, owner: string, name: string): Promise<string[]> {
|
||||
let environments: string[] = [];
|
||||
try {
|
||||
const response = await client.repos.getAllEnvironments({
|
||||
owner,
|
||||
repo: name
|
||||
});
|
||||
|
||||
if (response.data.environments) {
|
||||
environments = response.data.environments.map(env => env.name);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("Failure to retrieve environments: ", e);
|
||||
}
|
||||
|
||||
return environments;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import {log} from "@github/actions-languageservice/log";
|
||||
import {Value} from "@github/actions-languageservice/value-providers/config";
|
||||
import {DEFAULT_RUNNER_LABELS} from "@github/actions-languageservice/value-providers/default";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import {TTLCache} from "../utils/cache";
|
||||
|
||||
// Limitation: getRunnerLabels returns default hosted labels and labels for repository self-hosted runners.
|
||||
// It doesn't return labels for organization runners visible to the repository.
|
||||
export async function getRunnerLabels(client: Octokit, cache: TTLCache, owner: string, name: string): Promise<Value[]> {
|
||||
const repoLabels = await cache.get(`${owner}/${name}/runner-labels`, undefined, () =>
|
||||
fetchRunnerLabels(client, owner, name)
|
||||
);
|
||||
|
||||
for (const label of DEFAULT_RUNNER_LABELS) {
|
||||
repoLabels.add(label);
|
||||
}
|
||||
|
||||
return Array.from(repoLabels).map(label => ({label}));
|
||||
}
|
||||
|
||||
async function fetchRunnerLabels(client: Octokit, owner: string, name: string): Promise<Set<string>> {
|
||||
const labels = new Set<string>();
|
||||
try {
|
||||
const itor = client.paginate.iterator(client.actions.listSelfHostedRunnersForRepo, {
|
||||
owner,
|
||||
repo: name,
|
||||
per_page: 100
|
||||
});
|
||||
|
||||
for await (const response of itor) {
|
||||
for (const runner of response.data) {
|
||||
for (const label of runner.labels) {
|
||||
labels.add(label.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log(`Failure to retrieve runner labels: ${e}`);
|
||||
}
|
||||
|
||||
return labels;
|
||||
}
|
||||
Reference in New Issue
Block a user