Compare commits

..
Author SHA1 Message Date
Francesco Renzi 4efa31459b works 2025-12-09 10:43:21 +00:00
Francesco Renzi f8ea05739d Add more tests 2025-11-28 15:37:32 +00:00
Francesco Renzi 73dd3c33c4 prettier 2025-11-28 14:57:59 +00:00
Francesco Renzi e5800c8843 Setup CodeActions and add quickfix for missing inputs 2025-11-28 14:56:01 +00:00
Francesco Renzi bba2a01c01 docs 2025-11-28 08:59:43 +00:00
Francesco Renzi ec52bd7358 Add instructions on how to run locally and in neovim 2025-11-27 15:37:14 +00:00
230 changed files with 138598 additions and 41416 deletions
+3 -40
View File
@@ -12,55 +12,18 @@ jobs:
build: build:
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x, 22.x]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js 16.15
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node-version }} node-version: 16.15
cache: 'npm' cache: 'npm'
registry-url: 'https://npm.pkg.github.com' registry-url: 'https://npm.pkg.github.com'
- run: npm ci --engine-strict - run: npm ci
env: env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: npm run format-check -ws - run: npm run format-check -ws
- run: npm run build -ws - run: npm run build -ws
- run: npm run lint -ws - run: npm run lint -ws
- run: npm test -ws - run: npm test -ws
check-generated:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Use Node.js 22.x
uses: actions/setup-node@v4
with:
node-version: 22.x
cache: 'npm'
registry-url: 'https://npm.pkg.github.com'
- run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Regenerate JSON files
run: |
cd languageservice && npm run update-webhooks && cd ..
- name: Check for uncommitted changes
run: |
if ! git diff --exit-code; then
echo ""
echo "=========================================="
echo "ERROR: Generated files are out of date!"
echo "=========================================="
echo ""
echo "Please run the following commands locally and commit the changes:"
echo ""
echo " cd languageservice && npm run update-webhooks && cd .."
echo " git add -A && git commit -m 'Regenerate JSON files'"
echo ""
exit 1
fi
+1 -1
View File
@@ -69,7 +69,7 @@ jobs:
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22.x node-version: 16.x
cache: "npm" cache: "npm"
scope: '@actions' scope: '@actions'
+1 -9
View File
@@ -2,12 +2,4 @@
*/dist */dist
lerna-debug.log lerna-debug.log
node_modules node_modules
.DS_Store .DS_Store
# Minified JSON (generated at build time)
*.min.json
# Intermediate JSON for size comparison (generated by update-webhooks --all)
*.all.json
*.drop.json
*.strip.json
+152
View File
@@ -0,0 +1,152 @@
# Using GitHub Actions Language Server in Neovim
## Prerequisites
- Node.js 18+
- Neovim 0.11+ with the new LSP config format
## Setup Options
### Option 1: Install from npm (Recommended)
Once published, you can install globally:
```bash
npm install -g @actions/languageserver
```
Then configure Neovim to use the installed binary:
```lua
-- ~/.config/nvim/lsp/actionsls.lua
return {
cmd = { "actions-languageserver" },
filetypes = { "yaml.ghaction" }, -- GitHub Actions workflow files only
root_markers = { ".git" },
init_options = {
sessionToken = vim.fn.system("gh auth token"):gsub("%s+", ""),
logLevel = "info",
},
}
```
**Note:** This requires the package to be published to npm first.
### Option 2: Local Development Build
For development or if the npm package isn't published yet:
### 1. Clone and build
```bash
git clone https://github.com/actions/languageservices.git
cd languageservices
npm install
npm run build --workspaces --if-present
```
### 2. Bundle the server
The server needs to be bundled into a single file to avoid ESM module resolution issues:
```bash
cd languageserver
npx esbuild src/index.ts \
--bundle \
--platform=node \
--target=node18 \
--format=cjs \
--outfile=dist/server-bundled.cjs \
--external:vscode \
--loader:.json=json
```
This creates `dist/server-bundled.cjs` (~5.6MB) that contains the entire server.
### 3. Configure Neovim
Create `~/.config/nvim/lsp/actionsls.lua`:
```lua
return {
cmd = {
"/absolute/path/to/languageservices/languageserver/bin/actions-languageserver",
},
filetypes = { "yaml.ghaction" }, -- GitHub Actions workflow files only
root_markers = { ".git" },
init_options = {
sessionToken = vim.fn.system("gh auth token"):gsub("%s+", ""),
logLevel = "info",
},
}
```
**Important:** Replace `/absolute/path/to/languageservices` with your actual clone path.
## Filetype Detection for GitHub Actions Workflows
To ensure the LSP only runs on GitHub Actions workflow files (not all YAML files), set up filetype detection:
**Option A:** In `~/.config/nvim/init.lua`:
```lua
vim.api.nvim_create_autocmd({"BufRead", "BufNewFile"}, {
pattern = ".github/workflows/*.{yml,yaml}",
callback = function()
vim.bo.filetype = "yaml.ghaction"
end,
})
```
**Option B:** Create `~/.config/nvim/ftdetect/ghaction.vim`:
```vim
au BufRead,BufNewFile .github/workflows/*.yml,*.yaml setfiletype yaml.ghaction
```
This sets the filetype to `yaml.ghaction` for files in `.github/workflows/`, matching the `filetypes` setting in your LSP config.
### 4. Enable the LSP in your init.lua
Add to your Neovim configuration:
```lua
vim.lsp.enable('actionsls')
```
### 5. Restart Neovim
Open any `.github/workflows/*.yml` file. The filetype detection will set it to `yaml.ghaction`, and the language server will attach automatically.
## Files Created
- `languageserver/dist/server-bundled.cjs` - Bundled server (~5.6MB)
- `languageserver/bin/actions-languageserver` - Shell wrapper script
The `dist/` directory is gitignored; you'll need to rebuild after pulling updates.
## Troubleshooting
Check if the server is running:
```vim
:lua =vim.lsp.get_clients()
```
View LSP logs:
```bash
tail -f ~/.local/state/nvim/lsp.log
```
Manually start the server to test:
```vim
:lua vim.lsp.start({name='actionsls', cmd={'/path/to/bin/actions-languageserver'}, root_dir=vim.fn.getcwd(), init_options={sessionToken='', logLevel='info'}})
```
## Notes
- The main code change is in `languageserver/src/index.ts` to use dynamic imports, avoiding loading browser modules in Node.js
- The bundling step is necessary because TypeScript outputs ESM with bare imports that Node.js can't resolve
- Only workflow files in git repositories will activate the LSP (due to `root_markers = { ".git" }`)
-4
View File
@@ -8,10 +8,6 @@ This repository contains multiple npm packages for working with GitHub Actions w
- [languageserver](./languageserver) - Language Server for GitHub Actions, hosting the language service for LSP-compatible editors - [languageserver](./languageserver) - Language Server for GitHub Actions, hosting the language service for LSP-compatible editors
- [browser-playground](./browser-playground) - Browser-based playground for the language service - [browser-playground](./browser-playground) - Browser-based playground for the language service
## Documentation
- [JSON Data Files](./docs/json-data-files.md) - How the JSON data files are generated and maintained
### Note ### Note
Thank you for your interest in this GitHub repo, however, right now we are not taking contributions. Thank you for your interest in this GitHub repo, however, right now we are not taking contributions.
-299
View File
@@ -1,299 +0,0 @@
# ESM Migration Plan: Add File Extensions to Imports
## Overview
This document outlines the plan to migrate from TypeScript's deprecated `"moduleResolution": "node"` (node10) to `"moduleResolution": "node16"` or `"nodenext"`. This change is necessary because the published ESM packages have extensionless imports that don't work correctly in modern ESM environments.
## Issues Fixed
This migration will resolve the following issues:
- **#154** - Upgrade `moduleResolution` from `node` to `node16` or `nodenext` in tsconfig
- **#110** - Published ESM code has imports without file extensions
- **#64** - expressions: ERR_MODULE_NOT_FOUND attempting to run example demo script
- **#146** - Can not import `@actions/workflow-parser`
## Problem Statement
### Current State
All packages use `"moduleResolution": "node"`:
| Package | moduleResolution | TypeScript |
|---------|------------------|------------|
| expressions | `"node"` | ^4.7.4 |
| workflow-parser | `"node"` | ^4.8.4 |
| languageservice | `"node"` | ^4.8.4 |
| languageserver | `"node"` | ^4.8.4 |
| browser-playground | `"Node16"` ✅ | ^4.9.4 |
This causes TypeScript to emit code like:
```javascript
// Published to npm - INVALID ESM
export { Expr } from "./ast"; // Missing .js extension!
```
### Why This Fails
ESM in Node.js 12+ **requires** explicit file extensions. When users try to import these packages:
```javascript
// User's code
import { Expr } from "@actions/expressions";
```
Node.js fails with:
```
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../node_modules/@actions/expressions/dist/ast'
```
## Migration Strategy
### Option A: TypeScript 5.7+ with `rewriteRelativeImportExtensions` (Recommended)
TypeScript 5.7 introduced a new compiler option that automatically rewrites `.ts` extensions to `.js` in output:
```jsonc
{
"compilerOptions": {
"moduleResolution": "node16", // or "nodenext"
"rewriteRelativeImportExtensions": true
}
}
```
**Source code:**
```typescript
import { Expr } from "./ast.ts";
```
**Compiled output:**
```javascript
export { Expr } from "./ast.js";
```
**Pros:**
- Source uses `.ts` extensions (matches actual files)
- Works with Deno (which requires `.ts` extensions)
- TypeScript automatically transforms to `.js`
- Modern, forward-looking approach
**Cons:**
- Requires TypeScript 5.7+
- Relatively new feature
- **BUG:** See "Known Issues" section below
### Option B: Manual `.js` Extensions
Use `.js` extensions in source TypeScript files:
```typescript
import { Expr } from "./ast.js"; // Points to .ts file, but use .js extension
```
**Pros:**
- Works with TypeScript 4.7+ (with node16 moduleResolution)
- Well-established pattern
- No post-processing needed
- Works with ts-jest without extra configuration
**Cons:**
- Confusing - `.js` files don't exist at write time
- Doesn't work with Deno out of the box
### Recommendation
**Use Option B** (manual `.js` extensions). Option A with `rewriteRelativeImportExtensions` has compatibility issues with ts-jest and requires additional workarounds.
---
## Known Issues and Workarounds (December 2025)
### 1. TypeScript Version Conflicts in Monorepo
**Problem:** The root `node_modules/typescript` was version 4.9.5 (pulled in by `ts-node` and `tsutils` dependencies), while workspace packages specified `^5.8.3`.
**Symptoms:**
- `npx tsc --version` showed 4.9.5
- `require('typescript').version` in ts-jest showed 5.8.3
- Confusing build failures
**Solution:** Add npm overrides in root `package.json`:
```json
{
"overrides": {
"typescript": "5.8.3"
}
}
```
### 2. ts-jest Compatibility with TypeScript 5.9+
**Problem:** ts-jest 29.4.6 uses `typescript.JSDocParsingMode.ParseAll` which doesn't exist in TypeScript's ES module exports.
**Error:**
```
TypeError: Cannot read properties of undefined (reading 'ParseAll')
at Object.<anonymous> (node_modules/ts-jest/dist/compiler/ts-compiler.js:43:123)
```
**Root Cause:** ts-jest accesses `typescript_1.default.JSDocParsingMode.ParseAll` but TypeScript has no default export in ESM.
**Solution:**
- Use ts-jest 29.0.3 (older version that doesn't use this API)
- OR wait for ts-jest fix
- **Stay on TypeScript 5.8.3, not 5.9+**
### 3. TypeScript `rewriteRelativeImportExtensions` Bug with .d.ts Files
**Problem:** TypeScript's `rewriteRelativeImportExtensions: true` correctly rewrites `.ts``.js` in `.js` output files, but **incorrectly keeps `.ts` extensions in `.d.ts` declaration files**.
**Example:**
- Source: `export { Expr } from "./ast.ts";`
- Output `index.js`: `export { Expr } from "./ast.js";` ✅ Correct
- Output `index.d.ts`: `export { Expr } from "./ast.ts";` ❌ Wrong (should be `.js`)
**Upstream Issue:** https://github.com/microsoft/TypeScript/issues/61037 (marked "Help Wanted", in Backlog, NOT FIXED as of Dec 2025)
**Workaround:** Post-process `.d.ts` files with a script. See `script/fix-dts-extensions.cjs`.
**Note:** Since we use Option B (manual `.js` extensions), this bug does not affect our migration.
### 4. yaml Package Internal Types Not Exported
**Problem:** The `yaml` package does not export internal types like `LinePos` and `NodeBase` that are used in `workflow-parser/src/workflows/yaml-object-reader.ts`.
**Error:**
```
error TS2305: Module '"yaml"' has no exported member 'LinePos'.
error TS2305: Module '"yaml"' has no exported member 'NodeBase'.
```
**Solution:** Define local type aliases in the file that uses them:
```typescript
// Local type definitions to replace yaml internal imports
type LinePos = { line: number; col: number };
type NodeBase = { range?: [number, number, number] };
```
### 5. languageserver Blocked by vscode-languageserver Dependency
**Problem:** The `vscode-languageserver` package (v8.0.2) does not have proper ESM exports. When using `moduleResolution: "node16"`, TypeScript requires packages to have an `exports` field in `package.json` for subpath imports to work.
**Error:**
```
src/index.ts(6,8): error TS2307: Cannot find module 'vscode-languageserver/browser' or its corresponding type declarations.
src/connection.ts(1,43): error TS2307: Cannot find module 'vscode-languageserver/node' or its corresponding type declarations.
```
**Root Cause:** The `vscode-languageserver` package.json only has `main` and `browser` fields, but no `exports` field:
```json
{
"main": "./lib/node/main.js",
"browser": {
"./lib/node/main.js": "./lib/browser/main.js"
}
// No "exports" field!
}
```
With `moduleResolution: "node16"`, TypeScript follows Node.js ESM resolution rules which require explicit `exports` for subpath imports like `vscode-languageserver/browser` and `vscode-languageserver/node`.
**Status:** Verified December 2025. Version 9.0.1 is available but ESM export support is not confirmed.
**Current Decision:** The languageserver package is **deferred** from this migration until the upstream `vscode-languageserver` package adds proper ESM exports. It will continue using the old `moduleResolution: "node"` configuration.
**Options to resolve:**
- Wait for vscode-languageserver to add ESM exports
- Try upgrading to vscode-languageserver v9.x to see if exports were added
- Use a bundler to work around the module resolution
- Fork or patch the dependency
---
## Migration Status
| Package | Tests | ESM Status |
|---------|-------|------------|
| expressions | 1068 | ✅ Migrated |
| workflow-parser | 292 | ✅ Migrated |
| languageservice | 452 | ✅ Migrated |
| languageserver | 6 files | ⏸️ Deferred (vscode-languageserver lacks ESM exports) |
---
## Required Configuration Changes
### tsconfig.build.json (each migrated package)
**Note:** We use **Option B** (manual `.js` extensions in source files) rather than `rewriteRelativeImportExtensions` because Option A caused ts-jest compatibility issues (tests would hang indefinitely).
```json
{
"compilerOptions": {
"module": "node16",
"moduleResolution": "node16",
"skipLibCheck": true,
"lib": ["ES2022"],
"target": "ES2022"
}
}
```
The `skipLibCheck: true` is needed to work around @types/node compatibility issues with TypeScript 5.x (TS2386 overload signature errors).
```
### jest.config.js (each migrated package)
```javascript
/** @type {import('ts-jest').JestConfigWithTsJest} */
export default {
preset: "ts-jest/presets/default-esm",
moduleNameMapper: {
"^(\\.{1,2}/.*)\\.js$": "$1",
"^(\\.{1,2}/.*)\\.ts$": "$1",
},
transform: {
"^.+\\.tsx?$": [
"ts-jest",
{
useESM: true,
isolatedModules: true,
},
],
},
moduleFileExtensions: ["ts", "js"],
};
```
### Root package.json
```json
{
"overrides": {
"typescript": "5.8.3"
}
}
```
### Each workspace package.json
```json
{
"devDependencies": {
"typescript": "^5.8.3",
"ts-jest": "^29.0.3"
}
}
```
---
## References
- [TypeScript moduleResolution reference](https://www.typescriptlang.org/docs/handbook/modules/reference.html)
- [TypeScript 5.7 rewriteRelativeImportExtensions](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-5-7.html#path-rewriting-for-relative-paths)
- [TypeScript .d.ts extension bug #61037](https://github.com/microsoft/TypeScript/issues/61037)
- [Node.js ESM mandatory extensions](https://nodejs.org/api/esm.html#mandatory-file-extensions)
- [ts-jest ESM support](https://kulshekhar.github.io/ts-jest/docs/guides/esm-support)
- [Community fork that works](https://github.com/boxbuild-io/actions-languageservices/commit/077fb2b58dfd2cca3d6e3df1fdf9e26e75db24ae)
-197
View File
@@ -1,197 +0,0 @@
# JSON Data Files
This document describes the JSON data files used by the language service packages and how they are maintained.
## Overview
The language service uses several JSON files containing schema definitions, webhook payloads, and other metadata. To reduce bundle size, these files are:
1. **Optimized at generation time** — unused events are dropped, unused fields are stripped
2. **Minified at build time** — whitespace is removed to produce `.min.json` files
The source `.json` files are human-readable and checked into the repository. The `.min.json` files are generated during build and gitignored.
## Files
### languageservice
| File | Description |
|------|-------------|
| `src/context-providers/events/webhooks.json` | Webhook event payload schemas for autocompletion |
| `src/context-providers/events/objects.json` | Deduplicated shared object definitions referenced by webhooks |
| `src/context-providers/events/schedule.json` | Schedule event context data |
| `src/context-providers/events/workflow_call.json` | Reusable workflow call context data |
| `src/context-providers/descriptions.json` | Context variable descriptions for hover |
### workflow-parser
| File | Description |
|------|-------------|
| `src/workflow-v1.0.json` | Workflow YAML schema definition |
## Generation
### Webhooks and Objects
The `webhooks.json` and `objects.json` files are generated from the [GitHub REST API description](https://github.com/github/rest-api-description):
```bash
cd languageservice
npm run update-webhooks
```
This script:
1. Fetches webhook schemas from the GitHub API description
2. **Validates** all events are categorized (fails if new events are found)
3. **Drops** events that aren't valid workflow triggers (see [Dropped Events](#dropped-events))
4. **Strips** unused fields like `description` and `summary` (see [Stripped Fields](#stripped-fields))
5. **Deduplicates** shared object definitions into `objects.json`
6. Writes the optimized, pretty-printed JSON files
### Handling New Webhook Events
When GitHub adds a new webhook event, the script will fail with an error like:
```
ERROR: New webhook event(s) detected!
The following events are not categorized:
- new_event_name
Action required:
1. Check if the event is a valid workflow trigger
2. Add the event to DROPPED_EVENTS or KEPT_EVENTS
```
**To resolve:**
1. Check [Events that trigger workflows](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows)
2. Edit `languageservice/script/webhooks/index.ts`:
- Add to `KEPT_EVENTS` if it's a valid workflow trigger
- Add to `DROPPED_EVENTS` if it's GitHub App or API-only
3. Run `npm run update-webhooks` and commit the changes
#### Viewing Full Unprocessed Data
To see all available fields and events before optimization:
```bash
npm run update-webhooks -- --all
```
This generates `webhooks.all.json` and `objects.all.json` (gitignored) containing the complete unprocessed data from the GitHub API.
### Other Files
The other JSON files (`schedule.json`, `workflow_call.json`, `descriptions.json`, `workflow-v1.0.json`) are manually maintained.
## Minification
At build time, all JSON files are minified (whitespace removed) to produce `.min.json` versions:
```bash
npm run minify-json
```
This runs automatically via `prebuild` and `pretest` hooks, so you don't need to run it manually.
The code imports the minified versions:
```ts
import webhooks from "./events/webhooks.min.json"
```
## CI Verification
CI verifies that generated source files are up-to-date:
1. Runs `npm run update-webhooks` to regenerate webhooks.json and objects.json
2. Checks for uncommitted changes with `git diff --exit-code`
The `.min.json` files are generated at build time and are not committed to the repository.
If the build fails, run `cd languageservice && npm run update-webhooks` locally and commit the changes.
## Dropped Events
Webhook events that aren't valid workflow `on:` triggers are dropped (e.g., `installation`, `ping`, `member`, etc.). These are GitHub App or API-only events.
See `DROPPED_EVENTS` in `script/webhooks/index.ts` for the full list.
## Stripped Fields
Unused fields are stripped to reduce bundle size. For example:
```json
// Before (from webhooks.all.json)
{
"type": "object",
"name": "issue",
"in": "body",
"description": "The issue itself.",
"isRequired": true,
"childParamsGroups": [...]
}
// After (webhooks.json)
{
"name": "issue",
"description": "The issue itself.",
"childParamsGroups": [...]
}
```
Only `name`, `description`, and `childParamsGroups` are kept — these are used for autocompletion and hover docs.
To compare all fields vs stripped, run `npm run update-webhooks -- --all` and diff the `.all.json` files against the regular ones.
See `EVENT_ACTION_FIELDS` and `BODY_PARAM_FIELDS` in `script/webhooks/index.ts` to modify what gets stripped.
## Schema Synchronization
The `workflow-v1.0.json` schema defines which activity types are valid for each workflow trigger event. A test in `workflow-parser/src/schema-sync.test.ts` verifies these stay in sync with `webhooks.json`.
### When the Test Fails
If the schema-sync test fails, you'll see an error like:
```
Event "pull_request" is missing activity type "new_activity" in workflow-v1.0.json
```
**To resolve:**
1. Check [Events that trigger workflows](https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows) to verify the activity type is a valid workflow trigger:
- Find the event section (e.g., "pull_request")
- Look at the "Activity types" table — it lists which types can be used in `on.<event>.types`
- If the type is listed there, it's a valid workflow trigger
- If the type only appears in webhook docs but NOT in the workflow trigger docs, it's webhook-only
2. If it IS a valid workflow trigger:
- Edit `workflow-parser/src/workflow-v1.0.json`
- Find the `<event>-activity-type` definition (e.g., `pull-request-activity-type`)
- Add the new activity type to `allowed-values`
- Update the `description` in `<event>-activity` to list all types
- Run `npm test` to regenerate the minified JSON
3. If it is NOT a valid workflow trigger (webhook-only):
- Edit `workflow-parser/src/schema-sync.test.ts`
- Add the type to `WEBHOOK_ONLY` for that event
### Known Discrepancies
The test tracks several types of known discrepancies:
| Category | Purpose | Example |
|----------|---------|---------|
| `WEBHOOK_ONLY` | Types in webhooks that aren't valid workflow triggers | `check_suite.requested` |
| `SCHEMA_ONLY` | Types valid for workflows but missing from webhooks | `registry_package.updated` |
| `NAME_MAPPINGS` | Different names for the same concept | `project_column`: webhook uses `edited`, schema uses `updated` |
### Bidirectional Checking
The test checks both directions:
- **webhooks → schema**: Ensures all webhook activity types are in the schema (or listed in `WEBHOOK_ONLY`)
- **schema → webhooks**: Ensures the schema doesn't have types that don't exist in webhooks (or listed in `SCHEMA_ONLY` or `NAME_MAPPINGS`)
+3 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@actions/expressions", "name": "@actions/expressions",
"version": "0.3.26", "version": "0.3.20",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"source": "./src/index.ts", "source": "./src/index.ts",
@@ -44,7 +44,7 @@
"watch": "tsc --build tsconfig.build.json --watch" "watch": "tsc --build tsconfig.build.json --watch"
}, },
"engines": { "engines": {
"node": ">= 18" "node": ">= 16.15"
}, },
"files": [ "files": [
"dist/**/*" "dist/**/*"
@@ -60,6 +60,6 @@
"prettier": "^2.8.3", "prettier": "^2.8.3",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"ts-jest": "^29.0.3", "ts-jest": "^29.0.3",
"typescript": "^5.8.3" "typescript": "^4.7.4"
} }
} }
+2 -2
View File
@@ -1,5 +1,5 @@
import {ExpressionData} from "./data/index.js"; import {ExpressionData} from "./data";
import {Token} from "./lexer.js"; import {Token} from "./lexer";
export interface ExprVisitor<R> { export interface ExprVisitor<R> {
visitLiteral(literal: Literal): R; visitLiteral(literal: Literal): R;
+8 -8
View File
@@ -1,11 +1,11 @@
import {complete, CompletionItem, trimTokenVector} from "./completion.js"; import {complete, CompletionItem, trimTokenVector} from "./completion";
import {DescriptionDictionary} from "./completion/descriptionDictionary.js"; import {DescriptionDictionary} from "./completion/descriptionDictionary";
import {BooleanData} from "./data/boolean.js"; import {BooleanData} from "./data/boolean";
import {Dictionary} from "./data/dictionary.js"; import {Dictionary} from "./data/dictionary";
import {StringData} from "./data/string.js"; import {StringData} from "./data/string";
import {wellKnownFunctions} from "./funcs.js"; import {wellKnownFunctions} from "./funcs";
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js"; import {FunctionDefinition, FunctionInfo} from "./funcs/info";
import {Lexer, TokenType} from "./lexer.js"; import {Lexer, TokenType} from "./lexer";
const testContext = new Dictionary( const testContext = new Dictionary(
{ {
+8 -8
View File
@@ -1,11 +1,11 @@
import {DescriptionPair} from "./completion/descriptionDictionary.js"; import {DescriptionPair} from "./completion/descriptionDictionary";
import {Dictionary, isDictionary} from "./data/dictionary.js"; import {Dictionary, isDictionary} from "./data/dictionary";
import {ExpressionData} from "./data/expressiondata.js"; import {ExpressionData} from "./data/expressiondata";
import {Evaluator} from "./evaluator.js"; import {Evaluator} from "./evaluator";
import {wellKnownFunctions} from "./funcs.js"; import {wellKnownFunctions} from "./funcs";
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js"; import {FunctionDefinition, FunctionInfo} from "./funcs/info";
import {Lexer, Token, TokenType} from "./lexer.js"; import {Lexer, Token, TokenType} from "./lexer";
import {Parser} from "./parser.js"; import {Parser} from "./parser";
export type CompletionItem = { export type CompletionItem = {
label: string; label: string;
@@ -1,5 +1,5 @@
import {StringData} from "../data/index.js"; import {StringData} from "../data";
import {DescriptionDictionary} from "./descriptionDictionary.js"; import {DescriptionDictionary} from "./descriptionDictionary";
describe("description dictionary", () => { describe("description dictionary", () => {
it("pairs contains all values", () => { it("pairs contains all values", () => {
@@ -1,5 +1,5 @@
import {Dictionary} from "../data/dictionary.js"; import {Dictionary} from "../data/dictionary";
import {ExpressionData, Kind, Pair} from "../data/expressiondata.js"; import {ExpressionData, Kind, Pair} from "../data/expressiondata";
export type DescriptionPair = Pair & {description?: string}; export type DescriptionPair = Pair & {description?: string};
+1 -1
View File
@@ -1,4 +1,4 @@
import {ExpressionData, ExpressionDataInterface, Kind, kindStr} from "./expressiondata.js"; import {ExpressionData, ExpressionDataInterface, Kind, kindStr} from "./expressiondata";
export class Array implements ExpressionDataInterface { export class Array implements ExpressionDataInterface {
private v: ExpressionData[] = []; private v: ExpressionData[] = [];
+1 -1
View File
@@ -1,4 +1,4 @@
import {ExpressionDataInterface, Kind} from "./expressiondata.js"; import {ExpressionDataInterface, Kind} from "./expressiondata";
export class BooleanData implements ExpressionDataInterface { export class BooleanData implements ExpressionDataInterface {
constructor(public readonly value: boolean) {} constructor(public readonly value: boolean) {}
+2 -2
View File
@@ -1,5 +1,5 @@
import {Dictionary} from "./dictionary.js"; import {Dictionary} from "./dictionary";
import {StringData} from "./string.js"; import {StringData} from "./string";
describe("dictionary", () => { describe("dictionary", () => {
it("pairs contains all values", () => { it("pairs contains all values", () => {
+1 -1
View File
@@ -1,4 +1,4 @@
import {ExpressionData, ExpressionDataInterface, Kind, kindStr, Pair} from "./expressiondata.js"; import {ExpressionData, ExpressionDataInterface, Kind, kindStr, Pair} from "./expressiondata";
export class Dictionary implements ExpressionDataInterface { export class Dictionary implements ExpressionDataInterface {
private keys: string[] = []; private keys: string[] = [];
+6 -6
View File
@@ -1,9 +1,9 @@
import {Dictionary} from "./dictionary.js"; import {Dictionary} from "./dictionary";
import {Null} from "./null.js"; import {Null} from "./null";
import {Array} from "./array.js"; import {Array} from "./array";
import {StringData} from "./string.js"; import {StringData} from "./string";
import {NumberData} from "./number.js"; import {NumberData} from "./number";
import {BooleanData} from "./boolean.js"; import {BooleanData} from "./boolean";
export enum Kind { export enum Kind {
String = 0, String = 0,
+9 -9
View File
@@ -1,9 +1,9 @@
export {Array} from "./array.js"; export {Array} from "./array";
export {BooleanData} from "./boolean.js"; export {BooleanData} from "./boolean";
export {Dictionary} from "./dictionary.js"; export {Dictionary} from "./dictionary";
export {ExpressionData, Kind} from "./expressiondata.js"; export {ExpressionData, Kind} from "./expressiondata";
export {Null} from "./null.js"; export {Null} from "./null";
export {NumberData} from "./number.js"; export {NumberData} from "./number";
export {replacer} from "./replacer.js"; export {replacer} from "./replacer";
export {reviver} from "./reviver.js"; export {reviver} from "./reviver";
export {StringData} from "./string.js"; export {StringData} from "./string";
+1 -1
View File
@@ -1,4 +1,4 @@
import {ExpressionDataInterface, Kind} from "./expressiondata.js"; import {ExpressionDataInterface, Kind} from "./expressiondata";
export class Null implements ExpressionDataInterface { export class Null implements ExpressionDataInterface {
public readonly kind = Kind.Null; public readonly kind = Kind.Null;
+1 -1
View File
@@ -1,4 +1,4 @@
import {NumberData} from "./number.js"; import {NumberData} from "./number";
describe("number", () => { describe("number", () => {
it("coerces to string", () => { it("coerces to string", () => {
+1 -1
View File
@@ -1,4 +1,4 @@
import {ExpressionDataInterface, Kind} from "./expressiondata.js"; import {ExpressionDataInterface, Kind} from "./expressiondata";
export class NumberData implements ExpressionDataInterface { export class NumberData implements ExpressionDataInterface {
constructor(public readonly value: number) {} constructor(public readonly value: number) {}
+6 -6
View File
@@ -1,9 +1,9 @@
import {Array} from "./array.js"; import {Array} from "./array";
import {Dictionary} from "./dictionary.js"; import {Dictionary} from "./dictionary";
import {Null} from "./null.js"; import {Null} from "./null";
import {NumberData} from "./number.js"; import {NumberData} from "./number";
import {replacer} from "./replacer.js"; import {replacer} from "./replacer";
import {StringData} from "./string.js"; import {StringData} from "./string";
describe("replacer", () => { describe("replacer", () => {
it("null", () => { it("null", () => {
+6 -6
View File
@@ -1,9 +1,9 @@
import {Array} from "./array.js"; import {Array} from "./array";
import {BooleanData} from "./boolean.js"; import {BooleanData} from "./boolean";
import {Dictionary} from "./dictionary.js"; import {Dictionary} from "./dictionary";
import {Null} from "./null.js"; import {Null} from "./null";
import {NumberData} from "./number.js"; import {NumberData} from "./number";
import {StringData} from "./string.js"; import {StringData} from "./string";
/** /**
* Replacer can be passed to JSON.stringify to convert an ExpressionData object into plain JSON * Replacer can be passed to JSON.stringify to convert an ExpressionData object into plain JSON
+8 -8
View File
@@ -1,11 +1,11 @@
import {Array} from "./array.js"; import {Array} from "./array";
import {BooleanData} from "./boolean.js"; import {BooleanData} from "./boolean";
import {Dictionary} from "./dictionary.js"; import {Dictionary} from "./dictionary";
import {ExpressionData} from "./expressiondata.js"; import {ExpressionData} from "./expressiondata";
import {Null} from "./null.js"; import {Null} from "./null";
import {NumberData} from "./number.js"; import {NumberData} from "./number";
import {reviver} from "./reviver.js"; import {reviver} from "./reviver";
import {StringData} from "./string.js"; import {StringData} from "./string";
describe("reviver", () => { describe("reviver", () => {
const tests: { const tests: {
+7 -7
View File
@@ -1,10 +1,10 @@
import {Array as dArray} from "./array.js"; import {Array as dArray} from "./array";
import {BooleanData} from "./boolean.js"; import {BooleanData} from "./boolean";
import {Dictionary} from "./dictionary.js"; import {Dictionary} from "./dictionary";
import {ExpressionData} from "./expressiondata.js"; import {ExpressionData} from "./expressiondata";
import {Null} from "./null.js"; import {Null} from "./null";
import {NumberData} from "./number.js"; import {NumberData} from "./number";
import {StringData} from "./string.js"; import {StringData} from "./string";
/** /**
* Reviver can be passed to `JSON.parse` to convert plain JSON into an `ExpressionData` object. * Reviver can be passed to `JSON.parse` to convert plain JSON into an `ExpressionData` object.
+1 -1
View File
@@ -1,4 +1,4 @@
import {ExpressionDataInterface, Kind} from "./expressiondata.js"; import {ExpressionDataInterface, Kind} from "./expressiondata";
export class StringData implements ExpressionDataInterface { export class StringData implements ExpressionDataInterface {
constructor(public readonly value: string) {} constructor(public readonly value: string) {}
+1 -1
View File
@@ -1,4 +1,4 @@
import {Pos, Token, tokenString} from "./lexer.js"; import {Pos, Token, tokenString} from "./lexer";
export const MAX_PARSER_DEPTH = 50; export const MAX_PARSER_DEPTH = 50;
export const MAX_EXPRESSION_LENGTH = 21000; export const MAX_EXPRESSION_LENGTH = 21000;
+5 -5
View File
@@ -1,8 +1,8 @@
import * as data from "./data/index.js"; import * as data from "./data";
import {ExpressionEvaluationError} from "./errors.js"; import {ExpressionEvaluationError} from "./errors";
import {Evaluator} from "./evaluator.js"; import {Evaluator} from "./evaluator";
import {Lexer} from "./lexer.js"; import {Lexer} from "./lexer";
import {Parser} from "./parser.js"; import {Parser} from "./parser";
describe("evaluator", () => { describe("evaluator", () => {
const lexAndParse = (input: string) => { const lexAndParse = (input: string) => {
+8 -8
View File
@@ -10,14 +10,14 @@ import {
Logical, Logical,
Star, Star,
Unary Unary
} from "./ast.js"; } from "./ast";
import * as data from "./data/index.js"; import * as data from "./data";
import {FilteredArray} from "./filtered_array.js"; import {FilteredArray} from "./filtered_array";
import {wellKnownFunctions} from "./funcs.js"; import {wellKnownFunctions} from "./funcs";
import {FunctionDefinition} from "./funcs/info.js"; import {FunctionDefinition} from "./funcs/info";
import {idxHelper} from "./idxHelper.js"; import {idxHelper} from "./idxHelper";
import {TokenType} from "./lexer.js"; import {TokenType} from "./lexer";
import {equals, falsy, greaterThan, lessThan, truthy} from "./result.js"; import {equals, falsy, greaterThan, lessThan, truthy} from "./result";
export class Evaluator implements ExprVisitor<data.ExpressionData> { export class Evaluator implements ExprVisitor<data.ExpressionData> {
/** /**
+1 -1
View File
@@ -1,3 +1,3 @@
import * as data from "./data/index.js"; import * as data from "./data";
export class FilteredArray extends data.Array {} export class FilteredArray extends data.Array {}
+10 -10
View File
@@ -1,13 +1,13 @@
import {ErrorType, ExpressionError} from "./errors.js"; import {ErrorType, ExpressionError} from "./errors";
import {contains} from "./funcs/contains.js"; import {contains} from "./funcs/contains";
import {endswith} from "./funcs/endswith.js"; import {endswith} from "./funcs/endswith";
import {format} from "./funcs/format.js"; import {format} from "./funcs/format";
import {fromjson} from "./funcs/fromjson.js"; import {fromjson} from "./funcs/fromjson";
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js"; import {FunctionDefinition, FunctionInfo} from "./funcs/info";
import {join} from "./funcs/join.js"; import {join} from "./funcs/join";
import {startswith} from "./funcs/startswith.js"; import {startswith} from "./funcs/startswith";
import {tojson} from "./funcs/tojson.js"; import {tojson} from "./funcs/tojson";
import {Token} from "./lexer.js"; import {Token} from "./lexer";
export type ParseContext = { export type ParseContext = {
allowUnknownKeywords: boolean; allowUnknownKeywords: boolean;
+3 -3
View File
@@ -1,6 +1,6 @@
import {BooleanData, ExpressionData, Kind} from "../data/index.js"; import {BooleanData, ExpressionData, Kind} from "../data";
import {equals} from "../result.js"; import {equals} from "../result";
import {FunctionDefinition} from "./info.js"; import {FunctionDefinition} from "./info";
export const contains: FunctionDefinition = { export const contains: FunctionDefinition = {
name: "contains", name: "contains",
+3 -3
View File
@@ -1,6 +1,6 @@
import {BooleanData, ExpressionData} from "../data/index.js"; import {BooleanData, ExpressionData} from "../data";
import {toUpperSpecial} from "../result.js"; import {toUpperSpecial} from "../result";
import {FunctionDefinition} from "./info.js"; import {FunctionDefinition} from "./info";
export const endswith: FunctionDefinition = { export const endswith: FunctionDefinition = {
name: "endsWith", name: "endsWith",
+2 -2
View File
@@ -1,5 +1,5 @@
import {Null, NumberData, StringData} from "../data/index.js"; import {Null, NumberData, StringData} from "../data";
import {format} from "./format.js"; import {format} from "./format";
describe("format", () => { describe("format", () => {
it("null", () => { it("null", () => {
+2 -2
View File
@@ -1,5 +1,5 @@
import {ExpressionData, StringData} from "../data/index.js"; import {ExpressionData, StringData} from "../data";
import {FunctionDefinition} from "./info.js"; import {FunctionDefinition} from "./info";
export const format: FunctionDefinition = { export const format: FunctionDefinition = {
name: "format", name: "format",
+4 -4
View File
@@ -1,7 +1,7 @@
import {ExpressionData} from "../data/index.js"; import {ExpressionData} from "../data";
import {reviver} from "../data/reviver.js"; import {reviver} from "../data/reviver";
import {ExpressionEvaluationError} from "../errors.js"; import {ExpressionEvaluationError} from "../errors";
import {FunctionDefinition} from "./info.js"; import {FunctionDefinition} from "./info";
export const fromjson: FunctionDefinition = { export const fromjson: FunctionDefinition = {
name: "fromJson", name: "fromJson",
+1 -1
View File
@@ -1,4 +1,4 @@
import {ExpressionData} from "../data/index.js"; import {ExpressionData} from "../data";
export interface FunctionInfo { export interface FunctionInfo {
name: string; name: string;
+2 -2
View File
@@ -1,5 +1,5 @@
import {ExpressionData, Kind, StringData} from "../data/index.js"; import {ExpressionData, Kind, StringData} from "../data";
import {FunctionDefinition} from "./info.js"; import {FunctionDefinition} from "./info";
export const join: FunctionDefinition = { export const join: FunctionDefinition = {
name: "join", name: "join",
+3 -3
View File
@@ -1,6 +1,6 @@
import {BooleanData, ExpressionData} from "../data/index.js"; import {BooleanData, ExpressionData} from "../data";
import {toUpperSpecial} from "../result.js"; import {toUpperSpecial} from "../result";
import {FunctionDefinition} from "./info.js"; import {FunctionDefinition} from "./info";
export const startswith: FunctionDefinition = { export const startswith: FunctionDefinition = {
name: "startsWith", name: "startsWith",
+3 -3
View File
@@ -1,6 +1,6 @@
import {ExpressionData, StringData} from "../data/index.js"; import {ExpressionData, StringData} from "../data";
import {replacer} from "../data/replacer.js"; import {replacer} from "../data/replacer";
import {FunctionDefinition} from "./info.js"; import {FunctionDefinition} from "./info";
export const tojson: FunctionDefinition = { export const tojson: FunctionDefinition = {
name: "toJson", name: "toJson",
+1 -1
View File
@@ -1,4 +1,4 @@
import {ExpressionData} from "./data/index.js"; import {ExpressionData} from "./data";
export class idxHelper { export class idxHelper {
public readonly str: string | undefined; public readonly str: string | undefined;
+9 -9
View File
@@ -1,9 +1,9 @@
export {Expr} from "./ast.js"; export {Expr} from "./ast";
export {complete, CompletionItem} from "./completion.js"; export {complete, CompletionItem} from "./completion";
export {DescriptionDictionary, DescriptionPair, isDescriptionDictionary} from "./completion/descriptionDictionary.js"; export {DescriptionDictionary, DescriptionPair, isDescriptionDictionary} from "./completion/descriptionDictionary";
export * as data from "./data/index.js"; export * as data from "./data";
export {ExpressionError, ExpressionEvaluationError} from "./errors.js"; export {ExpressionError, ExpressionEvaluationError} from "./errors";
export {Evaluator} from "./evaluator.js"; export {Evaluator} from "./evaluator";
export {wellKnownFunctions} from "./funcs.js"; export {wellKnownFunctions} from "./funcs";
export {Lexer, Result} from "./lexer.js"; export {Lexer, Result} from "./lexer";
export {Parser} from "./parser.js"; export {Parser} from "./parser";
+1 -1
View File
@@ -1,4 +1,4 @@
import {Lexer, Token, TokenType} from "./lexer.js"; import {Lexer, Token, TokenType} from "./lexer";
describe("lexer", () => { describe("lexer", () => {
const tests: { const tests: {
+2 -2
View File
@@ -1,5 +1,5 @@
import {StringData} from "./data/index.js"; import {StringData} from "./data";
import {MAX_EXPRESSION_LENGTH} from "./errors.js"; import {MAX_EXPRESSION_LENGTH} from "./errors";
export enum TokenType { export enum TokenType {
UNKNOWN, UNKNOWN,
+6 -17
View File
@@ -1,20 +1,9 @@
import { import {Binary, ContextAccess, Expr, FunctionCall, Grouping, IndexAccess, Literal, Logical, Star, Unary} from "./ast";
Binary, import * as data from "./data";
ContextAccess, import {ErrorType, ExpressionError, MAX_PARSER_DEPTH} from "./errors";
Expr, import {ParseContext, validateFunction} from "./funcs";
FunctionCall, import {FunctionInfo} from "./funcs/info";
Grouping, import {Token, TokenType} from "./lexer";
IndexAccess,
Literal,
Logical,
Star,
Unary
} from "./ast.js";
import * as data from "./data/index.js";
import {ErrorType, ExpressionError, MAX_PARSER_DEPTH} from "./errors.js";
import {ParseContext, validateFunction} from "./funcs.js";
import {FunctionInfo} from "./funcs/info.js";
import {Token, TokenType} from "./lexer.js";
export class Parser { export class Parser {
private extContexts: Map<string, boolean>; private extContexts: Map<string, boolean>;
+2 -2
View File
@@ -1,5 +1,5 @@
import {BooleanData, ExpressionData, NumberData, StringData} from "./data/index.js"; import {BooleanData, ExpressionData, NumberData, StringData} from "./data";
import {coerceTypes, toUpperSpecial} from "./result.js"; import {coerceTypes, toUpperSpecial} from "./result";
describe("coerceTypes", () => { describe("coerceTypes", () => {
const tests: { const tests: {
+1 -1
View File
@@ -1,4 +1,4 @@
import * as data from "./data/index.js"; import * as data from "./data";
export function falsy(d: data.ExpressionData): boolean { export function falsy(d: data.ExpressionData): boolean {
switch (d.kind) { switch (d.kind) {
+9 -9
View File
@@ -1,14 +1,14 @@
import * as fs from "fs"; import * as fs from "fs";
import * as path from "path"; import * as path from "path";
import {Expr} from "./ast.js"; import {Expr} from "./ast";
import * as data from "./data/index.js"; import * as data from "./data";
import {kindStr} from "./data/expressiondata.js"; import {kindStr} from "./data/expressiondata";
import {replacer} from "./data/replacer.js"; import {replacer} from "./data/replacer";
import {reviver} from "./data/reviver.js"; import {reviver} from "./data/reviver";
import {ExpressionError} from "./errors.js"; import {ExpressionError} from "./errors";
import {Evaluator} from "./evaluator.js"; import {Evaluator} from "./evaluator";
import {Lexer, Result} from "./lexer.js"; import {Lexer, Result} from "./lexer";
import {Parser} from "./parser.js"; import {Parser} from "./parser";
interface TestResult { interface TestResult {
value: data.ExpressionData; value: data.ExpressionData;
+1 -4
View File
@@ -2,12 +2,9 @@
"exclude": ["./src/**/*.test.ts"], "exclude": ["./src/**/*.test.ts"],
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"module": "node16",
"moduleResolution": "node16",
"declaration": true, "declaration": true,
"declarationMap": true, "declarationMap": true,
"noEmit": false, "noEmit": false,
"outDir": "./dist", "outDir": "./dist"
"skipLibCheck": true
} }
} }
-173
View File
@@ -10,14 +10,6 @@ The [package](https://www.npmjs.com/package/@actions/languageserver) contains Ty
npm install @actions/languageserver npm install @actions/languageserver
``` ```
To install the language server as a standalone CLI:
```bash
npm install -g @actions/languageserver
```
This makes the `actions-languageserver` command available globally.
## Usage ## Usage
### Basic usage using `vscode-languageserver-node` ### Basic usage using `vscode-languageserver-node`
@@ -100,150 +92,6 @@ const clientOptions: LanguageClientOptions = {
const client = new LanguageClient("actions-language", "GitHub Actions Language Server", serverOptions, clientOptions); const client = new LanguageClient("actions-language", "GitHub Actions Language Server", serverOptions, clientOptions);
``` ```
### Standalone CLI
After installing globally, you can run the language server directly:
```bash
actions-languageserver --stdio
```
This starts the language server using stdio transport, which is the standard way for editors to communicate with language servers.
### In Neovim
#### 1. Install the language server
```bash
npm install -g @actions/languageserver
```
#### 2. Set up filetype detection
Add this to your `init.lua` to detect GitHub Actions workflow files:
```lua
vim.filetype.add({
pattern = {
[".*/%.github/workflows/.*%.ya?ml"] = "yaml.ghactions",
},
})
```
This sets the filetype to `yaml.ghactions` for YAML files in `.github/workflows/`, allowing you to keep separate YAML LSP configurations if needed.
#### 3. Create the LSP configuration
As of Neovim 0.11+ you can add this configuration in `~/.config/nvim/lsp/actionsls.lua`:
```lua
local function get_github_token()
local handle = io.popen("gh auth token 2>/dev/null")
if not handle then return nil end
local token = handle: read("*a"):gsub("%s+", "")
handle:close()
return token ~= "" and token or nil
end
local function parse_github_remote(url)
if not url or url == "" then return nil end
-- SSH format: [email protected]:owner/repo.git
local owner, repo = url:match("git@github%.com:([^/]+)/([^/%.]+)")
if owner and repo then
return owner, repo: gsub("%.git$", "")
end
-- HTTPS format: https://github.com/owner/repo.git
owner, repo = url:match("github%.com/([^/]+)/([^/%.]+)")
if owner and repo then
return owner, repo:gsub("%.git$", "")
end
return nil
end
local function get_repo_info(owner, repo)
local cmd = string.format(
"gh repo view %s/%s --json id,owner --template '{{.id}}\t{{.owner.type}}' 2>/dev/null",
owner,
repo
)
local handle = io.popen(cmd)
if not handle then return nil end
local result = handle: read("*a"):gsub("%s+$", "")
handle:close()
local id, owner_type = result:match("^(%d+)\t(.+)$")
if id then
return {
id = tonumber(id),
organizationOwned = owner_type == "Organization",
}
end
return nil
end
local function get_repos_config()
local handle = io.popen("git rev-parse --show-toplevel 2>/dev/null")
if not handle then return nil end
local git_root = handle: read("*a"):gsub("%s+", "")
handle:close()
if git_root == "" then return nil end
handle = io.popen("git remote get-url origin 2>/dev/null")
if not handle then return nil end
local remote_url = handle:read("*a"):gsub("%s+", "")
handle:close()
local owner, name = parse_github_remote(remote_url)
if not owner or not name then return nil end
local info = get_repo_info(owner, name)
return {
{
id = info and info.id or 0,
owner = owner,
name = name,
organizationOwned = info and info.organizationOwned or false,
workspaceUri = "file://" .. git_root,
},
}
end
return {
cmd = { "actions-languageserver", "--stdio" },
filetypes = { "yaml.ghactions" },
root_markers = { ".git" },
init_options = {
-- Optional: provide a GitHub token and repo context for added functionality
-- (e.g., repository-specific completions)
sessionToken = get_github_token(),
repos = get_repos_config(),
},
}
```
#### 4. Enable the LSP
Add to your `init.lua`:
```lua
vim.lsp.enable('actionsls')
```
#### 5. Verify it's working
Open any `.github/workflows/*.yml` file and run:
```vim
:checkhealth vim.lsp
```
You should see `actionsls` in the list of attached clients.
## Contributing ## Contributing
See [CONTRIBUTING.md](../CONTRIBUTING.md) at the root of the repository for general guidelines and recommendations. See [CONTRIBUTING.md](../CONTRIBUTING.md) at the root of the repository for general guidelines and recommendations.
@@ -262,27 +110,6 @@ or to watch for changes
npm run watch npm run watch
``` ```
### Running the language server locally
After running
```bash
npm run build:cli
npm link
```
`actions-languageserver` will be available globally. You can start it with:
```bash
actions-languageserver --stdio
```
Once linked you can also watch for changes and rebuild automatically:
```bash
npm run watch:cli
```
### Test ### Test
```bash ```bash
+7 -9
View File
@@ -1,6 +1,6 @@
{ {
"name": "@actions/languageserver", "name": "@actions/languageserver",
"version": "0.3.26", "version": "0.3.20",
"description": "Language server for GitHub Actions", "description": "Language server for GitHub Actions",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
@@ -31,7 +31,7 @@
"url": "https://github.com/actions/languageservices" "url": "https://github.com/actions/languageservices"
}, },
"scripts": { "scripts": {
"build": "tsc --build tsconfig.build.json && npm run build:cli", "build": "tsc --build tsconfig.build.json",
"build:cli": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/cli.bundle.cjs", "build:cli": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/cli.bundle.cjs",
"clean": "rimraf dist", "clean": "rimraf dist",
"format": "prettier --write '**/*.ts'", "format": "prettier --write '**/*.ts'",
@@ -41,15 +41,14 @@
"prepublishOnly": "npm run build && npm run test", "prepublishOnly": "npm run build && npm run test",
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest", "test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch", "test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
"watch": "tsc --build tsconfig.build.json --watch", "watch": "tsc --build tsconfig.build.json --watch"
"watch:cli": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/cli.bundle.cjs --watch"
}, },
"bin": { "bin": {
"actions-languageserver": "./bin/actions-languageserver" "actions-languageserver": "./bin/actions-languageserver"
}, },
"dependencies": { "dependencies": {
"@actions/languageservice": "^0.3.26", "@actions/languageservice": "^0.3.20",
"@actions/workflow-parser": "^0.3.26", "@actions/workflow-parser": "^0.3.20",
"@octokit/rest": "^21.1.1", "@octokit/rest": "^21.1.1",
"@octokit/types": "^9.0.0", "@octokit/types": "^9.0.0",
"vscode-languageserver": "^8.0.2", "vscode-languageserver": "^8.0.2",
@@ -57,11 +56,10 @@
"yaml": "^2.1.3" "yaml": "^2.1.3"
}, },
"engines": { "engines": {
"node": ">= 18" "node": ">= 16.15"
}, },
"files": [ "files": [
"dist/**/*", "dist/**/*"
"bin/**/*"
], ],
"devDependencies": { "devDependencies": {
"@types/jest": "^29.0.3", "@types/jest": "^29.0.3",
+10
View File
@@ -0,0 +1,10 @@
#!/bin/bash
npx esbuild src/index.ts \
--bundle \
--platform=node \
--target=node18 \
--format=cjs \
--outfile=dist/server-bundled.cjs \
--external:vscode \
--loader:.json=json
+17 -1
View File
@@ -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 {registerLogger, setLogLevel} from "@actions/languageservice/log";
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache"; import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
import {Octokit} from "@octokit/rest"; import {Octokit} from "@octokit/rest";
import { import {
CodeAction,
CodeActionKind,
CodeActionParams,
CompletionItem, CompletionItem,
Connection, Connection,
DocumentLink, DocumentLink,
@@ -72,6 +75,9 @@ export function initConnection(connection: Connection) {
hoverProvider: true, hoverProvider: true,
documentLinkProvider: { documentLinkProvider: {
resolveProvider: false resolveProvider: false
},
codeActionProvider: {
codeActionKinds: [CodeActionKind.QuickFix]
} }
} }
}; };
@@ -158,6 +164,16 @@ export function initConnection(connection: Connection) {
return documentLinks(getDocument(documents, textDocument), repoContext?.workspaceUri); return documentLinks(getDocument(documents, textDocument), repoContext?.workspaceUri);
}); });
connection.onCodeAction(async (params: CodeActionParams): Promise<CodeAction[]> => {
return timeOperation("codeAction", async () => {
return getCodeActions({
uri: params.textDocument.uri,
diagnostics: params.context.diagnostics,
only: params.context.only
});
});
});
// Make the text document manager listen on the connection // Make the text document manager listen on the connection
// for open, change and close text document events // for open, change and close text document events
documents.listen(connection); documents.listen(connection);
@@ -1,76 +0,0 @@
import {data, DescriptionDictionary} from "@actions/expressions";
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
import {Mode} from "@actions/languageservice/context-providers/default";
import {contextProviders} from "./context-providers";
import {RepositoryContext} from "./initializationOptions";
import {TTLCache} from "./utils/cache";
describe("contextProviders", () => {
const mockCache = new TTLCache();
const mockRepo: RepositoryContext = {
id: 123,
owner: "test-owner",
name: "test-repo",
organizationOwned: true,
workspaceUri: "file:///workspace"
};
const mockWorkflowContext: WorkflowContext = {
uri: "test.yaml",
template: undefined
};
describe("when client is undefined", () => {
it("should return incomplete context for secrets", async () => {
const config = contextProviders(undefined, mockRepo, mockCache);
const result = await config.getContext("secrets", undefined, mockWorkflowContext, Mode.Validation);
expect(result).toBeInstanceOf(DescriptionDictionary);
expect((result as DescriptionDictionary).complete).toBe(false);
});
it("should return incomplete context for vars", async () => {
const config = contextProviders(undefined, mockRepo, mockCache);
const result = await config.getContext("vars", undefined, mockWorkflowContext, Mode.Validation);
expect(result).toBeInstanceOf(DescriptionDictionary);
expect((result as DescriptionDictionary).complete).toBe(false);
});
it("should preserve defaultContext and mark as incomplete for secrets", async () => {
const config = contextProviders(undefined, mockRepo, mockCache);
const defaultContext = new DescriptionDictionary();
defaultContext.add("EXISTING_SECRET", new data.StringData("test"));
const result = await config.getContext("secrets", defaultContext, mockWorkflowContext, Mode.Validation);
expect(result).toBe(defaultContext);
expect((result as DescriptionDictionary).complete).toBe(false);
expect((result as DescriptionDictionary).get("EXISTING_SECRET")).toBeDefined();
});
it("should return undefined for other contexts like steps", async () => {
const config = contextProviders(undefined, mockRepo, mockCache);
const result = await config.getContext("steps", undefined, mockWorkflowContext, Mode.Validation);
expect(result).toBeUndefined();
});
});
describe("when both client and repo are undefined", () => {
it("should return incomplete context for secrets", async () => {
const config = contextProviders(undefined, undefined, mockCache);
const result = await config.getContext("secrets", undefined, mockWorkflowContext, Mode.Validation);
expect(result).toBeInstanceOf(DescriptionDictionary);
expect((result as DescriptionDictionary).complete).toBe(false);
});
it("should return incomplete context for vars", async () => {
const config = contextProviders(undefined, undefined, mockCache);
const result = await config.getContext("vars", undefined, mockWorkflowContext, Mode.Validation);
expect(result).toBeInstanceOf(DescriptionDictionary);
expect((result as DescriptionDictionary).complete).toBe(false);
});
});
});
+1 -12
View File
@@ -15,18 +15,7 @@ export function contextProviders(
cache: TTLCache cache: TTLCache
): ContextProviderConfig { ): ContextProviderConfig {
if (!repo || !client) { if (!repo || !client) {
// When GitHub client/repo is unavailable, return an incomplete dictionary return {getContext: () => Promise.resolve(undefined)};
// to avoid false "Context access might be invalid" warnings
return {
getContext: (name: string, defaultContext: DescriptionDictionary | undefined) => {
if (name === "secrets" || name === "vars") {
const context = defaultContext || new DescriptionDictionary();
context.complete = false;
return Promise.resolve(context);
}
return Promise.resolve(undefined);
}
};
} }
const getContext = async ( const getContext = async (
@@ -49,7 +49,7 @@ export async function getSecrets(
if (isString(x.value)) { if (isString(x.value)) {
environmentName = x.value.value; environmentName = x.value.value;
} else { } else {
// this means we have a dynamic environment, in those situations we // this means we have a dynamic enviornment, in those situations we
// want to make sure we skip doing secret validation // want to make sure we skip doing secret validation
secretsContext.complete = false; secretsContext.complete = false;
} }
@@ -1,4 +1,4 @@
import {data, DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions"; import {data, DescriptionDictionary} from "@actions/expressions";
import {getStepsContext as getDefaultStepsContext} from "@actions/languageservice/context-providers/steps"; import {getStepsContext as getDefaultStepsContext} from "@actions/languageservice/context-providers/steps";
import {Octokit} from "@octokit/rest"; import {Octokit} from "@octokit/rest";
import fetchMock from "fetch-mock"; import fetchMock from "fetch-mock";
@@ -63,43 +63,6 @@ it("returns default context when job is undefined", async () => {
expect(stepsContext).toEqual(defaultContext); expect(stepsContext).toEqual(defaultContext);
}); });
it("outputs is an incomplete dictionary to allow dynamic outputs", async () => {
const mock = fetchMock
.sandbox()
.getOnce("https://api.github.com/repos/actions/cache/contents/action.yml?ref=v3", actionMetadata);
const workflowContext = await createWorkflowContext(workflow, "build");
const defaultContext = getDefaultStepsContext(workflowContext);
const stepsContext = await getStepsContext(
new Octokit({
request: {
fetch: mock
}
}),
new TTLCache(),
defaultContext,
workflowContext
);
// Get the step context
const stepContext = stepsContext?.get("cache-primes");
expect(stepContext).toBeDefined();
expect(isDescriptionDictionary(stepContext!)).toBe(true);
// Get the outputs - should be a dictionary, not null
const outputs = (stepContext as DescriptionDictionary).get("outputs");
expect(outputs).toBeDefined();
expect(isDescriptionDictionary(outputs!)).toBe(true);
// Outputs should be marked incomplete to allow dynamic outputs
const outputsDict = outputs as DescriptionDictionary;
expect(outputsDict.complete).toBe(false);
// Known outputs from action.yml should be present
expect(outputsDict.get("cache-hit")).toBeDefined();
});
it("adds action outputs", async () => { it("adds action outputs", async () => {
const mock = fetchMock const mock = fetchMock
.sandbox() .sandbox()
@@ -120,22 +83,17 @@ it("adds action outputs", async () => {
); );
expect(stepsContext).toBeDefined(); expect(stepsContext).toBeDefined();
// Create expected outputs dict with complete = false
// (actions can have dynamic outputs beyond what's declared in action.yml)
const expectedOutputs = new DescriptionDictionary({
key: "cache-hit",
value: new data.StringData("A boolean value to indicate an exact match was found for the primary key"),
description: "A boolean value to indicate an exact match was found for the primary key"
});
expectedOutputs.complete = false;
expect(stepsContext).toEqual( expect(stepsContext).toEqual(
new DescriptionDictionary({ new DescriptionDictionary({
key: "cache-primes", key: "cache-primes",
value: new DescriptionDictionary( value: new DescriptionDictionary(
{ {
key: "outputs", key: "outputs",
value: expectedOutputs value: new DescriptionDictionary({
key: "cache-hit",
value: new data.StringData("A boolean value to indicate an exact match was found for the primary key"),
description: "A boolean value to indicate an exact match was found for the primary key"
})
}, },
{ {
key: "conclusion", key: "conclusion",
@@ -58,8 +58,6 @@ export async function getStepsContext(
continue; continue;
} }
const outputsDict = new DescriptionDictionary(); const outputsDict = new DescriptionDictionary();
// Actions can have dynamic outputs beyond what's declared in action.yml
outputsDict.complete = false;
for (const [key, value] of Object.entries(outputs)) { for (const [key, value] of Object.entries(outputs)) {
outputsDict.add(key, new data.StringData(value.description), value.description); outputsDict.add(key, new data.StringData(value.description), value.description);
} }
@@ -26,8 +26,6 @@ export async function getVariables(
return secretsContext; return secretsContext;
} }
const variablesContext = defaultContext || new DescriptionDictionary();
let environmentName: string | undefined; let environmentName: string | undefined;
if (workflowContext?.job?.environment) { if (workflowContext?.job?.environment) {
if (isString(workflowContext.job.environment)) { if (isString(workflowContext.job.environment)) {
@@ -37,19 +35,14 @@ export async function getVariables(
if (isString(x.key) && x.key.value === "name") { if (isString(x.key) && x.key.value === "name") {
if (isString(x.value)) { if (isString(x.value)) {
environmentName = x.value.value; environmentName = x.value.value;
} else {
// this means we have a dynamic environment, in those situations we want to skip validation
variablesContext.complete = false;
} }
break; break;
} }
} }
} else {
// if the expression is something like environment: ${{ ... }} then we want to skip validation
variablesContext.complete = false;
} }
} }
const variablesContext = defaultContext || new DescriptionDictionary();
try { try {
const variables = await getRemoteVariables(octokit, cache, repo, environmentName); const variables = await getRemoteVariables(octokit, cache, repo, environmentName);
+3 -3
View File
@@ -1,12 +1,12 @@
import {Connection} from "vscode-languageserver"; import { Connection } from "vscode-languageserver";
import { import {
BrowserMessageReader, BrowserMessageReader,
BrowserMessageWriter, BrowserMessageWriter,
createConnection as createBrowserConnection createConnection as createBrowserConnection
} from "vscode-languageserver/browser"; } from "vscode-languageserver/browser";
import {createConnection as createNodeConnection} from "vscode-languageserver/node"; import { createConnection as createNodeConnection } from "vscode-languageserver/node";
import {initConnection} from "./connection"; import { initConnection } from "./connection";
/** Helper function determining whether we are executing with node runtime */ /** Helper function determining whether we are executing with node runtime */
function isNode(): boolean { function isNode(): boolean {
+5 -8
View File
@@ -1,6 +1,6 @@
{ {
"name": "@actions/languageservice", "name": "@actions/languageservice",
"version": "0.3.26", "version": "0.3.20",
"description": "Language service for GitHub Actions", "description": "Language service for GitHub Actions",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
@@ -37,25 +37,22 @@
"format-check": "prettier --check '**/*.ts'", "format-check": "prettier --check '**/*.ts'",
"lint": "eslint 'src/**/*.ts'", "lint": "eslint 'src/**/*.ts'",
"lint-fix": "eslint --fix 'src/**/*.ts'", "lint-fix": "eslint --fix 'src/**/*.ts'",
"minify-json": "node ../script/minify-json.js src/context-providers/descriptions.json src/context-providers/events/webhooks.json src/context-providers/events/objects.json src/context-providers/events/schedule.json src/context-providers/events/workflow_call.json",
"prebuild": "npm run minify-json",
"prepublishOnly": "npm run build && npm run test", "prepublishOnly": "npm run build && npm run test",
"pretest": "npm run minify-json",
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest", "test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch", "test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
"update-webhooks": "npx tsx script/webhooks/index.ts", "update-webhooks": "ts-node-esm script/webhooks/index.ts",
"watch": "tsc --build tsconfig.build.json --watch" "watch": "tsc --build tsconfig.build.json --watch"
}, },
"dependencies": { "dependencies": {
"@actions/expressions": "^0.3.26", "@actions/expressions": "^0.3.20",
"@actions/workflow-parser": "^0.3.26", "@actions/workflow-parser": "^0.3.20",
"vscode-languageserver-textdocument": "^1.0.7", "vscode-languageserver-textdocument": "^1.0.7",
"vscode-languageserver-types": "^3.17.2", "vscode-languageserver-types": "^3.17.2",
"vscode-uri": "^3.0.8", "vscode-uri": "^3.0.8",
"yaml": "^2.1.1" "yaml": "^2.1.1"
}, },
"engines": { "engines": {
"node": ">= 18" "node": ">= 16.15"
}, },
"files": [ "files": [
"dist/**/*" "dist/**/*"
+2 -273
View File
@@ -7,185 +7,6 @@ const schema = schemaImport as any;
const OUTPUT_PATH = "./src/context-providers/events/webhooks.json"; const OUTPUT_PATH = "./src/context-providers/events/webhooks.json";
const OBJECTS_PATH = "./src/context-providers/events/objects.json"; const OBJECTS_PATH = "./src/context-providers/events/objects.json";
const ALL_OUTPUT_PATH = "./src/context-providers/events/webhooks.all.json";
const ALL_OBJECTS_PATH = "./src/context-providers/events/objects.all.json";
const DROP_OUTPUT_PATH = "./src/context-providers/events/webhooks.drop.json";
const DROP_OBJECTS_PATH = "./src/context-providers/events/objects.drop.json";
const STRIP_OUTPUT_PATH = "./src/context-providers/events/webhooks.strip.json";
const STRIP_OBJECTS_PATH = "./src/context-providers/events/objects.strip.json";
// Parse --all flag
const generateAll = process.argv.includes("--all");
// Events to drop - not valid workflow triggers (GitHub App or API-only events)
// See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
const DROPPED_EVENTS = new Set([
"branch_protection_configuration",
"code_scanning_alert",
"commit_comment",
"custom_property",
"custom_property_values",
"dependabot_alert",
"deploy_key",
"github_app_authorization",
"installation",
"installation_repositories",
"installation_target",
"marketplace_purchase",
"member",
"membership",
"merge_group",
"meta",
"org_block",
"organization",
"package",
"personal_access_token_request",
"ping",
"repository",
"repository_advisory",
"repository_ruleset",
"secret_scanning_alert",
"secret_scanning_alert_location",
"security_advisory",
"security_and_analysis",
"sponsorship",
"star",
"team",
"team_add"
]);
// Events to keep - valid workflow triggers
// See: https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows
const KEPT_EVENTS = new Set([
"branch_protection_rule",
"check_run",
"check_suite",
"create",
"delete",
"deployment",
"deployment_status",
"discussion",
"discussion_comment",
"fork",
"gollum",
"issue_comment",
"issues",
"label",
"milestone",
"page_build",
"project",
"project_card",
"project_column",
"projects_v2",
"projects_v2_item",
"public",
"pull_request",
"pull_request_review",
"pull_request_review_comment",
"pull_request_review_thread",
"push",
"registry_package",
"release",
"repository_dispatch",
"repository_import",
"repository_vulnerability_alert",
"status",
"watch",
"workflow_dispatch",
"workflow_job",
"workflow_run"
]);
/**
* Fields to strip from the JSON data.
*
* EVENT_ACTION_FIELDS: stripped from each event action object (top level only)
* Example event action object before stripping:
* {
* "description": "This event is triggered when...", // <-- stripped
* "summary": "A brief summary", // <-- stripped
* "availability": ["repository"], // <-- stripped
* "category": "issues", // <-- stripped
* "action": "opened", // kept
* "bodyParameters": [...] // kept
* }
*
* BODY_PARAM_FIELDS: stripped from every bodyParameters object, recursively through childParamsGroups
* Example bodyParameter object before stripping:
* {
* "type": "object", // <-- stripped
* "name": "changes", // kept (used for property names)
* "in": "body", // <-- stripped
* "description": "The changes that were made.", // kept (used for hover docs)
* "isRequired": true, // <-- stripped
* "enum": ["a", "b"], // <-- stripped
* "default": "a", // <-- stripped
* "childParamsGroups": [ // kept (used for nested properties)
* {
* "type": "string", // <-- stripped (recursive)
* "name": "from", // kept
* "isRequired": true // <-- stripped (recursive)
* }
* ]
* }
*/
const EVENT_ACTION_FIELDS = ["description", "summary", "availability", "category"];
const BODY_PARAM_FIELDS = ["type", "in", "isRequired", "enum", "default"];
/**
* Strip fields from a bodyParameter object and recursively from childParamsGroups.
*/
function stripBodyParam(param: any): any {
if (typeof param !== "object" || param === null) {
return param;
}
const result: any = {};
for (const [key, value] of Object.entries(param)) {
if (BODY_PARAM_FIELDS.includes(key)) {
continue; // Strip this field
}
if (key === "childParamsGroups" && Array.isArray(value)) {
result[key] = value.map(stripBodyParam);
} else {
result[key] = value;
}
}
return result;
}
/**
* Strip unused fields from event action data.
*/
function stripEventActionFields(action: any): any {
const result: any = {};
for (const [key, value] of Object.entries(action)) {
if (EVENT_ACTION_FIELDS.includes(key)) {
continue; // Strip this field
}
if (key === "bodyParameters" && Array.isArray(value)) {
result[key] = value.map((p: any) => (typeof p === "number" ? p : stripBodyParam(p)));
} else {
result[key] = value;
}
}
return result;
}
/**
* Strip unused fields from all webhooks.
* Structure: { eventName: { actionName: { ...fields } } }
*/
function stripFields(webhooks: Record<string, Record<string, any>>): Record<string, Record<string, any>> {
const result: Record<string, Record<string, any>> = {};
for (const [eventName, actions] of Object.entries(webhooks)) {
result[eventName] = {};
for (const [actionName, actionData] of Object.entries(actions)) {
result[eventName][actionName] = stripEventActionFields(actionData);
}
}
return result;
}
const rawWebhooks = Object.values(schema.webhooks || schema["x-webhooks"]) as any[]; const rawWebhooks = Object.values(schema.webhooks || schema["x-webhooks"]) as any[];
if (!rawWebhooks) { if (!rawWebhooks) {
@@ -199,51 +20,11 @@ for (const webhook of Object.values(rawWebhooks)) {
await Promise.all(webhooks.map(webhook => webhook.process())); await Promise.all(webhooks.map(webhook => webhook.process()));
// Check for unknown events (not in DROPPED_EVENTS or KEPT_EVENTS)
const unknownEvents: string[] = [];
for (const webhook of webhooks) {
if (!DROPPED_EVENTS.has(webhook.category) && !KEPT_EVENTS.has(webhook.category)) {
if (!unknownEvents.includes(webhook.category)) {
unknownEvents.push(webhook.category);
}
}
}
if (unknownEvents.length > 0) {
console.error("");
console.error("══════════════════════════════════════════════════════════════════");
console.error("ERROR: New webhook event(s) detected!");
console.error("══════════════════════════════════════════════════════════════════");
console.error("");
console.error("The following events are not categorized:");
for (const event of unknownEvents.sort()) {
console.error(` - ${event}`);
}
console.error("");
console.error("Action required:");
console.error(" 1. Check if the event is a valid workflow trigger:");
console.error(
" https://docs.github.com/en/actions/writing-workflows/choosing-when-your-workflow-runs/events-that-trigger-workflows"
);
console.error("");
console.error(" 2. Add the event to DROPPED_EVENTS or KEPT_EVENTS in:");
console.error(" languageservice/script/webhooks/index.ts");
console.error("");
console.error(" 3. See docs/json-data-files.md for more details.");
console.error("");
process.exit(1);
}
// The category is the name of the webhook // The category is the name of the webhook
const categorizedWebhooks: Record<string, Record<string, Webhook>> = {}; const categorizedWebhooks: Record<string, Record<string, Webhook>> = {};
for (const webhook of webhooks) { for (const webhook of webhooks) {
if (!webhook.action) webhook.action = "default"; if (!webhook.action) webhook.action = "default";
// Drop unused events
if (DROPPED_EVENTS.has(webhook.category)) {
continue;
}
if (categorizedWebhooks[webhook.category]) { if (categorizedWebhooks[webhook.category]) {
categorizedWebhooks[webhook.category][webhook.action] = webhook; categorizedWebhooks[webhook.category][webhook.action] = webhook;
} else { } else {
@@ -252,59 +33,7 @@ for (const webhook of webhooks) {
} }
} }
// Strip fields before deduplication const objectsArray = deduplicateWebhooks(categorizedWebhooks);
const strippedWebhooks = stripFields(categorizedWebhooks);
// Deduplicate after dropping and stripping
const objectsArray = deduplicateWebhooks(strippedWebhooks);
// Write optimized output
await fs.writeFile(OBJECTS_PATH, JSON.stringify(objectsArray, null, 2)); await fs.writeFile(OBJECTS_PATH, JSON.stringify(objectsArray, null, 2));
await fs.writeFile(OUTPUT_PATH, JSON.stringify(strippedWebhooks, null, 2)); await fs.writeFile(OUTPUT_PATH, JSON.stringify(categorizedWebhooks, null, 2));
console.log(`Wrote ${OUTPUT_PATH} (${Object.keys(strippedWebhooks).length} events)`);
console.log(`Wrote ${OBJECTS_PATH} (${objectsArray.length} objects)`);
// Optionally generate intermediate versions for size comparison
if (generateAll) {
// Helper to deep clone
function clone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj));
}
// Build full webhooks (no drop, no strip) from fresh data
const fullWebhooks: Record<string, Record<string, any>> = {};
for (const webhook of webhooks) {
const w = clone(webhook);
if (!w.action) w.action = "default";
fullWebhooks[w.category] ||= {};
fullWebhooks[w.category][w.action] = w;
}
// Generate all version (no drop, no strip)
const allWebhooks = clone(fullWebhooks);
const allObjects = deduplicateWebhooks(allWebhooks);
await fs.writeFile(ALL_OUTPUT_PATH, JSON.stringify(allWebhooks, null, 2));
await fs.writeFile(ALL_OBJECTS_PATH, JSON.stringify(allObjects, null, 2));
console.log(`Wrote ${ALL_OUTPUT_PATH} (${Object.keys(allWebhooks).length} events)`);
console.log(`Wrote ${ALL_OBJECTS_PATH} (${allObjects.length} objects)`);
// Generate drop-only version (drop events, no strip)
const dropWebhooks = clone(fullWebhooks);
for (const event of DROPPED_EVENTS) {
delete dropWebhooks[event];
}
const dropObjects = deduplicateWebhooks(dropWebhooks);
await fs.writeFile(DROP_OUTPUT_PATH, JSON.stringify(dropWebhooks, null, 2));
await fs.writeFile(DROP_OBJECTS_PATH, JSON.stringify(dropObjects, null, 2));
console.log(`Wrote ${DROP_OUTPUT_PATH} (${Object.keys(dropWebhooks).length} events)`);
console.log(`Wrote ${DROP_OBJECTS_PATH} (${dropObjects.length} objects)`);
// Generate strip-only version (strip fields, no drop)
const stripWebhooks = stripFields(clone(fullWebhooks));
const stripObjects = deduplicateWebhooks(stripWebhooks);
await fs.writeFile(STRIP_OUTPUT_PATH, JSON.stringify(stripWebhooks, null, 2));
await fs.writeFile(STRIP_OBJECTS_PATH, JSON.stringify(stripObjects, null, 2));
console.log(`Wrote ${STRIP_OUTPUT_PATH} (${Object.keys(stripWebhooks).length} events)`);
console.log(`Wrote ${STRIP_OBJECTS_PATH} (${stripObjects.length} objects)`);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import {actionIdentifier, parseActionReference as parse} from "./action.js"; import {actionIdentifier, parseActionReference as parse} from "./action";
describe("parseActionReference", () => { describe("parseActionReference", () => {
it("basic action", () => { it("basic action", () => {
+54
View File
@@ -0,0 +1,54 @@
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 CodeActionConfig {
// TODO: actionsMetadataProvider, fileProvider, etc.
}
export interface CodeActionParams {
uri: string;
diagnostics: Diagnostic[];
only?: string[];
}
export function getCodeActions(params: CodeActionParams, config?: CodeActionConfig): 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";
@@ -0,0 +1,67 @@
import { CodeAction, TextEdit } from "vscode-languageserver-types";
import { CodeActionContext, 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 {
const edits: TextEdit[] = [];
if (data.hasWithKey && data.withIndent !== undefined) {
// `with:` exists - use its indentation + 2 for inputs
const inputIndent = " ".repeat(data.withIndent + 2);
const inputLines = data.missingInputs.map(input => {
const value = input.default !== undefined ? input.default : '""';
return `${inputIndent}${input.name}: ${value}`;
});
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 : '""';
return `${inputIndent}${input.name}: ${value}`;
});
const newText = [`${withIndent}with:\n`, ...inputLines.map(line => `${line}\n`)].join("");
edits.push({
range: { start: data.insertPosition, end: data.insertPosition },
newText,
});
}
return edits;
}
@@ -0,0 +1,4 @@
import {CodeActionProvider} from "../types";
import {addMissingInputsProvider} from "./add-missing-inputs";
export const quickfixProviders: CodeActionProvider[] = [addMissingInputsProvider];
@@ -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);
}
});
}
});
@@ -0,0 +1,227 @@
import * as fs from "fs";
import * as path from "path";
import { TextEdit } from "vscode-languageserver-types";
import { TextDocument } from "vscode-languageserver-textdocument";
import { validate, ValidationConfig } from "../../validate";
import { getCodeActions, CodeActionParams } from "../index";
// Marker pattern: # want "diagnostic message" fix="code-action-name"
const MARKER_PATTERN = /#\s*want\s+"([^"]+)"(?:\s+fix="([^"]+)")?/;
export interface TestCase {
name: string;
inputPath: string;
goldenPath: string;
input: string;
golden: string;
markers: Marker[];
}
export interface Marker {
line: number;
message: string;
fix?: string;
}
export interface TestResult {
name: string;
passed: boolean;
error?: string;
expected?: string;
actual?: string;
}
/**
* Parse markers from input file content
*/
export function parseMarkers(content: string): Marker[] {
const lines = content.split("\n");
const markers: Marker[] = [];
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(MARKER_PATTERN);
if (match) {
markers.push({
line: i,
message: match[1],
fix: match[2]
});
}
}
return markers;
}
/**
* Strip markers from content (for processing)
*/
export function stripMarkers(content: string): string {
return content
.split("\n")
.map(line => line.replace(MARKER_PATTERN, "").trimEnd())
.join("\n");
}
/**
* Load all test cases from a testdata directory
*/
export function loadTestCases(testdataDir: string): TestCase[] {
const testCases: TestCase[] = [];
function walkDir(dir: string) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkDir(fullPath);
} else if (entry.isFile() && entry.name.endsWith(".yml") && !entry.name.endsWith(". golden.yml")) {
const goldenPath = fullPath.replace(".yml", ".golden.yml");
if (fs.existsSync(goldenPath)) {
const input = fs.readFileSync(fullPath, "utf-8");
const golden = fs.readFileSync(goldenPath, "utf-8");
testCases.push({
name: path.relative(testdataDir, fullPath),
inputPath: fullPath,
goldenPath,
input,
golden,
markers: parseMarkers(input)
});
}
}
}
}
walkDir(testdataDir);
return testCases;
}
/**
* Apply text edits to a document
*/
export function applyEdits(content: string, edits: TextEdit[]): string {
// Sort edits in reverse order by position to apply from bottom to top
const sortedEdits = [...edits].sort((a, b) => {
if (b.range.start.line !== a.range.start.line) {
return b.range.start.line - a.range.start.line;
}
return b.range.start.character - a.range.start.character;
});
const lines = content.split("\n");
for (const edit of sortedEdits) {
const startLine = edit.range.start.line;
const startChar = edit.range.start.character;
const endLine = edit.range.end.line;
const endChar = edit.range.end.character;
const before = lines[startLine].slice(0, startChar);
const after = lines[endLine].slice(endChar);
const newLines = edit.newText.split("\n");
newLines[0] = before + newLines[0];
newLines[newLines.length - 1] = newLines[newLines.length - 1] + after;
lines.splice(startLine, endLine - startLine + 1, ...newLines);
}
return lines.join("\n");
}
/**
* Run a single test case
*/
export async function runTestCase(testCase: TestCase, validationConfig: ValidationConfig): Promise<TestResult> {
const strippedInput = stripMarkers(testCase.input);
const document = TextDocument.create("file:///test.yml", "yaml", 1, strippedInput);
// 1. Validate and get diagnostics
const diagnostics = await validate(document, validationConfig);
// 2. Verify all expected diagnostics are present
const missingDiagnostics: string[] = [];
for (const marker of testCase.markers) {
const found = diagnostics.find(d => d.range.start.line === marker.line && d.message.includes(marker.message));
console.log(found);
if (!found) {
missingDiagnostics.push(`line ${marker.line}: "${marker.message}"`);
}
}
if (missingDiagnostics.length > 0) {
return {
name: testCase.name,
passed: false,
error: `Missing expected diagnostics:\n ${missingDiagnostics.join(
"\n "
)}\n\nActual diagnostics:\n ${diagnostics.map(d => `line ${d.range.start.line}: "${d.message}"`).join("\n ")}`
};
}
// 3. Collect all edits from all matching code actions
const allEdits: TextEdit[] = [];
for (const marker of testCase.markers) {
if (!marker.fix) {
continue;
}
const diagnostic = diagnostics.find(d => d.range.start.line === marker.line && d.message.includes(marker.message));
if (!diagnostic) {
continue; // Already reported above
}
const params: CodeActionParams = {
uri: document.uri,
diagnostics: [diagnostic]
};
const actions = getCodeActions(params);
const matchingAction = actions.find(a => a.title.toLowerCase().includes(marker.fix!.toLowerCase()));
if (!matchingAction) {
return {
name: testCase.name,
passed: false,
error: `Code action "${marker.fix}" not found for diagnostic on line ${marker.line}.\nAvailable actions: ${actions.map(a => a.title).join(", ") || "(none)"
}`
};
}
if (!matchingAction.edit?.changes) {
return {
name: testCase.name,
passed: false,
error: `Code action "${marker.fix}" has no edits`
};
}
const edits = matchingAction.edit.changes[document.uri] || [];
allEdits.push(...edits);
}
// 4. Apply all edits and compare to golden file
const actualOutput = applyEdits(strippedInput, allEdits);
const expectedOutput = testCase.golden;
if (actualOutput.trim() !== expectedOutput.trim()) {
return {
name: testCase.name,
passed: false,
error: "Output does not match golden file",
expected: expectedOutput,
actual: actualOutput
};
}
return {
name: testCase.name,
passed: true
};
}
@@ -0,0 +1,9 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
path: ""
key: ""
@@ -0,0 +1,7 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with: # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
@@ -0,0 +1,10 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
restore-keys: ${{ runner.os }}-
path: ""
key: ""
@@ -0,0 +1,8 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with: # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
restore-keys: ${{ runner.os }}-
@@ -0,0 +1,9 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1
with:
path: ""
key: ""
@@ -0,0 +1,6 @@
on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/cache@v1 # want "Missing required inputs: `path`, `key`" fix="Add missing inputs: path, key"
+21
View File
@@ -0,0 +1,21 @@
import {CodeAction, Diagnostic} from "vscode-languageserver-types";
export interface CodeActionContext {
uri: string;
// TODO: add things like workflow template, parsed content, etc.
}
/**
* A provider that can produce a code action for a given diagnostic
*/
export interface CodeActionProvider {
/**
* The diagnostic codes this provider handles
*/
diagnosticCodes: (string | number | undefined)[];
/**
* Create a code action for the diagnostic, if applicable
*/
createCodeAction(context: CodeActionContext, diagnostic: Diagnostic): CodeAction | undefined;
}
@@ -1,13 +1,13 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import {data, DescriptionDictionary} from "@actions/expressions"; import {data, DescriptionDictionary} from "@actions/expressions";
import {CompletionItem, CompletionItemKind} from "vscode-languageserver-types"; import {CompletionItem, CompletionItemKind} from "vscode-languageserver-types";
import {complete, getExpressionInput} from "./complete.js"; import {complete, getExpressionInput} from "./complete";
import {ContextProviderConfig} from "./context-providers/config.js"; import {ContextProviderConfig} from "./context-providers/config";
import {registerLogger} from "./log.js"; import {registerLogger} from "./log";
import {getPositionFromCursor} from "./test-utils/cursor-position.js"; import {getPositionFromCursor} from "./test-utils/cursor-position";
import {TestLogger} from "./test-utils/logger.js"; import {TestLogger} from "./test-utils/logger";
import {testFileProvider} from "./test-utils/test-file-provider.js"; import {testFileProvider} from "./test-utils/test-file-provider";
import {clearCache} from "./utils/workflow-cache.js"; import {clearCache} from "./utils/workflow-cache";
const contextProviderConfig: ContextProviderConfig = { const contextProviderConfig: ContextProviderConfig = {
getContext: (context: string) => { getContext: (context: string) => {
@@ -299,16 +299,7 @@ jobs:
"on: push\njobs:\n build:\n runs-on: ubuntu-latest\n environment:\n url: ${{ runner.| }}\n steps:\n - run: echo"; "on: push\njobs:\n build:\n runs-on: ubuntu-latest\n environment:\n url: ${{ runner.| }}\n steps:\n - run: echo";
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig}); const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toEqual([ expect(result.map(x => x.label)).toEqual(["arch", "name", "os", "temp", "tool_cache"]);
"arch",
"debug",
"environment",
"name",
"os",
"temp",
"tool_cache",
"workspace"
]);
}); });
describe("job if", () => { describe("job if", () => {
@@ -870,7 +861,7 @@ jobs:
}); });
describe("strategy context", () => { describe("strategy context", () => {
it("strategy is suggested even when no strategy defined", async () => { it("strategy is not suggested when outside of a matrix job", async () => {
const input = ` const input = `
on: push on: push
@@ -884,7 +875,7 @@ jobs:
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig}); const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toContain("strategy"); expect(result.map(x => x.label)).not.toContain("strategy");
}); });
it("strategy is suggested within a matrix job", async () => { it("strategy is suggested within a matrix job", async () => {
@@ -931,7 +922,7 @@ jobs:
}); });
describe("matrix context", () => { describe("matrix context", () => {
it("matrix is suggested even when no strategy defined", async () => { it("matrix is not suggested when outside of a matrix job", async () => {
const input = ` const input = `
on: push on: push
@@ -945,7 +936,7 @@ jobs:
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig}); const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
expect(result.map(x => x.label)).toContain("matrix"); expect(result.map(x => x.label)).not.toContain("strategy");
}); });
it("matrix is suggested within a matrix job", async () => { it("matrix is suggested within a matrix job", async () => {
@@ -1132,12 +1123,10 @@ jobs:
"github", "github",
"inputs", "inputs",
"job", "job",
"matrix",
"needs", "needs",
"runner", "runner",
"secrets", "secrets",
"steps", "steps",
"strategy",
"vars", "vars",
"contains", "contains",
"endsWith", "endsWith",
@@ -1279,7 +1268,7 @@ jobs:
on: push on: push
jobs: jobs:
a: a:
uses: ./.github/workflows/reusable-workflow-with-outputs.yaml uses: ./reusable-workflow-with-outputs.yaml
b: b:
needs: [a] needs: [a]
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -1,169 +0,0 @@
import {complete} from "./complete.js";
import {TextDocument} from "vscode-languageserver-textdocument";
import {clearCache} from "./utils/workflow-cache.js";
import {getPositionFromCursor} from "./test-utils/cursor-position.js";
beforeEach(() => {
clearCache();
});
describe("Issue #81 - multi-line if expression completion", () => {
it("should complete in block scalar if with | (exact position)", async () => {
// Exact reproduction from issue - cursor after "github." in block scalar
const input = `on: push
jobs:
build:
if: |
github.
runs-on: ubuntu-latest
steps:
- run: echo`;
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
// Line 5 (0-indexed) = " github.", character 13 = after the dot
const pos = {line: 5, character: 13};
const result = await complete(doc, pos, {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("event");
expect(result.map(x => x.label)).toContain("actor");
});
it("should complete in block scalar if with > (exact position)", async () => {
const input = `on: push
jobs:
build:
if: >
github.
runs-on: ubuntu-latest
steps:
- run: echo`;
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
const pos = {line: 5, character: 13};
const result = await complete(doc, pos, {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("event");
});
it("should complete in block scalar with multiple lines", async () => {
const input = `on: push
jobs:
build:
if: |
github.event_name == 'push' &&
github.|
runs-on: ubuntu-latest
steps:
- run: echo`;
// Skip 1 to skip the `|` block scalar indicator (same character as cursor marker)
const result = await complete(...getPositionFromCursor(input, 1), {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("event");
});
it("should complete step if in block scalar", async () => {
const input = `on: push
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: echo
if: |
github.
`;
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
// Line 7 = " github.", character 15 = after the dot (8 spaces + 7 chars)
const pos = {line: 7, character: 15};
const result = await complete(doc, pos, {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("event");
});
it("should complete in block scalar with ${{ expression markers", async () => {
// This case works because transform() skips lines with ${{
// Note: Using explicit position because | appears in multiple places (block scalar, ||, cursor)
const input = `on: push
jobs:
build:
if: |
\${{
github.ref == 'refs/heads/main' ||
github.
runs-on: ubuntu-latest
steps:
- run: echo`;
const doc = TextDocument.create("file:///test.yaml", "yaml", 1, input);
// Line 6 = " github." = 8 spaces + 7 chars = 15 chars, cursor after dot is at char 15
const pos = {line: 6, character: 15};
const result = await complete(doc, pos, {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("ref");
expect(result.map(x => x.label)).toContain("ref_name");
});
});
describe("Edge cases for getOffsetInContent", () => {
it("should complete in single-line if (not block scalar)", async () => {
const input = `on: push
jobs:
build:
if: github.|
runs-on: ubuntu-latest
steps:
- run: echo`;
const result = await complete(...getPositionFromCursor(input), {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("event");
});
it("should complete on third content line of block scalar", async () => {
const input = `on: push
jobs:
build:
if: |
github.event_name == 'push' &&
github.ref == 'refs/heads/main' &&
github.|
runs-on: ubuntu-latest
steps:
- run: echo`;
const result = await complete(...getPositionFromCursor(input, 1), {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("event");
});
it("should complete when block scalar has empty first line", async () => {
const input = `on: push
jobs:
build:
if: |
github.|
runs-on: ubuntu-latest
steps:
- run: echo`;
const result = await complete(...getPositionFromCursor(input, 1), {});
expect(result.length).toBeGreaterThan(0);
expect(result.map(x => x.label)).toContain("event");
});
});
@@ -1,8 +1,8 @@
import {CompletionItem, MarkupContent} from "vscode-languageserver-types"; import {CompletionItem, MarkupContent} from "vscode-languageserver-types";
import {complete} from "./complete.js"; import {complete} from "./complete";
import {getPositionFromCursor} from "./test-utils/cursor-position.js"; import {getPositionFromCursor} from "./test-utils/cursor-position";
import {testFileProvider} from "./test-utils/test-file-provider.js"; import {testFileProvider} from "./test-utils/test-file-provider";
import {clearCache} from "./utils/workflow-cache.js"; import {clearCache} from "./utils/workflow-cache";
function mapResult(result: CompletionItem[]) { function mapResult(result: CompletionItem[]) {
return result.map(x => { return result.map(x => {
@@ -21,7 +21,7 @@ on: push
jobs: jobs:
build: build:
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml uses: ./reusable-workflow-with-inputs.yaml
with: with:
| |
`; `;
@@ -49,7 +49,7 @@ on: push
jobs: jobs:
build: build:
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml uses: ./reusable-workflow-with-inputs.yaml
with: with:
username: monalisa username: monalisa
| |
@@ -74,7 +74,7 @@ on: push
jobs: jobs:
build: build:
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml uses: ./reusable-workflow-with-inputs.yaml
secrets: secrets:
| |
`; `;
@@ -102,7 +102,7 @@ on: push
jobs: jobs:
build: build:
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml uses: ./reusable-workflow-with-inputs.yaml
secrets: | secrets: |
`; `;
const result = await complete(...getPositionFromCursor(input), {fileProvider: testFileProvider}); const result = await complete(...getPositionFromCursor(input), {fileProvider: testFileProvider});
@@ -117,7 +117,7 @@ on: push
jobs: jobs:
build: build:
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml uses: ./reusable-workflow-with-inputs.yaml
secrets: secrets:
envPAT: "myPAT" envPAT: "myPAT"
| |
+8 -24
View File
@@ -1,11 +1,11 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import {MarkupContent, TextEdit} from "vscode-languageserver-types"; import {MarkupContent, TextEdit} from "vscode-languageserver-types";
import {complete} from "./complete.js"; import {complete} from "./complete";
import {registerLogger} from "./log.js"; import {registerLogger} from "./log";
import {getPositionFromCursor} from "./test-utils/cursor-position.js"; import {getPositionFromCursor} from "./test-utils/cursor-position";
import {TestLogger} from "./test-utils/logger.js"; import {TestLogger} from "./test-utils/logger";
import {clearCache} from "./utils/workflow-cache.js"; import {clearCache} from "./utils/workflow-cache";
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js"; import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
registerLogger(new TestLogger()); registerLogger(new TestLogger());
@@ -510,27 +510,11 @@ jobs:
expect(result.filter(x => x.label === "types").map(x => x.textEdit?.newText)).toEqual(["types: "]); expect(result.filter(x => x.label === "types").map(x => x.textEdit?.newText)).toEqual(["types: "]);
}); });
it("adds newline and indentation for one-of in key mode", async () => { it("does not add : for one-of in key mode", async () => {
const input = "on:\n check_run: ty|"; const input = "on:\n check_run: ty|";
const result = await complete(...getPositionFromCursor(input)); const result = await complete(...getPositionFromCursor(input));
// When completing a one-of property in key mode (after colon on same line), expect(result.filter(x => x.label === "types").map(x => x.textEdit?.newText)).toEqual(["types"]);
// insert newline + indentation + key + colon to create valid YAML structure
expect(result.filter(x => x.label === "types").map(x => x.textEdit?.newText)).toEqual(["\n types: "]);
});
it("handles mixed string and mapping completions for one-of in key mode", async () => {
const input = "on: push\npermissions: |";
const result = await complete(...getPositionFromCursor(input));
// String values (read-all, write-all) should insert directly without newline
expect(result.filter(x => x.label === "read-all").map(x => x.textEdit?.newText)).toEqual(["read-all"]);
expect(result.filter(x => x.label === "write-all").map(x => x.textEdit?.newText)).toEqual(["write-all"]);
// Mapping keys with one-of types should insert with newline and indentation
expect(result.filter(x => x.label === "actions").map(x => x.textEdit?.newText)).toEqual(["\n actions: "]);
expect(result.filter(x => x.label === "contents").map(x => x.textEdit?.newText)).toEqual(["\n contents: "]);
}); });
}); });
+18 -65
View File
@@ -5,26 +5,26 @@ import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants"; import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index"; import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token"; import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range";
import {TokenType} from "@actions/workflow-parser/templates/tokens/types"; import {TokenType} from "@actions/workflow-parser/templates/tokens/types";
import {File} from "@actions/workflow-parser/workflows/file"; import {File} from "@actions/workflow-parser/workflows/file";
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider"; import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
import {Position, TextDocument} from "vscode-languageserver-textdocument"; import {Position, TextDocument} from "vscode-languageserver-textdocument";
import {CompletionItem, CompletionItemKind, CompletionItemTag, Range, TextEdit} from "vscode-languageserver-types"; import {CompletionItem, CompletionItemKind, CompletionItemTag, Range, TextEdit} from "vscode-languageserver-types";
import {ContextProviderConfig} from "./context-providers/config.js"; import {ContextProviderConfig} from "./context-providers/config";
import {getContext, Mode} from "./context-providers/default.js"; import {getContext, Mode} from "./context-providers/default";
import {getWorkflowContext, WorkflowContext} from "./context/workflow-context.js"; import {getWorkflowContext, WorkflowContext} from "./context/workflow-context";
import {validatorFunctions} from "./expression-validation/functions.js"; import {validatorFunctions} from "./expression-validation/functions";
import {error} from "./log.js"; import {error} from "./log";
import {isPotentiallyExpression} from "./utils/expression-detection.js"; import {isPotentiallyExpression} from "./utils/expression-detection";
import {findToken} from "./utils/find-token.js"; import {findToken} from "./utils/find-token";
import {guessIndentation} from "./utils/indentation-guesser.js"; import {guessIndentation} from "./utils/indentation-guesser";
import {mapRange} from "./utils/range.js"; import {mapRange} from "./utils/range";
import {isPlaceholder, transform} from "./utils/transform.js"; import {getRelCharOffset} from "./utils/rel-char-pos";
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache.js"; import {isPlaceholder, transform} from "./utils/transform";
import {Value, ValueProviderConfig} from "./value-providers/config.js"; import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache";
import {defaultValueProviders} from "./value-providers/default.js"; import {Value, ValueProviderConfig} from "./value-providers/config";
import {DefinitionValueMode, definitionValues} from "./value-providers/definition.js"; import {defaultValueProviders} from "./value-providers/default";
import {DefinitionValueMode, definitionValues} from "./value-providers/definition";
export function getExpressionInput(input: string, pos: number): string { export function getExpressionInput(input: string, pos: number): string {
// Find start marker around the cursor position // Find start marker around the cursor position
@@ -238,12 +238,12 @@ function getExpressionCompletionItems(
currentInput = stringToken.source || stringToken.value; currentInput = stringToken.source || stringToken.value;
} }
const cursorOffset = getOffsetInContent(token.range, currentInput, pos); const relCharOffset = getRelCharOffset(token.range, currentInput, pos);
const expressionInput = (getExpressionInput(currentInput, cursorOffset) || "").trim(); const expressionInput = (getExpressionInput(currentInput, relCharOffset) || "").trim();
try { try {
return completeExpression(expressionInput, context, [], validatorFunctions).map(item => return completeExpression(expressionInput, context, [], validatorFunctions).map(item =>
mapExpressionCompletionItem(item, currentInput[cursorOffset]) mapExpressionCompletionItem(item, currentInput[relCharOffset])
); );
} catch (e) { } catch (e) {
error(`Error while completing expression: '${(e as Error)?.message || "<no details>"}'`); error(`Error while completing expression: '${(e as Error)?.message || "<no details>"}'`);
@@ -274,50 +274,3 @@ function mapExpressionCompletionItem(item: ExpressionCompletionItem, charAfterPo
kind: item.function ? CompletionItemKind.Function : CompletionItemKind.Variable kind: item.function ? CompletionItemKind.Function : CompletionItemKind.Variable
}; };
} }
/**
* Converts a document position to an offset within the token's content string.
*/
function getOffsetInContent(tokenRange: TokenRange, currentInput: string, pos: Position): number {
const range = mapRange(tokenRange);
if (range.start.line === range.end.line) {
// Single-line example:
// if: github.ref == 'main'
// ^8 ^15 (cursor)
// currentInput = "github.ref == 'main'"
// offset = 15 - 8 = 7
return pos.character - range.start.character;
}
// Multi-line example:
// if: | <- line 3 (range.start.line)
// first line <- line 4, content line 0
// second line <- line 5, content line 1
// github. <- line 6, content line 2, cursor at index 11
// ^11 (cursor)
//
// currentInput = " first line\n second line\n github."
// ^0 ^15 ^32 ^43
// Line index within content.
// From the example:
// lineIndexWithinContent = pos.line - range.start.line - 1
// = 6 - 3 - 1 = 2
const lineIndexWithinContent = pos.line - range.start.line - 1;
// Length of content before current line.
// From the example:
// lengthOfContentBeforeCurrentLine => 14 + 1 = 15 (after first iteration)
// => 31 + 1 = 32 (after second iteration)
let lengthOfContentBeforeCurrentLine = 0;
for (let i = 0; i < lineIndexWithinContent; i++) {
lengthOfContentBeforeCurrentLine = currentInput.indexOf("\n", lengthOfContentBeforeCurrentLine) + 1;
}
// Final offset within content.
// From the example:
// finalOffset = lengthOfContentBeforeCurrentLine + pos.character
// = 32 + 11 = 43
return lengthOfContentBeforeCurrentLine + pos.character;
}
@@ -1,6 +1,6 @@
import {DescriptionDictionary} from "@actions/expressions"; import {DescriptionDictionary} from "@actions/expressions";
import {WorkflowContext} from "../context/workflow-context.js"; import {WorkflowContext} from "../context/workflow-context";
import {Mode} from "./default.js"; import {Mode} from "./default";
export type ContextProviderConfig = { export type ContextProviderConfig = {
getContext: ( getContext: (
@@ -1,97 +0,0 @@
import {DescriptionDictionary} from "@actions/expressions";
import {WorkflowContext} from "../context/workflow-context.js";
import {getContext, Mode} from "./default.js";
describe("getContext", () => {
const emptyWorkflowContext: WorkflowContext = {
uri: "test.yaml",
template: undefined
};
describe("when no contextProviderConfig is provided", () => {
it("should mark secrets context as incomplete", async () => {
const result = await getContext(["secrets"], undefined, emptyWorkflowContext, Mode.Validation);
const secretsContext = result.get("secrets") as DescriptionDictionary;
expect(secretsContext).toBeDefined();
expect(secretsContext.complete).toBe(false);
});
it("should mark vars context as incomplete", async () => {
const result = await getContext(["vars"], undefined, emptyWorkflowContext, Mode.Validation);
const varsContext = result.get("vars") as DescriptionDictionary;
expect(varsContext).toBeDefined();
expect(varsContext.complete).toBe(false);
});
it("should not mark other contexts as incomplete", async () => {
const result = await getContext(["env", "github"], undefined, emptyWorkflowContext, Mode.Validation);
const envContext = result.get("env") as DescriptionDictionary;
const githubContext = result.get("github") as DescriptionDictionary;
// These contexts are derived from the workflow file, so they can be complete
expect(envContext).toBeDefined();
expect(envContext.complete).toBe(true);
expect(githubContext).toBeDefined();
expect(githubContext.complete).toBe(true);
});
});
describe("when contextProviderConfig returns a value", () => {
it("should use the provided context for secrets", async () => {
const providedContext = new DescriptionDictionary();
providedContext.complete = true; // Provider fetched from API, so it's complete
const config = {
getContext: () => Promise.resolve(providedContext)
};
const result = await getContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
const secretsContext = result.get("secrets");
expect(secretsContext).toBe(providedContext);
expect((secretsContext as DescriptionDictionary).complete).toBe(true);
});
it("should use the provided context for vars", async () => {
const providedContext = new DescriptionDictionary();
providedContext.complete = true;
const config = {
getContext: () => Promise.resolve(providedContext)
};
const result = await getContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
const varsContext = result.get("vars");
expect(varsContext).toBe(providedContext);
expect((varsContext as DescriptionDictionary).complete).toBe(true);
});
});
describe("when contextProviderConfig returns undefined", () => {
it("should mark secrets as incomplete", async () => {
const config = {
getContext: () => Promise.resolve(undefined)
};
const result = await getContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
const secretsContext = result.get("secrets") as DescriptionDictionary;
expect(secretsContext.complete).toBe(false);
});
it("should mark vars as incomplete", async () => {
const config = {
getContext: () => Promise.resolve(undefined)
};
const result = await getContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
const varsContext = result.get("vars") as DescriptionDictionary;
expect(varsContext.complete).toBe(false);
});
});
});
@@ -1,18 +1,18 @@
import {data, DescriptionDictionary} from "@actions/expressions"; import {data, DescriptionDictionary} from "@actions/expressions";
import {Kind} from "@actions/expressions/data/expressiondata"; import {Kind} from "@actions/expressions/data/expressiondata";
import {WorkflowContext} from "../context/workflow-context.js"; import {WorkflowContext} from "../context/workflow-context";
import {ContextProviderConfig} from "./config.js"; import {ContextProviderConfig} from "./config";
import {getDescription, RootContext} from "./descriptions.js"; import {getDescription, RootContext} from "./descriptions";
import {getEnvContext} from "./env.js"; import {getEnvContext} from "./env";
import {getGithubContext} from "./github.js"; import {getGithubContext} from "./github";
import {getInputsContext} from "./inputs.js"; import {getInputsContext} from "./inputs";
import {getJobContext} from "./job.js"; import {getJobContext} from "./job";
import {getJobsContext} from "./jobs.js"; import {getJobsContext} from "./jobs";
import {getMatrixContext} from "./matrix.js"; import {getMatrixContext} from "./matrix";
import {getNeedsContext} from "./needs.js"; import {getNeedsContext} from "./needs";
import {getSecretsContext} from "./secrets.js"; import {getSecretsContext} from "./secrets";
import {getStepsContext} from "./steps.js"; import {getStepsContext} from "./steps";
import {getStrategyContext} from "./strategy.js"; import {getStrategyContext} from "./strategy";
// ContextValue is the type of the value returned by a context provider // ContextValue is the type of the value returned by a context provider
// Null indicates that the context provider doesn't have any value to provide // Null indicates that the context provider doesn't have any value to provide
@@ -32,24 +32,15 @@ export async function getContext(
): Promise<DescriptionDictionary> { ): Promise<DescriptionDictionary> {
const context = new DescriptionDictionary(); const context = new DescriptionDictionary();
// All context names are valid - strategy and matrix are always available const filteredNames = filterContextNames(names, workflowContext);
// (with default values when no strategy block is defined) for (const contextName of filteredNames) {
for (const contextName of names) {
let value = getDefaultContext(contextName, workflowContext, mode) || new DescriptionDictionary(); let value = getDefaultContext(contextName, workflowContext, mode) || new DescriptionDictionary();
if (value.kind === Kind.Null) { if (value.kind === Kind.Null) {
context.add(contextName, value); context.add(contextName, value);
continue; continue;
} }
const remoteValue = await config?.getContext(contextName, value, workflowContext, mode); value = (await config?.getContext(contextName, value, workflowContext, mode)) || value;
if (remoteValue) {
value = remoteValue;
} else if (contextName === "secrets" || contextName === "vars") {
// Without a context provider to fetch remote secrets/vars, we can't know
// what values exist, so mark the context as incomplete to avoid false
// "Context access might be invalid" warnings
value.complete = false;
}
context.add(contextName, value, getDescription(RootContext, contextName)); context.add(contextName, value, getDescription(RootContext, contextName));
} }
@@ -83,14 +74,11 @@ function getDefaultContext(name: string, workflowContext: WorkflowContext, mode:
case "runner": case "runner":
return objectToDictionary({ return objectToDictionary({
arch: "X64",
debug: "1",
environment: "github-hosted",
name: "GitHub Actions 2",
os: "Linux", os: "Linux",
temp: "/home/runner/work/_temp", arch: "X64",
name: "GitHub Actions 2",
tool_cache: "/opt/hostedtoolcache", tool_cache: "/opt/hostedtoolcache",
workspace: "/home/runner/work/repo" temp: "/home/runner/work/_temp"
}); });
case "secrets": case "secrets":
@@ -115,3 +103,18 @@ function objectToDictionary(object: {[key: string]: string}): DescriptionDiction
return dictionary; return dictionary;
} }
function filterContextNames(contextNames: string[], workflowContext: WorkflowContext): string[] {
return contextNames.filter(name => {
switch (name) {
case "matrix":
case "strategy":
return hasStrategy(workflowContext);
}
return true;
});
}
function hasStrategy(workflowContext: WorkflowContext): boolean {
return workflowContext.job?.strategy !== undefined || workflowContext.reusableWorkflowJob?.strategy !== undefined;
}
@@ -239,13 +239,7 @@
"description": "The path to the directory containing preinstalled tools for GitHub-hosted runners. For more information, see \"[About GitHub-hosted runners](https://docs.github.com/actions/reference/specifications-for-github-hosted-runners/#supported-software).\"" "description": "The path to the directory containing preinstalled tools for GitHub-hosted runners. For more information, see \"[About GitHub-hosted runners](https://docs.github.com/actions/reference/specifications-for-github-hosted-runners/#supported-software).\""
}, },
"debug": { "debug": {
"description": "This is set only if [`ACTIONS_STEP_DEBUG`](https://docs.github.com/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging) is enabled, and always has the value of `\"1\"`. It can be useful as an indicator to enable additional debugging or verbose logging in your own job steps." "description": "This is set only if [debug logging](https://docs.github.com/actions/monitoring-and-troubleshooting-workflows/enabling-debug-logging) is enabled, and always has the value of `1`. It can be useful as an indicator to enable additional debugging or verbose logging in your own job steps."
},
"environment": {
"description": "The environment of the runner executing the job. Possible values are `github-hosted` for GitHub-hosted runners, or `self-hosted` for self-hosted runners."
},
"workspace": {
"description": "The runner-specific working directory path for the job."
} }
}, },
"strategy": { "strategy": {
@@ -1,4 +1,4 @@
import descriptions from "./descriptions.min.json"; import descriptions from "./descriptions.json";
export const RootContext = "root"; export const RootContext = "root";
const FunctionContext = "functions"; const FunctionContext = "functions";
+1 -1
View File
@@ -1,7 +1,7 @@
import {data, DescriptionDictionary} from "@actions/expressions"; import {data, DescriptionDictionary} from "@actions/expressions";
import {isScalar, isString} from "@actions/workflow-parser"; import {isScalar, isString} from "@actions/workflow-parser";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token"; import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {WorkflowContext} from "../context/workflow-context.js"; import {WorkflowContext} from "../context/workflow-context";
export function getEnvContext(workflowContext: WorkflowContext): DescriptionDictionary { export function getEnvContext(workflowContext: WorkflowContext): DescriptionDictionary {
const d = new DescriptionDictionary(); const d = new DescriptionDictionary();
@@ -1,102 +0,0 @@
import {DescriptionDictionary} from "@actions/expressions";
import {getEventPayload, getSupportedEventTypes} from "./eventPayloads.js";
describe("eventPayloads", () => {
describe("getSupportedEventTypes", () => {
it("returns action types for push event", () => {
const types = getSupportedEventTypes("push");
expect(types).toContain("default");
});
it("returns action types for issues event", () => {
const types = getSupportedEventTypes("issues");
expect(types.length).toBeGreaterThan(1);
expect(types).toContain("opened");
expect(types).toContain("closed");
});
});
describe("getEventPayload", () => {
it("returns payload for push event", () => {
const payload = getEventPayload("push", "default");
expect(payload).toBeDefined();
// Verify common fields exist
expect(payload?.get("ref")).toBeDefined();
expect(payload?.get("repository")).toBeDefined();
expect(payload?.get("sender")).toBeDefined();
});
it("returns payload for issues event", () => {
const payload = getEventPayload("issues", "opened");
expect(payload).toBeDefined();
expect(payload?.get("action")).toBeDefined();
expect(payload?.get("issue")).toBeDefined();
expect(payload?.get("repository")).toBeDefined();
});
it("preserves descriptions for hover documentation", () => {
// This test ensures bodyParameters[].description is not stripped
// during JSON optimization. The description field is used for hover
// documentation in the workflow editor.
const payload = getEventPayload("push", "default");
expect(payload).toBeDefined();
// Get the description for a well-known field
// repository should have a description like "A repository on GitHub"
const repoDescription = payload?.getDescription("repository");
expect(repoDescription).toBeDefined();
expect(repoDescription?.length).toBeGreaterThan(0);
// sender should have a description
const senderDescription = payload?.getDescription("sender");
expect(senderDescription).toBeDefined();
expect(senderDescription?.length).toBeGreaterThan(0);
});
it("preserves childParamsGroups for nested property access", () => {
// This test ensures bodyParameters[].childParamsGroups is not stripped
// during JSON optimization. childParamsGroups defines nested properties
// used for autocompletion like github.event.repository.owner.login
const payload = getEventPayload("push", "default");
expect(payload).toBeDefined();
// repository has nested properties like owner, license, etc.
const repository = payload?.get("repository") as DescriptionDictionary | undefined;
expect(repository).toBeDefined();
// repository.owner should exist (nested via childParamsGroups)
const owner = repository?.get("owner") as DescriptionDictionary | undefined;
expect(owner).toBeDefined();
// repository.owner.login should exist (deeply nested)
const login = owner?.get("login");
expect(login).toBeDefined();
});
it("preserves name fields for property identification", () => {
// This test ensures bodyParameters[].name is not stripped
// during JSON optimization. The name field identifies each property.
const payload = getEventPayload("issues", "opened");
expect(payload).toBeDefined();
// Verify well-known property names exist
expect(payload?.get("action")).toBeDefined();
expect(payload?.get("issue")).toBeDefined();
expect(payload?.get("repository")).toBeDefined();
expect(payload?.get("sender")).toBeDefined();
// Verify nested property names work
const issue = payload?.get("issue") as DescriptionDictionary | undefined;
expect(issue?.get("title")).toBeDefined();
expect(issue?.get("number")).toBeDefined();
expect(issue?.get("user")).toBeDefined();
});
it("returns undefined for unknown event", () => {
const payload = getEventPayload("not_a_real_event", "default");
expect(payload).toBeUndefined();
});
});
});
@@ -1,10 +1,10 @@
import {data, DescriptionDictionary} from "@actions/expressions"; import {data, DescriptionDictionary} from "@actions/expressions";
import webhookObjects from "./objects.min.json"; import webhookObjects from "./objects.json";
import webhooks from "./webhooks.min.json"; import webhooks from "./webhooks.json";
import schedule from "./schedule.min.json"; import schedule from "./schedule.json";
import workflow_call from "./workflow_call.min.json"; import workflow_call from "./workflow_call.json";
const customEventPayloads: {[name: string]: unknown} = { const customEventPayloads: {[name: string]: unknown} = {
schedule, schedule,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
import {DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions"; import {DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions/.";
import {testGetWorkflowContext} from "../test-utils/test-workflow-context.js"; import {testGetWorkflowContext} from "../test-utils/test-workflow-context";
import {Mode} from "./default.js"; import {Mode} from "./default";
import {getGithubContext} from "./github.js"; import {getGithubContext} from "./github";
describe("github context", () => { describe("github context", () => {
it("single event", async () => { it("single event", async () => {
@@ -1,11 +1,11 @@
import {data, DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions"; import {data, DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
import {ExpressionData} from "@actions/expressions/data/expressiondata"; import {ExpressionData} from "@actions/expressions/data/expressiondata";
import {TypesFilterConfig} from "@actions/workflow-parser/model/workflow-template"; import {TypesFilterConfig} from "@actions/workflow-parser/model/workflow-template";
import {WorkflowContext} from "../context/workflow-context.js"; import {WorkflowContext} from "../context/workflow-context";
import {Mode} from "./default.js"; import {Mode} from "./default";
import {getDescription} from "./descriptions.js"; import {getDescription} from "./descriptions";
import {getEventPayload, getSupportedEventTypes} from "./events/eventPayloads.js"; import {getEventPayload, getSupportedEventTypes} from "./events/eventPayloads";
import {getInputsContext} from "./inputs.js"; import {getInputsContext} from "./inputs";
export function getGithubContext(workflowContext: WorkflowContext, mode: Mode): DescriptionDictionary { export function getGithubContext(workflowContext: WorkflowContext, mode: Mode): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#github-context // https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
@@ -1,6 +1,6 @@
import {data, DescriptionDictionary} from "@actions/expressions"; import {data, DescriptionDictionary} from "@actions/expressions";
import {InputConfig} from "@actions/workflow-parser/model/workflow-template"; import {InputConfig} from "@actions/workflow-parser/model/workflow-template";
import {WorkflowContext} from "../context/workflow-context.js"; import {WorkflowContext} from "../context/workflow-context";
export function getInputsContext(workflowContext: WorkflowContext): DescriptionDictionary { export function getInputsContext(workflowContext: WorkflowContext): DescriptionDictionary {
const d = new DescriptionDictionary(); const d = new DescriptionDictionary();
+1 -1
View File
@@ -1,7 +1,7 @@
import {data, DescriptionDictionary} from "@actions/expressions"; import {data, DescriptionDictionary} from "@actions/expressions";
import {isMapping, isSequence} from "@actions/workflow-parser"; import {isMapping, isSequence} from "@actions/workflow-parser";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token"; import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {WorkflowContext} from "../context/workflow-context.js"; import {WorkflowContext} from "../context/workflow-context";
export function getJobContext(workflowContext: WorkflowContext): DescriptionDictionary { export function getJobContext(workflowContext: WorkflowContext): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context // https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
@@ -1,8 +1,8 @@
import {data, DescriptionDictionary} from "@actions/expressions"; import {data, DescriptionDictionary} from "@actions/expressions";
import {StringData} from "@actions/expressions/data/string"; import {StringData} from "@actions/expressions/data/string";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token"; import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {WorkflowContext} from "../context/workflow-context.js"; import {WorkflowContext} from "../context/workflow-context";
import {getDescription} from "./descriptions.js"; import {getDescription} from "./descriptions";
export function getJobsContext(workflowContext: WorkflowContext): DescriptionDictionary { export function getJobsContext(workflowContext: WorkflowContext): DescriptionDictionary {
// https://docs.github.com/en/actions/learn-github-actions/contexts#jobs-context // https://docs.github.com/en/actions/learn-github-actions/contexts#jobs-context
@@ -6,9 +6,9 @@ import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-to
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token"; import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token"; import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token"; import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {WorkflowContext} from "../context/workflow-context.js"; import {WorkflowContext} from "../context/workflow-context";
import {Mode} from "./default.js"; import {Mode} from "./default";
import {getMatrixContext} from "./matrix.js"; import {getMatrixContext} from "./matrix";
type MatrixMap = { type MatrixMap = {
[key: string]: Array<string> | Array<{[key: string]: string}>; [key: string]: Array<string> | Array<{[key: string]: string}>;
@@ -64,7 +64,7 @@ describe("matrix context", () => {
expect(workflowContext.job).toBeUndefined(); expect(workflowContext.job).toBeUndefined();
const context = getMatrixContext(workflowContext, Mode.Validation); const context = getMatrixContext(workflowContext, Mode.Validation);
expect(context).toEqual(new data.Null()); expect(context).toEqual(new DescriptionDictionary());
}); });
it("strategy not defined", () => { it("strategy not defined", () => {
@@ -73,7 +73,7 @@ describe("matrix context", () => {
expect(workflowContext.job!.strategy).toBeUndefined(); expect(workflowContext.job!.strategy).toBeUndefined();
const context = getMatrixContext(workflowContext, Mode.Validation); const context = getMatrixContext(workflowContext, Mode.Validation);
expect(context).toEqual(new data.Null()); expect(context).toEqual(new DescriptionDictionary());
}); });
it("strategy is not a mapping token", () => { it("strategy is not a mapping token", () => {
@@ -81,7 +81,7 @@ describe("matrix context", () => {
expect(workflowContext.job!.strategy).toBeDefined(); expect(workflowContext.job!.strategy).toBeDefined();
const context = getMatrixContext(workflowContext, Mode.Validation); const context = getMatrixContext(workflowContext, Mode.Validation);
expect(context).toEqual(new data.Null()); expect(context).toEqual(new DescriptionDictionary());
}); });
it("matrix is not defined", () => { it("matrix is not defined", () => {
@@ -3,15 +3,14 @@ import {isBasicExpression, isMapping, isSequence, isString} from "@actions/workf
import {KeyValuePair} from "@actions/workflow-parser/templates/tokens/key-value-pair"; import {KeyValuePair} from "@actions/workflow-parser/templates/tokens/key-value-pair";
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token"; import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token"; import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
import {WorkflowContext} from "../context/workflow-context.js"; import {WorkflowContext} from "../context/workflow-context";
import {ContextValue, Mode} from "./default.js"; import {ContextValue, Mode} from "./default";
export function getMatrixContext(workflowContext: WorkflowContext, mode: Mode): ContextValue { export function getMatrixContext(workflowContext: WorkflowContext, mode: Mode): ContextValue {
// https://docs.github.com/en/actions/learn-github-actions/contexts#matrix-context // https://docs.github.com/en/actions/learn-github-actions/contexts#matrix-context
const strategy = workflowContext.job?.strategy ?? workflowContext.reusableWorkflowJob?.strategy; const strategy = workflowContext.job?.strategy ?? workflowContext.reusableWorkflowJob?.strategy;
if (!strategy || !isMapping(strategy)) { if (!strategy || !isMapping(strategy)) {
// No strategy defined - matrix is null at runtime (not empty object) return new DescriptionDictionary();
return new data.Null();
} }
const matrix = strategy.find("matrix"); const matrix = strategy.find("matrix");
@@ -1,8 +1,8 @@
import {DescriptionDictionary} from "@actions/expressions"; import {DescriptionDictionary} from "@actions/expressions";
import {StringData} from "@actions/expressions/data/string"; import {StringData} from "@actions/expressions/data/string";
import {WorkflowContext} from "../context/workflow-context.js"; import {WorkflowContext} from "../context/workflow-context";
import {testGetWorkflowContext} from "../test-utils/test-workflow-context.js"; import {testGetWorkflowContext} from "../test-utils/test-workflow-context";
import {getNeedsContext} from "./needs.js"; import {getNeedsContext} from "./needs";
describe("needs context", () => { describe("needs context", () => {
describe("invalid workflow context", () => { describe("invalid workflow context", () => {
@@ -111,7 +111,7 @@ jobs:
on: push on: push
jobs: jobs:
a: a:
uses: ./.github/workflows/reusable-workflow-with-outputs.yaml uses: ./reusable-workflow-with-outputs.yaml
b: b:
needs: [a] needs: [a]
@@ -3,7 +3,7 @@ import {isMapping, isScalar, isString} from "@actions/workflow-parser";
import {isJob} from "@actions/workflow-parser/model/type-guards"; import {isJob} from "@actions/workflow-parser/model/type-guards";
import {WorkflowJob} from "@actions/workflow-parser/model/workflow-template"; import {WorkflowJob} from "@actions/workflow-parser/model/workflow-template";
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token"; import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
import {WorkflowContext} from "../context/workflow-context.js"; import {WorkflowContext} from "../context/workflow-context";
export function getNeedsContext(workflowContext: WorkflowContext): DescriptionDictionary { export function getNeedsContext(workflowContext: WorkflowContext): DescriptionDictionary {
const d = new DescriptionDictionary(); const d = new DescriptionDictionary();

Some files were not shown because too many files have changed in this diff Show More