Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 56ce46afa6 | |||
| e3b56c2416 | |||
| d2ffb50a92 | |||
| 3734de18ee | |||
| 90e7932e97 | |||
| f84e42c1f1 | |||
| 08c78d2a73 | |||
| 26f3969cde | |||
| 61a6fc54f2 | |||
| 6511be5ab4 | |||
| a06ceee92b | |||
| efd53330a3 | |||
| 86888cf4c8 | |||
| ed4c2ce44c | |||
| 9bb4c76612 | |||
| 8b86b48961 | |||
| c0062e5287 | |||
| 2eb53df976 | |||
| 656a821a94 | |||
| fbdc2a5749 | |||
| 47ec2dc734 | |||
| 1395ae198f | |||
| 589c1e34f4 | |||
| 1f2031c2f3 | |||
| ecebf60561 | |||
| 9922d3983f | |||
| 742b36d6b7 | |||
| 8507419ebf | |||
| 952dc89b78 | |||
| 2934e36944 | |||
| 8d2c24d7f5 | |||
| 4181cb3c90 | |||
| 78ea3ba17f | |||
| 4cf3365c68 | |||
| 1a63ee9de6 | |||
| 108b8c2766 | |||
| e20dbae803 | |||
| 69b383af3d | |||
| 4429c41275 | |||
| 7b9adb106e | |||
| 576402fc01 | |||
| 22c36bc946 | |||
| 4dd678cf30 | |||
| dfb411f71e | |||
| dec597b0db | |||
| bd7e5f0b70 | |||
| 37ba6ab105 | |||
| 216fcbb8c4 |
@@ -12,18 +12,55 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18.x, 20.x, 22.x]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js 16.15
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 16.15
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: 'npm'
|
||||
registry-url: 'https://npm.pkg.github.com'
|
||||
- run: npm ci
|
||||
- run: npm ci --engine-strict
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- run: npm run format-check -ws
|
||||
- run: npm run build -ws
|
||||
- run: npm run lint -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
|
||||
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 16.x
|
||||
node-version: 22.x
|
||||
cache: "npm"
|
||||
scope: '@actions'
|
||||
|
||||
|
||||
+12
-1
@@ -2,4 +2,15 @@
|
||||
*/dist
|
||||
lerna-debug.log
|
||||
node_modules
|
||||
.DS_Store
|
||||
.DS_Store
|
||||
|
||||
# Nx cache (generated by Lerna/Nx)
|
||||
.nx/
|
||||
|
||||
# Minified JSON (generated at build time)
|
||||
*.min.json
|
||||
|
||||
# Intermediate JSON for size comparison (generated by update-webhooks --all)
|
||||
*.all.json
|
||||
*.drop.json
|
||||
*.strip.json
|
||||
@@ -8,6 +8,10 @@ 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
|
||||
- [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
|
||||
|
||||
Thank you for your interest in this GitHub repo, however, right now we are not taking contributions.
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
# 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)
|
||||
@@ -0,0 +1,197 @@
|
||||
# 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`)
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/expressions",
|
||||
"version": "0.3.20",
|
||||
"version": "0.3.31",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"source": "./src/index.ts",
|
||||
@@ -36,7 +36,7 @@
|
||||
"clean": "rimraf dist",
|
||||
"format": "prettier --write '**/*.ts'",
|
||||
"format-check": "prettier --check '**/*.ts'",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"lint": "eslint --max-warnings 0 'src/**/*.ts'",
|
||||
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
||||
"prepublishOnly": "npm run build && npm run test",
|
||||
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
|
||||
@@ -44,7 +44,7 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.15"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
@@ -60,6 +60,6 @@
|
||||
"prettier": "^2.8.3",
|
||||
"rimraf": "^3.0.2",
|
||||
"ts-jest": "^29.0.3",
|
||||
"typescript": "^4.7.4"
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {ExpressionData} from "./data";
|
||||
import {Token} from "./lexer";
|
||||
import {ExpressionData} from "./data/index.js";
|
||||
import {Token} from "./lexer.js";
|
||||
|
||||
export interface ExprVisitor<R> {
|
||||
visitLiteral(literal: Literal): R;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {complete, CompletionItem, trimTokenVector} from "./completion";
|
||||
import {DescriptionDictionary} from "./completion/descriptionDictionary";
|
||||
import {BooleanData} from "./data/boolean";
|
||||
import {Dictionary} from "./data/dictionary";
|
||||
import {StringData} from "./data/string";
|
||||
import {wellKnownFunctions} from "./funcs";
|
||||
import {FunctionDefinition, FunctionInfo} from "./funcs/info";
|
||||
import {Lexer, TokenType} from "./lexer";
|
||||
import {complete, CompletionItem, trimTokenVector} from "./completion.js";
|
||||
import {DescriptionDictionary} from "./completion/descriptionDictionary.js";
|
||||
import {BooleanData} from "./data/boolean.js";
|
||||
import {Dictionary} from "./data/dictionary.js";
|
||||
import {StringData} from "./data/string.js";
|
||||
import {wellKnownFunctions} from "./funcs.js";
|
||||
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js";
|
||||
import {Lexer, TokenType} from "./lexer.js";
|
||||
|
||||
const testContext = new Dictionary(
|
||||
{
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {DescriptionPair} from "./completion/descriptionDictionary";
|
||||
import {Dictionary, isDictionary} from "./data/dictionary";
|
||||
import {ExpressionData} from "./data/expressiondata";
|
||||
import {Evaluator} from "./evaluator";
|
||||
import {wellKnownFunctions} from "./funcs";
|
||||
import {FunctionDefinition, FunctionInfo} from "./funcs/info";
|
||||
import {Lexer, Token, TokenType} from "./lexer";
|
||||
import {Parser} from "./parser";
|
||||
import {DescriptionPair} from "./completion/descriptionDictionary.js";
|
||||
import {Dictionary, isDictionary} from "./data/dictionary.js";
|
||||
import {ExpressionData} from "./data/expressiondata.js";
|
||||
import {Evaluator} from "./evaluator.js";
|
||||
import {wellKnownFunctions} from "./funcs.js";
|
||||
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js";
|
||||
import {Lexer, Token, TokenType} from "./lexer.js";
|
||||
import {Parser} from "./parser.js";
|
||||
|
||||
export type CompletionItem = {
|
||||
label: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {StringData} from "../data";
|
||||
import {DescriptionDictionary} from "./descriptionDictionary";
|
||||
import {StringData} from "../data/index.js";
|
||||
import {DescriptionDictionary} from "./descriptionDictionary.js";
|
||||
|
||||
describe("description dictionary", () => {
|
||||
it("pairs contains all values", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Dictionary} from "../data/dictionary";
|
||||
import {ExpressionData, Kind, Pair} from "../data/expressiondata";
|
||||
import {Dictionary} from "../data/dictionary.js";
|
||||
import {ExpressionData, Kind, Pair} from "../data/expressiondata.js";
|
||||
|
||||
export type DescriptionPair = Pair & {description?: string};
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ExpressionData, ExpressionDataInterface, Kind, kindStr} from "./expressiondata";
|
||||
import {ExpressionData, ExpressionDataInterface, Kind, kindStr} from "./expressiondata.js";
|
||||
|
||||
export class Array implements ExpressionDataInterface {
|
||||
private v: ExpressionData[] = [];
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ExpressionDataInterface, Kind} from "./expressiondata";
|
||||
import {ExpressionDataInterface, Kind} from "./expressiondata.js";
|
||||
|
||||
export class BooleanData implements ExpressionDataInterface {
|
||||
constructor(public readonly value: boolean) {}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Dictionary} from "./dictionary";
|
||||
import {StringData} from "./string";
|
||||
import {Dictionary} from "./dictionary.js";
|
||||
import {StringData} from "./string.js";
|
||||
|
||||
describe("dictionary", () => {
|
||||
it("pairs contains all values", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ExpressionData, ExpressionDataInterface, Kind, kindStr, Pair} from "./expressiondata";
|
||||
import {ExpressionData, ExpressionDataInterface, Kind, kindStr, Pair} from "./expressiondata.js";
|
||||
|
||||
export class Dictionary implements ExpressionDataInterface {
|
||||
private keys: string[] = [];
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {Dictionary} from "./dictionary";
|
||||
import {Null} from "./null";
|
||||
import {Array} from "./array";
|
||||
import {StringData} from "./string";
|
||||
import {NumberData} from "./number";
|
||||
import {BooleanData} from "./boolean";
|
||||
import {Dictionary} from "./dictionary.js";
|
||||
import {Null} from "./null.js";
|
||||
import {Array} from "./array.js";
|
||||
import {StringData} from "./string.js";
|
||||
import {NumberData} from "./number.js";
|
||||
import {BooleanData} from "./boolean.js";
|
||||
|
||||
export enum Kind {
|
||||
String = 0,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export {Array} from "./array";
|
||||
export {BooleanData} from "./boolean";
|
||||
export {Dictionary} from "./dictionary";
|
||||
export {ExpressionData, Kind} from "./expressiondata";
|
||||
export {Null} from "./null";
|
||||
export {NumberData} from "./number";
|
||||
export {replacer} from "./replacer";
|
||||
export {reviver} from "./reviver";
|
||||
export {StringData} from "./string";
|
||||
export {Array} from "./array.js";
|
||||
export {BooleanData} from "./boolean.js";
|
||||
export {Dictionary} from "./dictionary.js";
|
||||
export {ExpressionData, Kind} from "./expressiondata.js";
|
||||
export {Null} from "./null.js";
|
||||
export {NumberData} from "./number.js";
|
||||
export {replacer} from "./replacer.js";
|
||||
export {reviver} from "./reviver.js";
|
||||
export {StringData} from "./string.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ExpressionDataInterface, Kind} from "./expressiondata";
|
||||
import {ExpressionDataInterface, Kind} from "./expressiondata.js";
|
||||
|
||||
export class Null implements ExpressionDataInterface {
|
||||
public readonly kind = Kind.Null;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {NumberData} from "./number";
|
||||
import {NumberData} from "./number.js";
|
||||
|
||||
describe("number", () => {
|
||||
it("coerces to string", () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ExpressionDataInterface, Kind} from "./expressiondata";
|
||||
import {ExpressionDataInterface, Kind} from "./expressiondata.js";
|
||||
|
||||
export class NumberData implements ExpressionDataInterface {
|
||||
constructor(public readonly value: number) {}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {Array} from "./array";
|
||||
import {Dictionary} from "./dictionary";
|
||||
import {Null} from "./null";
|
||||
import {NumberData} from "./number";
|
||||
import {replacer} from "./replacer";
|
||||
import {StringData} from "./string";
|
||||
import {Array} from "./array.js";
|
||||
import {Dictionary} from "./dictionary.js";
|
||||
import {Null} from "./null.js";
|
||||
import {NumberData} from "./number.js";
|
||||
import {replacer} from "./replacer.js";
|
||||
import {StringData} from "./string.js";
|
||||
|
||||
describe("replacer", () => {
|
||||
it("null", () => {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {Array} from "./array";
|
||||
import {BooleanData} from "./boolean";
|
||||
import {Dictionary} from "./dictionary";
|
||||
import {Null} from "./null";
|
||||
import {NumberData} from "./number";
|
||||
import {StringData} from "./string";
|
||||
import {Array} from "./array.js";
|
||||
import {BooleanData} from "./boolean.js";
|
||||
import {Dictionary} from "./dictionary.js";
|
||||
import {Null} from "./null.js";
|
||||
import {NumberData} from "./number.js";
|
||||
import {StringData} from "./string.js";
|
||||
|
||||
/**
|
||||
* Replacer can be passed to JSON.stringify to convert an ExpressionData object into plain JSON
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {Array} from "./array";
|
||||
import {BooleanData} from "./boolean";
|
||||
import {Dictionary} from "./dictionary";
|
||||
import {ExpressionData} from "./expressiondata";
|
||||
import {Null} from "./null";
|
||||
import {NumberData} from "./number";
|
||||
import {reviver} from "./reviver";
|
||||
import {StringData} from "./string";
|
||||
import {Array} from "./array.js";
|
||||
import {BooleanData} from "./boolean.js";
|
||||
import {Dictionary} from "./dictionary.js";
|
||||
import {ExpressionData} from "./expressiondata.js";
|
||||
import {Null} from "./null.js";
|
||||
import {NumberData} from "./number.js";
|
||||
import {reviver} from "./reviver.js";
|
||||
import {StringData} from "./string.js";
|
||||
|
||||
describe("reviver", () => {
|
||||
const tests: {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import {Array as dArray} from "./array";
|
||||
import {BooleanData} from "./boolean";
|
||||
import {Dictionary} from "./dictionary";
|
||||
import {ExpressionData} from "./expressiondata";
|
||||
import {Null} from "./null";
|
||||
import {NumberData} from "./number";
|
||||
import {StringData} from "./string";
|
||||
import {Array as dArray} from "./array.js";
|
||||
import {BooleanData} from "./boolean.js";
|
||||
import {Dictionary} from "./dictionary.js";
|
||||
import {ExpressionData} from "./expressiondata.js";
|
||||
import {Null} from "./null.js";
|
||||
import {NumberData} from "./number.js";
|
||||
import {StringData} from "./string.js";
|
||||
|
||||
/**
|
||||
* Reviver can be passed to `JSON.parse` to convert plain JSON into an `ExpressionData` object.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ExpressionDataInterface, Kind} from "./expressiondata";
|
||||
import {ExpressionDataInterface, Kind} from "./expressiondata.js";
|
||||
|
||||
export class StringData implements ExpressionDataInterface {
|
||||
constructor(public readonly value: string) {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Pos, Token, tokenString} from "./lexer";
|
||||
import {Pos, Token, tokenString} from "./lexer.js";
|
||||
|
||||
export const MAX_PARSER_DEPTH = 50;
|
||||
export const MAX_EXPRESSION_LENGTH = 21000;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import * as data from "./data";
|
||||
import {ExpressionEvaluationError} from "./errors";
|
||||
import {Evaluator} from "./evaluator";
|
||||
import {Lexer} from "./lexer";
|
||||
import {Parser} from "./parser";
|
||||
import * as data from "./data/index.js";
|
||||
import {ExpressionEvaluationError} from "./errors.js";
|
||||
import {Evaluator} from "./evaluator.js";
|
||||
import {Lexer} from "./lexer.js";
|
||||
import {Parser} from "./parser.js";
|
||||
|
||||
describe("evaluator", () => {
|
||||
const lexAndParse = (input: string) => {
|
||||
|
||||
@@ -10,14 +10,14 @@ import {
|
||||
Logical,
|
||||
Star,
|
||||
Unary
|
||||
} from "./ast";
|
||||
import * as data from "./data";
|
||||
import {FilteredArray} from "./filtered_array";
|
||||
import {wellKnownFunctions} from "./funcs";
|
||||
import {FunctionDefinition} from "./funcs/info";
|
||||
import {idxHelper} from "./idxHelper";
|
||||
import {TokenType} from "./lexer";
|
||||
import {equals, falsy, greaterThan, lessThan, truthy} from "./result";
|
||||
} from "./ast.js";
|
||||
import * as data from "./data/index.js";
|
||||
import {FilteredArray} from "./filtered_array.js";
|
||||
import {wellKnownFunctions} from "./funcs.js";
|
||||
import {FunctionDefinition} from "./funcs/info.js";
|
||||
import {idxHelper} from "./idxHelper.js";
|
||||
import {TokenType} from "./lexer.js";
|
||||
import {equals, falsy, greaterThan, lessThan, truthy} from "./result.js";
|
||||
|
||||
export class Evaluator implements ExprVisitor<data.ExpressionData> {
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
import * as data from "./data";
|
||||
import * as data from "./data/index.js";
|
||||
|
||||
export class FilteredArray extends data.Array {}
|
||||
|
||||
+10
-10
@@ -1,13 +1,13 @@
|
||||
import {ErrorType, ExpressionError} from "./errors";
|
||||
import {contains} from "./funcs/contains";
|
||||
import {endswith} from "./funcs/endswith";
|
||||
import {format} from "./funcs/format";
|
||||
import {fromjson} from "./funcs/fromjson";
|
||||
import {FunctionDefinition, FunctionInfo} from "./funcs/info";
|
||||
import {join} from "./funcs/join";
|
||||
import {startswith} from "./funcs/startswith";
|
||||
import {tojson} from "./funcs/tojson";
|
||||
import {Token} from "./lexer";
|
||||
import {ErrorType, ExpressionError} from "./errors.js";
|
||||
import {contains} from "./funcs/contains.js";
|
||||
import {endswith} from "./funcs/endswith.js";
|
||||
import {format} from "./funcs/format.js";
|
||||
import {fromjson} from "./funcs/fromjson.js";
|
||||
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js";
|
||||
import {join} from "./funcs/join.js";
|
||||
import {startswith} from "./funcs/startswith.js";
|
||||
import {tojson} from "./funcs/tojson.js";
|
||||
import {Token} from "./lexer.js";
|
||||
|
||||
export type ParseContext = {
|
||||
allowUnknownKeywords: boolean;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {BooleanData, ExpressionData, Kind} from "../data";
|
||||
import {equals} from "../result";
|
||||
import {FunctionDefinition} from "./info";
|
||||
import {BooleanData, ExpressionData, Kind} from "../data/index.js";
|
||||
import {equals} from "../result.js";
|
||||
import {FunctionDefinition} from "./info.js";
|
||||
|
||||
export const contains: FunctionDefinition = {
|
||||
name: "contains",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {BooleanData, ExpressionData} from "../data";
|
||||
import {toUpperSpecial} from "../result";
|
||||
import {FunctionDefinition} from "./info";
|
||||
import {BooleanData, ExpressionData} from "../data/index.js";
|
||||
import {toUpperSpecial} from "../result.js";
|
||||
import {FunctionDefinition} from "./info.js";
|
||||
|
||||
export const endswith: FunctionDefinition = {
|
||||
name: "endsWith",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Null, NumberData, StringData} from "../data";
|
||||
import {format} from "./format";
|
||||
import {Null, NumberData, StringData} from "../data/index.js";
|
||||
import {format} from "./format.js";
|
||||
|
||||
describe("format", () => {
|
||||
it("null", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {ExpressionData, StringData} from "../data";
|
||||
import {FunctionDefinition} from "./info";
|
||||
import {ExpressionData, StringData} from "../data/index.js";
|
||||
import {FunctionDefinition} from "./info.js";
|
||||
|
||||
export const format: FunctionDefinition = {
|
||||
name: "format",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {ExpressionData} from "../data";
|
||||
import {reviver} from "../data/reviver";
|
||||
import {ExpressionEvaluationError} from "../errors";
|
||||
import {FunctionDefinition} from "./info";
|
||||
import {ExpressionData} from "../data/index.js";
|
||||
import {reviver} from "../data/reviver.js";
|
||||
import {ExpressionEvaluationError} from "../errors.js";
|
||||
import {FunctionDefinition} from "./info.js";
|
||||
|
||||
export const fromjson: FunctionDefinition = {
|
||||
name: "fromJson",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ExpressionData} from "../data";
|
||||
import {ExpressionData} from "../data/index.js";
|
||||
|
||||
export interface FunctionInfo {
|
||||
name: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {ExpressionData, Kind, StringData} from "../data";
|
||||
import {FunctionDefinition} from "./info";
|
||||
import {ExpressionData, Kind, StringData} from "../data/index.js";
|
||||
import {FunctionDefinition} from "./info.js";
|
||||
|
||||
export const join: FunctionDefinition = {
|
||||
name: "join",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {BooleanData, ExpressionData} from "../data";
|
||||
import {toUpperSpecial} from "../result";
|
||||
import {FunctionDefinition} from "./info";
|
||||
import {BooleanData, ExpressionData} from "../data/index.js";
|
||||
import {toUpperSpecial} from "../result.js";
|
||||
import {FunctionDefinition} from "./info.js";
|
||||
|
||||
export const startswith: FunctionDefinition = {
|
||||
name: "startsWith",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {ExpressionData, StringData} from "../data";
|
||||
import {replacer} from "../data/replacer";
|
||||
import {FunctionDefinition} from "./info";
|
||||
import {ExpressionData, StringData} from "../data/index.js";
|
||||
import {replacer} from "../data/replacer.js";
|
||||
import {FunctionDefinition} from "./info.js";
|
||||
|
||||
export const tojson: FunctionDefinition = {
|
||||
name: "toJson",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ExpressionData} from "./data";
|
||||
import {ExpressionData} from "./data/index.js";
|
||||
|
||||
export class idxHelper {
|
||||
public readonly str: string | undefined;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export {Expr} from "./ast";
|
||||
export {complete, CompletionItem} from "./completion";
|
||||
export {DescriptionDictionary, DescriptionPair, isDescriptionDictionary} from "./completion/descriptionDictionary";
|
||||
export * as data from "./data";
|
||||
export {ExpressionError, ExpressionEvaluationError} from "./errors";
|
||||
export {Evaluator} from "./evaluator";
|
||||
export {wellKnownFunctions} from "./funcs";
|
||||
export {Lexer, Result} from "./lexer";
|
||||
export {Parser} from "./parser";
|
||||
export {Expr} from "./ast.js";
|
||||
export {complete, CompletionItem} from "./completion.js";
|
||||
export {DescriptionDictionary, DescriptionPair, isDescriptionDictionary} from "./completion/descriptionDictionary.js";
|
||||
export * as data from "./data/index.js";
|
||||
export {ExpressionError, ExpressionEvaluationError} from "./errors.js";
|
||||
export {Evaluator} from "./evaluator.js";
|
||||
export {wellKnownFunctions} from "./funcs.js";
|
||||
export {Lexer, Result} from "./lexer.js";
|
||||
export {Parser} from "./parser.js";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Lexer, Token, TokenType} from "./lexer";
|
||||
import {Lexer, Token, TokenType} from "./lexer.js";
|
||||
|
||||
describe("lexer", () => {
|
||||
const tests: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {StringData} from "./data";
|
||||
import {MAX_EXPRESSION_LENGTH} from "./errors";
|
||||
import {StringData} from "./data/index.js";
|
||||
import {MAX_EXPRESSION_LENGTH} from "./errors.js";
|
||||
|
||||
export enum TokenType {
|
||||
UNKNOWN,
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import {Binary, ContextAccess, Expr, FunctionCall, Grouping, IndexAccess, Literal, Logical, Star, Unary} from "./ast";
|
||||
import * as data from "./data";
|
||||
import {ErrorType, ExpressionError, MAX_PARSER_DEPTH} from "./errors";
|
||||
import {ParseContext, validateFunction} from "./funcs";
|
||||
import {FunctionInfo} from "./funcs/info";
|
||||
import {Token, TokenType} from "./lexer";
|
||||
import {
|
||||
Binary,
|
||||
ContextAccess,
|
||||
Expr,
|
||||
FunctionCall,
|
||||
Grouping,
|
||||
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 {
|
||||
private extContexts: Map<string, boolean>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {BooleanData, ExpressionData, NumberData, StringData} from "./data";
|
||||
import {coerceTypes, toUpperSpecial} from "./result";
|
||||
import {BooleanData, ExpressionData, NumberData, StringData} from "./data/index.js";
|
||||
import {coerceTypes, toUpperSpecial} from "./result.js";
|
||||
|
||||
describe("coerceTypes", () => {
|
||||
const tests: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as data from "./data";
|
||||
import * as data from "./data/index.js";
|
||||
|
||||
export function falsy(d: data.ExpressionData): boolean {
|
||||
switch (d.kind) {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
import {Expr} from "./ast";
|
||||
import * as data from "./data";
|
||||
import {kindStr} from "./data/expressiondata";
|
||||
import {replacer} from "./data/replacer";
|
||||
import {reviver} from "./data/reviver";
|
||||
import {ExpressionError} from "./errors";
|
||||
import {Evaluator} from "./evaluator";
|
||||
import {Lexer, Result} from "./lexer";
|
||||
import {Parser} from "./parser";
|
||||
import {Expr} from "./ast.js";
|
||||
import * as data from "./data/index.js";
|
||||
import {kindStr} from "./data/expressiondata.js";
|
||||
import {replacer} from "./data/replacer.js";
|
||||
import {reviver} from "./data/reviver.js";
|
||||
import {ExpressionError} from "./errors.js";
|
||||
import {Evaluator} from "./evaluator.js";
|
||||
import {Lexer, Result} from "./lexer.js";
|
||||
import {Parser} from "./parser.js";
|
||||
|
||||
interface TestResult {
|
||||
value: data.ExpressionData;
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
"exclude": ["./src/**/*.test.ts"],
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"module": "node16",
|
||||
"moduleResolution": "node16",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"noEmit": false,
|
||||
"outDir": "./dist"
|
||||
"outDir": "./dist",
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,14 @@ The [package](https://www.npmjs.com/package/@actions/languageserver) contains Ty
|
||||
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
|
||||
|
||||
### Basic usage using `vscode-languageserver-node`
|
||||
@@ -92,6 +100,150 @@ const clientOptions: LanguageClientOptions = {
|
||||
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: git@github.com: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
|
||||
|
||||
See [CONTRIBUTING.md](../CONTRIBUTING.md) at the root of the repository for general guidelines and recommendations.
|
||||
@@ -110,6 +262,27 @@ or to watch for changes
|
||||
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
|
||||
|
||||
```bash
|
||||
|
||||
Executable
+2
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env node
|
||||
import "../dist/cli.bundle.cjs";
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageserver",
|
||||
"version": "0.3.20",
|
||||
"version": "0.3.31",
|
||||
"description": "Language server for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -31,20 +31,25 @@
|
||||
"url": "https://github.com/actions/languageservices"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc --build tsconfig.build.json",
|
||||
"build": "tsc --build tsconfig.build.json && npm run build:cli",
|
||||
"build:cli": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/cli.bundle.cjs",
|
||||
"clean": "rimraf dist",
|
||||
"format": "prettier --write '**/*.ts'",
|
||||
"format-check": "prettier --check '**/*.ts'",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"lint": "eslint --max-warnings 0 'src/**/*.ts'",
|
||||
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
||||
"prepublishOnly": "npm run build && npm run test",
|
||||
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
|
||||
"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": {
|
||||
"actions-languageserver": "./bin/actions-languageserver"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/languageservice": "^0.3.20",
|
||||
"@actions/workflow-parser": "^0.3.20",
|
||||
"@actions/languageservice": "^0.3.31",
|
||||
"@actions/workflow-parser": "^0.3.31",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@octokit/types": "^9.0.0",
|
||||
"vscode-languageserver": "^8.0.2",
|
||||
@@ -52,15 +57,17 @@
|
||||
"yaml": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.15"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
"dist/**/*",
|
||||
"bin/**/*"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.0.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.56.0",
|
||||
"@typescript-eslint/parser": "^5.56.0",
|
||||
"esbuild": "^0.27.1",
|
||||
"eslint": "^8.36.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {documentLinks, hover, validate, ValidationConfig} from "@actions/languageservice";
|
||||
import {documentLinks, getInlayHints, hover, validate, ValidationConfig} from "@actions/languageservice";
|
||||
import {registerLogger, setLogLevel} from "@actions/languageservice/log";
|
||||
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
HoverParams,
|
||||
InitializeParams,
|
||||
InitializeResult,
|
||||
InlayHint,
|
||||
InlayHintParams,
|
||||
TextDocumentIdentifier,
|
||||
TextDocumentPositionParams,
|
||||
TextDocuments,
|
||||
@@ -72,7 +74,8 @@ export function initConnection(connection: Connection) {
|
||||
hoverProvider: true,
|
||||
documentLinkProvider: {
|
||||
resolveProvider: false
|
||||
}
|
||||
},
|
||||
inlayHintProvider: true
|
||||
}
|
||||
};
|
||||
|
||||
@@ -158,6 +161,12 @@ export function initConnection(connection: Connection) {
|
||||
return documentLinks(getDocument(documents, textDocument), repoContext?.workspaceUri);
|
||||
});
|
||||
|
||||
connection.languages.inlayHint.on(async ({textDocument}: InlayHintParams): Promise<InlayHint[] | null> => {
|
||||
return timeOperation("inlayHints", () => {
|
||||
return getInlayHints(getDocument(documents, textDocument));
|
||||
});
|
||||
});
|
||||
|
||||
// Make the text document manager listen on the connection
|
||||
// for open, change and close text document events
|
||||
documents.listen(connection);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,18 @@ export function contextProviders(
|
||||
cache: TTLCache
|
||||
): ContextProviderConfig {
|
||||
if (!repo || !client) {
|
||||
return {getContext: () => Promise.resolve(undefined)};
|
||||
// When GitHub client/repo is unavailable, return an incomplete dictionary
|
||||
// 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 (
|
||||
|
||||
@@ -49,7 +49,7 @@ export async function getSecrets(
|
||||
if (isString(x.value)) {
|
||||
environmentName = x.value.value;
|
||||
} else {
|
||||
// this means we have a dynamic enviornment, in those situations we
|
||||
// this means we have a dynamic environment, in those situations we
|
||||
// want to make sure we skip doing secret validation
|
||||
secretsContext.complete = false;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
import {data, DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
|
||||
import {getStepsContext as getDefaultStepsContext} from "@actions/languageservice/context-providers/steps";
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import fetchMock from "fetch-mock";
|
||||
@@ -63,6 +63,47 @@ it("returns default context when job is undefined", async () => {
|
||||
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");
|
||||
if (!stepContext) {
|
||||
throw new Error("Expected stepContext to be defined");
|
||||
}
|
||||
expect(isDescriptionDictionary(stepContext)).toBe(true);
|
||||
|
||||
// Get the outputs - should be a dictionary, not null
|
||||
const outputs = (stepContext as DescriptionDictionary).get("outputs");
|
||||
if (!outputs) {
|
||||
throw new Error("Expected outputs to be defined");
|
||||
}
|
||||
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 () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
@@ -83,17 +124,22 @@ it("adds action outputs", async () => {
|
||||
);
|
||||
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(
|
||||
new DescriptionDictionary({
|
||||
key: "cache-primes",
|
||||
value: new DescriptionDictionary(
|
||||
{
|
||||
key: "outputs",
|
||||
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"
|
||||
})
|
||||
value: expectedOutputs
|
||||
},
|
||||
{
|
||||
key: "conclusion",
|
||||
|
||||
@@ -58,6 +58,8 @@ export async function getStepsContext(
|
||||
continue;
|
||||
}
|
||||
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)) {
|
||||
outputsDict.add(key, new data.StringData(value.description), value.description);
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ export async function getVariables(
|
||||
return secretsContext;
|
||||
}
|
||||
|
||||
const variablesContext = defaultContext || new DescriptionDictionary();
|
||||
|
||||
let environmentName: string | undefined;
|
||||
if (workflowContext?.job?.environment) {
|
||||
if (isString(workflowContext.job.environment)) {
|
||||
@@ -35,14 +37,19 @@ export async function getVariables(
|
||||
if (isString(x.key) && x.key.value === "name") {
|
||||
if (isString(x.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;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// if the expression is something like environment: ${{ ... }} then we want to skip validation
|
||||
variablesContext.complete = false;
|
||||
}
|
||||
}
|
||||
|
||||
const variablesContext = defaultContext || new DescriptionDictionary();
|
||||
try {
|
||||
const variables = await getRemoteVariables(octokit, cache, repo, environmentName);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageservice",
|
||||
"version": "0.3.20",
|
||||
"version": "0.3.31",
|
||||
"description": "Language service for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -35,24 +35,27 @@
|
||||
"clean": "rimraf dist",
|
||||
"format": "prettier --write '**/*.ts'",
|
||||
"format-check": "prettier --check '**/*.ts'",
|
||||
"lint": "eslint 'src/**/*.ts'",
|
||||
"lint": "eslint --max-warnings 0 '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",
|
||||
"pretest": "npm run minify-json",
|
||||
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
|
||||
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
|
||||
"update-webhooks": "ts-node-esm script/webhooks/index.ts",
|
||||
"update-webhooks": "npx tsx script/webhooks/index.ts",
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.20",
|
||||
"@actions/workflow-parser": "^0.3.20",
|
||||
"@actions/expressions": "^0.3.31",
|
||||
"@actions/workflow-parser": "^0.3.31",
|
||||
"vscode-languageserver-textdocument": "^1.0.7",
|
||||
"vscode-languageserver-types": "^3.17.2",
|
||||
"vscode-uri": "^3.0.8",
|
||||
"yaml": "^2.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.15"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*"
|
||||
|
||||
@@ -7,6 +7,185 @@ const schema = schemaImport as any;
|
||||
|
||||
const OUTPUT_PATH = "./src/context-providers/events/webhooks.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[];
|
||||
if (!rawWebhooks) {
|
||||
@@ -20,11 +199,51 @@ for (const webhook of Object.values(rawWebhooks)) {
|
||||
|
||||
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
|
||||
const categorizedWebhooks: Record<string, Record<string, Webhook>> = {};
|
||||
for (const webhook of webhooks) {
|
||||
if (!webhook.action) webhook.action = "default";
|
||||
|
||||
// Drop unused events
|
||||
if (DROPPED_EVENTS.has(webhook.category)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (categorizedWebhooks[webhook.category]) {
|
||||
categorizedWebhooks[webhook.category][webhook.action] = webhook;
|
||||
} else {
|
||||
@@ -33,7 +252,59 @@ for (const webhook of webhooks) {
|
||||
}
|
||||
}
|
||||
|
||||
const objectsArray = deduplicateWebhooks(categorizedWebhooks);
|
||||
// Strip fields before deduplication
|
||||
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(OUTPUT_PATH, JSON.stringify(categorizedWebhooks, null, 2));
|
||||
await fs.writeFile(OUTPUT_PATH, JSON.stringify(strippedWebhooks, 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,4 +1,4 @@
|
||||
import {actionIdentifier, parseActionReference as parse} from "./action";
|
||||
import {actionIdentifier, parseActionReference as parse} from "./action.js";
|
||||
|
||||
describe("parseActionReference", () => {
|
||||
it("basic action", () => {
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {complete} from "./complete";
|
||||
import {clearCache} from "./utils/workflow-cache";
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
});
|
||||
|
||||
describe("complete action files", () => {
|
||||
function createActionDocument(
|
||||
content: string,
|
||||
uri = "file:///test/action.yml"
|
||||
): [TextDocument, {line: number; character: number}] {
|
||||
// Parse cursor position and remove the | character
|
||||
const cursorIndex = content.indexOf("|");
|
||||
if (cursorIndex === -1) {
|
||||
throw new Error("No cursor (|) found in content");
|
||||
}
|
||||
const newContent = content.substring(0, cursorIndex) + content.substring(cursorIndex + 1);
|
||||
const doc = TextDocument.create(uri, "yaml", 1, newContent);
|
||||
const position = doc.positionAt(cursorIndex);
|
||||
return [doc, position];
|
||||
}
|
||||
|
||||
describe("expression completion in composite actions", () => {
|
||||
it("completes inputs context", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test action
|
||||
inputs:
|
||||
name:
|
||||
description: The name
|
||||
greeting:
|
||||
description: The greeting
|
||||
default: Hello
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo "\${{ inputs.| }}"
|
||||
shell: bash`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("name");
|
||||
expect(labels).toContain("greeting");
|
||||
});
|
||||
|
||||
it("completes steps context with prior step IDs", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test action
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: step1
|
||||
run: echo "hello"
|
||||
shell: bash
|
||||
- id: step2
|
||||
run: echo "\${{ steps.| }}"
|
||||
shell: bash`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("step1");
|
||||
expect(labels).not.toContain("step2"); // Current step should not be included
|
||||
});
|
||||
|
||||
it("completes step properties", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test action
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: greet
|
||||
run: echo "hello"
|
||||
shell: bash
|
||||
- run: echo "\${{ steps.greet.| }}"
|
||||
shell: bash`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("outputs");
|
||||
expect(labels).toContain("outcome");
|
||||
expect(labels).toContain("conclusion");
|
||||
});
|
||||
|
||||
it("does not include steps from after cursor position", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test action
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: first
|
||||
run: echo "first"
|
||||
shell: bash
|
||||
- run: echo "\${{ steps.| }}"
|
||||
shell: bash
|
||||
- id: last
|
||||
run: echo "last"
|
||||
shell: bash`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("first");
|
||||
expect(labels).not.toContain("last");
|
||||
});
|
||||
|
||||
it("completes github context in actions", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test action
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo "\${{ github.| }}"
|
||||
shell: bash`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("actor");
|
||||
expect(labels).toContain("repository");
|
||||
expect(labels).toContain("ref");
|
||||
});
|
||||
|
||||
it("completes runner context in actions", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test action
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: echo "\${{ runner.| }}"
|
||||
shell: bash`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("os");
|
||||
expect(labels).toContain("arch");
|
||||
expect(labels).toContain("temp");
|
||||
});
|
||||
});
|
||||
|
||||
describe("top-level completions", () => {
|
||||
it("completes top-level keys", async () => {
|
||||
const [doc, position] = createActionDocument(`n|`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("name");
|
||||
});
|
||||
|
||||
it("completes at empty line", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
|`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("description");
|
||||
expect(labels).toContain("runs");
|
||||
expect(labels).toContain("inputs");
|
||||
expect(labels).toContain("outputs");
|
||||
expect(labels).toContain("branding");
|
||||
expect(labels).toContain("author");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runs completions", () => {
|
||||
it("completes runs.using values", async () => {
|
||||
const [doc, position] = createActionDocument(`name: Test
|
||||
description: Test
|
||||
runs:
|
||||
using: |`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("composite");
|
||||
expect(labels).toContain("node20");
|
||||
expect(labels).toContain("docker");
|
||||
});
|
||||
|
||||
it("completes runs keys", async () => {
|
||||
const [doc, position] = createActionDocument(`name: Test
|
||||
description: Test
|
||||
runs:
|
||||
|`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("using");
|
||||
});
|
||||
});
|
||||
|
||||
describe("branding completions", () => {
|
||||
it("completes branding keys", async () => {
|
||||
const [doc, position] = createActionDocument(`name: Test
|
||||
description: Test
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
branding:
|
||||
|`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("icon");
|
||||
expect(labels).toContain("color");
|
||||
});
|
||||
|
||||
it("completes branding color values", async () => {
|
||||
const [doc, position] = createActionDocument(`name: Test
|
||||
description: Test
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js
|
||||
branding:
|
||||
color: |`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("blue");
|
||||
expect(labels).toContain("green");
|
||||
expect(labels).toContain("red");
|
||||
});
|
||||
});
|
||||
|
||||
describe("inputs completions", () => {
|
||||
it("completes input property keys", async () => {
|
||||
const [doc, position] = createActionDocument(`name: Test
|
||||
description: Test
|
||||
inputs:
|
||||
my-input:
|
||||
|
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js`);
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("description");
|
||||
expect(labels).toContain("required");
|
||||
expect(labels).toContain("default");
|
||||
expect(labels).toContain("deprecationMessage");
|
||||
});
|
||||
});
|
||||
|
||||
describe("document type routing", () => {
|
||||
it("routes action.yml to action completion", async () => {
|
||||
const [doc, position] = createActionDocument(`n|`, "file:///my-repo/action.yml");
|
||||
const completions = await complete(doc, position);
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("name");
|
||||
// Should NOT contain workflow-specific keys
|
||||
expect(labels).not.toContain("on");
|
||||
expect(labels).not.toContain("jobs");
|
||||
});
|
||||
|
||||
it("does not route workflow files to action completion", async () => {
|
||||
const doc = TextDocument.create("file:///repo/.github/workflows/ci.yml", "yaml", 1, `o`);
|
||||
const completions = await complete(doc, {line: 0, character: 1});
|
||||
const labels = completions.map(c => c.label);
|
||||
|
||||
expect(labels).toContain("on");
|
||||
expect(labels).toContain("jobs");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,13 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
import {CompletionItem, CompletionItemKind} from "vscode-languageserver-types";
|
||||
import {complete, getExpressionInput} from "./complete";
|
||||
import {ContextProviderConfig} from "./context-providers/config";
|
||||
import {registerLogger} from "./log";
|
||||
import {getPositionFromCursor} from "./test-utils/cursor-position";
|
||||
import {TestLogger} from "./test-utils/logger";
|
||||
import {testFileProvider} from "./test-utils/test-file-provider";
|
||||
import {clearCache} from "./utils/workflow-cache";
|
||||
import {complete, getExpressionInput} from "./complete.js";
|
||||
import {ContextProviderConfig} from "./context-providers/config.js";
|
||||
import {registerLogger} from "./log.js";
|
||||
import {getPositionFromCursor} from "./test-utils/cursor-position.js";
|
||||
import {TestLogger} from "./test-utils/logger.js";
|
||||
import {testFileProvider} from "./test-utils/test-file-provider.js";
|
||||
import {clearCache} from "./utils/workflow-cache.js";
|
||||
|
||||
const contextProviderConfig: ContextProviderConfig = {
|
||||
getContext: (context: string) => {
|
||||
@@ -299,7 +299,16 @@ jobs:
|
||||
"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});
|
||||
|
||||
expect(result.map(x => x.label)).toEqual(["arch", "name", "os", "temp", "tool_cache"]);
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
"arch",
|
||||
"debug",
|
||||
"environment",
|
||||
"name",
|
||||
"os",
|
||||
"temp",
|
||||
"tool_cache",
|
||||
"workspace"
|
||||
]);
|
||||
});
|
||||
|
||||
describe("job if", () => {
|
||||
@@ -861,7 +870,7 @@ jobs:
|
||||
});
|
||||
|
||||
describe("strategy context", () => {
|
||||
it("strategy is not suggested when outside of a matrix job", async () => {
|
||||
it("strategy is suggested even when no strategy defined", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
|
||||
@@ -875,7 +884,7 @@ jobs:
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||
|
||||
expect(result.map(x => x.label)).not.toContain("strategy");
|
||||
expect(result.map(x => x.label)).toContain("strategy");
|
||||
});
|
||||
|
||||
it("strategy is suggested within a matrix job", async () => {
|
||||
@@ -922,7 +931,7 @@ jobs:
|
||||
});
|
||||
|
||||
describe("matrix context", () => {
|
||||
it("matrix is not suggested when outside of a matrix job", async () => {
|
||||
it("matrix is suggested even when no strategy defined", async () => {
|
||||
const input = `
|
||||
on: push
|
||||
|
||||
@@ -936,7 +945,7 @@ jobs:
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||
|
||||
expect(result.map(x => x.label)).not.toContain("strategy");
|
||||
expect(result.map(x => x.label)).toContain("matrix");
|
||||
});
|
||||
|
||||
it("matrix is suggested within a matrix job", async () => {
|
||||
@@ -1101,7 +1110,7 @@ jobs:
|
||||
`;
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||
expect(result.map(x => x.label)).toEqual(["container", "services", "status"]);
|
||||
expect(result.map(x => x.label)).toEqual(["check_run_id", "container", "services", "status"]);
|
||||
});
|
||||
|
||||
it("job context is suggested within a job output", async () => {
|
||||
@@ -1123,10 +1132,12 @@ jobs:
|
||||
"github",
|
||||
"inputs",
|
||||
"job",
|
||||
"matrix",
|
||||
"needs",
|
||||
"runner",
|
||||
"secrets",
|
||||
"steps",
|
||||
"strategy",
|
||||
"vars",
|
||||
"contains",
|
||||
"endsWith",
|
||||
@@ -1268,7 +1279,7 @@ jobs:
|
||||
on: push
|
||||
jobs:
|
||||
a:
|
||||
uses: ./reusable-workflow-with-outputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-outputs.yaml
|
||||
b:
|
||||
needs: [a]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
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 {complete} from "./complete";
|
||||
import {getPositionFromCursor} from "./test-utils/cursor-position";
|
||||
import {testFileProvider} from "./test-utils/test-file-provider";
|
||||
import {clearCache} from "./utils/workflow-cache";
|
||||
import {complete} from "./complete.js";
|
||||
import {getPositionFromCursor} from "./test-utils/cursor-position.js";
|
||||
import {testFileProvider} from "./test-utils/test-file-provider.js";
|
||||
import {clearCache} from "./utils/workflow-cache.js";
|
||||
|
||||
function mapResult(result: CompletionItem[]) {
|
||||
return result.map(x => {
|
||||
@@ -21,7 +21,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow-with-inputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||
with:
|
||||
|
|
||||
`;
|
||||
@@ -49,7 +49,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow-with-inputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||
with:
|
||||
username: monalisa
|
||||
|
|
||||
@@ -74,7 +74,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow-with-inputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||
secrets:
|
||||
|
|
||||
`;
|
||||
@@ -102,7 +102,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow-with-inputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||
secrets: |
|
||||
`;
|
||||
const result = await complete(...getPositionFromCursor(input), {fileProvider: testFileProvider});
|
||||
@@ -117,7 +117,7 @@ on: push
|
||||
|
||||
jobs:
|
||||
build:
|
||||
uses: ./reusable-workflow-with-inputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-inputs.yaml
|
||||
secrets:
|
||||
envPAT: "myPAT"
|
||||
|
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import {MarkupContent, TextEdit} from "vscode-languageserver-types";
|
||||
import {complete} from "./complete";
|
||||
import {registerLogger} from "./log";
|
||||
import {getPositionFromCursor} from "./test-utils/cursor-position";
|
||||
import {TestLogger} from "./test-utils/logger";
|
||||
import {clearCache} from "./utils/workflow-cache";
|
||||
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
|
||||
import {complete} from "./complete.js";
|
||||
import {registerLogger} from "./log.js";
|
||||
import {getPositionFromCursor} from "./test-utils/cursor-position.js";
|
||||
import {TestLogger} from "./test-utils/logger.js";
|
||||
import {clearCache} from "./utils/workflow-cache.js";
|
||||
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js";
|
||||
|
||||
registerLogger(new TestLogger());
|
||||
|
||||
@@ -19,9 +19,12 @@ describe("completion", () => {
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result.length).toEqual(12);
|
||||
// 12 runner labels + 2 escape hatches (switch to list, switch to full syntax)
|
||||
expect(result.length).toEqual(14);
|
||||
const labels = result.map(x => x.label);
|
||||
expect(labels).toContain("macos-latest");
|
||||
expect(labels).toContain("(switch to list)");
|
||||
expect(labels).toContain("(switch to mapping)");
|
||||
});
|
||||
|
||||
it("needs", async () => {
|
||||
@@ -44,7 +47,7 @@ jobs:
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result.length).toEqual(9);
|
||||
expect(result.length).toEqual(13);
|
||||
expect(result[0].label).toEqual("concurrency");
|
||||
});
|
||||
|
||||
@@ -70,7 +73,7 @@ jobs:
|
||||
|`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result.length).toEqual(21);
|
||||
expect(result.length).toEqual(30);
|
||||
});
|
||||
|
||||
it("string definition completion in sequence", async () => {
|
||||
@@ -95,6 +98,7 @@ jobs:
|
||||
release:
|
||||
types: |`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
// Expect string values plus escape hatch to switch to list form
|
||||
expect(result.map(x => x.label)).toEqual([
|
||||
"created",
|
||||
"deleted",
|
||||
@@ -102,7 +106,8 @@ jobs:
|
||||
"prereleased",
|
||||
"published",
|
||||
"released",
|
||||
"unpublished"
|
||||
"unpublished",
|
||||
"(switch to list)"
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -190,8 +195,11 @@ jobs:
|
||||
const result = await complete(...getPositionFromCursor(input), {valueProviderConfig: config});
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result.length).toEqual(1);
|
||||
// Custom value plus escape hatches for list and full syntax
|
||||
expect(result.length).toEqual(3);
|
||||
expect(result[0].label).toEqual("my-custom-label");
|
||||
expect(result.map(x => x.label)).toContain("(switch to list)");
|
||||
expect(result.map(x => x.label)).toContain("(switch to mapping)");
|
||||
});
|
||||
|
||||
it("custom value providers for sequences", async () => {
|
||||
@@ -212,7 +220,9 @@ jobs:
|
||||
expect(result[0].label).toEqual("my-custom-label");
|
||||
});
|
||||
|
||||
it("does not show parent mapping sibling keys", async () => {
|
||||
it("does not show mapping keys or parent sibling keys in Key mode", async () => {
|
||||
// At `container: |`, the scalar form is a string with no constants.
|
||||
// Mapping keys should NOT be shown inline - but escape hatch to full syntax IS shown.
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
@@ -220,20 +230,21 @@ jobs:
|
||||
runs-on: ubuntu-latest`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result.length).toEqual(6);
|
||||
// Should not contain other top-level job keys like `if` and `runs-on`
|
||||
expect(result.map(x => x.label)).not.toContain("if");
|
||||
expect(result.map(x => x.label)).not.toContain("runs-on");
|
||||
// Only escape hatch to full syntax (container has mapping form but no sequence)
|
||||
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
|
||||
});
|
||||
|
||||
it("shows mapping keys within a new map ", async () => {
|
||||
it("does not show mapping keys in Key mode when structure is uncommitted", async () => {
|
||||
// At `concurrency: |`, user is in Key mode but hasn't committed to a structure.
|
||||
// The scalar form is a string with no constants, so no scalar completions.
|
||||
// But escape hatch to full syntax IS shown as a way out.
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
concurrency: |`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result.map(x => x.label).sort()).toEqual(["cancel-in-progress", "group"]);
|
||||
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
|
||||
});
|
||||
|
||||
it("job key", async () => {
|
||||
@@ -243,7 +254,7 @@ jobs:
|
||||
runs-|`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result).toHaveLength(21);
|
||||
expect(result).toHaveLength(30);
|
||||
});
|
||||
|
||||
it("job key with comment afterwards", async () => {
|
||||
@@ -254,7 +265,7 @@ jobs:
|
||||
#`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result).toHaveLength(21);
|
||||
expect(result).toHaveLength(30);
|
||||
});
|
||||
|
||||
it("job key with other values afterwards", async () => {
|
||||
@@ -266,7 +277,10 @@ jobs:
|
||||
concurrency: 'group-name'`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result).toHaveLength(20);
|
||||
// Verify we get job-level completions, but concurrency is already present so excluded
|
||||
expect(result.length).toBeGreaterThan(20);
|
||||
expect(result.some(x => x.label === "runs-on")).toBe(true);
|
||||
expect(result.some(x => x.label === "concurrency")).toBe(false);
|
||||
});
|
||||
|
||||
it("step key without space after colon", async () => {
|
||||
@@ -335,7 +349,9 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
expect(result).toHaveLength(17);
|
||||
// Verify we get job-level completions including runs-on variants
|
||||
expect(result.length).toBeGreaterThan(20);
|
||||
expect(result.some(x => x.label === "steps")).toBe(true);
|
||||
});
|
||||
|
||||
it("complete from behind a colon will replace it", async () => {
|
||||
@@ -348,7 +364,8 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
expect(result).toHaveLength(17);
|
||||
// Verify we get job-level completions
|
||||
expect(result.length).toBeGreaterThan(20);
|
||||
const textEdit = result[0].textEdit as TextEdit;
|
||||
expect(textEdit.range).toEqual({
|
||||
start: {line: 5, character: 4},
|
||||
@@ -447,8 +464,9 @@ jobs:
|
||||
"timeout-minutes: "
|
||||
]);
|
||||
|
||||
// One-of
|
||||
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency: "]);
|
||||
// One-of (scalar variant)
|
||||
const concurrencyScalar = result.find(x => x.label === "concurrency" && x.labelDetails === undefined);
|
||||
expect(concurrencyScalar?.textEdit?.newText).toEqual("concurrency: ");
|
||||
});
|
||||
|
||||
it("custom indentation", async () => {
|
||||
@@ -470,20 +488,21 @@ jobs:
|
||||
"timeout-minutes: "
|
||||
]);
|
||||
|
||||
// One-of
|
||||
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency: "]);
|
||||
// One-of (scalar variant)
|
||||
const concurrencyScalar = result.find(x => x.label === "concurrency" && x.labelDetails === undefined);
|
||||
expect(concurrencyScalar?.textEdit?.newText).toEqual("concurrency: ");
|
||||
});
|
||||
});
|
||||
|
||||
it("adds a new line and indentation for mapping keys when the key is given", async () => {
|
||||
it("does not show mapping keys in Key mode for one-of with mapping variant", async () => {
|
||||
// At `concurrency: |`, mapping keys should NOT be shown.
|
||||
// Users who want the mapping form should use `concurrency (full syntax)` at parent level.
|
||||
const input = "concurrency: |";
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result.filter(x => x.label === "cancel-in-progress").map(x => x.textEdit?.newText)).toEqual([
|
||||
"\n cancel-in-progress: "
|
||||
]);
|
||||
expect(result.filter(x => x.label === "group").map(x => x.textEdit?.newText)).toEqual(["\n group: "]);
|
||||
expect(result.filter(x => x.label === "cancel-in-progress")).toEqual([]);
|
||||
expect(result.filter(x => x.label === "group")).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not add new line if no key in line", async () => {
|
||||
@@ -494,12 +513,15 @@ jobs:
|
||||
expect(result.filter(x => x.label === "run-name").map(x => x.textEdit?.newText)).toEqual(["run-name: "]);
|
||||
});
|
||||
|
||||
it("adds new line for nested mapping", async () => {
|
||||
it("does not show mapping keys when user has started typing a scalar value", async () => {
|
||||
// User typed `workflow_dispatch: in` - they've committed to a scalar value
|
||||
// Should not show mapping keys like `inputs`
|
||||
const input = "on:\n workflow_dispatch: in|";
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result.filter(x => x.label === "inputs").map(x => x.textEdit?.newText)).toEqual(["\n inputs:\n "]);
|
||||
// No mapping keys should be shown since user started typing a scalar
|
||||
expect(result.filter(x => x.label === "inputs")).toEqual([]);
|
||||
});
|
||||
|
||||
it("adds : for one-of", async () => {
|
||||
@@ -507,14 +529,351 @@ jobs:
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result.filter(x => x.label === "types").map(x => x.textEdit?.newText)).toEqual(["types: "]);
|
||||
// Scalar variant inserts "types: "
|
||||
const scalarVariant = result.find(x => x.label === "types" && x.labelDetails === undefined);
|
||||
expect(scalarVariant?.textEdit?.newText).toEqual("types: ");
|
||||
});
|
||||
|
||||
it("does not add : for one-of in key mode", async () => {
|
||||
it("does not show mapping keys for one-of when user has typed a scalar value", async () => {
|
||||
// User typed `check_run: ty` - they've committed to scalar form
|
||||
// The only valid value for check_run scalar is null, so no completions
|
||||
const input = "on:\n check_run: ty|";
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result.filter(x => x.label === "types").map(x => x.textEdit?.newText)).toEqual(["types"]);
|
||||
// check_run's scalar form only accepts null, so typing anything should show no completions
|
||||
// (we don't show mapping keys like `types` anymore - user should use check_run with detail "full syntax" instead)
|
||||
expect(result.filter(x => x.label === "types")).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows only scalar options for one-of in Key mode when user hasn't committed to a type", async () => {
|
||||
// At `permissions: |` user hasn't typed anything yet - show only scalar options
|
||||
// Mapping keys are NOT shown because they would require a newline
|
||||
// Users who want the mapping form can use `permissions (full syntax)` at the parent level
|
||||
const input = "on: push\npermissions: |";
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// String values (read-all, write-all) should be available
|
||||
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 should NOT be shown - they require a newline which is confusing inline
|
||||
expect(result.filter(x => x.label === "actions")).toEqual([]);
|
||||
expect(result.filter(x => x.label === "contents")).toEqual([]);
|
||||
});
|
||||
|
||||
it("filters to scalar options when user has started typing a scalar", async () => {
|
||||
// User typed `permissions: r` - they've committed to scalar form
|
||||
const input = "on: push\npermissions: r|";
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// Only scalar values should be shown (filtering on 'r')
|
||||
expect(result.some(x => x.label === "read-all")).toBe(true);
|
||||
// Mapping keys should NOT be shown
|
||||
expect(result.filter(x => x.label === "actions")).toEqual([]);
|
||||
expect(result.filter(x => x.label === "contents")).toEqual([]);
|
||||
});
|
||||
|
||||
it("shows both simple and full syntax for null+mapping one-of", async () => {
|
||||
// check_run is a one-of: [null, mapping]. Show both:
|
||||
// - check_run (simple, just the key with colon)
|
||||
// - check_run with detail "full syntax" (ready to add mapping keys)
|
||||
const input = "on:\n |";
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// Should have both check_run (scalar) and check_run with detail "full syntax"
|
||||
const checkRunVariants = result.filter(x => x.label === "check_run");
|
||||
expect(checkRunVariants.some(x => x.labelDetails === undefined)).toBe(true);
|
||||
expect(checkRunVariants.some(x => x.labelDetails?.description === "full syntax")).toBe(true);
|
||||
});
|
||||
|
||||
it("shows all three variants for scalar+sequence+mapping one-of", async () => {
|
||||
// runs-on is a one-of: [string, sequence, mapping]
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
|`;
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// Should have runs-on (scalar), runs-on with detail "list", and runs-on with detail "full syntax"
|
||||
const runsOnVariants = result.filter(x => x.label === "runs-on");
|
||||
expect(runsOnVariants.length).toBe(3);
|
||||
expect(runsOnVariants.some(x => x.labelDetails === undefined)).toBe(true);
|
||||
expect(runsOnVariants.some(x => x.labelDetails?.description === "list")).toBe(true);
|
||||
expect(runsOnVariants.some(x => x.labelDetails?.description === "full syntax")).toBe(true);
|
||||
});
|
||||
|
||||
it("generates correct insertText for one-of variants in parent mode", async () => {
|
||||
// runs-on is a one-of: [string, sequence, mapping]
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
|`;
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
const runsOnVariants = result.filter(x => x.label === "runs-on");
|
||||
|
||||
// Scalar: just key with colon and space
|
||||
expect(runsOnVariants.find(x => x.labelDetails === undefined)?.textEdit?.newText).toEqual("runs-on: ");
|
||||
|
||||
// Sequence: key with colon, newline, and list item
|
||||
expect(runsOnVariants.find(x => x.labelDetails?.description === "list")?.textEdit?.newText).toEqual(
|
||||
"runs-on:\n - "
|
||||
);
|
||||
|
||||
// Mapping: key with colon, newline, and indentation for nested keys
|
||||
expect(runsOnVariants.find(x => x.labelDetails?.description === "full syntax")?.textEdit?.newText).toEqual(
|
||||
"runs-on:\n "
|
||||
);
|
||||
});
|
||||
|
||||
it("generates correct insertText for one-of variants in parent mode", async () => {
|
||||
// concurrency is a one-of: [string, mapping] - testing parent mode (inside mapping)
|
||||
// At `concurrency:\n |`, user HAS committed to mapping structure, so mapping keys are shown
|
||||
const input = "concurrency:\n |";
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// In parent mode: just key + colon + space (no leading newline)
|
||||
expect(result.find(x => x.label === "group")?.textEdit?.newText).toEqual("group: ");
|
||||
|
||||
// Boolean in parent mode (cancel-in-progress): key + colon + space
|
||||
expect(result.find(x => x.label === "cancel-in-progress")?.textEdit?.newText).toEqual("cancel-in-progress: ");
|
||||
});
|
||||
|
||||
it("uses sortText for ordering qualified one-of variants", async () => {
|
||||
// runs-on has multiple structural types, so variants need sorting
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
|`;
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
const runsOnVariants = result.filter(x => x.label === "runs-on");
|
||||
|
||||
// Scalar: no sortText needed (sorts naturally first)
|
||||
expect(runsOnVariants.find(x => x.labelDetails === undefined)?.sortText).toBeUndefined();
|
||||
|
||||
// Sequence and mapping: sortText controls ordering
|
||||
expect(runsOnVariants.find(x => x.labelDetails?.description === "list")?.sortText).toEqual("runs-on 1");
|
||||
expect(runsOnVariants.find(x => x.labelDetails?.description === "full syntax")?.sortText).toEqual("runs-on 2");
|
||||
});
|
||||
|
||||
it("scalar event completion inserts inline without newline", async () => {
|
||||
// At `on: |` user is completing the value for 'on' key
|
||||
// Scalar events like `push`, `check_run` should insert inline
|
||||
const input = "on: |";
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// Scalar forms should NOT have newline - they insert inline
|
||||
const push = result.find(x => x.label === "push");
|
||||
expect(push?.textEdit?.newText).toEqual("push");
|
||||
|
||||
const checkRun = result.find(x => x.label === "check_run" && x.labelDetails === undefined);
|
||||
expect(checkRun?.textEdit?.newText).toEqual("check_run");
|
||||
|
||||
// Full syntax form should NOT be shown in Key mode - it requires a newline
|
||||
// which is confusing when typing inline. Users who want the mapping form
|
||||
// can use `on (full syntax)` at the parent level.
|
||||
expect(result.find(x => x.label === "check_run" && x.labelDetails?.description === "full syntax")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("filters to sequence options when user has started a sequence", async () => {
|
||||
// User started a sequence with `- ` syntax - they've committed to sequence form
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on:
|
||||
- |`;
|
||||
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// Should show runner labels (sequence item values)
|
||||
expect(result.some(x => x.label === "ubuntu-latest")).toBe(true);
|
||||
expect(result.some(x => x.label === "macos-latest")).toBe(true);
|
||||
|
||||
// Should NOT show mapping keys like `group` or `labels` (those are for full syntax)
|
||||
expect(result.filter(x => x.label === "group")).toEqual([]);
|
||||
expect(result.filter(x => x.label === "labels")).toEqual([]);
|
||||
});
|
||||
|
||||
describe("escape hatch completions", () => {
|
||||
it("runs-on shows switch to list and full syntax", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: |`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// Should have escape hatches at the end
|
||||
const switchToList = result.find(x => x.label === "(switch to list)");
|
||||
const switchToFull = result.find(x => x.label === "(switch to mapping)");
|
||||
|
||||
expect(switchToList).toBeDefined();
|
||||
expect(switchToFull).toBeDefined();
|
||||
|
||||
// Escape hatches should sort last
|
||||
expect(switchToList!.sortText).toEqual("zzz_switch_1");
|
||||
expect(switchToFull!.sortText).toEqual("zzz_switch_2");
|
||||
|
||||
// Escape hatches should have textEdit that restructures the YAML
|
||||
const listEdit = switchToList!.textEdit as TextEdit;
|
||||
const fullEdit = switchToFull!.textEdit as TextEdit;
|
||||
|
||||
expect(listEdit.newText).toEqual("runs-on:\n - ");
|
||||
expect(fullEdit.newText).toEqual("runs-on:\n ");
|
||||
|
||||
// TextEdit range should cover from key start to cursor position
|
||||
expect(listEdit.range.start).toEqual({line: 3, character: 4});
|
||||
expect(fullEdit.range.start).toEqual({line: 3, character: 4});
|
||||
});
|
||||
|
||||
it("permissions shows only switch to full syntax (no sequence form)", async () => {
|
||||
const input = `on: push
|
||||
permissions: |`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// Should have full syntax escape hatch but NOT list (permissions has no sequence form)
|
||||
expect(result.some(x => x.label === "(switch to mapping)")).toBe(true);
|
||||
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
|
||||
});
|
||||
|
||||
it("escape hatches are not shown when value is non-empty", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-|`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// User has started typing a scalar value, no escape hatches
|
||||
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
|
||||
expect(result.some(x => x.label === "(switch to mapping)")).toBe(false);
|
||||
});
|
||||
|
||||
it("escape hatches are not shown when inside a sequence", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on:
|
||||
- |`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// User is already in sequence form, no escape hatches
|
||||
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
|
||||
expect(result.some(x => x.label === "(switch to mapping)")).toBe(false);
|
||||
});
|
||||
|
||||
it("escape hatches are not shown when inside a mapping", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on:
|
||||
group: |`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// User is in mapping form completing a value, no escape hatches for the parent
|
||||
expect(result.some(x => x.label === "(switch to list)")).toBe(false);
|
||||
expect(result.some(x => x.label === "(switch to mapping)")).toBe(false);
|
||||
});
|
||||
|
||||
it("escape hatches ARE shown even when no scalar completions exist", async () => {
|
||||
// concurrency: | has no scalar constants, but escape hatch provides a way out
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
concurrency: |`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// Escape hatch to mapping should be available even with no scalar completions
|
||||
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
|
||||
});
|
||||
|
||||
it("pure mapping type (strategy) shows switch to mapping", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
strategy: |`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result.some(x => x.label === "(switch to mapping)")).toBe(true);
|
||||
});
|
||||
|
||||
it("pure sequence type (steps) shows switch to list", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
steps: |`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result.some(x => x.label === "(switch to list)")).toBe(true);
|
||||
});
|
||||
|
||||
it("selecting switch to list restructures YAML", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on: |`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
const switchToList = result.find(x => x.label === "(switch to list)");
|
||||
const textEdit = switchToList!.textEdit as TextEdit;
|
||||
|
||||
// Applying this edit to "runs-on: " should produce "runs-on:\n - "
|
||||
expect(textEdit.newText).toEqual("runs-on:\n - ");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runs-on mapping syntax", () => {
|
||||
it("provides label completions for labels as scalar", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on:
|
||||
labels: |`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// Should show runner labels
|
||||
expect(result.some(x => x.label === "ubuntu-latest")).toBe(true);
|
||||
expect(result.some(x => x.label === "macos-latest")).toBe(true);
|
||||
expect(result.some(x => x.label === "self-hosted")).toBe(true);
|
||||
});
|
||||
|
||||
it("provides label completions for labels as sequence item", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on:
|
||||
labels:
|
||||
- |`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// Should show runner labels
|
||||
expect(result.some(x => x.label === "ubuntu-latest")).toBe(true);
|
||||
expect(result.some(x => x.label === "macos-latest")).toBe(true);
|
||||
expect(result.some(x => x.label === "self-hosted")).toBe(true);
|
||||
});
|
||||
|
||||
it("excludes already used labels in sequence", async () => {
|
||||
const input = `on: push
|
||||
jobs:
|
||||
build:
|
||||
runs-on:
|
||||
labels:
|
||||
- ubuntu-latest
|
||||
- |`;
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
// Should NOT show ubuntu-latest since it's already in the list
|
||||
expect(result.some(x => x.label === "ubuntu-latest")).toBe(false);
|
||||
// But should show other labels
|
||||
expect(result.some(x => x.label === "macos-latest")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+360
-58
@@ -1,30 +1,42 @@
|
||||
import {complete as completeExpression, DescriptionDictionary} from "@actions/expressions";
|
||||
import {CompletionItem as ExpressionCompletionItem} from "@actions/expressions/completion";
|
||||
import {isBasicExpression, isSequence, isString} from "@actions/workflow-parser";
|
||||
import {getActionSchema} from "@actions/workflow-parser/actions/action-schema";
|
||||
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
||||
import {DefinitionType} from "@actions/workflow-parser/templates/schema/definition-type";
|
||||
import {OneOfDefinition} from "@actions/workflow-parser/templates/schema/one-of-definition";
|
||||
import {TemplateSchema} from "@actions/workflow-parser/templates/schema/template-schema";
|
||||
import {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
|
||||
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 {File} from "@actions/workflow-parser/workflows/file";
|
||||
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
||||
import {getWorkflowSchema} from "@actions/workflow-parser/workflows/workflow-schema";
|
||||
import {Position, TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {CompletionItem, CompletionItemKind, CompletionItemTag, Range, TextEdit} from "vscode-languageserver-types";
|
||||
import {ContextProviderConfig} from "./context-providers/config";
|
||||
import {getContext, Mode} from "./context-providers/default";
|
||||
import {getWorkflowContext, WorkflowContext} from "./context/workflow-context";
|
||||
import {validatorFunctions} from "./expression-validation/functions";
|
||||
import {error} from "./log";
|
||||
import {isPotentiallyExpression} from "./utils/expression-detection";
|
||||
import {findToken} from "./utils/find-token";
|
||||
import {guessIndentation} from "./utils/indentation-guesser";
|
||||
import {mapRange} from "./utils/range";
|
||||
import {getRelCharOffset} from "./utils/rel-char-pos";
|
||||
import {isPlaceholder, transform} from "./utils/transform";
|
||||
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache";
|
||||
import {Value, ValueProviderConfig} from "./value-providers/config";
|
||||
import {defaultValueProviders} from "./value-providers/default";
|
||||
import {DefinitionValueMode, definitionValues} from "./value-providers/definition";
|
||||
import {ContextProviderConfig} from "./context-providers/config.js";
|
||||
import {getActionExpressionContext, getWorkflowExpressionContext, Mode} from "./context-providers/default.js";
|
||||
import {ActionContext, getActionContext} from "./context/action-context.js";
|
||||
import {getWorkflowContext, WorkflowContext} from "./context/workflow-context.js";
|
||||
import {validatorFunctions} from "./expression-validation/functions.js";
|
||||
import {error} from "./log.js";
|
||||
import {detectDocumentType} from "./utils/document-type.js";
|
||||
import {isPotentiallyExpression} from "./utils/expression-detection.js";
|
||||
import {findToken} from "./utils/find-token.js";
|
||||
import {guessIndentation} from "./utils/indentation-guesser.js";
|
||||
import {mapRange} from "./utils/range.js";
|
||||
import {isPlaceholder, transform} from "./utils/transform.js";
|
||||
import {
|
||||
getOrConvertActionTemplate,
|
||||
getOrConvertWorkflowTemplate,
|
||||
getOrParseAction,
|
||||
getOrParseWorkflow
|
||||
} from "./utils/workflow-cache.js";
|
||||
import {Value, ValueProviderConfig} from "./value-providers/config.js";
|
||||
import {defaultValueProviders} from "./value-providers/default.js";
|
||||
import {DefinitionValueMode, definitionValues, TokenStructure} from "./value-providers/definition.js";
|
||||
|
||||
export function getExpressionInput(input: string, pos: number): string {
|
||||
// Find start marker around the cursor position
|
||||
@@ -65,43 +77,85 @@ export async function complete(
|
||||
content: newDoc.getText()
|
||||
};
|
||||
|
||||
const parsedWorkflow = fetchOrParseWorkflow(file, textDocument.uri, true);
|
||||
if (!parsedWorkflow.value) {
|
||||
// Determine document type - unknown defaults to workflow (backwards compatibility)
|
||||
const isAction = detectDocumentType(textDocument.uri) === "action";
|
||||
|
||||
// Parse the document
|
||||
const parsedTemplate = isAction
|
||||
? getOrParseAction(file, textDocument.uri, true)
|
||||
: getOrParseWorkflow(file, textDocument.uri, true);
|
||||
if (!parsedTemplate.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const template = await fetchOrConvertWorkflowTemplate(
|
||||
parsedWorkflow.context,
|
||||
parsedWorkflow.value,
|
||||
textDocument.uri,
|
||||
config,
|
||||
{
|
||||
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
}
|
||||
);
|
||||
const schema = isAction ? getActionSchema() : getWorkflowSchema();
|
||||
const {token, keyToken, parent, path} = findToken(newPos, parsedTemplate.value);
|
||||
|
||||
const {token, keyToken, parent, path} = findToken(newPos, parsedWorkflow.value);
|
||||
const workflowContext = getWorkflowContext(textDocument.uri, template, path);
|
||||
// Build context for position-aware completions (e.g., steps.*, needs.*, inputs.*)
|
||||
let workflowContext: WorkflowContext | undefined;
|
||||
let actionContext: ActionContext | undefined;
|
||||
if (isAction) {
|
||||
const actionTemplate = getOrConvertActionTemplate(
|
||||
parsedTemplate.context,
|
||||
parsedTemplate.value,
|
||||
textDocument.uri,
|
||||
{errorPolicy: ErrorPolicy.TryConversion},
|
||||
true
|
||||
);
|
||||
actionContext = getActionContext(textDocument.uri, actionTemplate, path);
|
||||
} else {
|
||||
const workflowTemplate = await getOrConvertWorkflowTemplate(
|
||||
parsedTemplate.context,
|
||||
parsedTemplate.value,
|
||||
textDocument.uri,
|
||||
config,
|
||||
{
|
||||
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
},
|
||||
true
|
||||
);
|
||||
workflowContext = workflowTemplate ? getWorkflowContext(textDocument.uri, workflowTemplate, path) : undefined;
|
||||
}
|
||||
|
||||
// If we are inside an expression, take a different code-path. The workflow parser does not correctly create
|
||||
// expression nodes for invalid expressions and during editing expressions are invalid most of the time.
|
||||
if (token) {
|
||||
if (isBasicExpression(token) || isPotentiallyExpression(token)) {
|
||||
const allowedContext = token.definitionInfo?.allowedContext || [];
|
||||
const context = await getContext(allowedContext, config?.contextProviderConfig, workflowContext, Mode.Completion);
|
||||
// Expression completions
|
||||
if (token && (isBasicExpression(token) || isPotentiallyExpression(token))) {
|
||||
const allowedContext = token.definitionInfo?.allowedContext || [];
|
||||
const context = isAction
|
||||
? getActionExpressionContext(allowedContext, config?.contextProviderConfig, actionContext, Mode.Completion)
|
||||
: await getWorkflowExpressionContext(
|
||||
allowedContext,
|
||||
config?.contextProviderConfig,
|
||||
workflowContext,
|
||||
Mode.Completion
|
||||
);
|
||||
|
||||
return getExpressionCompletionItems(token, context, newPos);
|
||||
}
|
||||
return getExpressionCompletionItems(token, context, newPos);
|
||||
}
|
||||
|
||||
const indentation = guessIndentation(newDoc, 2, true); // Use 2 spaces as default and most common for YAML
|
||||
const indentString = " ".repeat(indentation.tabSize);
|
||||
|
||||
const values = await getValues(token, keyToken, parent, config?.valueProviderConfig, workflowContext, indentString);
|
||||
// YAML key/value completions
|
||||
const values = await getValues(
|
||||
token,
|
||||
keyToken,
|
||||
parent,
|
||||
config?.valueProviderConfig,
|
||||
workflowContext,
|
||||
indentString,
|
||||
schema
|
||||
);
|
||||
|
||||
// Offer "(switch to list)" / "(switch to mapping)" when the schema allows alternative forms
|
||||
const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos, schema);
|
||||
values.push(...escapeHatches);
|
||||
|
||||
// Figure out what text to replace when the user picks a completion.
|
||||
// For example, if they typed `runs-|` and pick `runs-on`, we need to replace `runs-`.
|
||||
let replaceRange: Range | undefined;
|
||||
if (token?.range) {
|
||||
// Prefer the token's range since it accounts for YAML syntax like quotes
|
||||
replaceRange = mapRange(token.range);
|
||||
} else if (!token) {
|
||||
// Not a valid token, create a range from the current position
|
||||
@@ -124,30 +178,56 @@ export async function complete(
|
||||
}
|
||||
}
|
||||
|
||||
// Convert values to LSP CompletionItems
|
||||
return values.map(value => {
|
||||
const newText = value.insertText || value.label;
|
||||
|
||||
// Escape hatches provide their own textEdit to restructure the YAML
|
||||
let textEdit: TextEdit;
|
||||
if (value.textEdit) {
|
||||
textEdit = TextEdit.replace(value.textEdit.range, value.textEdit.newText);
|
||||
} else if (replaceRange) {
|
||||
textEdit = TextEdit.replace(replaceRange, newText);
|
||||
} else {
|
||||
textEdit = TextEdit.insert(position, newText);
|
||||
}
|
||||
|
||||
const item: CompletionItem = {
|
||||
label: value.label,
|
||||
labelDetails: value.labelDetail ? {description: value.labelDetail} : undefined,
|
||||
filterText: value.filterText,
|
||||
sortText: value.sortText,
|
||||
documentation: value.description && {
|
||||
kind: "markdown",
|
||||
value: value.description
|
||||
},
|
||||
tags: value.deprecated ? [CompletionItemTag.Deprecated] : undefined,
|
||||
textEdit: replaceRange ? TextEdit.replace(replaceRange, newText) : TextEdit.insert(position, newText)
|
||||
textEdit
|
||||
};
|
||||
|
||||
return item;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves completion values for a token based on value providers and definitions.
|
||||
*
|
||||
* This function determines which values to suggest for auto-completion by:
|
||||
* 1. First checking for custom value providers configured for the token's definition key
|
||||
* 2. Then checking for default value providers for the token's definition key
|
||||
* 3. Finally falling back to values derived from the token's schema definition
|
||||
*
|
||||
* The results are filtered to exclude duplicates (e.g., keys already defined in a mapping
|
||||
* or values already present in a sequence) and sorted alphabetically.
|
||||
*/
|
||||
async function getValues(
|
||||
token: TemplateToken | null,
|
||||
keyToken: TemplateToken | null,
|
||||
parent: TemplateToken | null,
|
||||
valueProviderConfig: ValueProviderConfig | undefined,
|
||||
workflowContext: WorkflowContext,
|
||||
indentation: string
|
||||
workflowContext: WorkflowContext | undefined,
|
||||
indentation: string,
|
||||
schema: TemplateSchema
|
||||
): Promise<Value[]> {
|
||||
if (!parent) {
|
||||
return [];
|
||||
@@ -158,20 +238,23 @@ async function getValues(
|
||||
// Use the value providers from the parent if the current key is null
|
||||
const valueProviderToken = keyToken || parent;
|
||||
|
||||
const customValueProvider =
|
||||
valueProviderToken?.definition?.key && valueProviderConfig?.[valueProviderToken.definition.key];
|
||||
if (customValueProvider) {
|
||||
const customValues = await customValueProvider.get(workflowContext, existingValues);
|
||||
if (customValues) {
|
||||
return filterAndSortCompletionOptions(customValues, existingValues);
|
||||
// Value providers require workflow context - only use them for workflows
|
||||
if (workflowContext) {
|
||||
const customValueProvider =
|
||||
valueProviderToken?.definition?.key && valueProviderConfig?.[valueProviderToken.definition.key];
|
||||
if (customValueProvider) {
|
||||
const customValues = await customValueProvider.get(workflowContext, existingValues);
|
||||
if (customValues) {
|
||||
return filterAndSortCompletionOptions(customValues, existingValues);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultValueProvider =
|
||||
valueProviderToken?.definition?.key && defaultValueProviders[valueProviderToken.definition.key];
|
||||
if (defaultValueProvider) {
|
||||
const values = await defaultValueProvider.get(workflowContext, existingValues);
|
||||
return filterAndSortCompletionOptions(values, existingValues);
|
||||
const defaultValueProvider =
|
||||
valueProviderToken?.definition?.key && defaultValueProviders[valueProviderToken.definition.key];
|
||||
if (defaultValueProvider) {
|
||||
const values = await defaultValueProvider.get(workflowContext, existingValues);
|
||||
return filterAndSortCompletionOptions(values, existingValues);
|
||||
}
|
||||
}
|
||||
|
||||
// Use the definition if there are no value providers
|
||||
@@ -180,10 +263,182 @@ async function getValues(
|
||||
return [];
|
||||
}
|
||||
|
||||
const values = definitionValues(def, indentation, keyToken ? DefinitionValueMode.Key : DefinitionValueMode.Parent);
|
||||
// When a schema allows multiple formats (e.g., `runs-on` can be a string OR a mapping),
|
||||
// only suggest completions that match what the user has already started typing.
|
||||
// For example, if they've started a mapping, don't suggest string values.
|
||||
const tokenStructure = getTokenStructure(token);
|
||||
const values = definitionValues(
|
||||
def,
|
||||
indentation,
|
||||
keyToken ? DefinitionValueMode.Key : DefinitionValueMode.Parent,
|
||||
tokenStructure,
|
||||
schema
|
||||
);
|
||||
return filterAndSortCompletionOptions(values, existingValues);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines what YAML structure the user has committed to, if any.
|
||||
*
|
||||
* Returns:
|
||||
* - "mapping" if the user has started a key-value structure (e.g., `runs-on:\n group: |`)
|
||||
* - "sequence" if the user has started a list (e.g., `runs-on:\n - |`)
|
||||
* - "scalar" if the user has started typing a plain value (e.g., `runs-on: ubuntu-|`)
|
||||
* - undefined if the user hasn't committed yet (e.g., `runs-on: |` with nothing typed)
|
||||
*/
|
||||
function getTokenStructure(token: TemplateToken | null): TokenStructure {
|
||||
if (!token) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
switch (token.templateTokenType) {
|
||||
case TokenType.Mapping:
|
||||
return "mapping";
|
||||
case TokenType.Sequence:
|
||||
return "sequence";
|
||||
case TokenType.Null:
|
||||
// Null means `key: ` with nothing - user hasn't committed to a type yet
|
||||
return undefined;
|
||||
case TokenType.String: {
|
||||
// Empty string means `key: |` - user hasn't committed yet
|
||||
// Non-empty string means user has started typing a scalar value
|
||||
const stringToken = token.assertString("getTokenStructure expected string token");
|
||||
if (stringToken.value === "") {
|
||||
return undefined;
|
||||
}
|
||||
return "scalar";
|
||||
}
|
||||
case TokenType.Boolean:
|
||||
case TokenType.Number:
|
||||
return "scalar";
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates escape hatch completions that allow switching from scalar form to
|
||||
* alternative structural forms (sequence or mapping) when the value is empty.
|
||||
*
|
||||
* For example, at `runs-on: |`, this adds "(switch to list)" and "(switch to full syntax)"
|
||||
* completions that restructure the YAML to `runs-on:\n - |` or `runs-on:\n |`.
|
||||
*
|
||||
* Only shown when:
|
||||
* - Completing in value position (keyToken exists)
|
||||
* - Value is empty (user hasn't committed to a structure yet)
|
||||
* - Definition allows sequence or mapping structure
|
||||
*/
|
||||
function getEscapeHatchCompletions(
|
||||
token: TemplateToken | null,
|
||||
keyToken: TemplateToken | null,
|
||||
indentation: string,
|
||||
position: Position,
|
||||
schema: TemplateSchema
|
||||
): Value[] {
|
||||
// Only show escape hatches when value is empty
|
||||
const tokenStructure = getTokenStructure(token);
|
||||
if (tokenStructure !== undefined) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Need a key token with a definition
|
||||
if (!keyToken?.definition) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Determine which structural types are available from the definition
|
||||
const def = keyToken.definition;
|
||||
const buckets = {
|
||||
sequence: false,
|
||||
mapping: false
|
||||
};
|
||||
|
||||
if (def instanceof OneOfDefinition) {
|
||||
// OneOf: check each variant
|
||||
for (const variantKey of def.oneOf) {
|
||||
const variantDef = schema.definitions[variantKey];
|
||||
if (variantDef) {
|
||||
switch (variantDef.definitionType) {
|
||||
case DefinitionType.Sequence:
|
||||
buckets.sequence = true;
|
||||
break;
|
||||
case DefinitionType.Mapping:
|
||||
buckets.mapping = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Single definition type
|
||||
switch (def.definitionType) {
|
||||
case DefinitionType.Sequence:
|
||||
buckets.sequence = true;
|
||||
break;
|
||||
case DefinitionType.Mapping:
|
||||
buckets.mapping = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const results: Value[] = [];
|
||||
const keyName = isString(keyToken) ? keyToken.value : "";
|
||||
const keyRange = keyToken.range;
|
||||
|
||||
if (!keyRange || !keyName) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Calculate the range from key start to current position
|
||||
// This covers "key: " so we can replace it with "key:\n - " or "key:\n "
|
||||
const editRange = {
|
||||
start: {line: keyRange.start.line - 1, character: keyRange.start.column - 1},
|
||||
end: {line: position.line, character: position.character}
|
||||
};
|
||||
|
||||
if (buckets.sequence) {
|
||||
results.push({
|
||||
label: "(switch to list)",
|
||||
sortText: "zzz_switch_1",
|
||||
filterText: keyName, // Allow filtering by key name
|
||||
textEdit: {
|
||||
range: editRange,
|
||||
newText: `${keyName}:\n${indentation}- `
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (buckets.mapping) {
|
||||
results.push({
|
||||
label: "(switch to mapping)",
|
||||
sortText: "zzz_switch_2",
|
||||
filterText: keyName, // Allow filtering by key name
|
||||
textEdit: {
|
||||
range: editRange,
|
||||
newText: `${keyName}:\n${indentation}`
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects values that are already present in the current context, so they can be
|
||||
* excluded from completion suggestions.
|
||||
*
|
||||
* For sequences (lists), returns all existing items. For example, if the user has:
|
||||
* labels:
|
||||
* - bug
|
||||
* - |
|
||||
* This returns {"bug"} so we don't suggest "bug" again.
|
||||
*
|
||||
* For mappings, returns all existing keys. For example, if the user has:
|
||||
* jobs:
|
||||
* build:
|
||||
* runs-on: ubuntu-latest
|
||||
* |
|
||||
* This returns {"runs-on"} so we don't suggest "runs-on" again.
|
||||
*/
|
||||
export function getExistingValues(token: TemplateToken | null, parent: TemplateToken) {
|
||||
// For incomplete YAML, we may only have a parent token
|
||||
if (token) {
|
||||
@@ -238,12 +493,12 @@ function getExpressionCompletionItems(
|
||||
currentInput = stringToken.source || stringToken.value;
|
||||
}
|
||||
|
||||
const relCharOffset = getRelCharOffset(token.range, currentInput, pos);
|
||||
const expressionInput = (getExpressionInput(currentInput, relCharOffset) || "").trim();
|
||||
const cursorOffset = getOffsetInContent(token.range, currentInput, pos);
|
||||
const expressionInput = (getExpressionInput(currentInput, cursorOffset) || "").trim();
|
||||
|
||||
try {
|
||||
return completeExpression(expressionInput, context, [], validatorFunctions).map(item =>
|
||||
mapExpressionCompletionItem(item, currentInput[relCharOffset])
|
||||
mapExpressionCompletionItem(item, currentInput[cursorOffset])
|
||||
);
|
||||
} catch (e) {
|
||||
error(`Error while completing expression: '${(e as Error)?.message || "<no details>"}'`);
|
||||
@@ -253,7 +508,7 @@ function getExpressionCompletionItems(
|
||||
|
||||
function filterAndSortCompletionOptions(options: Value[], existingValues?: Set<string>) {
|
||||
options = options.filter(x => !existingValues?.has(x.label));
|
||||
options.sort((a, b) => a.label.localeCompare(b.label));
|
||||
options.sort((a, b) => (a.sortText ?? a.label).localeCompare(b.sortText ?? b.label));
|
||||
return options;
|
||||
}
|
||||
|
||||
@@ -274,3 +529,50 @@ function mapExpressionCompletionItem(item: ExpressionCompletionItem, charAfterPo
|
||||
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 {WorkflowContext} from "../context/workflow-context";
|
||||
import {Mode} from "./default";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {Mode} from "./default.js";
|
||||
|
||||
export type ContextProviderConfig = {
|
||||
getContext: (
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import {DescriptionDictionary} from "@actions/expressions";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {getWorkflowExpressionContext, Mode} from "./default.js";
|
||||
|
||||
describe("getWorkflowExpressionContext", () => {
|
||||
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 getWorkflowExpressionContext(["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 getWorkflowExpressionContext(["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 getWorkflowExpressionContext(
|
||||
["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 getWorkflowExpressionContext(["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 getWorkflowExpressionContext(["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 getWorkflowExpressionContext(["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 getWorkflowExpressionContext(["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 {Kind} from "@actions/expressions/data/expressiondata";
|
||||
import {WorkflowContext} from "../context/workflow-context";
|
||||
import {ContextProviderConfig} from "./config";
|
||||
import {getDescription, RootContext} from "./descriptions";
|
||||
import {getEnvContext} from "./env";
|
||||
import {getGithubContext} from "./github";
|
||||
import {getInputsContext} from "./inputs";
|
||||
import {getJobContext} from "./job";
|
||||
import {getJobsContext} from "./jobs";
|
||||
import {getMatrixContext} from "./matrix";
|
||||
import {getNeedsContext} from "./needs";
|
||||
import {getSecretsContext} from "./secrets";
|
||||
import {getStepsContext} from "./steps";
|
||||
import {getStrategyContext} from "./strategy";
|
||||
import {ActionContext, getActionInputs, getActionStepIdsBefore} from "../context/action-context.js";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {ContextProviderConfig} from "./config.js";
|
||||
import {getDescription, RootContext} from "./descriptions.js";
|
||||
import {getEnvContext} from "./env.js";
|
||||
import {getGithubContext} from "./github.js";
|
||||
import {getInputsContext} from "./inputs.js";
|
||||
import {getJobContext} from "./job.js";
|
||||
import {getJobsContext} from "./jobs.js";
|
||||
import {getMatrixContext} from "./matrix.js";
|
||||
import {getNeedsContext} from "./needs.js";
|
||||
import {getSecretsContext} from "./secrets.js";
|
||||
import {getStepsContext} from "./steps.js";
|
||||
|
||||
// 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
|
||||
@@ -24,23 +24,37 @@ export enum Mode {
|
||||
Hover
|
||||
}
|
||||
|
||||
export async function getContext(
|
||||
/**
|
||||
* Build expression context for workflow files (e.g., github.*, steps.*, needs.*)
|
||||
*/
|
||||
export async function getWorkflowExpressionContext(
|
||||
names: string[],
|
||||
config: ContextProviderConfig | undefined,
|
||||
workflowContext: WorkflowContext,
|
||||
workflowContext: WorkflowContext | undefined,
|
||||
mode: Mode
|
||||
): Promise<DescriptionDictionary> {
|
||||
const context = new DescriptionDictionary();
|
||||
|
||||
const filteredNames = filterContextNames(names, workflowContext);
|
||||
for (const contextName of filteredNames) {
|
||||
// All context names are valid - strategy and matrix are always available
|
||||
// (with default values when no strategy block is defined)
|
||||
for (const contextName of names) {
|
||||
let value = getDefaultContext(contextName, workflowContext, mode) || new DescriptionDictionary();
|
||||
if (value.kind === Kind.Null) {
|
||||
context.add(contextName, value);
|
||||
continue;
|
||||
}
|
||||
|
||||
value = (await config?.getContext(contextName, value, workflowContext, mode)) || value;
|
||||
const remoteValue = workflowContext
|
||||
? await config?.getContext(contextName, value, workflowContext, mode)
|
||||
: undefined;
|
||||
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));
|
||||
}
|
||||
@@ -48,73 +62,198 @@ export async function getContext(
|
||||
return context;
|
||||
}
|
||||
|
||||
function getDefaultContext(name: string, workflowContext: WorkflowContext, mode: Mode): ContextValue | undefined {
|
||||
/**
|
||||
* Maps context name to its provider (e.g., "steps" -> getStepsContext)
|
||||
*/
|
||||
function getDefaultContext(
|
||||
name: string,
|
||||
workflowContext: WorkflowContext | undefined,
|
||||
mode: Mode
|
||||
): ContextValue | undefined {
|
||||
switch (name) {
|
||||
case "env":
|
||||
return getEnvContext(workflowContext);
|
||||
return workflowContext ? getEnvContext(workflowContext) : new DescriptionDictionary();
|
||||
|
||||
case "github":
|
||||
return getGithubContext(workflowContext, mode);
|
||||
|
||||
case "inputs":
|
||||
return getInputsContext(workflowContext);
|
||||
return workflowContext ? getInputsContext(workflowContext) : new DescriptionDictionary();
|
||||
|
||||
case "reusableWorkflowJob":
|
||||
case "job":
|
||||
return getJobContext(workflowContext);
|
||||
return workflowContext ? getJobContext(workflowContext) : new DescriptionDictionary();
|
||||
|
||||
case "jobs":
|
||||
return getJobsContext(workflowContext);
|
||||
return workflowContext ? getJobsContext(workflowContext) : new DescriptionDictionary();
|
||||
|
||||
case "matrix":
|
||||
return getMatrixContext(workflowContext, mode);
|
||||
return workflowContext ? getMatrixContext(workflowContext, mode) : new DescriptionDictionary();
|
||||
|
||||
case "needs":
|
||||
return getNeedsContext(workflowContext);
|
||||
return workflowContext ? getNeedsContext(workflowContext) : new DescriptionDictionary();
|
||||
|
||||
case "runner":
|
||||
return objectToDictionary({
|
||||
os: "Linux",
|
||||
arch: "X64",
|
||||
name: "GitHub Actions 2",
|
||||
tool_cache: "/opt/hostedtoolcache",
|
||||
temp: "/home/runner/work/_temp"
|
||||
});
|
||||
return getRunnerContext();
|
||||
|
||||
case "secrets":
|
||||
return getSecretsContext(workflowContext, mode);
|
||||
return workflowContext ? getSecretsContext(workflowContext, mode) : new DescriptionDictionary();
|
||||
|
||||
case "steps":
|
||||
return getStepsContext(workflowContext);
|
||||
return workflowContext ? getStepsContext(workflowContext) : new DescriptionDictionary();
|
||||
|
||||
case "strategy":
|
||||
return getStrategyContext(workflowContext);
|
||||
return getStrategyContext();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function objectToDictionary(object: {[key: string]: string}): DescriptionDictionary {
|
||||
const dictionary = new DescriptionDictionary();
|
||||
/**
|
||||
* Returns the strategy context with default values (fail-fast, job-index, etc.)
|
||||
*/
|
||||
function getStrategyContext(): DescriptionDictionary {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#strategy-context
|
||||
return new DescriptionDictionary(
|
||||
{key: "fail-fast", value: new data.BooleanData(true), description: getDescription("strategy", "fail-fast")},
|
||||
{key: "job-index", value: new data.NumberData(0), description: getDescription("strategy", "job-index")},
|
||||
{key: "job-total", value: new data.NumberData(1), description: getDescription("strategy", "job-total")},
|
||||
{key: "max-parallel", value: new data.NumberData(1), description: getDescription("strategy", "max-parallel")}
|
||||
);
|
||||
}
|
||||
|
||||
for (const key in object) {
|
||||
dictionary.add(key, new data.StringData(object[key]));
|
||||
/**
|
||||
* Returns the runner context with environment info (arch, os, temp, workspace, etc.)
|
||||
*/
|
||||
function getRunnerContext(): DescriptionDictionary {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#runner-context
|
||||
return new DescriptionDictionary(
|
||||
{key: "arch", value: new data.StringData("X64"), description: getDescription("runner", "arch")},
|
||||
{key: "debug", value: new data.StringData("1"), description: getDescription("runner", "debug")},
|
||||
{
|
||||
key: "environment",
|
||||
value: new data.StringData("github-hosted"),
|
||||
description: getDescription("runner", "environment")
|
||||
},
|
||||
{key: "name", value: new data.StringData("GitHub Actions 2"), description: getDescription("runner", "name")},
|
||||
{key: "os", value: new data.StringData("Linux"), description: getDescription("runner", "os")},
|
||||
{key: "temp", value: new data.StringData("/home/runner/work/_temp"), description: getDescription("runner", "temp")},
|
||||
{
|
||||
key: "tool_cache",
|
||||
value: new data.StringData("/opt/hostedtoolcache"),
|
||||
description: getDescription("runner", "tool_cache")
|
||||
},
|
||||
{
|
||||
key: "workspace",
|
||||
value: new data.StringData("/home/runner/work/repo"),
|
||||
description: getDescription("runner", "workspace")
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get context for expression completion in action.yml files.
|
||||
* Actions have a more limited set of contexts available compared to workflows.
|
||||
*/
|
||||
export function getActionExpressionContext(
|
||||
names: string[],
|
||||
config: ContextProviderConfig | undefined,
|
||||
actionContext: ActionContext | undefined,
|
||||
mode: Mode
|
||||
): DescriptionDictionary {
|
||||
const context = new DescriptionDictionary();
|
||||
|
||||
for (const contextName of names) {
|
||||
const value = getDefaultActionContext(contextName, actionContext, mode);
|
||||
if (value) {
|
||||
context.add(contextName, value, getDescription(RootContext, contextName));
|
||||
}
|
||||
}
|
||||
|
||||
return dictionary;
|
||||
return context;
|
||||
}
|
||||
|
||||
function filterContextNames(contextNames: string[], workflowContext: WorkflowContext): string[] {
|
||||
return contextNames.filter(name => {
|
||||
switch (name) {
|
||||
case "matrix":
|
||||
case "strategy":
|
||||
return hasStrategy(workflowContext);
|
||||
/**
|
||||
* Maps context name to its provider for action.yml files (e.g., "inputs" -> getActionInputsContext)
|
||||
*/
|
||||
function getDefaultActionContext(
|
||||
name: string,
|
||||
actionContext: ActionContext | undefined,
|
||||
mode: Mode
|
||||
): ContextValue | undefined {
|
||||
switch (name) {
|
||||
case "inputs":
|
||||
// Return empty dictionary if no context - still allows completion, just without specific input names
|
||||
return actionContext ? getActionInputsContext(actionContext) : new DescriptionDictionary();
|
||||
|
||||
case "steps":
|
||||
// Return empty dictionary if no context - still allows completion, just without specific step IDs
|
||||
return actionContext ? getActionStepsContext(actionContext) : new DescriptionDictionary();
|
||||
|
||||
case "github":
|
||||
// Use the same github context but without workflow-specific event info
|
||||
// Actions inherit the event context from the calling workflow at runtime
|
||||
return getGithubContext(undefined, mode);
|
||||
|
||||
case "runner":
|
||||
return getRunnerContext();
|
||||
|
||||
case "env":
|
||||
// Actions can access env but we don't have runtime values
|
||||
return new DescriptionDictionary();
|
||||
|
||||
case "job": {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
|
||||
const jobContext = new DescriptionDictionary();
|
||||
jobContext.add("status", new data.StringData(""), getDescription("job", "status"));
|
||||
jobContext.add("check_run_id", new data.StringData(""), getDescription("job", "check_run_id"));
|
||||
const containerContext = new DescriptionDictionary();
|
||||
containerContext.add("id", new data.StringData(""), getDescription("job", "container.id"));
|
||||
containerContext.add("network", new data.StringData(""), getDescription("job", "container.network"));
|
||||
jobContext.add("container", containerContext, getDescription("job", "container"));
|
||||
jobContext.add("services", new DescriptionDictionary(), getDescription("job", "services"));
|
||||
return jobContext;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
case "strategy":
|
||||
return getStrategyContext();
|
||||
|
||||
case "matrix":
|
||||
// Actions can access matrix context at runtime
|
||||
return new DescriptionDictionary();
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasStrategy(workflowContext: WorkflowContext): boolean {
|
||||
return workflowContext.job?.strategy !== undefined || workflowContext.reusableWorkflowJob?.strategy !== undefined;
|
||||
/**
|
||||
* Get inputs context for action files based on defined inputs
|
||||
*/
|
||||
function getActionInputsContext(actionContext: ActionContext): DescriptionDictionary {
|
||||
const dict = new DescriptionDictionary();
|
||||
const inputs = getActionInputs(actionContext.template);
|
||||
|
||||
for (const input of inputs) {
|
||||
dict.add(input.id, new data.StringData(""), input.description || "");
|
||||
}
|
||||
|
||||
return dict;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get steps context for composite action files based on step IDs
|
||||
*/
|
||||
function getActionStepsContext(actionContext: ActionContext): DescriptionDictionary {
|
||||
const dict = new DescriptionDictionary();
|
||||
const stepIds = getActionStepIdsBefore(actionContext);
|
||||
|
||||
for (const stepId of stepIds) {
|
||||
const stepDict = new DescriptionDictionary();
|
||||
stepDict.add("outputs", new DescriptionDictionary(), getDescription("steps", "outputs"));
|
||||
stepDict.add("outcome", new data.StringData("success"), getDescription("steps", "outcome"));
|
||||
stepDict.add("conclusion", new data.StringData("success"), getDescription("steps", "conclusion"));
|
||||
dict.add(stepId, stepDict, `Step: ${stepId}`);
|
||||
}
|
||||
|
||||
return dict;
|
||||
}
|
||||
|
||||
@@ -198,6 +198,35 @@
|
||||
"description": "The default working directory on the runner for steps, and the default location of your repository when using the [`checkout`](https://github.com/actions/checkout) action."
|
||||
}
|
||||
},
|
||||
"job": {
|
||||
"container": {
|
||||
"description": "Information about the job's container. For more information about containers, see \"[Running jobs in a container](https://docs.github.com/actions/using-jobs/running-jobs-in-a-container).\""
|
||||
},
|
||||
"container.id": {
|
||||
"description": "The ID of the container."
|
||||
},
|
||||
"container.network": {
|
||||
"description": "The ID of the container network. The runner creates the network used by all containers in a job."
|
||||
},
|
||||
"services": {
|
||||
"description": "The service containers created for a job. For more information about service containers, see \"[Using service containers](https://docs.github.com/actions/using-containerized-services/about-service-containers).\""
|
||||
},
|
||||
"services.<service_id>.id": {
|
||||
"description": "The ID of the service container."
|
||||
},
|
||||
"services.<service_id>.network": {
|
||||
"description": "The ID of the service container network. The runner creates the network used by all containers in a job."
|
||||
},
|
||||
"services.<service_id>.ports": {
|
||||
"description": "The exposed ports of the service container."
|
||||
},
|
||||
"status": {
|
||||
"description": "The current status of the job. Possible values are `success`, `failure`, or `cancelled`."
|
||||
},
|
||||
"check_run_id": {
|
||||
"description": "The unique identifier of the check run for this job."
|
||||
}
|
||||
},
|
||||
"secrets": {
|
||||
"GITHUB_TOKEN": {
|
||||
"description": "Automatically created token for each workflow run. For more information, see \"[Automatic token authentication](https://docs.github.com/actions/security-guides/automatic-token-authentication).\""
|
||||
@@ -239,7 +268,13 @@
|
||||
"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": {
|
||||
"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."
|
||||
"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."
|
||||
},
|
||||
"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": {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import descriptions from "./descriptions.json";
|
||||
import descriptions from "./descriptions.min.json";
|
||||
|
||||
export const RootContext = "root";
|
||||
const FunctionContext = "functions";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
import {isScalar, isString} from "@actions/workflow-parser";
|
||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||
import {WorkflowContext} from "../context/workflow-context";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
|
||||
export function getEnvContext(workflowContext: WorkflowContext): DescriptionDictionary {
|
||||
const d = new DescriptionDictionary();
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
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 webhookObjects from "./objects.json";
|
||||
import webhooks from "./webhooks.json";
|
||||
import webhookObjects from "./objects.min.json";
|
||||
import webhooks from "./webhooks.min.json";
|
||||
|
||||
import schedule from "./schedule.json";
|
||||
import workflow_call from "./workflow_call.json";
|
||||
import schedule from "./schedule.min.json";
|
||||
import workflow_call from "./workflow_call.min.json";
|
||||
|
||||
const customEventPayloads: {[name: string]: unknown} = {
|
||||
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 {testGetWorkflowContext} from "../test-utils/test-workflow-context";
|
||||
import {Mode} from "./default";
|
||||
import {getGithubContext} from "./github";
|
||||
import {DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
|
||||
import {testGetWorkflowContext} from "../test-utils/test-workflow-context.js";
|
||||
import {Mode} from "./default.js";
|
||||
import {getGithubContext} from "./github.js";
|
||||
|
||||
describe("github context", () => {
|
||||
it("single event", async () => {
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import {data, DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
|
||||
import {ExpressionData} from "@actions/expressions/data/expressiondata";
|
||||
import {TypesFilterConfig} from "@actions/workflow-parser/model/workflow-template";
|
||||
import {WorkflowContext} from "../context/workflow-context";
|
||||
import {Mode} from "./default";
|
||||
import {getDescription} from "./descriptions";
|
||||
import {getEventPayload, getSupportedEventTypes} from "./events/eventPayloads";
|
||||
import {getInputsContext} from "./inputs";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {Mode} from "./default.js";
|
||||
import {getDescription} from "./descriptions.js";
|
||||
import {getEventPayload, getSupportedEventTypes} from "./events/eventPayloads.js";
|
||||
import {getInputsContext} from "./inputs.js";
|
||||
|
||||
export function getGithubContext(workflowContext: WorkflowContext, mode: Mode): DescriptionDictionary {
|
||||
/**
|
||||
* Returns the github context with properties like actor, ref, sha, event, etc.
|
||||
*/
|
||||
export function getGithubContext(workflowContext: WorkflowContext | undefined, mode: Mode): DescriptionDictionary {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
|
||||
const keys = [
|
||||
"action",
|
||||
@@ -73,7 +76,10 @@ export function getGithubContext(workflowContext: WorkflowContext, mode: Mode):
|
||||
);
|
||||
}
|
||||
|
||||
function getEventContext(workflowContext: WorkflowContext, mode: Mode): ExpressionData {
|
||||
/**
|
||||
* Builds the github.event context based on workflow trigger configuration.
|
||||
*/
|
||||
function getEventContext(workflowContext: WorkflowContext | undefined, mode: Mode): ExpressionData {
|
||||
const d = new DescriptionDictionary();
|
||||
const eventsConfig = workflowContext?.template?.events;
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
import {InputConfig} from "@actions/workflow-parser/model/workflow-template";
|
||||
import {WorkflowContext} from "../context/workflow-context";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
|
||||
export function getInputsContext(workflowContext: WorkflowContext): DescriptionDictionary {
|
||||
const d = new DescriptionDictionary();
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import {DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
|
||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
|
||||
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {getJobContext} from "./job.js";
|
||||
|
||||
function stringToToken(value: string): StringToken {
|
||||
return new StringToken(undefined, undefined, value, undefined);
|
||||
}
|
||||
|
||||
describe("job context", () => {
|
||||
it("returns empty context when no job", () => {
|
||||
const workflowContext = {} as WorkflowContext;
|
||||
const context = getJobContext(workflowContext);
|
||||
|
||||
// When there's no job, context is empty
|
||||
expect(context.pairs().length).toBe(0);
|
||||
});
|
||||
|
||||
it("returns status and check_run_id when job has no container or services", () => {
|
||||
const workflowContext = {job: {}} as WorkflowContext;
|
||||
const context = getJobContext(workflowContext);
|
||||
|
||||
expect(context.get("status")).toBeDefined();
|
||||
expect(context.get("check_run_id")).toBeDefined();
|
||||
expect(context.get("container")).toBeUndefined();
|
||||
expect(context.get("services")).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("container context", () => {
|
||||
it("includes container with id and network when container is defined", () => {
|
||||
const containerToken = new MappingToken(undefined, undefined, undefined);
|
||||
containerToken.add(stringToToken("image"), stringToToken("node:18"));
|
||||
|
||||
const workflowContext = {
|
||||
job: {container: containerToken}
|
||||
} as unknown as WorkflowContext;
|
||||
|
||||
const context = getJobContext(workflowContext);
|
||||
const container = context.get("container");
|
||||
|
||||
expect(container).toBeDefined();
|
||||
if (!container) return;
|
||||
expect(isDescriptionDictionary(container)).toBe(true);
|
||||
|
||||
const containerDict = container as DescriptionDictionary;
|
||||
expect(containerDict.get("id")).toBeDefined();
|
||||
expect(containerDict.get("network")).toBeDefined();
|
||||
expect(containerDict.get("ports")).toBeUndefined(); // job container has no ports
|
||||
});
|
||||
|
||||
it("container has descriptions", () => {
|
||||
const containerToken = new MappingToken(undefined, undefined, undefined);
|
||||
containerToken.add(stringToToken("image"), stringToToken("node:18"));
|
||||
|
||||
const workflowContext = {
|
||||
job: {container: containerToken}
|
||||
} as unknown as WorkflowContext;
|
||||
|
||||
const context = getJobContext(workflowContext);
|
||||
|
||||
const containerDescription = context.getDescription("container");
|
||||
expect(containerDescription).toBeDefined();
|
||||
|
||||
const containerDict = context.get("container") as DescriptionDictionary;
|
||||
expect(containerDict.getDescription("id")).toBeDefined();
|
||||
expect(containerDict.getDescription("network")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("services context", () => {
|
||||
it("includes services with id, network, and ports", () => {
|
||||
const redisToken = new MappingToken(undefined, undefined, undefined);
|
||||
redisToken.add(stringToToken("image"), stringToToken("redis:latest"));
|
||||
|
||||
const servicesToken = new MappingToken(undefined, undefined, undefined);
|
||||
servicesToken.add(stringToToken("redis"), redisToken);
|
||||
|
||||
const workflowContext = {
|
||||
job: {services: servicesToken}
|
||||
} as unknown as WorkflowContext;
|
||||
|
||||
const context = getJobContext(workflowContext);
|
||||
const services = context.get("services");
|
||||
|
||||
expect(services).toBeDefined();
|
||||
if (!services) return;
|
||||
expect(isDescriptionDictionary(services)).toBe(true);
|
||||
|
||||
const servicesDict = services as DescriptionDictionary;
|
||||
const redis = servicesDict.get("redis");
|
||||
expect(redis).toBeDefined();
|
||||
if (!redis) return;
|
||||
expect(isDescriptionDictionary(redis)).toBe(true);
|
||||
|
||||
const redisDict = redis as DescriptionDictionary;
|
||||
expect(redisDict.get("id")).toBeDefined();
|
||||
expect(redisDict.get("network")).toBeDefined();
|
||||
expect(redisDict.get("ports")).toBeDefined(); // services have ports
|
||||
});
|
||||
|
||||
it("parses service ports in host:container format", () => {
|
||||
const portsSequence = new SequenceToken(undefined, undefined, undefined);
|
||||
portsSequence.add(stringToToken("6379:6379"));
|
||||
portsSequence.add(stringToToken("8080:80"));
|
||||
|
||||
const redisToken = new MappingToken(undefined, undefined, undefined);
|
||||
redisToken.add(stringToToken("image"), stringToToken("redis:latest"));
|
||||
redisToken.add(stringToToken("ports"), portsSequence);
|
||||
|
||||
const servicesToken = new MappingToken(undefined, undefined, undefined);
|
||||
servicesToken.add(stringToToken("redis"), redisToken);
|
||||
|
||||
const workflowContext = {
|
||||
job: {services: servicesToken}
|
||||
} as unknown as WorkflowContext;
|
||||
|
||||
const context = getJobContext(workflowContext);
|
||||
const services = context.get("services") as DescriptionDictionary;
|
||||
const redis = services.get("redis") as DescriptionDictionary;
|
||||
const ports = redis.get("ports") as DescriptionDictionary;
|
||||
|
||||
// Container ports should be the keys (second part of host:container)
|
||||
expect(ports.get("6379")).toBeDefined();
|
||||
expect(ports.get("80")).toBeDefined();
|
||||
});
|
||||
|
||||
it("parses service ports in single port format", () => {
|
||||
const portsSequence = new SequenceToken(undefined, undefined, undefined);
|
||||
portsSequence.add(stringToToken("6379"));
|
||||
|
||||
const redisToken = new MappingToken(undefined, undefined, undefined);
|
||||
redisToken.add(stringToToken("image"), stringToToken("redis:latest"));
|
||||
redisToken.add(stringToToken("ports"), portsSequence);
|
||||
|
||||
const servicesToken = new MappingToken(undefined, undefined, undefined);
|
||||
servicesToken.add(stringToToken("redis"), redisToken);
|
||||
|
||||
const workflowContext = {
|
||||
job: {services: servicesToken}
|
||||
} as unknown as WorkflowContext;
|
||||
|
||||
const context = getJobContext(workflowContext);
|
||||
const services = context.get("services") as DescriptionDictionary;
|
||||
const redis = services.get("redis") as DescriptionDictionary;
|
||||
const ports = redis.get("ports") as DescriptionDictionary;
|
||||
|
||||
// Single port format uses the port as the key
|
||||
expect(ports.get("6379")).toBeDefined();
|
||||
});
|
||||
|
||||
it("services have descriptions", () => {
|
||||
const redisToken = new MappingToken(undefined, undefined, undefined);
|
||||
redisToken.add(stringToToken("image"), stringToToken("redis:latest"));
|
||||
|
||||
const servicesToken = new MappingToken(undefined, undefined, undefined);
|
||||
servicesToken.add(stringToToken("redis"), redisToken);
|
||||
|
||||
const workflowContext = {
|
||||
job: {services: servicesToken}
|
||||
} as unknown as WorkflowContext;
|
||||
|
||||
const context = getJobContext(workflowContext);
|
||||
|
||||
const servicesDescription = context.getDescription("services");
|
||||
expect(servicesDescription).toBeDefined();
|
||||
|
||||
const services = context.get("services") as DescriptionDictionary;
|
||||
const redis = services.get("redis") as DescriptionDictionary;
|
||||
expect(redis.getDescription("id")).toBeDefined();
|
||||
expect(redis.getDescription("network")).toBeDefined();
|
||||
expect(redis.getDescription("ports")).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,12 @@
|
||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
import {isMapping, isSequence} from "@actions/workflow-parser";
|
||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||
import {WorkflowContext} from "../context/workflow-context";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {getDescription} from "./descriptions.js";
|
||||
|
||||
/**
|
||||
* Returns the job context with container, services, status, and check_run_id.
|
||||
*/
|
||||
export function getJobContext(workflowContext: WorkflowContext): DescriptionDictionary {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
|
||||
const jobContext = new DescriptionDictionary();
|
||||
@@ -15,7 +19,7 @@ export function getJobContext(workflowContext: WorkflowContext): DescriptionDict
|
||||
const jobContainer = job.container;
|
||||
if (jobContainer && isMapping(jobContainer)) {
|
||||
const containerContext = createContainerContext(jobContainer, false);
|
||||
jobContext.add("container", containerContext);
|
||||
jobContext.add("container", containerContext, getDescription("job", "container"));
|
||||
}
|
||||
|
||||
// Services
|
||||
@@ -29,39 +33,48 @@ export function getJobContext(workflowContext: WorkflowContext): DescriptionDict
|
||||
const serviceContext = createContainerContext(service.value, true);
|
||||
servicesContext.add(service.key.toString(), serviceContext);
|
||||
}
|
||||
jobContext.add("services", servicesContext);
|
||||
jobContext.add("services", servicesContext, getDescription("job", "services"));
|
||||
}
|
||||
|
||||
// Status
|
||||
jobContext.add("status", new data.Null());
|
||||
jobContext.add("status", new data.StringData(""), getDescription("job", "status"));
|
||||
|
||||
// Check run ID
|
||||
jobContext.add("check_run_id", new data.StringData(""), getDescription("job", "check_run_id"));
|
||||
|
||||
return jobContext;
|
||||
}
|
||||
|
||||
function createContainerContext(container: MappingToken, isServices: boolean): data.Dictionary {
|
||||
const containerContext = new data.Dictionary();
|
||||
for (const {key, value} of container) {
|
||||
if (isSequence(value)) {
|
||||
// service ports are the only thing that is part of the job context
|
||||
if (key.toString() !== "ports") {
|
||||
continue;
|
||||
}
|
||||
const ports = new data.Dictionary();
|
||||
for (const item of value) {
|
||||
// We can determine the context mapping fully only if the port is defined
|
||||
// as a mapping (i.e. <port1>:<port2>), single ports are assigned randomly
|
||||
const portParts = item.toString().split(":");
|
||||
if (isServices && portParts.length === 2) {
|
||||
ports.add(portParts[1], new data.StringData(portParts[0]));
|
||||
} else {
|
||||
// If the port isn't a mapping, just use null
|
||||
ports.add(portParts[0], new data.Null());
|
||||
function createContainerContext(container: MappingToken, isServices: boolean): DescriptionDictionary {
|
||||
const containerContext = new DescriptionDictionary();
|
||||
|
||||
// id and network are always available
|
||||
containerContext.add(
|
||||
"id",
|
||||
new data.StringData(""),
|
||||
getDescription("job", isServices ? "services.<service_id>.id" : "container.id")
|
||||
);
|
||||
containerContext.add(
|
||||
"network",
|
||||
new data.StringData(""),
|
||||
getDescription("job", isServices ? "services.<service_id>.network" : "container.network")
|
||||
);
|
||||
|
||||
// ports are only available for service containers (not job container)
|
||||
if (isServices) {
|
||||
const ports = new DescriptionDictionary();
|
||||
for (const {key, value} of container) {
|
||||
if (key.toString() === "ports" && isSequence(value)) {
|
||||
for (const item of value) {
|
||||
const portParts = item.toString().split(":");
|
||||
// The key is the container port (second part if host:container format)
|
||||
const containerPort = portParts.length === 2 ? portParts[1] : portParts[0];
|
||||
ports.add(containerPort, new data.StringData(""));
|
||||
}
|
||||
}
|
||||
containerContext.add(key.toString(), ports);
|
||||
}
|
||||
containerContext.add("ports", ports, getDescription("job", "services.<service_id>.ports"));
|
||||
}
|
||||
containerContext.add("id", new data.Null());
|
||||
containerContext.add("network", new data.Null());
|
||||
|
||||
return containerContext;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
import {StringData} from "@actions/expressions/data/string";
|
||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||
import {WorkflowContext} from "../context/workflow-context";
|
||||
import {getDescription} from "./descriptions";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {getDescription} from "./descriptions.js";
|
||||
|
||||
export function getJobsContext(workflowContext: WorkflowContext): DescriptionDictionary {
|
||||
// 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 {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
import {WorkflowContext} from "../context/workflow-context";
|
||||
import {Mode} from "./default";
|
||||
import {getMatrixContext} from "./matrix";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {Mode} from "./default.js";
|
||||
import {getMatrixContext} from "./matrix.js";
|
||||
|
||||
type MatrixMap = {
|
||||
[key: string]: Array<string> | Array<{[key: string]: string}>;
|
||||
@@ -64,7 +64,7 @@ describe("matrix context", () => {
|
||||
expect(workflowContext.job).toBeUndefined();
|
||||
|
||||
const context = getMatrixContext(workflowContext, Mode.Validation);
|
||||
expect(context).toEqual(new DescriptionDictionary());
|
||||
expect(context).toEqual(new data.Null());
|
||||
});
|
||||
|
||||
it("strategy not defined", () => {
|
||||
@@ -73,7 +73,7 @@ describe("matrix context", () => {
|
||||
expect(workflowContext.job!.strategy).toBeUndefined();
|
||||
|
||||
const context = getMatrixContext(workflowContext, Mode.Validation);
|
||||
expect(context).toEqual(new DescriptionDictionary());
|
||||
expect(context).toEqual(new data.Null());
|
||||
});
|
||||
|
||||
it("strategy is not a mapping token", () => {
|
||||
@@ -81,7 +81,7 @@ describe("matrix context", () => {
|
||||
expect(workflowContext.job!.strategy).toBeDefined();
|
||||
|
||||
const context = getMatrixContext(workflowContext, Mode.Validation);
|
||||
expect(context).toEqual(new DescriptionDictionary());
|
||||
expect(context).toEqual(new data.Null());
|
||||
});
|
||||
|
||||
it("matrix is not defined", () => {
|
||||
|
||||
@@ -3,14 +3,15 @@ import {isBasicExpression, isMapping, isSequence, isString} from "@actions/workf
|
||||
import {KeyValuePair} from "@actions/workflow-parser/templates/tokens/key-value-pair";
|
||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
|
||||
import {WorkflowContext} from "../context/workflow-context";
|
||||
import {ContextValue, Mode} from "./default";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {ContextValue, Mode} from "./default.js";
|
||||
|
||||
export function getMatrixContext(workflowContext: WorkflowContext, mode: Mode): ContextValue {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#matrix-context
|
||||
const strategy = workflowContext.job?.strategy ?? workflowContext.reusableWorkflowJob?.strategy;
|
||||
if (!strategy || !isMapping(strategy)) {
|
||||
return new DescriptionDictionary();
|
||||
// No strategy defined - matrix is null at runtime (not empty object)
|
||||
return new data.Null();
|
||||
}
|
||||
|
||||
const matrix = strategy.find("matrix");
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {DescriptionDictionary} from "@actions/expressions";
|
||||
import {StringData} from "@actions/expressions/data/string";
|
||||
import {WorkflowContext} from "../context/workflow-context";
|
||||
import {testGetWorkflowContext} from "../test-utils/test-workflow-context";
|
||||
import {getNeedsContext} from "./needs";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {testGetWorkflowContext} from "../test-utils/test-workflow-context.js";
|
||||
import {getNeedsContext} from "./needs.js";
|
||||
|
||||
describe("needs context", () => {
|
||||
describe("invalid workflow context", () => {
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
on: push
|
||||
jobs:
|
||||
a:
|
||||
uses: ./reusable-workflow-with-outputs.yaml
|
||||
uses: ./.github/workflows/reusable-workflow-with-outputs.yaml
|
||||
|
||||
b:
|
||||
needs: [a]
|
||||
|
||||
@@ -3,7 +3,7 @@ import {isMapping, isScalar, isString} from "@actions/workflow-parser";
|
||||
import {isJob} from "@actions/workflow-parser/model/type-guards";
|
||||
import {WorkflowJob} from "@actions/workflow-parser/model/workflow-template";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
import {WorkflowContext} from "../context/workflow-context";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
|
||||
export function getNeedsContext(workflowContext: WorkflowContext): DescriptionDictionary {
|
||||
const d = new DescriptionDictionary();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
import {StringData} from "@actions/expressions/data/string";
|
||||
import {WorkflowContext} from "../context/workflow-context";
|
||||
import {Mode} from "./default";
|
||||
import {getDescription} from "./descriptions";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {Mode} from "./default.js";
|
||||
import {getDescription} from "./descriptions.js";
|
||||
|
||||
export function getSecretsContext(workflowContext: WorkflowContext, mode: Mode): DescriptionDictionary {
|
||||
const d = new DescriptionDictionary({
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import {DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {getStepsContext} from "./steps.js";
|
||||
|
||||
function createWorkflowContext(stepIds: string[], currentStepId?: string): WorkflowContext {
|
||||
return {
|
||||
job: {
|
||||
steps: stepIds.map(id => ({id}))
|
||||
},
|
||||
step: currentStepId ? {id: currentStepId} : undefined
|
||||
} as WorkflowContext;
|
||||
}
|
||||
|
||||
describe("steps context", () => {
|
||||
it("returns empty dictionary when no job", () => {
|
||||
const workflowContext = {} as WorkflowContext;
|
||||
const context = getStepsContext(workflowContext);
|
||||
expect(context.pairs().length).toBe(0);
|
||||
});
|
||||
|
||||
it("returns empty dictionary when no steps", () => {
|
||||
const workflowContext = {job: {}} as WorkflowContext;
|
||||
const context = getStepsContext(workflowContext);
|
||||
expect(context.pairs().length).toBe(0);
|
||||
});
|
||||
|
||||
it("includes steps with user-defined ids", () => {
|
||||
const workflowContext = createWorkflowContext(["step-a", "step-b"]);
|
||||
const context = getStepsContext(workflowContext);
|
||||
|
||||
expect(context.get("step-a")).toBeDefined();
|
||||
expect(context.get("step-b")).toBeDefined();
|
||||
});
|
||||
|
||||
it("excludes generated step ids (starting with __)", () => {
|
||||
const workflowContext = createWorkflowContext(["step-a", "__generated"]);
|
||||
const context = getStepsContext(workflowContext);
|
||||
|
||||
expect(context.get("step-a")).toBeDefined();
|
||||
expect(context.get("__generated")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("excludes current step and later steps", () => {
|
||||
const workflowContext = createWorkflowContext(["step-a", "step-b", "step-c"], "step-b");
|
||||
const context = getStepsContext(workflowContext);
|
||||
|
||||
expect(context.get("step-a")).toBeDefined();
|
||||
expect(context.get("step-b")).toBeUndefined();
|
||||
expect(context.get("step-c")).toBeUndefined();
|
||||
});
|
||||
|
||||
describe("step outputs", () => {
|
||||
it("outputs is a dictionary, not null", () => {
|
||||
const workflowContext = createWorkflowContext(["step-a"]);
|
||||
const context = getStepsContext(workflowContext);
|
||||
|
||||
const stepContext = context.get("step-a");
|
||||
expect(stepContext).toBeDefined();
|
||||
expect(isDescriptionDictionary(stepContext!)).toBe(true);
|
||||
|
||||
const outputs = (stepContext as DescriptionDictionary).get("outputs");
|
||||
expect(outputs).toBeDefined();
|
||||
expect(isDescriptionDictionary(outputs!)).toBe(true);
|
||||
});
|
||||
|
||||
it("outputs is marked incomplete to allow dynamic outputs", () => {
|
||||
const workflowContext = createWorkflowContext(["step-a"]);
|
||||
const context = getStepsContext(workflowContext);
|
||||
|
||||
const stepContext = context.get("step-a") as DescriptionDictionary;
|
||||
const outputs = stepContext.get("outputs") as DescriptionDictionary;
|
||||
|
||||
// Outputs should be incomplete since we can't know what outputs a step will produce
|
||||
expect(outputs.complete).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
import {Step} from "@actions/workflow-parser/model/workflow-template";
|
||||
import {WorkflowContext} from "../context/workflow-context";
|
||||
import {getDescription} from "./descriptions";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {getDescription} from "./descriptions.js";
|
||||
|
||||
export function getStepsContext(workflowContext: WorkflowContext): DescriptionDictionary {
|
||||
const d = new DescriptionDictionary();
|
||||
@@ -31,7 +31,10 @@ function stepContext(): DescriptionDictionary {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#steps-context
|
||||
const d = new DescriptionDictionary();
|
||||
|
||||
d.add("outputs", new data.Null(), getDescription("steps", "outputs"));
|
||||
// Step outputs are dynamic - actions can generate outputs based on their inputs
|
||||
const outputs = new DescriptionDictionary();
|
||||
outputs.complete = false;
|
||||
d.add("outputs", outputs, getDescription("steps", "outputs"));
|
||||
|
||||
// Can be "success", "failure", "cancelled", or "skipped"
|
||||
d.add("conclusion", new data.Null(), getDescription("steps", "conclusion"));
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||
import {isMapping, isScalar, isString} from "@actions/workflow-parser";
|
||||
import {WorkflowContext} from "../context/workflow-context";
|
||||
import {scalarToData} from "../utils/scalar-to-data";
|
||||
|
||||
export function getStrategyContext(workflowContext: WorkflowContext): DescriptionDictionary {
|
||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#strategy-context
|
||||
const keys = ["fail-fast", "job-index", "job-total", "max-parallel"];
|
||||
|
||||
const strategy = workflowContext.job?.strategy ?? workflowContext.reusableWorkflowJob?.strategy;
|
||||
if (!strategy || !isMapping(strategy)) {
|
||||
return new DescriptionDictionary(
|
||||
...keys.map(key => {
|
||||
return {key, value: new data.Null()};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const strategyContext = new DescriptionDictionary();
|
||||
for (const pair of strategy) {
|
||||
if (!isString(pair.key)) {
|
||||
continue;
|
||||
}
|
||||
if (!keys.includes(pair.key.value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const value = isScalar(pair.value) ? scalarToData(pair.value) : new data.Null();
|
||||
strategyContext.add(pair.key.value, value);
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
if (!strategyContext.get(key)) {
|
||||
strategyContext.add(key, new data.Null());
|
||||
}
|
||||
}
|
||||
|
||||
return strategyContext;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import {isMapping} from "@actions/workflow-parser";
|
||||
import {ActionInputDefinition, ActionTemplate} from "@actions/workflow-parser/actions/action-template";
|
||||
import {Step} from "@actions/workflow-parser/model/workflow-template";
|
||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
|
||||
/**
|
||||
* Context information for an action.yml file, used to provide
|
||||
* expression completion with action-specific values.
|
||||
*/
|
||||
export interface ActionContext {
|
||||
uri: string;
|
||||
|
||||
/** The converted action template */
|
||||
template: ActionTemplate | undefined;
|
||||
|
||||
/** If the context is for a position within a composite step, this will be the step */
|
||||
step?: Step;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build context from a converted action template and token path.
|
||||
* Similar to getWorkflowContext but for action files.
|
||||
*/
|
||||
export function getActionContext(
|
||||
uri: string,
|
||||
template: ActionTemplate | undefined,
|
||||
tokenPath: TemplateToken[]
|
||||
): ActionContext {
|
||||
const context: ActionContext = {uri, template};
|
||||
if (!template) {
|
||||
return context;
|
||||
}
|
||||
|
||||
// Only composite actions have steps
|
||||
if (template.runs?.using !== "composite") {
|
||||
return context;
|
||||
}
|
||||
|
||||
const compositeRuns = template.runs;
|
||||
if (!compositeRuns.steps?.length) {
|
||||
return context;
|
||||
}
|
||||
|
||||
// Find the current step from the token path
|
||||
let stepsSequence: SequenceToken | undefined;
|
||||
let stepToken: MappingToken | undefined;
|
||||
|
||||
for (const token of tokenPath) {
|
||||
const defKey = token.definition?.key;
|
||||
if (defKey === "composite-steps" && token instanceof SequenceToken) {
|
||||
stepsSequence = token;
|
||||
} else if ((defKey === "run-step" || defKey === "uses-step") && isMapping(token)) {
|
||||
stepToken = token;
|
||||
}
|
||||
}
|
||||
|
||||
if (stepsSequence && stepToken) {
|
||||
context.step = findStep(compositeRuns.steps, stepsSequence, stepToken);
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the Step that corresponds to the given step token.
|
||||
*/
|
||||
function findStep(steps: Step[], stepsSequence: SequenceToken, stepToken: MappingToken): Step | undefined {
|
||||
// Find the step by matching index in the sequence
|
||||
let stepIndex = -1;
|
||||
for (let i = 0; i < stepsSequence.count; i++) {
|
||||
if (stepsSequence.get(i) === stepToken) {
|
||||
stepIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (stepIndex === -1 || stepIndex >= steps.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return steps[stepIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get input definitions from the action template.
|
||||
*/
|
||||
export function getActionInputs(template: ActionTemplate | undefined): ActionInputDefinition[] {
|
||||
return template?.inputs ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get step IDs from composite action steps that appear before the current step.
|
||||
* This is used for `steps.<id>` context completion - you can only reference
|
||||
* steps that have already run.
|
||||
*/
|
||||
export function getActionStepIdsBefore(context: ActionContext): string[] {
|
||||
const template = context.template;
|
||||
if (!template || template.runs?.using !== "composite") {
|
||||
return [];
|
||||
}
|
||||
|
||||
const compositeRuns = template.runs;
|
||||
const steps = compositeRuns.steps ?? [];
|
||||
const currentStep = context.step;
|
||||
|
||||
const stepIds: string[] = [];
|
||||
for (const step of steps) {
|
||||
// Stop when we reach the current step
|
||||
if (currentStep && step === currentStep) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Only include steps with explicit IDs
|
||||
if (step.id) {
|
||||
stepIds.push(step.id);
|
||||
}
|
||||
}
|
||||
|
||||
return stepIds;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import {ActionStep, RunStep} from "@actions/workflow-parser/model/workflow-template";
|
||||
import {testGetWorkflowContext} from "../test-utils/test-workflow-context";
|
||||
import {testGetWorkflowContext} from "../test-utils/test-workflow-context.js";
|
||||
|
||||
describe("getWorkflowContext", () => {
|
||||
it("context for workflow", async () => {
|
||||
|
||||
@@ -6,6 +6,10 @@ import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-
|
||||
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
|
||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||
|
||||
/**
|
||||
* Represents the contextual position within a workflow file.
|
||||
* Used to determine which expression contexts are available at a given location.
|
||||
*/
|
||||
export interface WorkflowContext {
|
||||
uri: string;
|
||||
|
||||
@@ -21,6 +25,12 @@ export interface WorkflowContext {
|
||||
step?: Step;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a WorkflowContext by walking the token path to identify the current job and step.
|
||||
* @param uri - The URI of the workflow file
|
||||
* @param template - The parsed workflow template
|
||||
* @param tokenPath - The path of tokens from root to the current position
|
||||
*/
|
||||
export function getWorkflowContext(
|
||||
uri: string,
|
||||
template: WorkflowTemplate | undefined,
|
||||
@@ -73,6 +83,10 @@ export function getWorkflowContext(
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a Step by matching the step token's position in the steps sequence.
|
||||
* Steps may not have IDs, so we locate them by index rather than by identifier.
|
||||
*/
|
||||
function findStep(steps?: Step[], stepSequence?: SequenceToken, stepToken?: MappingToken): Step | undefined {
|
||||
if (!steps || !stepSequence || !stepToken) {
|
||||
return undefined;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import {isMapping, isString} from "@actions/workflow-parser";
|
||||
import {DESCRIPTION} from "@actions/workflow-parser/templates/template-constants";
|
||||
import {WorkflowContext} from "../context/workflow-context";
|
||||
import {TokenResult} from "../utils/find-token";
|
||||
import {WorkflowContext} from "../context/workflow-context.js";
|
||||
import {TokenResult} from "../utils/find-token.js";
|
||||
|
||||
/**
|
||||
* Checks if the token is an input value in a reusable workflow job's `with:` block.
|
||||
*/
|
||||
export function isReusableWorkflowJobInput(tokenResult: TokenResult): boolean {
|
||||
return (
|
||||
tokenResult.parent?.definition?.key === "workflow-job-with" &&
|
||||
@@ -11,6 +14,11 @@ export function isReusableWorkflowJobInput(tokenResult: TokenResult): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the description of an input from a called reusable workflow.
|
||||
* When a workflow calls another workflow with `uses:`, this fetches the input's
|
||||
* description from the called workflow's `workflow_call.inputs` definitions.
|
||||
*/
|
||||
export function getReusableWorkflowInputDescription(
|
||||
workflowContext: WorkflowContext,
|
||||
tokenResult: TokenResult
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {documentLinks} from "./document-links";
|
||||
import {createDocument} from "./test-utils/document";
|
||||
import {clearCache} from "./utils/workflow-cache";
|
||||
import {documentLinks} from "./document-links.js";
|
||||
import {createDocument} from "./test-utils/document.js";
|
||||
import {clearCache} from "./utils/workflow-cache.js";
|
||||
|
||||
beforeEach(() => {
|
||||
clearCache();
|
||||
@@ -129,4 +129,31 @@ jobs:
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("links for actions in composite action", async () => {
|
||||
const input = `name: My Composite Action
|
||||
description: A composite action with nested actions
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- run: echo "Hello"
|
||||
shell: bash`;
|
||||
const result = await documentLinks(createDocument("action.yml", input), undefined);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].target).toBe("https://www.github.com/actions/checkout/tree/v4/");
|
||||
expect(result[0].tooltip).toBe("Open action on GitHub");
|
||||
expect(result[1].target).toBe("https://www.github.com/actions/setup-node/tree/v4/");
|
||||
});
|
||||
|
||||
it("no links for non-composite action", async () => {
|
||||
const input = `name: My Node Action
|
||||
description: A node action
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js`;
|
||||
const result = await documentLinks(createDocument("action.yml", input), undefined);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,30 +5,83 @@ import {parseFileReference} from "@actions/workflow-parser/workflows/file-refere
|
||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||
import {DocumentLink} from "vscode-languageserver-types";
|
||||
import * as vscodeURI from "vscode-uri";
|
||||
import {actionUrl, parseActionReference} from "./action";
|
||||
import {mapRange} from "./utils/range";
|
||||
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache";
|
||||
import {actionUrl, parseActionReference} from "./action.js";
|
||||
import {isActionDocument} from "./utils/document-type.js";
|
||||
import {mapRange} from "./utils/range.js";
|
||||
import {
|
||||
getOrConvertActionTemplate,
|
||||
getOrConvertWorkflowTemplate,
|
||||
getOrParseAction,
|
||||
getOrParseWorkflow
|
||||
} from "./utils/workflow-cache.js";
|
||||
|
||||
/**
|
||||
* Generates clickable links for action references and reusable workflows.
|
||||
*/
|
||||
export async function documentLinks(document: TextDocument, workspace: string | undefined): Promise<DocumentLink[]> {
|
||||
const file: File = {
|
||||
name: document.uri,
|
||||
content: document.getText()
|
||||
};
|
||||
|
||||
const parsedWorkflow = fetchOrParseWorkflow(file, document.uri);
|
||||
return isActionDocument(document.uri)
|
||||
? actionDocumentLinks(file, document.uri)
|
||||
: workflowDocumentLinks(file, document.uri, workspace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates clickable links for action references in action.yml files.
|
||||
*/
|
||||
function actionDocumentLinks(file: File, uri: string): DocumentLink[] {
|
||||
const parsedAction = getOrParseAction(file, uri);
|
||||
if (!parsedAction?.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const template = getOrConvertActionTemplate(parsedAction.context, parsedAction.value, uri, {
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
});
|
||||
|
||||
const links: DocumentLink[] = [];
|
||||
|
||||
// Only composite actions have steps
|
||||
if (template?.runs?.using !== "composite") {
|
||||
return links;
|
||||
}
|
||||
|
||||
const steps = template.runs.steps ?? [];
|
||||
for (const step of steps) {
|
||||
if ("uses" in step) {
|
||||
const actionRef = parseActionReference(step.uses.value);
|
||||
if (!actionRef) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const url = actionUrl(actionRef);
|
||||
|
||||
links.push({
|
||||
range: mapRange(step.uses.range),
|
||||
target: url,
|
||||
tooltip: `Open action on GitHub`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return links;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates clickable links for action references and reusable workflows in workflow files.
|
||||
*/
|
||||
async function workflowDocumentLinks(file: File, uri: string, workspace: string | undefined): Promise<DocumentLink[]> {
|
||||
const parsedWorkflow = getOrParseWorkflow(file, uri);
|
||||
if (!parsedWorkflow?.value) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const template = await fetchOrConvertWorkflowTemplate(
|
||||
parsedWorkflow.context,
|
||||
parsedWorkflow.value,
|
||||
document.uri,
|
||||
undefined,
|
||||
{
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
}
|
||||
);
|
||||
const template = await getOrConvertWorkflowTemplate(parsedWorkflow.context, parsedWorkflow.value, uri, undefined, {
|
||||
errorPolicy: ErrorPolicy.TryConversion
|
||||
});
|
||||
|
||||
const links: DocumentLink[] = [];
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {complete} from "./complete";
|
||||
import {hover} from "./hover";
|
||||
import {registerLogger} from "./log";
|
||||
import {getPositionFromCursor} from "./test-utils/cursor-position";
|
||||
import {TestLogger} from "./test-utils/logger";
|
||||
import {clearCache} from "./utils/workflow-cache";
|
||||
import {complete} from "./complete.js";
|
||||
import {hover} from "./hover.js";
|
||||
import {registerLogger} from "./log.js";
|
||||
import {getPositionFromCursor} from "./test-utils/cursor-position.js";
|
||||
import {TestLogger} from "./test-utils/logger.js";
|
||||
import {clearCache} from "./utils/workflow-cache.js";
|
||||
|
||||
registerLogger(new TestLogger());
|
||||
|
||||
@@ -21,17 +21,23 @@ describe("end-to-end", () => {
|
||||
const result = await complete(...getPositionFromCursor(input));
|
||||
|
||||
expect(result).not.toBeUndefined();
|
||||
expect(result.length).toEqual(9);
|
||||
const labels = result.map(x => x.label);
|
||||
expect(labels).toEqual([
|
||||
expect(result.length).toEqual(13);
|
||||
const labelsWithDetails = result.map(x =>
|
||||
x.labelDetails?.description ? `${x.label} (${x.labelDetails.description})` : x.label
|
||||
);
|
||||
expect(labelsWithDetails).toEqual([
|
||||
"concurrency",
|
||||
"concurrency (full syntax)",
|
||||
"defaults",
|
||||
"description",
|
||||
"env",
|
||||
"jobs",
|
||||
"name",
|
||||
"on",
|
||||
"on (list)",
|
||||
"on (full syntax)",
|
||||
"permissions",
|
||||
"permissions (full syntax)",
|
||||
"run-name"
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {parseWorkflow} from "@actions/workflow-parser";
|
||||
import {File} from "@actions/workflow-parser/workflows/file";
|
||||
import {nullTrace} from "../nulltrace";
|
||||
import {getPositionFromCursor} from "../test-utils/cursor-position";
|
||||
import {findToken} from "../utils/find-token";
|
||||
import {ExpressionPos, mapToExpressionPos} from "./expression-pos";
|
||||
import {nullTrace} from "../nulltrace.js";
|
||||
import {getPositionFromCursor} from "../test-utils/cursor-position.js";
|
||||
import {findToken} from "../utils/find-token.js";
|
||||
import {ExpressionPos, mapToExpressionPos} from "./expression-pos.js";
|
||||
|
||||
describe("mapToExpressionPos", () => {
|
||||
it("simple expression", () => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user