diff --git a/actions-languageserver/src/connection.ts b/actions-languageserver/src/connection.ts index af1bcad..d1650bc 100644 --- a/actions-languageserver/src/connection.ts +++ b/actions-languageserver/src/connection.ts @@ -23,6 +23,7 @@ import {TTLCache} from "./utils/cache"; import {valueProviders} from "./value-providers"; import {getActionInputs} from "./value-providers/action-inputs"; import {Commands} from "./commands"; +import {descriptionProvider} from "./description-provider"; export function initConnection(connection: Connection) { const documents: TextDocuments = new TextDocuments(TextDocument); @@ -120,7 +121,9 @@ export function initConnection(connection: Connection) { }); connection.onHover(async ({position, textDocument}: HoverParams): Promise => { - return hover(documents.get(textDocument.uri)!, position); + return hover(documents.get(textDocument.uri)!, position, { + descriptionProvider: descriptionProvider(sessionToken, cache) + }); }); connection.onRequest("workspace/executeCommand", (params: ExecuteCommandParams) => { diff --git a/actions-languageserver/src/description-provider.ts b/actions-languageserver/src/description-provider.ts new file mode 100644 index 0000000..bd724ed --- /dev/null +++ b/actions-languageserver/src/description-provider.ts @@ -0,0 +1,26 @@ +import {DescriptionProvider} from "@github/actions-languageservice/hover"; +import {Octokit} from "@octokit/rest"; +import {TTLCache} from "./utils/cache"; +import {getActionInputDescription} from "./description-providers/action-input"; + +export function descriptionProvider(sessionToken: string | undefined, cache: TTLCache): DescriptionProvider { + const octokit = + sessionToken && + new Octokit({ + auth: sessionToken + }); + + const getDescription: DescriptionProvider["getDescription"] = async (context, token, path) => { + if (!octokit) { + return undefined; + } + const parent = path[path.length - 1]; + if (context.step && parent.definition?.key === "step-with") { + return await getActionInputDescription(octokit, cache, context.step, token); + } + }; + + return { + getDescription + }; +} diff --git a/actions-languageserver/src/description-providers/action-input.ts b/actions-languageserver/src/description-providers/action-input.ts new file mode 100644 index 0000000..1f26abf --- /dev/null +++ b/actions-languageserver/src/description-providers/action-input.ts @@ -0,0 +1,35 @@ +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 { + 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; + } + + return metadata.inputs[inputName]?.description; +} diff --git a/actions-languageservice/src/hover.test.ts b/actions-languageservice/src/hover.test.ts index 1fce7e9..8581a27 100644 --- a/actions-languageservice/src/hover.test.ts +++ b/actions-languageservice/src/hover.test.ts @@ -1,6 +1,25 @@ -import {hover} from "./hover"; +import {isString} from "@github/actions-workflow-parser/."; +import {StringToken} from "@github/actions-workflow-parser/templates/tokens/string-token"; +import {DescriptionProvider, hover, HoverConfig} from "./hover"; import {getPositionFromCursor} from "./test-utils/cursor-position"; +function testHoverConfig(tokenValue: string, tokenKey: string, description?: string) { + return { + descriptionProvider: { + getDescription: async (_, token, __) => { + if (!isString(token)) { + throw new Error("Test provider only supports string tokens"); + } + + expect((token as StringToken).value).toEqual(tokenValue); + expect(token.definition!.key).toEqual(tokenKey); + + return description; + } + } satisfies DescriptionProvider + } satisfies HoverConfig +} + describe("hover", () => { it("on a key", async () => { const input = `o|n: push @@ -77,3 +96,37 @@ jobs: expect(result?.contents).toEqual("Runs your workflow when you push a commit or tag."); }); }); + +describe("hover with description provider", () => { + it("uses the description provider", async () => { + const input = ` +on: push +jobs: + build: + runs-on: [self-hosted] + steps: + - uses: actions/checkout@v2 + with: + ref|: main +`; + + const result = await hover(...getPositionFromCursor(input), testHoverConfig("ref", "string", "The branch, tag or SHA to checkout.")); + expect(result).not.toBeUndefined(); + expect(result?.contents).toEqual("The branch, tag or SHA to checkout."); + }); + + it("falls back to the token description", async () => { + const input = ` +on: push +jobs: + build: + runs-on: [self-hosted] + steps: + - uses|: actions/checkout@v2 +`; + + const result = await hover(...getPositionFromCursor(input), testHoverConfig("uses", "non-empty-string", undefined)); + expect(result).not.toBeUndefined(); + expect(result?.contents).toEqual("Selects an action to run as part of a step in your job. An action is a reusable unit of code. You can use an action defined in the same repository as the workflow, a public repository, or in a published Docker container image."); + }); +}); diff --git a/actions-languageservice/src/hover.ts b/actions-languageservice/src/hover.ts index 7d39495..4d5ebdd 100644 --- a/actions-languageservice/src/hover.ts +++ b/actions-languageservice/src/hover.ts @@ -1,49 +1,67 @@ -import {parseWorkflow} from "@github/actions-workflow-parser"; +import {convertWorkflowTemplate, parseWorkflow, ParseWorkflowResult} from "@github/actions-workflow-parser"; +import {ErrorPolicy} from "@github/actions-workflow-parser/model/convert"; import {TemplateToken} from "@github/actions-workflow-parser/templates/tokens/template-token"; import {File} from "@github/actions-workflow-parser/workflows/file"; import {Position, TextDocument} from "vscode-languageserver-textdocument"; import {Hover} from "vscode-languageserver-types"; +import {getWorkflowContext, WorkflowContext} from "./context/workflow-context"; import {info} from "./log"; import {nullTrace} from "./nulltrace"; import {findToken} from "./utils/find-token"; import {mapRange} from "./utils/range"; +export type DescriptionProvider = { + getDescription(context: WorkflowContext, token: TemplateToken, path: TemplateToken[]): Promise; +}; + +export type HoverConfig = { + descriptionProvider?: DescriptionProvider; +}; + // Render value description and Context when hovering over a key in a MappingToken -export async function hover(document: TextDocument, position: Position): Promise { +export async function hover(document: TextDocument, position: Position, config?: HoverConfig): Promise { const file: File = { name: document.uri, content: document.getText() }; const result = parseWorkflow(file.name, [file], nullTrace); - const {token} = findToken(position, result.value); - - if (result.value && token) { - return getHover(token); + const {token, path} = findToken(position, result.value); + if (!token?.definition) { + return null; } - return null; + + info(`Calculating hover for token with definition ${token.definition.key}`); + + let description = await getDescription(document, config, result, token, path); + + if (token.definition.evaluatorContext.length > 0) { + // Only add padding if there is a description + description += `${description.length > 0 ? `\n\n` : ""}**Context:** ${token.definition.evaluatorContext.join( + ", " + )}`; + } + + return { + contents: description, + range: mapRange(token.range) + } satisfies Hover; } -function getHover(token: TemplateToken): Hover | null { - if (token.definition) { - info(`Calculating hover for token with definition ${token.definition.key}`); - - let description = ""; - if (token.description) { - description = token.description; - } - - if (token.definition.evaluatorContext.length > 0) { - // Only add padding if there is a description - description += `${description.length > 0 ? `\n\n` : ""}**Context:** ${token.definition.evaluatorContext.join( - ", " - )}`; - } - - return { - contents: description, - range: mapRange(token.range) - } as Hover; +async function getDescription( + document: TextDocument, + config: HoverConfig | undefined, + result: ParseWorkflowResult | undefined, + token: TemplateToken, + path: TemplateToken[] +) { + const defaultDescription = token.description || ""; + if (!result?.value || !config?.descriptionProvider) { + return defaultDescription; } - return null; + + const template = convertWorkflowTemplate(result.context, result.value, ErrorPolicy.TryConversion); + const workflowContext = getWorkflowContext(document.uri, template, path); + const description = await config.descriptionProvider.getDescription(workflowContext, token, path); + return description || defaultDescription; } diff --git a/actions-languageservice/src/value-providers/config.ts b/actions-languageservice/src/value-providers/config.ts index 916a569..40b88df 100644 --- a/actions-languageservice/src/value-providers/config.ts +++ b/actions-languageservice/src/value-providers/config.ts @@ -4,7 +4,7 @@ export interface Value { /** Label of this value */ label: string; - /** Optional description to show when auto-completing or hovering */ + /** Optional description to show when auto-completing */ description?: string; /** Whether this value is deprecated */