Skip to content

Commit f35d847

Browse files
committed
Setup CodeActions and add quickfix for missing inputs
1 parent 742b36d commit f35d847

20 files changed

+1410
-67
lines changed

languageserver/README.md

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ The [package](https://www.npmjs.com/package/@actions/languageserver) contains Ty
1010
npm install @actions/languageserver
1111
```
1212

13+
To install the language server as a standalone CLI:
14+
15+
```bash
16+
npm install -g @actions/languageserver
17+
```
18+
19+
This makes the `actions-languageserver` command available globally.
20+
1321
## Usage
1422

1523
### Basic usage using `vscode-languageserver-node`
@@ -92,6 +100,150 @@ const clientOptions: LanguageClientOptions = {
92100
const client = new LanguageClient("actions-language", "GitHub Actions Language Server", serverOptions, clientOptions);
93101
```
94102

103+
### Standalone CLI
104+
105+
After installing globally, you can run the language server directly:
106+
107+
```bash
108+
actions-languageserver --stdio
109+
```
110+
111+
This starts the language server using stdio transport, which is the standard way for editors to communicate with language servers.
112+
113+
### In Neovim
114+
115+
#### 1. Install the language server
116+
117+
```bash
118+
npm install -g @actions/languageserver
119+
```
120+
121+
#### 2. Set up filetype detection
122+
123+
Add this to your `init.lua` to detect GitHub Actions workflow files:
124+
125+
```lua
126+
vim.filetype.add({
127+
pattern = {
128+
[".*/%.github/workflows/.*%.ya?ml"] = "yaml.ghactions",
129+
},
130+
})
131+
```
132+
133+
This sets the filetype to `yaml.ghactions` for YAML files in `.github/workflows/`, allowing you to keep separate YAML LSP configurations if needed.
134+
135+
#### 3. Create the LSP configuration
136+
137+
As of Neovim 0.11+ you can add this configuration in `~/.config/nvim/lsp/actionsls.lua`:
138+
139+
```lua
140+
local function get_github_token()
141+
local handle = io.popen("gh auth token 2>/dev/null")
142+
if not handle then return nil end
143+
local token = handle: read("*a"):gsub("%s+", "")
144+
handle:close()
145+
return token ~= "" and token or nil
146+
end
147+
148+
local function parse_github_remote(url)
149+
if not url or url == "" then return nil end
150+
151+
-- SSH format: git@github.com:owner/repo.git
152+
local owner, repo = url:match("git@github%.com:([^/]+)/([^/%.]+)")
153+
if owner and repo then
154+
return owner, repo: gsub("%.git$", "")
155+
end
156+
157+
-- HTTPS format: https://github.com/owner/repo.git
158+
owner, repo = url:match("github%.com/([^/]+)/([^/%.]+)")
159+
if owner and repo then
160+
return owner, repo:gsub("%.git$", "")
161+
end
162+
163+
return nil
164+
end
165+
166+
local function get_repo_info(owner, repo)
167+
local cmd = string.format(
168+
"gh repo view %s/%s --json id,owner --template '{{.id}}\t{{.owner.type}}' 2>/dev/null",
169+
owner,
170+
repo
171+
)
172+
local handle = io.popen(cmd)
173+
if not handle then return nil end
174+
local result = handle: read("*a"):gsub("%s+$", "")
175+
handle:close()
176+
177+
local id, owner_type = result:match("^(%d+)\t(.+)$")
178+
if id then
179+
return {
180+
id = tonumber(id),
181+
organizationOwned = owner_type == "Organization",
182+
}
183+
end
184+
return nil
185+
end
186+
187+
local function get_repos_config()
188+
local handle = io.popen("git rev-parse --show-toplevel 2>/dev/null")
189+
if not handle then return nil end
190+
local git_root = handle: read("*a"):gsub("%s+", "")
191+
handle:close()
192+
193+
if git_root == "" then return nil end
194+
195+
handle = io.popen("git remote get-url origin 2>/dev/null")
196+
if not handle then return nil end
197+
local remote_url = handle:read("*a"):gsub("%s+", "")
198+
handle:close()
199+
200+
local owner, name = parse_github_remote(remote_url)
201+
if not owner or not name then return nil end
202+
203+
local info = get_repo_info(owner, name)
204+
205+
return {
206+
{
207+
id = info and info.id or 0,
208+
owner = owner,
209+
name = name,
210+
organizationOwned = info and info.organizationOwned or false,
211+
workspaceUri = "file://" .. git_root,
212+
},
213+
}
214+
end
215+
216+
return {
217+
cmd = { "actions-languageserver", "--stdio" },
218+
filetypes = { "yaml.ghactions" },
219+
root_markers = { ".git" },
220+
init_options = {
221+
-- Optional: provide a GitHub token and repo context for added functionality
222+
-- (e.g., repository-specific completions)
223+
sessionToken = get_github_token(),
224+
repos = get_repos_config(),
225+
},
226+
}
227+
```
228+
229+
#### 4. Enable the LSP
230+
231+
Add to your `init.lua`:
232+
233+
```lua
234+
vim.lsp.enable('actionsls')
235+
```
236+
237+
#### 5. Verify it's working
238+
239+
Open any `.github/workflows/*.yml` file and run:
240+
241+
```vim
242+
:checkhealth vim.lsp
243+
```
244+
245+
You should see `actionsls` in the list of attached clients.
246+
95247
## Contributing
96248

97249
See [CONTRIBUTING.md](../CONTRIBUTING.md) at the root of the repository for general guidelines and recommendations.
@@ -110,6 +262,27 @@ or to watch for changes
110262
npm run watch
111263
```
112264

265+
### Running the language server locally
266+
267+
After running
268+
269+
```bash
270+
npm run build:cli
271+
npm link
272+
```
273+
274+
`actions-languageserver` will be available globally. You can start it with:
275+
276+
```bash
277+
actions-languageserver --stdio
278+
```
279+
280+
Once linked you can also watch for changes and rebuild automatically:
281+
282+
```bash
283+
npm run watch:cli
284+
```
285+
113286
### Test
114287

115288
```bash
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#!/usr/bin/env node
2+
import "../dist/cli.bundle.cjs";

languageserver/package.json

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@
3131
"url": "https://github.com/actions/languageservices"
3232
},
3333
"scripts": {
34-
"build": "tsc --build tsconfig.build.json",
34+
"build": "tsc --build tsconfig.build.json && npm run build:cli",
35+
"build:cli": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/cli.bundle.cjs",
3536
"clean": "rimraf dist",
3637
"format": "prettier --write '**/*.ts'",
3738
"format-check": "prettier --check '**/*.ts'",
@@ -40,7 +41,11 @@
4041
"prepublishOnly": "npm run build && npm run test",
4142
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
4243
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
43-
"watch": "tsc --build tsconfig.build.json --watch"
44+
"watch": "tsc --build tsconfig.build.json --watch",
45+
"watch:cli": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/cli.bundle.cjs --watch"
46+
},
47+
"bin": {
48+
"actions-languageserver": "./bin/actions-languageserver"
4449
},
4550
"dependencies": {
4651
"@actions/languageservice": "^0.3.25",
@@ -55,12 +60,14 @@
5560
"node": ">= 18"
5661
},
5762
"files": [
58-
"dist/**/*"
63+
"dist/**/*",
64+
"bin/**/*"
5965
],
6066
"devDependencies": {
6167
"@types/jest": "^29.0.3",
6268
"@typescript-eslint/eslint-plugin": "^5.56.0",
6369
"@typescript-eslint/parser": "^5.56.0",
70+
"esbuild": "^0.27.1",
6471
"eslint": "^8.36.0",
6572
"eslint-config-prettier": "^8.8.0",
6673
"eslint-plugin-prettier": "^4.2.1",

languageserver/src/connection.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
import {documentLinks, hover, validate, ValidationConfig} from "@actions/languageservice";
1+
import {documentLinks, getCodeActions, hover, validate, ValidationConfig} from "@actions/languageservice";
22
import {registerLogger, setLogLevel} from "@actions/languageservice/log";
33
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
44
import {Octokit} from "@octokit/rest";
55
import {
6+
CodeAction,
7+
CodeActionKind,
8+
CodeActionParams,
69
CompletionItem,
710
Connection,
811
DocumentLink,
@@ -72,6 +75,9 @@ export function initConnection(connection: Connection) {
7275
hoverProvider: true,
7376
documentLinkProvider: {
7477
resolveProvider: false
78+
},
79+
codeActionProvider: {
80+
codeActionKinds: [CodeActionKind.QuickFix]
7581
}
7682
}
7783
};
@@ -158,6 +164,14 @@ export function initConnection(connection: Connection) {
158164
return documentLinks(getDocument(documents, textDocument), repoContext?.workspaceUri);
159165
});
160166

167+
connection.onCodeAction((params: CodeActionParams): CodeAction[] => {
168+
return getCodeActions({
169+
uri: params.textDocument.uri,
170+
diagnostics: params.context.diagnostics,
171+
only: params.context.only
172+
});
173+
});
174+
161175
// Make the text document manager listen on the connection
162176
// for open, change and close text document events
163177
documents.listen(connection);
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import {CodeAction, CodeActionKind, Diagnostic} from "vscode-languageserver-types";
2+
import {CodeActionContext, CodeActionProvider} from "./types";
3+
import {quickfixProviders} from "./quickfix";
4+
5+
// Aggregate all providers by kind
6+
const providersByKind: Map<string, CodeActionProvider[]> = new Map([
7+
[CodeActionKind.QuickFix, quickfixProviders]
8+
// [CodeActionKind.Refactor, refactorProviders],
9+
// [CodeActionKind.Source, sourceProviders],
10+
// etc
11+
]);
12+
13+
export interface CodeActionParams {
14+
uri: string;
15+
diagnostics: Diagnostic[];
16+
only?: string[];
17+
}
18+
19+
export function getCodeActions(params: CodeActionParams): CodeAction[] {
20+
const actions: CodeAction[] = [];
21+
const context: CodeActionContext = {
22+
uri: params.uri
23+
};
24+
25+
// Filter to requested kinds, or use all if none specified
26+
const requestedKinds = params.only;
27+
const kindsToCheck = requestedKinds
28+
? [...providersByKind.keys()].filter(kind => requestedKinds.some(requested => kind.startsWith(requested)))
29+
: [...providersByKind.keys()];
30+
31+
for (const diagnostic of params.diagnostics) {
32+
for (const kind of kindsToCheck) {
33+
const providers = providersByKind.get(kind) ?? [];
34+
for (const provider of providers) {
35+
if (provider.diagnosticCodes.includes(diagnostic.code)) {
36+
const action = provider.createCodeAction(context, diagnostic);
37+
if (action) {
38+
action.kind = kind;
39+
action.diagnostics = [diagnostic];
40+
actions.push(action);
41+
}
42+
}
43+
}
44+
}
45+
}
46+
47+
return actions;
48+
}
49+
50+
export type {CodeActionContext, CodeActionProvider} from "./types";
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import {CodeAction, TextEdit} from "vscode-languageserver-types";
2+
import {CodeActionProvider} from "../types";
3+
import {DiagnosticCode, MissingInputsDiagnosticData} from "../../validate-action";
4+
5+
export const addMissingInputsProvider: CodeActionProvider = {
6+
diagnosticCodes: [DiagnosticCode.MissingRequiredInputs],
7+
8+
createCodeAction(context, diagnostic): CodeAction | undefined {
9+
const data = diagnostic.data as MissingInputsDiagnosticData | undefined;
10+
if (!data) {
11+
return undefined;
12+
}
13+
14+
const edits = createInputEdits(data);
15+
if (!edits) {
16+
return undefined;
17+
}
18+
19+
const inputNames = data.missingInputs.map(i => i.name).join(", ");
20+
21+
return {
22+
title: `Add missing input${data.missingInputs.length > 1 ? "s" : ""}: ${inputNames}`,
23+
edit: {
24+
changes: {
25+
[context.uri]: edits
26+
}
27+
}
28+
};
29+
}
30+
};
31+
32+
function createInputEdits(data: MissingInputsDiagnosticData): TextEdit[] | undefined {
33+
const edits: TextEdit[] = [];
34+
35+
if (data.hasWithKey && data.withIndent !== undefined) {
36+
// `with:` exists - use its indentation + 2 for inputs
37+
const inputIndent = " ".repeat(data.withIndent + 2);
38+
39+
const inputLines = data.missingInputs.map(input => {
40+
const value = input.default !== undefined ? input.default : '""';
41+
return `${inputIndent}${input.name}: ${value}`;
42+
});
43+
44+
edits.push({
45+
range: {start: data.insertPosition, end: data.insertPosition},
46+
newText: inputLines.map(line => line + "\n").join("")
47+
});
48+
} else {
49+
// No `with:` key - `with:` at step indentation, inputs at step indentation + 2
50+
const withIndent = " ".repeat(data.stepIndent);
51+
const inputIndent = " ".repeat(data.stepIndent + 2);
52+
53+
const inputLines = data.missingInputs.map(input => {
54+
const value = input.default !== undefined ? input.default : '""';
55+
return `${inputIndent}${input.name}: ${value}`;
56+
});
57+
58+
const newText = [`${withIndent}with:\n`, ...inputLines.map(line => `${line}\n`)].join("");
59+
60+
edits.push({
61+
range: {start: data.insertPosition, end: data.insertPosition},
62+
newText
63+
});
64+
}
65+
66+
return edits;
67+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import {CodeActionProvider} from "../types";
2+
import {addMissingInputsProvider} from "./add-missing-inputs";
3+
4+
export const quickfixProviders: CodeActionProvider[] = [addMissingInputsProvider];

0 commit comments

Comments
 (0)