diff --git a/actions-expressions/package.json b/actions-expressions/package.json index 52f6bd9..57376e9 100755 --- a/actions-expressions/package.json +++ b/actions-expressions/package.json @@ -1,6 +1,6 @@ { "name": "@github/actions-expressions", - "version": "0.1.91", + "version": "0.1.92", "license": "MIT", "type": "module", "source": "./src/index.ts", diff --git a/actions-languageserver/package.json b/actions-languageserver/package.json index 2f373be..d594df9 100644 --- a/actions-languageserver/package.json +++ b/actions-languageserver/package.json @@ -1,6 +1,6 @@ { "name": "@github/actions-languageserver", - "version": "0.1.91", + "version": "0.1.92", "description": "Language server for GitHub Actions", "license": "MIT", "type": "module", @@ -38,8 +38,8 @@ "watch": "tsc --build tsconfig.build.json --watch" }, "dependencies": { - "@github/actions-languageservice": "^0.1.91", - "@github/actions-workflow-parser": "^0.1.91", + "@github/actions-languageservice": "^0.1.92", + "@github/actions-workflow-parser": "^0.1.92", "@octokit/rest": "^19.0.5", "vscode-languageserver": "^8.0.2", "vscode-languageserver-textdocument": "^1.0.7", 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..a6b1385 --- /dev/null +++ b/actions-languageserver/src/description-providers/action-input.ts @@ -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 { + 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; +} diff --git a/actions-languageservice/package.json b/actions-languageservice/package.json index 3278d3b..fa3e3c6 100644 --- a/actions-languageservice/package.json +++ b/actions-languageservice/package.json @@ -1,6 +1,6 @@ { "name": "@github/actions-languageservice", - "version": "0.1.91", + "version": "0.1.92", "description": "Language service for GitHub Actions", "license": "MIT", "type": "module", @@ -38,8 +38,8 @@ "watch": "tsc --build tsconfig.build.json --watch" }, "dependencies": { - "@github/actions-expressions": "^0.1.91", - "@github/actions-workflow-parser": "^0.1.91", + "@github/actions-expressions": "^0.1.92", + "@github/actions-workflow-parser": "^0.1.92", "vscode-languageserver-textdocument": "^1.0.7", "vscode-languageserver-types": "^3.17.2", "yaml": "^2.1.1" diff --git a/actions-languageservice/src/hover.test.ts b/actions-languageservice/src/hover.test.ts index 35dc317..6a3e35d 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 @@ -111,3 +130,37 @@ jobs: expect(result?.contents).toEqual("Invalid cron expression"); }); }); + +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 8ad7107..354af03 100644 --- a/actions-languageservice/src/hover.ts +++ b/actions-languageservice/src/hover.ts @@ -1,4 +1,5 @@ -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 {TokenResult} from "./utils/find-token"; import {File} from "@github/actions-workflow-parser/workflows/file"; @@ -6,14 +7,23 @@ import {isString} from "@github/actions-workflow-parser/templates/tokens/type-gu import {StringToken} from "@github/actions-workflow-parser/templates/tokens/string-token"; 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"; import {getSentence} from "@github/actions-workflow-parser/model/converter/cron"; +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() @@ -21,18 +31,13 @@ export async function hover(document: TextDocument, position: Position): Promise const result = parseWorkflow(file.name, [file], nullTrace); const tokenResult = findToken(position, result.value); - - if (result.value) { - return getHover(tokenResult); - } - return null; -} - -function getHover(tokenResult: TokenResult): Hover | null { const token = tokenResult.token; - if (!token) { + if (!token?.definition) { return null; } + + info(`Calculating hover for token with definition ${token.definition.key}`); + if (tokenResult.parent && isCronMappingValue(tokenResult)) { const tokenValue = (token as StringToken).value let description = getSentence(tokenValue); @@ -50,27 +55,38 @@ function getHover(tokenResult: TokenResult): Hover | null { range: mapRange(token.range) } as Hover; } - if (token.definition) { - info(`Calculating hover for token with definition ${token.definition.key}`); - let description = ""; - if (token.description) { - description = token.description; - } + let description = await getDescription(document, config, result, token, tokenResult.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) - } as Hover; + 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 null; + + return { + contents: description, + range: mapRange(token.range) + } satisfies 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; + } + + 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; } function isCronMappingValue(tokenResult: TokenResult): boolean { 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 */ diff --git a/actions-workflow-parser/package.json b/actions-workflow-parser/package.json index 9302656..4a525b8 100644 --- a/actions-workflow-parser/package.json +++ b/actions-workflow-parser/package.json @@ -1,6 +1,6 @@ { "name": "@github/actions-workflow-parser", - "version": "0.1.91", + "version": "0.1.92", "license": "MIT", "type": "module", "source": "./src/index.ts", @@ -40,7 +40,7 @@ "watch": "tsc --build tsconfig.build.json --watch" }, "dependencies": { - "@github/actions-expressions": "^0.1.91", + "@github/actions-expressions": "^0.1.92", "yaml": "^2.0.0-8" }, "engines": { diff --git a/browser-playground/package.json b/browser-playground/package.json index d2d32bb..d5656ad 100644 --- a/browser-playground/package.json +++ b/browser-playground/package.json @@ -1,12 +1,12 @@ { "name": "browser-playground", - "version": "0.1.91", + "version": "0.1.92", "description": "", "private": true, "main": "index.js", "type": "module", "dependencies": { - "@github/actions-languageserver": "^0.1.91", + "@github/actions-languageserver": "^0.1.92", "monaco-editor-webpack-plugin": "^7.0.1", "monaco-editor-workers": "^0.34.2", "monaco-languageclient": "^4.0.3", diff --git a/lerna.json b/lerna.json index 39120ec..99258c8 100644 --- a/lerna.json +++ b/lerna.json @@ -1,5 +1,5 @@ { "$schema": "node_modules/lerna/schemas/lerna-schema.json", "useWorkspaces": true, - "version": "0.1.91" + "version": "0.1.92" } diff --git a/package-lock.json b/package-lock.json index 90f53ee..ad4580d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ }, "actions-expressions": { "name": "@github/actions-expressions", - "version": "0.1.91", + "version": "0.1.92", "license": "MIT", "devDependencies": { "@types/jest": "^29.0.3", @@ -37,11 +37,11 @@ }, "actions-languageserver": { "name": "@github/actions-languageserver", - "version": "0.1.91", + "version": "0.1.92", "license": "MIT", "dependencies": { - "@github/actions-languageservice": "^0.1.91", - "@github/actions-workflow-parser": "^0.1.91", + "@github/actions-languageservice": "^0.1.92", + "@github/actions-workflow-parser": "^0.1.92", "@octokit/rest": "^19.0.5", "vscode-languageserver": "^8.0.2", "vscode-languageserver-textdocument": "^1.0.7", @@ -62,11 +62,11 @@ }, "actions-languageservice": { "name": "@github/actions-languageservice", - "version": "0.1.91", + "version": "0.1.92", "license": "MIT", "dependencies": { - "@github/actions-expressions": "^0.1.91", - "@github/actions-workflow-parser": "^0.1.91", + "@github/actions-expressions": "^0.1.92", + "@github/actions-workflow-parser": "^0.1.92", "vscode-languageserver-textdocument": "^1.0.7", "vscode-languageserver-types": "^3.17.2", "yaml": "^2.1.1" @@ -85,10 +85,10 @@ }, "actions-workflow-parser": { "name": "@github/actions-workflow-parser", - "version": "0.1.91", + "version": "0.1.92", "license": "MIT", "dependencies": { - "@github/actions-expressions": "^0.1.91", + "@github/actions-expressions": "^0.1.92", "yaml": "^2.0.0-8" }, "devDependencies": { @@ -107,10 +107,10 @@ } }, "browser-playground": { - "version": "0.1.91", + "version": "0.1.92", "license": "MIT", "dependencies": { - "@github/actions-languageserver": "^0.1.91", + "@github/actions-languageserver": "^0.1.92", "monaco-editor-webpack-plugin": "^7.0.1", "monaco-editor-workers": "^0.34.2", "monaco-languageclient": "^4.0.3", @@ -14439,8 +14439,8 @@ "@github/actions-languageserver": { "version": "file:actions-languageserver", "requires": { - "@github/actions-languageservice": "^0.1.91", - "@github/actions-workflow-parser": "^0.1.91", + "@github/actions-languageservice": "^0.1.92", + "@github/actions-workflow-parser": "^0.1.92", "@octokit/rest": "^19.0.5", "@types/jest": "^29.0.3", "jest": "^29.0.3", @@ -14457,8 +14457,8 @@ "@github/actions-languageservice": { "version": "file:actions-languageservice", "requires": { - "@github/actions-expressions": "^0.1.91", - "@github/actions-workflow-parser": "^0.1.91", + "@github/actions-expressions": "^0.1.92", + "@github/actions-workflow-parser": "^0.1.92", "@types/jest": "^29.0.3", "jest": "^29.0.3", "prettier": "^2.7.1", @@ -14473,7 +14473,7 @@ "@github/actions-workflow-parser": { "version": "file:actions-workflow-parser", "requires": { - "@github/actions-expressions": "^0.1.91", + "@github/actions-expressions": "^0.1.92", "@types/jest": "^29.0.3", "@typescript-eslint/eslint-plugin": "^5.40.0", "@typescript-eslint/parser": "^5.40.0", @@ -17400,7 +17400,7 @@ "browser-playground": { "version": "file:browser-playground", "requires": { - "@github/actions-languageserver": "^0.1.91", + "@github/actions-languageserver": "^0.1.92", "css-loader": "^6.7.2", "monaco-editor-webpack-plugin": "^7.0.1", "monaco-editor-workers": "^0.34.2",