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";
67 changes: 67 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,67 @@
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[] | undefined {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like this can only return TextEdit[], not sure we need undefined here unless I'm mistaken

Suggested change
function createInputEdits(data: MissingInputsDiagnosticData): TextEdit[] | undefined {
function createInputEdits(data: MissingInputsDiagnosticData): TextEdit[] {

const edits: TextEdit[] = [];

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we put this as a consant somewhere, I see + 2 is used quite a bit, I think we can put something like const YAML_INDENT = 2;.

Copy link
Collaborator

@ericsciple ericsciple Dec 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of assuming 2, have you considered matching the customer's indent? You could detect this based on the indent amount of the first job. For example:

function detectIndentSize(rootToken: MappingToken): number {
  const jobsValue = rootToken.find("jobs");
  if (jobsValue && isMapping(jobsValue) && jobsValue.count > 0) {
    const firstJob = jobsValue.get(0);
    if (firstJob?.key.range && jobsValue.range) {
      return firstJob.key.range.start.column - jobsValue.range.start.column;
    }
  }
  return 2; // fallback
}


const inputLines = data.missingInputs.map(input => {
const value = input.default !== undefined ? input.default : '""';
return `${inputIndent}${input.name}: ${value}`;
});
Comment on lines +39 to +42
Copy link

@salmanmkc salmanmkc Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd probably refactor this, looks the exact same as lines 53-36

const inputLines = data.missingInputs.map(input => {


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 + 2);

const inputLines = data.missingInputs.map(input => {
const value = input.default !== undefined ? input.default : '""';
Copy link

@salmanmkc salmanmkc Dec 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cleaner to write if "" (empty string) is not a valid input

Suggested change
const value = input.default !== undefined ? input.default : '""';
const value = input.default ?? '""';

return `${inputIndent}${input.name}: ${value}`;
});

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit I think we can simplify to this but double check, though you can ignore these, looks like the exact same

Suggested change
const newText = [`${withIndent}with:\n`, ...inputLines.map(line => `${line}\n`)].join("");
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