Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion languageserver/src/connection.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import {documentLinks, hover, validate, ValidationConfig} from "@actions/languageservice";
import {documentLinks, getCodeActions, hover, validate, ValidationConfig} from "@actions/languageservice";
import {registerLogger, setLogLevel} from "@actions/languageservice/log";
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
import {Octokit} from "@octokit/rest";
import {
CodeAction,
CodeActionKind,
CodeActionParams,
CompletionItem,
Connection,
DocumentLink,
Expand Down Expand Up @@ -72,6 +75,9 @@ export function initConnection(connection: Connection) {
hoverProvider: true,
documentLinkProvider: {
resolveProvider: false
},
codeActionProvider: {
codeActionKinds: [CodeActionKind.QuickFix]
}
}
};
Expand Down Expand Up @@ -158,6 +164,14 @@ export function initConnection(connection: Connection) {
return documentLinks(getDocument(documents, textDocument), repoContext?.workspaceUri);
});

connection.onCodeAction((params: CodeActionParams): CodeAction[] => {
return getCodeActions({
uri: params.textDocument.uri,
diagnostics: params.context.diagnostics,
only: params.context.only
});
});

// Make the text document manager listen on the connection
// for open, change and close text document events
documents.listen(connection);
Expand Down
50 changes: 50 additions & 0 deletions languageservice/src/code-actions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import {CodeAction, CodeActionKind, Diagnostic} from "vscode-languageserver-types";
import {CodeActionContext, CodeActionProvider} from "./types";
import {quickfixProviders} from "./quickfix";

// Aggregate all providers by kind
const providersByKind: Map<string, CodeActionProvider[]> = new Map([
[CodeActionKind.QuickFix, quickfixProviders]
// [CodeActionKind.Refactor, refactorProviders],
// [CodeActionKind.Source, sourceProviders],
// etc
]);

export interface CodeActionParams {
uri: string;
diagnostics: Diagnostic[];
only?: string[];
}

export function getCodeActions(params: CodeActionParams): CodeAction[] {
const actions: CodeAction[] = [];
const context: CodeActionContext = {
uri: params.uri
};

// Filter to requested kinds, or use all if none specified
const requestedKinds = params.only;
const kindsToCheck = requestedKinds
? [...providersByKind.keys()].filter(kind => requestedKinds.some(requested => kind.startsWith(requested)))
: [...providersByKind.keys()];

for (const diagnostic of params.diagnostics) {
for (const kind of kindsToCheck) {
const providers = providersByKind.get(kind) ?? [];
for (const provider of providers) {
if (provider.diagnosticCodes.includes(diagnostic.code)) {
const action = provider.createCodeAction(context, diagnostic);
if (action) {
action.kind = kind;
action.diagnostics = [diagnostic];
actions.push(action);
}
}
}
}
}

return actions;
}

export type {CodeActionContext, CodeActionProvider} from "./types";
65 changes: 65 additions & 0 deletions languageservice/src/code-actions/quickfix/add-missing-inputs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import {CodeAction, TextEdit} from "vscode-languageserver-types";
import {CodeActionProvider} from "../types";
import {DiagnosticCode, MissingInputsDiagnosticData} from "../../validate-action";

export const addMissingInputsProvider: CodeActionProvider = {
diagnosticCodes: [DiagnosticCode.MissingRequiredInputs],

createCodeAction(context, diagnostic): CodeAction | undefined {
const data = diagnostic.data as MissingInputsDiagnosticData | undefined;
if (!data) {
return undefined;
}

const edits = createInputEdits(data);
if (!edits) {
return undefined;
}

const inputNames = data.missingInputs.map(i => i.name).join(", ");

return {
title: `Add missing input${data.missingInputs.length > 1 ? "s" : ""}: ${inputNames}`,
edit: {
changes: {
[context.uri]: edits
}
}
};
}
};

function createInputEdits(data: MissingInputsDiagnosticData): TextEdit[] {
const edits: TextEdit[] = [];

const formatInputLines = (indent: string) =>
data.missingInputs.map(input => {
const value = input.default ?? '""';
return `${indent}${input.name}: ${value}`;
});

if (data.hasWithKey && data.withIndent !== undefined) {
// `with:` exists - use its indentation + 2 for inputs
const inputIndent = " ".repeat(data.withIndent + data.indentSize);
const inputLines = formatInputLines(inputIndent);

edits.push({
range: {start: data.insertPosition, end: data.insertPosition},
newText: inputLines.map(line => line + "\n").join("")
});
} else {
// No `with:` key - `with:` at step indentation, inputs at step indentation + 2
const withIndent = " ".repeat(data.stepIndent);
const inputIndent = " ".repeat(data.stepIndent + data.indentSize);
const inputLines = formatInputLines(inputIndent);

const newText = `${withIndent}with:\n` + inputLines.map(line => `${line}\n`).join("");

edits.push({
range: {start: data.insertPosition, end: data.insertPosition},
newText
});
}

return edits;
}
4 changes: 4 additions & 0 deletions languageservice/src/code-actions/quickfix/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import {CodeActionProvider} from "../types";
import {addMissingInputsProvider} from "./add-missing-inputs";

export const quickfixProviders: CodeActionProvider[] = [addMissingInputsProvider];
90 changes: 90 additions & 0 deletions languageservice/src/code-actions/tests/runner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import * as path from "path";
import {fileURLToPath} from "url";
import {loadTestCases, runTestCase} from "./runner";
import {ValidationConfig} from "../../validate";
import {ActionMetadata, ActionReference} from "../../action";
import {clearCache} from "../../utils/workflow-cache";

// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);

// Mock action metadata provider for tests
const validationConfig: ValidationConfig = {
actionsMetadataProvider: {
fetchActionMetadata: (ref: ActionReference): Promise<ActionMetadata | undefined> => {
const key = `${ref.owner}/${ref.name}@${ref.ref}`;

const metadata: Record<string, ActionMetadata> = {
"actions/cache@v1": {
name: "Cache",
description: "Cache dependencies",
inputs: {
path: {
description: "A list of files to cache",
required: true
},
key: {
description: "Cache key",
required: true
},
"restore-keys": {
description: "Restore keys",
required: false
}
}
},
"actions/setup-node@v3": {
name: "Setup Node",
description: "Setup Node.js",
inputs: {
"node-version": {
description: "Node version",
required: true,
default: "16"
}
}
}
};

return Promise.resolve(metadata[key]);
}
}
};

// Point to the source testdata directory
const testdataDir = path.join(__dirname, "testdata");

beforeEach(() => {
clearCache();
});

describe("code action golden tests", () => {
const testCases = loadTestCases(testdataDir);

if (testCases.length === 0) {
it.todo("no test cases found - add .yml files to testdata/");
return;
}

for (const testCase of testCases) {
it(testCase.name, async () => {
const result = await runTestCase(testCase, validationConfig);

if (!result.passed) {
let errorMessage = result.error || "Test failed";

if (result.expected !== undefined && result.actual !== undefined) {
errorMessage += "\n\n";
errorMessage += "=== EXPECTED (golden file) ===\n";
errorMessage += result.expected;
errorMessage += "\n\n";
errorMessage += "=== ACTUAL ===\n";
errorMessage += result.actual;
}

throw new Error(errorMessage);
}
});
}
});
Loading
Loading