Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de148476d4 |
@@ -4,9 +4,6 @@ lerna-debug.log
|
|||||||
node_modules
|
node_modules
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# Nx cache (generated by Lerna/Nx)
|
|
||||||
.nx/
|
|
||||||
|
|
||||||
# Minified JSON (generated at build time)
|
# Minified JSON (generated at build time)
|
||||||
*.min.json
|
*.min.json
|
||||||
|
|
||||||
|
|||||||
@@ -1,319 +0,0 @@
|
|||||||
# ESM Migration Plan: Add File Extensions to Imports
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This document outlines the plan to migrate from TypeScript's deprecated `"moduleResolution": "node"` (node10) to `"moduleResolution": "node16"` or `"nodenext"`. This change is necessary because the published ESM packages have extensionless imports that don't work correctly in modern ESM environments.
|
|
||||||
|
|
||||||
## TL;DR - Remaining Work
|
|
||||||
|
|
||||||
- [x] expressions - Migrated ✅
|
|
||||||
- [x] workflow-parser - Migrated ✅
|
|
||||||
- [x] languageservice - Migrated ✅
|
|
||||||
- [x] languageserver - Add `.js` extensions to imports ✅
|
|
||||||
- [ ] languageserver - Update `tsconfig.build.json` to `moduleResolution: "node16"` (blocked by vscode-languageserver)
|
|
||||||
- [ ] languageserver - Upgrade `vscode-languageserver` to stable v10+ when released
|
|
||||||
|
|
||||||
**Blocker:** `[email protected]` lacks ESM exports. Stable v10 with `exports` field needed.
|
|
||||||
|
|
||||||
### ⚠️ Important: `skipLibCheck: true` Required
|
|
||||||
|
|
||||||
All migrated packages use `skipLibCheck: true` in their `tsconfig.build.json`. This works around a TS2386 "Overload signatures must all be optional or required" error in `@types/node/module.d.ts`.
|
|
||||||
|
|
||||||
**Why can't we just fix the error?** The error is in `@types/node`, a third-party package maintained by DefinitelyTyped. We can't modify `node_modules`, and upstream fixes take time.
|
|
||||||
|
|
||||||
**Is `skipLibCheck` safe?** Yes. It only skips type checking of `.d.ts` files (declaration files from dependencies). Our own `.ts` source files are still fully type-checked. This is a common and recommended workaround for issues in third-party type definitions.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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:** Partial - `.js` extensions added, waiting for stable `vscode-languageserver` release with ESM exports to complete migration.
|
|
||||||
|
|
||||||
**Completed:** All relative imports in languageserver source files have been updated to use `.js` extensions. This is compatible with the current `moduleResolution: "node"` and will enable a seamless migration once a stable vscode-languageserver version with ESM exports is available.
|
|
||||||
|
|
||||||
**Options to resolve:**
|
|
||||||
- Wait for stable vscode-languageserver v10+ with ESM exports
|
|
||||||
- Use pre-release `[email protected]` (has proper exports but is unstable)
|
|
||||||
- Fork or patch the dependency
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Migration Status
|
|
||||||
|
|
||||||
| Package | Tests | ESM Status |
|
|
||||||
|---------|-------|------------|
|
|
||||||
| expressions | 1068 | ✅ Migrated |
|
|
||||||
| workflow-parser | 292 | ✅ Migrated |
|
|
||||||
| languageservice | 452 | ✅ Migrated |
|
|
||||||
| languageserver | 31 | 🔶 Partial (`.js` extensions added, awaiting stable vscode-languageserver) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 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)
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/expressions",
|
"name": "@actions/expressions",
|
||||||
"version": "0.3.35",
|
"version": "0.3.25",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"source": "./src/index.ts",
|
"source": "./src/index.ts",
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
"clean": "rimraf dist",
|
"clean": "rimraf dist",
|
||||||
"format": "prettier --write '**/*.ts'",
|
"format": "prettier --write '**/*.ts'",
|
||||||
"format-check": "prettier --check '**/*.ts'",
|
"format-check": "prettier --check '**/*.ts'",
|
||||||
"lint": "eslint --max-warnings 0 'src/**/*.ts'",
|
"lint": "eslint 'src/**/*.ts'",
|
||||||
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
||||||
"prepublishOnly": "npm run build && npm run test",
|
"prepublishOnly": "npm run build && npm run test",
|
||||||
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
|
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
|
||||||
@@ -60,6 +60,6 @@
|
|||||||
"prettier": "^2.8.3",
|
"prettier": "^2.8.3",
|
||||||
"rimraf": "^3.0.2",
|
"rimraf": "^3.0.2",
|
||||||
"ts-jest": "^29.0.3",
|
"ts-jest": "^29.0.3",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^4.7.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {ExpressionData} from "./data/index.js";
|
import {ExpressionData} from "./data";
|
||||||
import {Token} from "./lexer.js";
|
import {Token} from "./lexer";
|
||||||
|
|
||||||
export interface ExprVisitor<R> {
|
export interface ExprVisitor<R> {
|
||||||
visitLiteral(literal: Literal): R;
|
visitLiteral(literal: Literal): R;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import {complete, CompletionItem, trimTokenVector} from "./completion.js";
|
import {complete, CompletionItem, trimTokenVector} from "./completion";
|
||||||
import {DescriptionDictionary} from "./completion/descriptionDictionary.js";
|
import {DescriptionDictionary} from "./completion/descriptionDictionary";
|
||||||
import {BooleanData} from "./data/boolean.js";
|
import {BooleanData} from "./data/boolean";
|
||||||
import {Dictionary} from "./data/dictionary.js";
|
import {Dictionary} from "./data/dictionary";
|
||||||
import {StringData} from "./data/string.js";
|
import {StringData} from "./data/string";
|
||||||
import {wellKnownFunctions} from "./funcs.js";
|
import {wellKnownFunctions} from "./funcs";
|
||||||
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js";
|
import {FunctionDefinition, FunctionInfo} from "./funcs/info";
|
||||||
import {Lexer, TokenType} from "./lexer.js";
|
import {Lexer, TokenType} from "./lexer";
|
||||||
|
|
||||||
const testContext = new Dictionary(
|
const testContext = new Dictionary(
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import {DescriptionPair} from "./completion/descriptionDictionary.js";
|
import {DescriptionPair} from "./completion/descriptionDictionary";
|
||||||
import {Dictionary, isDictionary} from "./data/dictionary.js";
|
import {Dictionary, isDictionary} from "./data/dictionary";
|
||||||
import {ExpressionData} from "./data/expressiondata.js";
|
import {ExpressionData} from "./data/expressiondata";
|
||||||
import {Evaluator} from "./evaluator.js";
|
import {Evaluator} from "./evaluator";
|
||||||
import {wellKnownFunctions} from "./funcs.js";
|
import {wellKnownFunctions} from "./funcs";
|
||||||
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js";
|
import {FunctionDefinition, FunctionInfo} from "./funcs/info";
|
||||||
import {Lexer, Token, TokenType} from "./lexer.js";
|
import {Lexer, Token, TokenType} from "./lexer";
|
||||||
import {Parser} from "./parser.js";
|
import {Parser} from "./parser";
|
||||||
|
|
||||||
export type CompletionItem = {
|
export type CompletionItem = {
|
||||||
label: string;
|
label: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {StringData} from "../data/index.js";
|
import {StringData} from "../data";
|
||||||
import {DescriptionDictionary} from "./descriptionDictionary.js";
|
import {DescriptionDictionary} from "./descriptionDictionary";
|
||||||
|
|
||||||
describe("description dictionary", () => {
|
describe("description dictionary", () => {
|
||||||
it("pairs contains all values", () => {
|
it("pairs contains all values", () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {Dictionary} from "../data/dictionary.js";
|
import {Dictionary} from "../data/dictionary";
|
||||||
import {ExpressionData, Kind, Pair} from "../data/expressiondata.js";
|
import {ExpressionData, Kind, Pair} from "../data/expressiondata";
|
||||||
|
|
||||||
export type DescriptionPair = Pair & {description?: string};
|
export type DescriptionPair = Pair & {description?: string};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ExpressionData, ExpressionDataInterface, Kind, kindStr} from "./expressiondata.js";
|
import {ExpressionData, ExpressionDataInterface, Kind, kindStr} from "./expressiondata";
|
||||||
|
|
||||||
export class Array implements ExpressionDataInterface {
|
export class Array implements ExpressionDataInterface {
|
||||||
private v: ExpressionData[] = [];
|
private v: ExpressionData[] = [];
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ExpressionDataInterface, Kind} from "./expressiondata.js";
|
import {ExpressionDataInterface, Kind} from "./expressiondata";
|
||||||
|
|
||||||
export class BooleanData implements ExpressionDataInterface {
|
export class BooleanData implements ExpressionDataInterface {
|
||||||
constructor(public readonly value: boolean) {}
|
constructor(public readonly value: boolean) {}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {Dictionary} from "./dictionary.js";
|
import {Dictionary} from "./dictionary";
|
||||||
import {StringData} from "./string.js";
|
import {StringData} from "./string";
|
||||||
|
|
||||||
describe("dictionary", () => {
|
describe("dictionary", () => {
|
||||||
it("pairs contains all values", () => {
|
it("pairs contains all values", () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ExpressionData, ExpressionDataInterface, Kind, kindStr, Pair} from "./expressiondata.js";
|
import {ExpressionData, ExpressionDataInterface, Kind, kindStr, Pair} from "./expressiondata";
|
||||||
|
|
||||||
export class Dictionary implements ExpressionDataInterface {
|
export class Dictionary implements ExpressionDataInterface {
|
||||||
private keys: string[] = [];
|
private keys: string[] = [];
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {Dictionary} from "./dictionary.js";
|
import {Dictionary} from "./dictionary";
|
||||||
import {Null} from "./null.js";
|
import {Null} from "./null";
|
||||||
import {Array} from "./array.js";
|
import {Array} from "./array";
|
||||||
import {StringData} from "./string.js";
|
import {StringData} from "./string";
|
||||||
import {NumberData} from "./number.js";
|
import {NumberData} from "./number";
|
||||||
import {BooleanData} from "./boolean.js";
|
import {BooleanData} from "./boolean";
|
||||||
|
|
||||||
export enum Kind {
|
export enum Kind {
|
||||||
String = 0,
|
String = 0,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
export {Array} from "./array.js";
|
export {Array} from "./array";
|
||||||
export {BooleanData} from "./boolean.js";
|
export {BooleanData} from "./boolean";
|
||||||
export {Dictionary} from "./dictionary.js";
|
export {Dictionary} from "./dictionary";
|
||||||
export {ExpressionData, Kind} from "./expressiondata.js";
|
export {ExpressionData, Kind} from "./expressiondata";
|
||||||
export {Null} from "./null.js";
|
export {Null} from "./null";
|
||||||
export {NumberData} from "./number.js";
|
export {NumberData} from "./number";
|
||||||
export {replacer} from "./replacer.js";
|
export {replacer} from "./replacer";
|
||||||
export {reviver} from "./reviver.js";
|
export {reviver} from "./reviver";
|
||||||
export {StringData} from "./string.js";
|
export {StringData} from "./string";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ExpressionDataInterface, Kind} from "./expressiondata.js";
|
import {ExpressionDataInterface, Kind} from "./expressiondata";
|
||||||
|
|
||||||
export class Null implements ExpressionDataInterface {
|
export class Null implements ExpressionDataInterface {
|
||||||
public readonly kind = Kind.Null;
|
public readonly kind = Kind.Null;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {NumberData} from "./number.js";
|
import {NumberData} from "./number";
|
||||||
|
|
||||||
describe("number", () => {
|
describe("number", () => {
|
||||||
it("coerces to string", () => {
|
it("coerces to string", () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ExpressionDataInterface, Kind} from "./expressiondata.js";
|
import {ExpressionDataInterface, Kind} from "./expressiondata";
|
||||||
|
|
||||||
export class NumberData implements ExpressionDataInterface {
|
export class NumberData implements ExpressionDataInterface {
|
||||||
constructor(public readonly value: number) {}
|
constructor(public readonly value: number) {}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {Array} from "./array.js";
|
import {Array} from "./array";
|
||||||
import {Dictionary} from "./dictionary.js";
|
import {Dictionary} from "./dictionary";
|
||||||
import {Null} from "./null.js";
|
import {Null} from "./null";
|
||||||
import {NumberData} from "./number.js";
|
import {NumberData} from "./number";
|
||||||
import {replacer} from "./replacer.js";
|
import {replacer} from "./replacer";
|
||||||
import {StringData} from "./string.js";
|
import {StringData} from "./string";
|
||||||
|
|
||||||
describe("replacer", () => {
|
describe("replacer", () => {
|
||||||
it("null", () => {
|
it("null", () => {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {Array} from "./array.js";
|
import {Array} from "./array";
|
||||||
import {BooleanData} from "./boolean.js";
|
import {BooleanData} from "./boolean";
|
||||||
import {Dictionary} from "./dictionary.js";
|
import {Dictionary} from "./dictionary";
|
||||||
import {Null} from "./null.js";
|
import {Null} from "./null";
|
||||||
import {NumberData} from "./number.js";
|
import {NumberData} from "./number";
|
||||||
import {StringData} from "./string.js";
|
import {StringData} from "./string";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replacer can be passed to JSON.stringify to convert an ExpressionData object into plain JSON
|
* Replacer can be passed to JSON.stringify to convert an ExpressionData object into plain JSON
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import {Array} from "./array.js";
|
import {Array} from "./array";
|
||||||
import {BooleanData} from "./boolean.js";
|
import {BooleanData} from "./boolean";
|
||||||
import {Dictionary} from "./dictionary.js";
|
import {Dictionary} from "./dictionary";
|
||||||
import {ExpressionData} from "./expressiondata.js";
|
import {ExpressionData} from "./expressiondata";
|
||||||
import {Null} from "./null.js";
|
import {Null} from "./null";
|
||||||
import {NumberData} from "./number.js";
|
import {NumberData} from "./number";
|
||||||
import {reviver} from "./reviver.js";
|
import {reviver} from "./reviver";
|
||||||
import {StringData} from "./string.js";
|
import {StringData} from "./string";
|
||||||
|
|
||||||
describe("reviver", () => {
|
describe("reviver", () => {
|
||||||
const tests: {
|
const tests: {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import {Array as dArray} from "./array.js";
|
import {Array as dArray} from "./array";
|
||||||
import {BooleanData} from "./boolean.js";
|
import {BooleanData} from "./boolean";
|
||||||
import {Dictionary} from "./dictionary.js";
|
import {Dictionary} from "./dictionary";
|
||||||
import {ExpressionData} from "./expressiondata.js";
|
import {ExpressionData} from "./expressiondata";
|
||||||
import {Null} from "./null.js";
|
import {Null} from "./null";
|
||||||
import {NumberData} from "./number.js";
|
import {NumberData} from "./number";
|
||||||
import {StringData} from "./string.js";
|
import {StringData} from "./string";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reviver can be passed to `JSON.parse` to convert plain JSON into an `ExpressionData` object.
|
* Reviver can be passed to `JSON.parse` to convert plain JSON into an `ExpressionData` object.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ExpressionDataInterface, Kind} from "./expressiondata.js";
|
import {ExpressionDataInterface, Kind} from "./expressiondata";
|
||||||
|
|
||||||
export class StringData implements ExpressionDataInterface {
|
export class StringData implements ExpressionDataInterface {
|
||||||
constructor(public readonly value: string) {}
|
constructor(public readonly value: string) {}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {Pos, Token, tokenString} from "./lexer.js";
|
import {Pos, Token, tokenString} from "./lexer";
|
||||||
|
|
||||||
export const MAX_PARSER_DEPTH = 50;
|
export const MAX_PARSER_DEPTH = 50;
|
||||||
export const MAX_EXPRESSION_LENGTH = 21000;
|
export const MAX_EXPRESSION_LENGTH = 21000;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import * as data from "./data/index.js";
|
import * as data from "./data";
|
||||||
import {ExpressionEvaluationError} from "./errors.js";
|
import {ExpressionEvaluationError} from "./errors";
|
||||||
import {Evaluator} from "./evaluator.js";
|
import {Evaluator} from "./evaluator";
|
||||||
import {Lexer} from "./lexer.js";
|
import {Lexer} from "./lexer";
|
||||||
import {Parser} from "./parser.js";
|
import {Parser} from "./parser";
|
||||||
|
|
||||||
describe("evaluator", () => {
|
describe("evaluator", () => {
|
||||||
const lexAndParse = (input: string) => {
|
const lexAndParse = (input: string) => {
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ import {
|
|||||||
Logical,
|
Logical,
|
||||||
Star,
|
Star,
|
||||||
Unary
|
Unary
|
||||||
} from "./ast.js";
|
} from "./ast";
|
||||||
import * as data from "./data/index.js";
|
import * as data from "./data";
|
||||||
import {FilteredArray} from "./filtered_array.js";
|
import {FilteredArray} from "./filtered_array";
|
||||||
import {wellKnownFunctions} from "./funcs.js";
|
import {wellKnownFunctions} from "./funcs";
|
||||||
import {FunctionDefinition} from "./funcs/info.js";
|
import {FunctionDefinition} from "./funcs/info";
|
||||||
import {idxHelper} from "./idxHelper.js";
|
import {idxHelper} from "./idxHelper";
|
||||||
import {TokenType} from "./lexer.js";
|
import {TokenType} from "./lexer";
|
||||||
import {equals, falsy, greaterThan, lessThan, truthy} from "./result.js";
|
import {equals, falsy, greaterThan, lessThan, truthy} from "./result";
|
||||||
|
|
||||||
export class Evaluator implements ExprVisitor<data.ExpressionData> {
|
export class Evaluator implements ExprVisitor<data.ExpressionData> {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
import {FeatureFlags} from "./features.js";
|
|
||||||
|
|
||||||
describe("FeatureFlags", () => {
|
|
||||||
describe("isEnabled", () => {
|
|
||||||
it("returns false by default when no options provided", () => {
|
|
||||||
const flags = new FeatureFlags();
|
|
||||||
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false by default when empty options provided", () => {
|
|
||||||
const flags = new FeatureFlags({});
|
|
||||||
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true when feature is explicitly enabled", () => {
|
|
||||||
const flags = new FeatureFlags({missingInputsQuickfix: true});
|
|
||||||
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns false when feature is explicitly disabled", () => {
|
|
||||||
const flags = new FeatureFlags({missingInputsQuickfix: false});
|
|
||||||
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns true when all is enabled", () => {
|
|
||||||
const flags = new FeatureFlags({all: true});
|
|
||||||
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("explicit feature flag takes precedence over all:true", () => {
|
|
||||||
const flags = new FeatureFlags({all: true, missingInputsQuickfix: false});
|
|
||||||
expect(flags.isEnabled("missingInputsQuickfix")).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("explicit feature flag takes precedence over all:false", () => {
|
|
||||||
const flags = new FeatureFlags({all: false, missingInputsQuickfix: true});
|
|
||||||
expect(flags.isEnabled("missingInputsQuickfix")).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("getEnabledFeatures", () => {
|
|
||||||
it("returns empty array when no features enabled", () => {
|
|
||||||
const flags = new FeatureFlags();
|
|
||||||
expect(flags.getEnabledFeatures()).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns enabled features", () => {
|
|
||||||
const flags = new FeatureFlags({missingInputsQuickfix: true});
|
|
||||||
expect(flags.getEnabledFeatures()).toEqual(["missingInputsQuickfix"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns all features when all is enabled", () => {
|
|
||||||
const flags = new FeatureFlags({all: true});
|
|
||||||
expect(flags.getEnabledFeatures()).toEqual([
|
|
||||||
"missingInputsQuickfix",
|
|
||||||
"blockScalarChompingWarning",
|
|
||||||
"actionScaffoldingSnippets"
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
/**
|
|
||||||
* Experimental feature flags.
|
|
||||||
*
|
|
||||||
* Individual feature flags take precedence over `all`.
|
|
||||||
* Example: { all: true, missingInputsQuickfix: false } enables all
|
|
||||||
* experimental features EXCEPT missingInputsQuickfix.
|
|
||||||
*
|
|
||||||
* When a feature graduates to stable, its flag becomes a no-op
|
|
||||||
* (the feature will be enabled regardless of the configuration value).
|
|
||||||
*/
|
|
||||||
export interface ExperimentalFeatures {
|
|
||||||
/**
|
|
||||||
* Enable all experimental features.
|
|
||||||
* Individual feature flags take precedence over this setting.
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
all?: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable quickfix code action for missing required action inputs.
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
missingInputsQuickfix?: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Warn when block scalars (| or >) use implicit clip chomping,
|
|
||||||
* which adds a trailing newline that may be unintentional.
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
blockScalarChompingWarning?: boolean;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enable action scaffolding snippets in action.yml files.
|
|
||||||
* Offers Node.js, Composite, and Docker action scaffolds.
|
|
||||||
* @default false
|
|
||||||
*/
|
|
||||||
actionScaffoldingSnippets?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Keys of ExperimentalFeatures that represent actual features (excludes 'all')
|
|
||||||
*/
|
|
||||||
export type ExperimentalFeatureKey = Exclude<keyof ExperimentalFeatures, "all">;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All known experimental feature keys.
|
|
||||||
* This list must be kept in sync with the ExperimentalFeatures interface.
|
|
||||||
*/
|
|
||||||
const allFeatureKeys: ExperimentalFeatureKey[] = [
|
|
||||||
"missingInputsQuickfix",
|
|
||||||
"blockScalarChompingWarning",
|
|
||||||
"actionScaffoldingSnippets"
|
|
||||||
];
|
|
||||||
|
|
||||||
export class FeatureFlags {
|
|
||||||
private readonly features: ExperimentalFeatures;
|
|
||||||
|
|
||||||
constructor(features?: ExperimentalFeatures) {
|
|
||||||
this.features = features ?? {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if an experimental feature is enabled.
|
|
||||||
*
|
|
||||||
* Resolution order:
|
|
||||||
* 1. Explicit feature flag (if set)
|
|
||||||
* 2. `all` flag (if set)
|
|
||||||
* 3. false (default)
|
|
||||||
*/
|
|
||||||
isEnabled(feature: ExperimentalFeatureKey): boolean {
|
|
||||||
const explicit = this.features[feature];
|
|
||||||
if (explicit !== undefined) {
|
|
||||||
return explicit;
|
|
||||||
}
|
|
||||||
return this.features.all ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns list of all enabled experimental features.
|
|
||||||
*/
|
|
||||||
getEnabledFeatures(): ExperimentalFeatureKey[] {
|
|
||||||
return allFeatureKeys.filter(key => this.isEnabled(key));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
import * as data from "./data/index.js";
|
import * as data from "./data";
|
||||||
|
|
||||||
export class FilteredArray extends data.Array {}
|
export class FilteredArray extends data.Array {}
|
||||||
|
|||||||
+10
-10
@@ -1,13 +1,13 @@
|
|||||||
import {ErrorType, ExpressionError} from "./errors.js";
|
import {ErrorType, ExpressionError} from "./errors";
|
||||||
import {contains} from "./funcs/contains.js";
|
import {contains} from "./funcs/contains";
|
||||||
import {endswith} from "./funcs/endswith.js";
|
import {endswith} from "./funcs/endswith";
|
||||||
import {format} from "./funcs/format.js";
|
import {format} from "./funcs/format";
|
||||||
import {fromjson} from "./funcs/fromjson.js";
|
import {fromjson} from "./funcs/fromjson";
|
||||||
import {FunctionDefinition, FunctionInfo} from "./funcs/info.js";
|
import {FunctionDefinition, FunctionInfo} from "./funcs/info";
|
||||||
import {join} from "./funcs/join.js";
|
import {join} from "./funcs/join";
|
||||||
import {startswith} from "./funcs/startswith.js";
|
import {startswith} from "./funcs/startswith";
|
||||||
import {tojson} from "./funcs/tojson.js";
|
import {tojson} from "./funcs/tojson";
|
||||||
import {Token} from "./lexer.js";
|
import {Token} from "./lexer";
|
||||||
|
|
||||||
export type ParseContext = {
|
export type ParseContext = {
|
||||||
allowUnknownKeywords: boolean;
|
allowUnknownKeywords: boolean;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {BooleanData, ExpressionData, Kind} from "../data/index.js";
|
import {BooleanData, ExpressionData, Kind} from "../data";
|
||||||
import {equals} from "../result.js";
|
import {equals} from "../result";
|
||||||
import {FunctionDefinition} from "./info.js";
|
import {FunctionDefinition} from "./info";
|
||||||
|
|
||||||
export const contains: FunctionDefinition = {
|
export const contains: FunctionDefinition = {
|
||||||
name: "contains",
|
name: "contains",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {BooleanData, ExpressionData} from "../data/index.js";
|
import {BooleanData, ExpressionData} from "../data";
|
||||||
import {toUpperSpecial} from "../result.js";
|
import {toUpperSpecial} from "../result";
|
||||||
import {FunctionDefinition} from "./info.js";
|
import {FunctionDefinition} from "./info";
|
||||||
|
|
||||||
export const endswith: FunctionDefinition = {
|
export const endswith: FunctionDefinition = {
|
||||||
name: "endsWith",
|
name: "endsWith",
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {Null, NumberData, StringData} from "../data/index.js";
|
import {Null, NumberData, StringData} from "../data";
|
||||||
import {format} from "./format.js";
|
import {format} from "./format";
|
||||||
|
|
||||||
describe("format", () => {
|
describe("format", () => {
|
||||||
it("null", () => {
|
it("null", () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {ExpressionData, StringData} from "../data/index.js";
|
import {ExpressionData, StringData} from "../data";
|
||||||
import {FunctionDefinition} from "./info.js";
|
import {FunctionDefinition} from "./info";
|
||||||
|
|
||||||
export const format: FunctionDefinition = {
|
export const format: FunctionDefinition = {
|
||||||
name: "format",
|
name: "format",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {ExpressionData} from "../data/index.js";
|
import {ExpressionData} from "../data";
|
||||||
import {reviver} from "../data/reviver.js";
|
import {reviver} from "../data/reviver";
|
||||||
import {ExpressionEvaluationError} from "../errors.js";
|
import {ExpressionEvaluationError} from "../errors";
|
||||||
import {FunctionDefinition} from "./info.js";
|
import {FunctionDefinition} from "./info";
|
||||||
|
|
||||||
export const fromjson: FunctionDefinition = {
|
export const fromjson: FunctionDefinition = {
|
||||||
name: "fromJson",
|
name: "fromJson",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ExpressionData} from "../data/index.js";
|
import {ExpressionData} from "../data";
|
||||||
|
|
||||||
export interface FunctionInfo {
|
export interface FunctionInfo {
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {ExpressionData, Kind, StringData} from "../data/index.js";
|
import {ExpressionData, Kind, StringData} from "../data";
|
||||||
import {FunctionDefinition} from "./info.js";
|
import {FunctionDefinition} from "./info";
|
||||||
|
|
||||||
export const join: FunctionDefinition = {
|
export const join: FunctionDefinition = {
|
||||||
name: "join",
|
name: "join",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {BooleanData, ExpressionData} from "../data/index.js";
|
import {BooleanData, ExpressionData} from "../data";
|
||||||
import {toUpperSpecial} from "../result.js";
|
import {toUpperSpecial} from "../result";
|
||||||
import {FunctionDefinition} from "./info.js";
|
import {FunctionDefinition} from "./info";
|
||||||
|
|
||||||
export const startswith: FunctionDefinition = {
|
export const startswith: FunctionDefinition = {
|
||||||
name: "startsWith",
|
name: "startsWith",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {ExpressionData, StringData} from "../data/index.js";
|
import {ExpressionData, StringData} from "../data";
|
||||||
import {replacer} from "../data/replacer.js";
|
import {replacer} from "../data/replacer";
|
||||||
import {FunctionDefinition} from "./info.js";
|
import {FunctionDefinition} from "./info";
|
||||||
|
|
||||||
export const tojson: FunctionDefinition = {
|
export const tojson: FunctionDefinition = {
|
||||||
name: "toJson",
|
name: "toJson",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ExpressionData} from "./data/index.js";
|
import {ExpressionData} from "./data";
|
||||||
|
|
||||||
export class idxHelper {
|
export class idxHelper {
|
||||||
public readonly str: string | undefined;
|
public readonly str: string | undefined;
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
export {Expr} from "./ast.js";
|
export {Expr} from "./ast";
|
||||||
export {complete, CompletionItem} from "./completion.js";
|
export {complete, CompletionItem} from "./completion";
|
||||||
export {DescriptionDictionary, DescriptionPair, isDescriptionDictionary} from "./completion/descriptionDictionary.js";
|
export {DescriptionDictionary, DescriptionPair, isDescriptionDictionary} from "./completion/descriptionDictionary";
|
||||||
export * as data from "./data/index.js";
|
export * as data from "./data";
|
||||||
export {ExpressionError, ExpressionEvaluationError} from "./errors.js";
|
export {ExpressionError, ExpressionEvaluationError} from "./errors";
|
||||||
export {Evaluator} from "./evaluator.js";
|
export {Evaluator} from "./evaluator";
|
||||||
export {ExperimentalFeatureKey, ExperimentalFeatures, FeatureFlags} from "./features.js";
|
export {wellKnownFunctions} from "./funcs";
|
||||||
export {wellKnownFunctions} from "./funcs.js";
|
export {Lexer, Result} from "./lexer";
|
||||||
export {Lexer, Result} from "./lexer.js";
|
export {Parser} from "./parser";
|
||||||
export {Parser} from "./parser.js";
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {Lexer, Token, TokenType} from "./lexer.js";
|
import {Lexer, Token, TokenType} from "./lexer";
|
||||||
|
|
||||||
describe("lexer", () => {
|
describe("lexer", () => {
|
||||||
const tests: {
|
const tests: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {StringData} from "./data/index.js";
|
import {StringData} from "./data";
|
||||||
import {MAX_EXPRESSION_LENGTH} from "./errors.js";
|
import {MAX_EXPRESSION_LENGTH} from "./errors";
|
||||||
|
|
||||||
export enum TokenType {
|
export enum TokenType {
|
||||||
UNKNOWN,
|
UNKNOWN,
|
||||||
|
|||||||
@@ -1,20 +1,9 @@
|
|||||||
import {
|
import {Binary, ContextAccess, Expr, FunctionCall, Grouping, IndexAccess, Literal, Logical, Star, Unary} from "./ast";
|
||||||
Binary,
|
import * as data from "./data";
|
||||||
ContextAccess,
|
import {ErrorType, ExpressionError, MAX_PARSER_DEPTH} from "./errors";
|
||||||
Expr,
|
import {ParseContext, validateFunction} from "./funcs";
|
||||||
FunctionCall,
|
import {FunctionInfo} from "./funcs/info";
|
||||||
Grouping,
|
import {Token, TokenType} from "./lexer";
|
||||||
IndexAccess,
|
|
||||||
Literal,
|
|
||||||
Logical,
|
|
||||||
Star,
|
|
||||||
Unary
|
|
||||||
} from "./ast.js";
|
|
||||||
import * as data from "./data/index.js";
|
|
||||||
import {ErrorType, ExpressionError, MAX_PARSER_DEPTH} from "./errors.js";
|
|
||||||
import {ParseContext, validateFunction} from "./funcs.js";
|
|
||||||
import {FunctionInfo} from "./funcs/info.js";
|
|
||||||
import {Token, TokenType} from "./lexer.js";
|
|
||||||
|
|
||||||
export class Parser {
|
export class Parser {
|
||||||
private extContexts: Map<string, boolean>;
|
private extContexts: Map<string, boolean>;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {BooleanData, ExpressionData, NumberData, StringData} from "./data/index.js";
|
import {BooleanData, ExpressionData, NumberData, StringData} from "./data";
|
||||||
import {coerceTypes, toUpperSpecial} from "./result.js";
|
import {coerceTypes, toUpperSpecial} from "./result";
|
||||||
|
|
||||||
describe("coerceTypes", () => {
|
describe("coerceTypes", () => {
|
||||||
const tests: {
|
const tests: {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as data from "./data/index.js";
|
import * as data from "./data";
|
||||||
|
|
||||||
export function falsy(d: data.ExpressionData): boolean {
|
export function falsy(d: data.ExpressionData): boolean {
|
||||||
switch (d.kind) {
|
switch (d.kind) {
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import * as fs from "fs";
|
import * as fs from "fs";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import {Expr} from "./ast.js";
|
import {Expr} from "./ast";
|
||||||
import * as data from "./data/index.js";
|
import * as data from "./data";
|
||||||
import {kindStr} from "./data/expressiondata.js";
|
import {kindStr} from "./data/expressiondata";
|
||||||
import {replacer} from "./data/replacer.js";
|
import {replacer} from "./data/replacer";
|
||||||
import {reviver} from "./data/reviver.js";
|
import {reviver} from "./data/reviver";
|
||||||
import {ExpressionError} from "./errors.js";
|
import {ExpressionError} from "./errors";
|
||||||
import {Evaluator} from "./evaluator.js";
|
import {Evaluator} from "./evaluator";
|
||||||
import {Lexer, Result} from "./lexer.js";
|
import {Lexer, Result} from "./lexer";
|
||||||
import {Parser} from "./parser.js";
|
import {Parser} from "./parser";
|
||||||
|
|
||||||
interface TestResult {
|
interface TestResult {
|
||||||
value: data.ExpressionData;
|
value: data.ExpressionData;
|
||||||
|
|||||||
@@ -2,12 +2,9 @@
|
|||||||
"exclude": ["./src/**/*.test.ts"],
|
"exclude": ["./src/**/*.test.ts"],
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "node16",
|
|
||||||
"moduleResolution": "node16",
|
|
||||||
"declaration": true,
|
"declaration": true,
|
||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
"noEmit": false,
|
"noEmit": false,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist"
|
||||||
"skipLibCheck": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,6 @@ The [package](https://www.npmjs.com/package/@actions/languageserver) contains Ty
|
|||||||
npm install @actions/languageserver
|
npm install @actions/languageserver
|
||||||
```
|
```
|
||||||
|
|
||||||
To install the language server as a standalone CLI:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install -g @actions/languageserver
|
|
||||||
```
|
|
||||||
|
|
||||||
This makes the `actions-languageserver` command available globally.
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Basic usage using `vscode-languageserver-node`
|
### Basic usage using `vscode-languageserver-node`
|
||||||
@@ -84,11 +76,6 @@ export interface InitializationOptions {
|
|||||||
* Desired log level
|
* Desired log level
|
||||||
*/
|
*/
|
||||||
logLevel?: LogLevel;
|
logLevel?: LogLevel;
|
||||||
|
|
||||||
/**
|
|
||||||
* Experimental features that are opt-in
|
|
||||||
*/
|
|
||||||
experimentalFeatures?: ExperimentalFeatures;
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -105,177 +92,6 @@ const clientOptions: LanguageClientOptions = {
|
|||||||
const client = new LanguageClient("actions-language", "GitHub Actions Language Server", serverOptions, clientOptions);
|
const client = new LanguageClient("actions-language", "GitHub Actions Language Server", serverOptions, clientOptions);
|
||||||
```
|
```
|
||||||
|
|
||||||
### Experimental Features
|
|
||||||
|
|
||||||
The language server supports opt-in experimental features via the `experimentalFeatures` initialization option. These features may change or be removed in between releases.
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
initializationOptions: {
|
|
||||||
experimentalFeatures: {
|
|
||||||
// Enable all experimental features
|
|
||||||
all: true,
|
|
||||||
|
|
||||||
// Or enable specific features
|
|
||||||
missingInputsQuickfix: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Available experimental features:**
|
|
||||||
|
|
||||||
| Feature | Description |
|
|
||||||
|---------|-------------|
|
|
||||||
| `missingInputsQuickfix` | Code action to add missing required inputs for actions |
|
|
||||||
| `blockScalarChompingWarning` | Warn when block scalars (`\|` or `>`) use implicit clip chomping, which adds a trailing newline that may be unintentional |
|
|
||||||
|
|
||||||
Individual feature flags take precedence over `all`. For example, `{ all: true, missingInputsQuickfix: false }` enables all experimental features except `missingInputsQuickfix`.
|
|
||||||
|
|
||||||
When a feature graduates to stable, its flag becomes a no-op and the feature will be enabled regardless of the configuration value.
|
|
||||||
|
|
||||||
### Standalone CLI
|
|
||||||
|
|
||||||
After installing globally, you can run the language server directly:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
actions-languageserver --stdio
|
|
||||||
```
|
|
||||||
|
|
||||||
This starts the language server using stdio transport, which is the standard way for editors to communicate with language servers.
|
|
||||||
|
|
||||||
### In Neovim
|
|
||||||
|
|
||||||
#### 1. Install the language server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install -g @actions/languageserver
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Set up filetype detection
|
|
||||||
|
|
||||||
Add this to your `init.lua` to detect GitHub Actions workflow files:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
vim.filetype.add({
|
|
||||||
pattern = {
|
|
||||||
[".*/%.github/workflows/.*%.ya?ml"] = "yaml.ghactions",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
This sets the filetype to `yaml.ghactions` for YAML files in `.github/workflows/`, allowing you to keep separate YAML LSP configurations if needed.
|
|
||||||
|
|
||||||
#### 3. Create the LSP configuration
|
|
||||||
|
|
||||||
As of Neovim 0.11+ you can add this configuration in `~/.config/nvim/lsp/actionsls.lua`:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
local function get_github_token()
|
|
||||||
local handle = io.popen("gh auth token 2>/dev/null")
|
|
||||||
if not handle then return nil end
|
|
||||||
local token = handle: read("*a"):gsub("%s+", "")
|
|
||||||
handle:close()
|
|
||||||
return token ~= "" and token or nil
|
|
||||||
end
|
|
||||||
|
|
||||||
local function parse_github_remote(url)
|
|
||||||
if not url or url == "" then return nil end
|
|
||||||
|
|
||||||
-- SSH format: [email protected]:owner/repo.git
|
|
||||||
local owner, repo = url:match("git@github%.com:([^/]+)/([^/%.]+)")
|
|
||||||
if owner and repo then
|
|
||||||
return owner, repo: gsub("%.git$", "")
|
|
||||||
end
|
|
||||||
|
|
||||||
-- HTTPS format: https://github.com/owner/repo.git
|
|
||||||
owner, repo = url:match("github%.com/([^/]+)/([^/%.]+)")
|
|
||||||
if owner and repo then
|
|
||||||
return owner, repo:gsub("%.git$", "")
|
|
||||||
end
|
|
||||||
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
|
|
||||||
local function get_repo_info(owner, repo)
|
|
||||||
local cmd = string.format(
|
|
||||||
"gh repo view %s/%s --json id,owner --template '{{.id}}\t{{.owner.type}}' 2>/dev/null",
|
|
||||||
owner,
|
|
||||||
repo
|
|
||||||
)
|
|
||||||
local handle = io.popen(cmd)
|
|
||||||
if not handle then return nil end
|
|
||||||
local result = handle: read("*a"):gsub("%s+$", "")
|
|
||||||
handle:close()
|
|
||||||
|
|
||||||
local id, owner_type = result:match("^(%d+)\t(.+)$")
|
|
||||||
if id then
|
|
||||||
return {
|
|
||||||
id = tonumber(id),
|
|
||||||
organizationOwned = owner_type == "Organization",
|
|
||||||
}
|
|
||||||
end
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
|
|
||||||
local function get_repos_config()
|
|
||||||
local handle = io.popen("git rev-parse --show-toplevel 2>/dev/null")
|
|
||||||
if not handle then return nil end
|
|
||||||
local git_root = handle: read("*a"):gsub("%s+", "")
|
|
||||||
handle:close()
|
|
||||||
|
|
||||||
if git_root == "" then return nil end
|
|
||||||
|
|
||||||
handle = io.popen("git remote get-url origin 2>/dev/null")
|
|
||||||
if not handle then return nil end
|
|
||||||
local remote_url = handle:read("*a"):gsub("%s+", "")
|
|
||||||
handle:close()
|
|
||||||
|
|
||||||
local owner, name = parse_github_remote(remote_url)
|
|
||||||
if not owner or not name then return nil end
|
|
||||||
|
|
||||||
local info = get_repo_info(owner, name)
|
|
||||||
|
|
||||||
return {
|
|
||||||
{
|
|
||||||
id = info and info.id or 0,
|
|
||||||
owner = owner,
|
|
||||||
name = name,
|
|
||||||
organizationOwned = info and info.organizationOwned or false,
|
|
||||||
workspaceUri = "file://" .. git_root,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
return {
|
|
||||||
cmd = { "actions-languageserver", "--stdio" },
|
|
||||||
filetypes = { "yaml.ghactions" },
|
|
||||||
root_markers = { ".git" },
|
|
||||||
init_options = {
|
|
||||||
-- Optional: provide a GitHub token and repo context for added functionality
|
|
||||||
-- (e.g., repository-specific completions)
|
|
||||||
sessionToken = get_github_token(),
|
|
||||||
repos = get_repos_config(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4. Enable the LSP
|
|
||||||
|
|
||||||
Add to your `init.lua`:
|
|
||||||
|
|
||||||
```lua
|
|
||||||
vim.lsp.enable('actionsls')
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 5. Verify it's working
|
|
||||||
|
|
||||||
Open any `.github/workflows/*.yml` file and run:
|
|
||||||
|
|
||||||
```vim
|
|
||||||
:checkhealth vim.lsp
|
|
||||||
```
|
|
||||||
|
|
||||||
You should see `actionsls` in the list of attached clients.
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
See [CONTRIBUTING.md](../CONTRIBUTING.md) at the root of the repository for general guidelines and recommendations.
|
See [CONTRIBUTING.md](../CONTRIBUTING.md) at the root of the repository for general guidelines and recommendations.
|
||||||
@@ -294,27 +110,6 @@ or to watch for changes
|
|||||||
npm run watch
|
npm run watch
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running the language server locally
|
|
||||||
|
|
||||||
After running
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run build:cli
|
|
||||||
npm link
|
|
||||||
```
|
|
||||||
|
|
||||||
`actions-languageserver` will be available globally. You can start it with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
actions-languageserver --stdio
|
|
||||||
```
|
|
||||||
|
|
||||||
Once linked you can also watch for changes and rebuild automatically:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run watch:cli
|
|
||||||
```
|
|
||||||
|
|
||||||
### Test
|
### Test
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Executable → Regular
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/languageserver",
|
"name": "@actions/languageserver",
|
||||||
"version": "0.3.35",
|
"version": "0.4.0",
|
||||||
"description": "Language server for GitHub Actions",
|
"description": "Language server for GitHub Actions",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -31,25 +31,24 @@
|
|||||||
"url": "https://github.com/actions/languageservices"
|
"url": "https://github.com/actions/languageservices"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --build tsconfig.build.json && npm run build:cli",
|
"build": "tsc --build tsconfig.build.json",
|
||||||
"build:cli": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/cli.bundle.cjs",
|
"build:cli": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/cli.bundle.cjs",
|
||||||
"clean": "rimraf dist",
|
"clean": "rimraf dist",
|
||||||
"format": "prettier --write '**/*.ts'",
|
"format": "prettier --write '**/*.ts'",
|
||||||
"format-check": "prettier --check '**/*.ts'",
|
"format-check": "prettier --check '**/*.ts'",
|
||||||
"lint": "eslint --max-warnings 0 'src/**/*.ts'",
|
"lint": "eslint 'src/**/*.ts'",
|
||||||
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
||||||
"prepublishOnly": "npm run build && npm run test",
|
"prepublishOnly": "npm run build && npm run test",
|
||||||
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
|
"test": "NODE_OPTIONS=\"--experimental-vm-modules\" jest",
|
||||||
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
|
"test-watch": "NODE_OPTIONS=\"--experimental-vm-modules\" jest --watch",
|
||||||
"watch": "tsc --build tsconfig.build.json --watch",
|
"watch": "tsc --build tsconfig.build.json --watch"
|
||||||
"watch:cli": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/cli.bundle.cjs --watch"
|
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"actions-languageserver": "./bin/actions-languageserver"
|
"actions-languageserver": "./bin/actions-languageserver"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/languageservice": "^0.3.35",
|
"@actions/languageservice": "^0.3.25",
|
||||||
"@actions/workflow-parser": "^0.3.35",
|
"@actions/workflow-parser": "^0.3.25",
|
||||||
"@octokit/rest": "^21.1.1",
|
"@octokit/rest": "^21.1.1",
|
||||||
"@octokit/types": "^9.0.0",
|
"@octokit/types": "^9.0.0",
|
||||||
"vscode-languageserver": "^8.0.2",
|
"vscode-languageserver": "^8.0.2",
|
||||||
@@ -60,8 +59,7 @@
|
|||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*",
|
"dist/**/*"
|
||||||
"bin/**/*"
|
|
||||||
],
|
],
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "^29.0.3",
|
"@types/jest": "^29.0.3",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {documentLinks, getInlayHints, hover, validate, ValidationConfig} from "@actions/languageservice";
|
import {documentLinks, hover, validate, ValidationConfig} from "@actions/languageservice";
|
||||||
import {registerLogger, setLogLevel} from "@actions/languageservice/log";
|
import {registerLogger, setLogLevel} from "@actions/languageservice/log";
|
||||||
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
|
import {clearCache, clearCacheEntry} from "@actions/languageservice/utils/workflow-cache";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
@@ -12,27 +12,24 @@ import {
|
|||||||
HoverParams,
|
HoverParams,
|
||||||
InitializeParams,
|
InitializeParams,
|
||||||
InitializeResult,
|
InitializeResult,
|
||||||
InlayHint,
|
|
||||||
InlayHintParams,
|
|
||||||
TextDocumentIdentifier,
|
TextDocumentIdentifier,
|
||||||
TextDocumentPositionParams,
|
TextDocumentPositionParams,
|
||||||
TextDocuments,
|
TextDocuments,
|
||||||
TextDocumentSyncKind
|
TextDocumentSyncKind
|
||||||
} from "vscode-languageserver";
|
} from "vscode-languageserver";
|
||||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||||
import {getClient} from "./client.js";
|
import {getClient} from "./client";
|
||||||
import {Commands} from "./commands.js";
|
import {Commands} from "./commands";
|
||||||
import {contextProviders} from "./context-providers.js";
|
import {contextProviders} from "./context-providers";
|
||||||
import {descriptionProvider} from "./description-provider.js";
|
import {descriptionProvider} from "./description-provider";
|
||||||
import {FeatureFlags} from "@actions/expressions";
|
import {getFileProvider} from "./file-provider";
|
||||||
import {getFileProvider} from "./file-provider.js";
|
import {InitializationOptions, RepositoryContext} from "./initializationOptions";
|
||||||
import {InitializationOptions, RepositoryContext} from "./initializationOptions.js";
|
import {onCompletion} from "./on-completion";
|
||||||
import {onCompletion} from "./on-completion.js";
|
import {ReadFileRequest, Requests} from "./request";
|
||||||
import {ReadFileRequest, Requests} from "./request.js";
|
import {getActionsMetadataProvider} from "./utils/action-metadata";
|
||||||
import {getActionsMetadataProvider} from "./utils/action-metadata.js";
|
import {TTLCache} from "./utils/cache";
|
||||||
import {TTLCache} from "./utils/cache.js";
|
import {timeOperation} from "./utils/timer";
|
||||||
import {timeOperation} from "./utils/timer.js";
|
import {valueProviders} from "./value-providers";
|
||||||
import {valueProviders} from "./value-providers.js";
|
|
||||||
|
|
||||||
export function initConnection(connection: Connection) {
|
export function initConnection(connection: Connection) {
|
||||||
const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
|
const documents: TextDocuments<TextDocument> = new TextDocuments(TextDocument);
|
||||||
@@ -42,7 +39,6 @@ export function initConnection(connection: Connection) {
|
|||||||
const cache = new TTLCache();
|
const cache = new TTLCache();
|
||||||
|
|
||||||
let hasWorkspaceFolderCapability = false;
|
let hasWorkspaceFolderCapability = false;
|
||||||
let featureFlags = new FeatureFlags();
|
|
||||||
|
|
||||||
// Register remote console logger with language service
|
// Register remote console logger with language service
|
||||||
registerLogger(connection.console);
|
registerLogger(connection.console);
|
||||||
@@ -66,8 +62,6 @@ export function initConnection(connection: Connection) {
|
|||||||
setLogLevel(options.logLevel);
|
setLogLevel(options.logLevel);
|
||||||
}
|
}
|
||||||
|
|
||||||
featureFlags = new FeatureFlags(options.experimentalFeatures);
|
|
||||||
|
|
||||||
const result: InitializeResult = {
|
const result: InitializeResult = {
|
||||||
capabilities: {
|
capabilities: {
|
||||||
textDocumentSync: TextDocumentSyncKind.Full,
|
textDocumentSync: TextDocumentSyncKind.Full,
|
||||||
@@ -78,8 +72,7 @@ export function initConnection(connection: Connection) {
|
|||||||
hoverProvider: true,
|
hoverProvider: true,
|
||||||
documentLinkProvider: {
|
documentLinkProvider: {
|
||||||
resolveProvider: false
|
resolveProvider: false
|
||||||
},
|
}
|
||||||
inlayHintProvider: true
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,11 +88,6 @@ export function initConnection(connection: Connection) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
connection.onInitialized(() => {
|
connection.onInitialized(() => {
|
||||||
const enabledFeatures = featureFlags.getEnabledFeatures();
|
|
||||||
if (enabledFeatures.length > 0) {
|
|
||||||
connection.console.info(`Experimental features enabled: ${enabledFeatures.join(", ")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasWorkspaceFolderCapability) {
|
if (hasWorkspaceFolderCapability) {
|
||||||
connection.workspace.onDidChangeWorkspaceFolders(() => {
|
connection.workspace.onDidChangeWorkspaceFolders(() => {
|
||||||
clearCache();
|
clearCache();
|
||||||
@@ -123,8 +111,7 @@ export function initConnection(connection: Connection) {
|
|||||||
actionsMetadataProvider: getActionsMetadataProvider(client, cache),
|
actionsMetadataProvider: getActionsMetadataProvider(client, cache),
|
||||||
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
|
fileProvider: getFileProvider(client, cache, repoContext?.workspaceUri, async path => {
|
||||||
return await connection.sendRequest(Requests.ReadFile, {path} satisfies ReadFileRequest);
|
return await connection.sendRequest(Requests.ReadFile, {path} satisfies ReadFileRequest);
|
||||||
}),
|
})
|
||||||
featureFlags
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await validate(textDocument, config);
|
const result = await validate(textDocument, config);
|
||||||
@@ -171,12 +158,6 @@ export function initConnection(connection: Connection) {
|
|||||||
return documentLinks(getDocument(documents, textDocument), repoContext?.workspaceUri);
|
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
|
// Make the text document manager listen on the connection
|
||||||
// for open, change and close text document events
|
// for open, change and close text document events
|
||||||
documents.listen(connection);
|
documents.listen(connection);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||||
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
||||||
import {Mode} from "@actions/languageservice/context-providers/default";
|
import {Mode} from "@actions/languageservice/context-providers/default";
|
||||||
import {contextProviders} from "./context-providers.js";
|
import {contextProviders} from "./context-providers";
|
||||||
import {RepositoryContext} from "./initializationOptions.js";
|
import {RepositoryContext} from "./initializationOptions";
|
||||||
import {TTLCache} from "./utils/cache.js";
|
import {TTLCache} from "./utils/cache";
|
||||||
|
|
||||||
describe("contextProviders", () => {
|
describe("contextProviders", () => {
|
||||||
const mockCache = new TTLCache();
|
const mockCache = new TTLCache();
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import {ContextProviderConfig} from "@actions/languageservice";
|
|||||||
import {Mode} from "@actions/languageservice/context-providers/default";
|
import {Mode} from "@actions/languageservice/context-providers/default";
|
||||||
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {getSecrets} from "./context-providers/secrets.js";
|
import {getSecrets} from "./context-providers/secrets";
|
||||||
import {getStepsContext} from "./context-providers/steps.js";
|
import {getStepsContext} from "./context-providers/steps";
|
||||||
import {getVariables} from "./context-providers/variables.js";
|
import {getVariables} from "./context-providers/variables";
|
||||||
import {RepositoryContext} from "./initializationOptions.js";
|
import {RepositoryContext} from "./initializationOptions";
|
||||||
import {TTLCache} from "./utils/cache.js";
|
import {TTLCache} from "./utils/cache";
|
||||||
|
|
||||||
export function contextProviders(
|
export function contextProviders(
|
||||||
client: Octokit | undefined,
|
client: Octokit | undefined,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {ActionOutputs, ActionReference} from "@actions/languageservice/action";
|
import {ActionOutputs, ActionReference} from "@actions/languageservice/action";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {fetchActionMetadata} from "../utils/action-metadata.js";
|
import {fetchActionMetadata} from "../utils/action-metadata";
|
||||||
import {TTLCache} from "../utils/cache.js";
|
import {TTLCache} from "../utils/cache";
|
||||||
|
|
||||||
export async function getActionOutputs(
|
export async function getActionOutputs(
|
||||||
octokit: Octokit,
|
octokit: Octokit,
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import {warn} from "@actions/languageservice/log";
|
|||||||
import {isMapping, isString} from "@actions/workflow-parser";
|
import {isMapping, isString} from "@actions/workflow-parser";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
|
|
||||||
import {RepositoryContext} from "../initializationOptions.js";
|
import {RepositoryContext} from "../initializationOptions";
|
||||||
import {TTLCache} from "../utils/cache.js";
|
import {TTLCache} from "../utils/cache";
|
||||||
import {errorStatus} from "../utils/error.js";
|
import {errorStatus} from "../utils/error";
|
||||||
import {getRepoPermission} from "../utils/repo-permission.js";
|
import {getRepoPermission} from "../utils/repo-permission";
|
||||||
|
|
||||||
export async function getSecrets(
|
export async function getSecrets(
|
||||||
workflowContext: WorkflowContext,
|
workflowContext: WorkflowContext,
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import {getStepsContext as getDefaultStepsContext} from "@actions/languageservic
|
|||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import fetchMock from "fetch-mock";
|
import fetchMock from "fetch-mock";
|
||||||
|
|
||||||
import {createWorkflowContext} from "../test-utils/workflow-context.js";
|
import {createWorkflowContext} from "../test-utils/workflow-context";
|
||||||
import {TTLCache} from "../utils/cache.js";
|
import {TTLCache} from "../utils/cache";
|
||||||
import {getStepsContext} from "./steps.js";
|
import {getStepsContext} from "./steps";
|
||||||
|
|
||||||
const workflow = `
|
const workflow = `
|
||||||
name: Caching Primes
|
name: Caching Primes
|
||||||
@@ -84,17 +84,13 @@ it("outputs is an incomplete dictionary to allow dynamic outputs", async () => {
|
|||||||
|
|
||||||
// Get the step context
|
// Get the step context
|
||||||
const stepContext = stepsContext?.get("cache-primes");
|
const stepContext = stepsContext?.get("cache-primes");
|
||||||
if (!stepContext) {
|
expect(stepContext).toBeDefined();
|
||||||
throw new Error("Expected stepContext to be defined");
|
expect(isDescriptionDictionary(stepContext!)).toBe(true);
|
||||||
}
|
|
||||||
expect(isDescriptionDictionary(stepContext)).toBe(true);
|
|
||||||
|
|
||||||
// Get the outputs - should be a dictionary, not null
|
// Get the outputs - should be a dictionary, not null
|
||||||
const outputs = (stepContext as DescriptionDictionary).get("outputs");
|
const outputs = (stepContext as DescriptionDictionary).get("outputs");
|
||||||
if (!outputs) {
|
expect(outputs).toBeDefined();
|
||||||
throw new Error("Expected outputs to be defined");
|
expect(isDescriptionDictionary(outputs!)).toBe(true);
|
||||||
}
|
|
||||||
expect(isDescriptionDictionary(outputs)).toBe(true);
|
|
||||||
|
|
||||||
// Outputs should be marked incomplete to allow dynamic outputs
|
// Outputs should be marked incomplete to allow dynamic outputs
|
||||||
const outputsDict = outputs as DescriptionDictionary;
|
const outputsDict = outputs as DescriptionDictionary;
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import {parseActionReference} from "@actions/languageservice/action";
|
|||||||
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
||||||
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {TTLCache} from "../utils/cache.js";
|
import {TTLCache} from "../utils/cache";
|
||||||
import {getActionOutputs} from "./action-outputs.js";
|
import {getActionOutputs} from "./action-outputs";
|
||||||
|
|
||||||
export async function getStepsContext(
|
export async function getStepsContext(
|
||||||
octokit: Octokit,
|
octokit: Octokit,
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import {isMapping, isString} from "@actions/workflow-parser";
|
|||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {RequestError} from "@octokit/request-error";
|
import {RequestError} from "@octokit/request-error";
|
||||||
|
|
||||||
import {RepositoryContext} from "../initializationOptions.js";
|
import {RepositoryContext} from "../initializationOptions";
|
||||||
import {TTLCache} from "../utils/cache.js";
|
import {TTLCache} from "../utils/cache";
|
||||||
import {errorStatus} from "../utils/error.js";
|
import {errorStatus} from "../utils/error";
|
||||||
import {getRepoPermission} from "../utils/repo-permission.js";
|
import {getRepoPermission} from "../utils/repo-permission";
|
||||||
|
|
||||||
export async function getVariables(
|
export async function getVariables(
|
||||||
workflowContext: WorkflowContext,
|
workflowContext: WorkflowContext,
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {DescriptionProvider} from "@actions/languageservice/hover";
|
import {DescriptionProvider} from "@actions/languageservice/hover";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {getActionDescription} from "./description-providers/action-description.js";
|
import {getActionDescription} from "./description-providers/action-description";
|
||||||
import {getActionInputDescription} from "./description-providers/action-input.js";
|
import {getActionInputDescription} from "./description-providers/action-input";
|
||||||
import {TTLCache} from "./utils/cache.js";
|
import {TTLCache} from "./utils/cache";
|
||||||
|
|
||||||
export function descriptionProvider(client: Octokit | undefined, cache: TTLCache): DescriptionProvider {
|
export function descriptionProvider(client: Octokit | undefined, cache: TTLCache): DescriptionProvider {
|
||||||
const getDescription: DescriptionProvider["getDescription"] = async (context, token, path) => {
|
const getDescription: DescriptionProvider["getDescription"] = async (context, token, path) => {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import fetchMock from "fetch-mock";
|
import fetchMock from "fetch-mock";
|
||||||
import {createWorkflowContext} from "../test-utils/workflow-context.js";
|
import {createWorkflowContext} from "../test-utils/workflow-context";
|
||||||
import {TTLCache} from "../utils/cache.js";
|
import {TTLCache} from "../utils/cache";
|
||||||
import {getActionDescription} from "./action-description.js";
|
import {getActionDescription} from "./action-description";
|
||||||
import {actionsCheckoutMetadata} from "../test-utils/action-metadata.js";
|
import {actionsCheckoutMetadata} from "../test-utils/action-metadata";
|
||||||
|
|
||||||
const workflow = `
|
const workflow = `
|
||||||
name: Hello World
|
name: Hello World
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import {actionUrl, parseActionReference} from "@actions/languageservice/action";
|
|||||||
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
||||||
import {Step} from "@actions/workflow-parser/model/workflow-template";
|
import {Step} from "@actions/workflow-parser/model/workflow-template";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {fetchActionMetadata} from "../utils/action-metadata.js";
|
import {fetchActionMetadata} from "../utils/action-metadata";
|
||||||
import {TTLCache} from "../utils/cache.js";
|
import {TTLCache} from "../utils/cache";
|
||||||
|
|
||||||
export async function getActionDescription(client: Octokit, cache: TTLCache, step: Step): Promise<string | undefined> {
|
export async function getActionDescription(client: Octokit, cache: TTLCache, step: Step): Promise<string | undefined> {
|
||||||
if (!isActionStep(step)) {
|
if (!isActionStep(step)) {
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import {StringToken} from "@actions/workflow-parser/templates/tokens/string-toke
|
|||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import fetchMock from "fetch-mock";
|
import fetchMock from "fetch-mock";
|
||||||
|
|
||||||
import {actionsCheckoutMetadata} from "../test-utils/action-metadata.js";
|
import {actionsCheckoutMetadata} from "../test-utils/action-metadata";
|
||||||
import {createWorkflowContext} from "../test-utils/workflow-context.js";
|
import {createWorkflowContext} from "../test-utils/workflow-context";
|
||||||
import {TTLCache} from "../utils/cache.js";
|
import {TTLCache} from "../utils/cache";
|
||||||
import {getActionInputDescription} from "./action-input.js";
|
import {getActionInputDescription} from "./action-input";
|
||||||
|
|
||||||
const workflow = `
|
const workflow = `
|
||||||
name: Hello World
|
name: Hello World
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
|||||||
import {Step} from "@actions/workflow-parser/model/workflow-template";
|
import {Step} from "@actions/workflow-parser/model/workflow-template";
|
||||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {fetchActionMetadata} from "../utils/action-metadata.js";
|
import {fetchActionMetadata} from "../utils/action-metadata";
|
||||||
import {TTLCache} from "../utils/cache.js";
|
import {TTLCache} from "../utils/cache";
|
||||||
|
|
||||||
export async function getActionInputDescription(
|
export async function getActionInputDescription(
|
||||||
client: Octokit,
|
client: Octokit,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import {File} from "@actions/workflow-parser/workflows/file";
|
|||||||
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
||||||
import {fileIdentifier} from "@actions/workflow-parser/workflows/file-reference";
|
import {fileIdentifier} from "@actions/workflow-parser/workflows/file-reference";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {TTLCache} from "./utils/cache.js";
|
import {TTLCache} from "./utils/cache";
|
||||||
import * as vscodeURI from "vscode-uri";
|
import * as vscodeURI from "vscode-uri";
|
||||||
|
|
||||||
export function getFileProvider(
|
export function getFileProvider(
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from "vscode-languageserver/browser";
|
} from "vscode-languageserver/browser";
|
||||||
import {createConnection as createNodeConnection} from "vscode-languageserver/node";
|
import {createConnection as createNodeConnection} from "vscode-languageserver/node";
|
||||||
|
|
||||||
import {initConnection} from "./connection.js";
|
import {initConnection} from "./connection";
|
||||||
|
|
||||||
/** Helper function determining whether we are executing with node runtime */
|
/** Helper function determining whether we are executing with node runtime */
|
||||||
function isNode(): boolean {
|
function isNode(): boolean {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import {ExperimentalFeatures} from "@actions/expressions";
|
|
||||||
import {LogLevel} from "@actions/languageservice/log";
|
import {LogLevel} from "@actions/languageservice/log";
|
||||||
export {LogLevel} from "@actions/languageservice/log";
|
export {LogLevel} from "@actions/languageservice/log";
|
||||||
|
|
||||||
@@ -29,12 +28,6 @@ export interface InitializationOptions {
|
|||||||
* If a GitHub Enterprise Server should be used, the URL of the API endpoint, eg "https://ghe.my-company.com/api/v3"
|
* If a GitHub Enterprise Server should be used, the URL of the API endpoint, eg "https://ghe.my-company.com/api/v3"
|
||||||
*/
|
*/
|
||||||
gitHubApiUrl?: string;
|
gitHubApiUrl?: string;
|
||||||
|
|
||||||
/**
|
|
||||||
* Experimental features that are opt-in.
|
|
||||||
* Features listed here may change or be removed without notice.
|
|
||||||
*/
|
|
||||||
experimentalFeatures?: ExperimentalFeatures;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RepositoryContext {
|
export interface RepositoryContext {
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import {complete} from "@actions/languageservice/complete";
|
|||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {CompletionItem, Connection, Position} from "vscode-languageserver";
|
import {CompletionItem, Connection, Position} from "vscode-languageserver";
|
||||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||||
import {contextProviders} from "./context-providers.js";
|
import {contextProviders} from "./context-providers";
|
||||||
import {getFileProvider} from "./file-provider.js";
|
import {getFileProvider} from "./file-provider";
|
||||||
import {RepositoryContext} from "./initializationOptions.js";
|
import {RepositoryContext} from "./initializationOptions";
|
||||||
import {Requests} from "./request.js";
|
import {Requests} from "./request";
|
||||||
import {TTLCache} from "./utils/cache.js";
|
import {TTLCache} from "./utils/cache";
|
||||||
import {valueProviders} from "./value-providers.js";
|
import {valueProviders} from "./value-providers";
|
||||||
|
|
||||||
export async function onCompletion(
|
export async function onCompletion(
|
||||||
connection: Connection,
|
connection: Connection,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import fetchMock from "fetch-mock";
|
import fetchMock from "fetch-mock";
|
||||||
import {fetchActionMetadata} from "./action-metadata.js";
|
import {fetchActionMetadata} from "./action-metadata";
|
||||||
import {TTLCache} from "./cache.js";
|
import {TTLCache} from "./cache";
|
||||||
|
|
||||||
// A simplified version of the action.yml file from actions/checkout
|
// A simplified version of the action.yml file from actions/checkout
|
||||||
const actionMetadataContent = `
|
const actionMetadataContent = `
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import {ActionsMetadataProvider} from "@actions/languageservice";
|
|||||||
import {error} from "@actions/languageservice/log";
|
import {error} from "@actions/languageservice/log";
|
||||||
import {Octokit, RestEndpointMethodTypes} from "@octokit/rest";
|
import {Octokit, RestEndpointMethodTypes} from "@octokit/rest";
|
||||||
import {parse} from "yaml";
|
import {parse} from "yaml";
|
||||||
import {TTLCache} from "./cache.js";
|
import {TTLCache} from "./cache";
|
||||||
import {errorMessage, errorStatus} from "./error.js";
|
import {errorMessage, errorStatus} from "./error";
|
||||||
|
|
||||||
export function getActionsMetadataProvider(
|
export function getActionsMetadataProvider(
|
||||||
client: Octokit | undefined,
|
client: Octokit | undefined,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {error} from "@actions/languageservice/log";
|
import {error} from "@actions/languageservice/log";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {RepositoryContext} from "../initializationOptions.js";
|
import {RepositoryContext} from "../initializationOptions";
|
||||||
import {TTLCache} from "./cache.js";
|
import {TTLCache} from "./cache";
|
||||||
import {errorStatus} from "./error.js";
|
import {errorStatus} from "./error";
|
||||||
import {getUsername} from "./username.js";
|
import {getUsername} from "./username";
|
||||||
|
|
||||||
export type RepoPermission = "admin" | "write" | "read" | "none";
|
export type RepoPermission = "admin" | "write" | "read" | "none";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {TTLCache} from "./cache.js";
|
import {TTLCache} from "./cache";
|
||||||
|
|
||||||
export async function getUsername(octokit: Octokit, cache: TTLCache): Promise<string> {
|
export async function getUsername(octokit: Octokit, cache: TTLCache): Promise<string> {
|
||||||
return await cache.get(`/username`, undefined, () => fetchUsername(octokit));
|
return await cache.get(`/username`, undefined, () => fetchUsername(octokit));
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import {ValueProviderConfig} from "@actions/languageservice";
|
|||||||
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
|
||||||
import {ValueProviderKind} from "@actions/languageservice/value-providers/config";
|
import {ValueProviderKind} from "@actions/languageservice/value-providers/config";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {RepositoryContext} from "./initializationOptions.js";
|
import {RepositoryContext} from "./initializationOptions";
|
||||||
import {TTLCache} from "./utils/cache.js";
|
import {TTLCache} from "./utils/cache";
|
||||||
import {getActionInputValues} from "./value-providers/action-inputs.js";
|
import {getActionInputValues} from "./value-providers/action-inputs";
|
||||||
import {getEnvironments} from "./value-providers/job-environment.js";
|
import {getEnvironments} from "./value-providers/job-environment";
|
||||||
import {getRunnerLabels} from "./value-providers/runs-on.js";
|
import {getRunnerLabels} from "./value-providers/runs-on";
|
||||||
|
|
||||||
export function valueProviders(
|
export function valueProviders(
|
||||||
client: Octokit | undefined,
|
client: Octokit | undefined,
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import {WorkflowContext} from "@actions/languageservice/context/workflow-context
|
|||||||
import {Value} from "@actions/languageservice/value-providers/config";
|
import {Value} from "@actions/languageservice/value-providers/config";
|
||||||
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
import {isActionStep} from "@actions/workflow-parser/model/type-guards";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {fetchActionMetadata} from "../utils/action-metadata.js";
|
import {fetchActionMetadata} from "../utils/action-metadata";
|
||||||
import {TTLCache} from "../utils/cache.js";
|
import {TTLCache} from "../utils/cache";
|
||||||
|
|
||||||
export async function getActionInputs(
|
export async function getActionInputs(
|
||||||
client: Octokit,
|
client: Octokit,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {Value} from "@actions/languageservice/value-providers/config";
|
import {Value} from "@actions/languageservice/value-providers/config";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {TTLCache} from "../utils/cache.js";
|
import {TTLCache} from "../utils/cache";
|
||||||
|
|
||||||
export async function getEnvironments(client: Octokit, cache: TTLCache, owner: string, name: string): Promise<Value[]> {
|
export async function getEnvironments(client: Octokit, cache: TTLCache, owner: string, name: string): Promise<Value[]> {
|
||||||
const environments = await cache.get(`${owner}/${name}/environments`, undefined, () =>
|
const environments = await cache.get(`${owner}/${name}/environments`, undefined, () =>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import {log} from "@actions/languageservice/log";
|
|||||||
import {Value} from "@actions/languageservice/value-providers/config";
|
import {Value} from "@actions/languageservice/value-providers/config";
|
||||||
import {DEFAULT_RUNNER_LABELS} from "@actions/languageservice/value-providers/default";
|
import {DEFAULT_RUNNER_LABELS} from "@actions/languageservice/value-providers/default";
|
||||||
import {Octokit} from "@octokit/rest";
|
import {Octokit} from "@octokit/rest";
|
||||||
import {TTLCache} from "../utils/cache.js";
|
import {TTLCache} from "../utils/cache";
|
||||||
import {errorMessage} from "../utils/error.js";
|
import {errorMessage} from "../utils/error";
|
||||||
|
|
||||||
// Limitation: getRunnerLabels returns default hosted labels and labels for repository self-hosted runners.
|
// Limitation: getRunnerLabels returns default hosted labels and labels for repository self-hosted runners.
|
||||||
// It doesn't return labels for organization runners visible to the repository.
|
// It doesn't return labels for organization runners visible to the repository.
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
"declaration": true,
|
"declaration": true,
|
||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
"noEmit": false,
|
"noEmit": false,
|
||||||
"outDir": "./dist",
|
"outDir": "./dist"
|
||||||
"skipLibCheck": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@actions/languageservice",
|
"name": "@actions/languageservice",
|
||||||
"version": "0.3.35",
|
"version": "0.3.25",
|
||||||
"description": "Language service for GitHub Actions",
|
"description": "Language service for GitHub Actions",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
"clean": "rimraf dist",
|
"clean": "rimraf dist",
|
||||||
"format": "prettier --write '**/*.ts'",
|
"format": "prettier --write '**/*.ts'",
|
||||||
"format-check": "prettier --check '**/*.ts'",
|
"format-check": "prettier --check '**/*.ts'",
|
||||||
"lint": "eslint --max-warnings 0 'src/**/*.ts'",
|
"lint": "eslint 'src/**/*.ts'",
|
||||||
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
"lint-fix": "eslint --fix 'src/**/*.ts'",
|
||||||
"minify-json": "node ../script/minify-json.js src/context-providers/descriptions.json src/context-providers/events/webhooks.json src/context-providers/events/objects.json src/context-providers/events/schedule.json src/context-providers/events/workflow_call.json",
|
"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",
|
"prebuild": "npm run minify-json",
|
||||||
@@ -47,8 +47,8 @@
|
|||||||
"watch": "tsc --build tsconfig.build.json --watch"
|
"watch": "tsc --build tsconfig.build.json --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@actions/expressions": "^0.3.35",
|
"@actions/expressions": "^0.3.25",
|
||||||
"@actions/workflow-parser": "^0.3.35",
|
"@actions/workflow-parser": "^0.3.25",
|
||||||
"vscode-languageserver-textdocument": "^1.0.7",
|
"vscode-languageserver-textdocument": "^1.0.7",
|
||||||
"vscode-languageserver-types": "^3.17.2",
|
"vscode-languageserver-types": "^3.17.2",
|
||||||
"vscode-uri": "^3.0.8",
|
"vscode-uri": "^3.0.8",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {actionIdentifier, parseActionReference as parse} from "./action.js";
|
import {actionIdentifier, parseActionReference as parse} from "./action";
|
||||||
|
|
||||||
describe("parseActionReference", () => {
|
describe("parseActionReference", () => {
|
||||||
it("basic action", () => {
|
it("basic action", () => {
|
||||||
|
|||||||
@@ -1,557 +0,0 @@
|
|||||||
import {FeatureFlags} from "@actions/expressions";
|
|
||||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
|
||||||
import {complete, CompletionConfig} from "./complete";
|
|
||||||
import {clearCache} from "./utils/workflow-cache";
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
clearCache();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Config to enable action scaffolding snippets
|
|
||||||
const scaffoldingConfig: CompletionConfig = {
|
|
||||||
featureFlags: new FeatureFlags({actionScaffoldingSnippets: true})
|
|
||||||
};
|
|
||||||
|
|
||||||
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");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("filters runs keys for node20 actions", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`name: Test
|
|
||||||
description: Test
|
|
||||||
runs:
|
|
||||||
using: node20
|
|
||||||
|`);
|
|
||||||
const completions = await complete(doc, position);
|
|
||||||
const labels = completions.map(c => c.label);
|
|
||||||
|
|
||||||
// Should show Node.js action keys
|
|
||||||
expect(labels).toContain("main");
|
|
||||||
expect(labels).toContain("pre");
|
|
||||||
expect(labels).toContain("post");
|
|
||||||
expect(labels).toContain("pre-if");
|
|
||||||
expect(labels).toContain("post-if");
|
|
||||||
|
|
||||||
// Should NOT show composite or docker keys
|
|
||||||
expect(labels).not.toContain("steps");
|
|
||||||
expect(labels).not.toContain("image");
|
|
||||||
expect(labels).not.toContain("entrypoint");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("filters runs keys for composite actions", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`name: Test
|
|
||||||
description: Test
|
|
||||||
runs:
|
|
||||||
using: composite
|
|
||||||
|`);
|
|
||||||
const completions = await complete(doc, position);
|
|
||||||
const labels = completions.map(c => c.label);
|
|
||||||
|
|
||||||
// Should show composite action keys
|
|
||||||
expect(labels).toContain("steps");
|
|
||||||
|
|
||||||
// Should NOT show Node.js or docker keys
|
|
||||||
expect(labels).not.toContain("main");
|
|
||||||
expect(labels).not.toContain("pre");
|
|
||||||
expect(labels).not.toContain("post");
|
|
||||||
expect(labels).not.toContain("image");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("filters runs keys for docker actions", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`name: Test
|
|
||||||
description: Test
|
|
||||||
runs:
|
|
||||||
using: docker
|
|
||||||
|`);
|
|
||||||
const completions = await complete(doc, position);
|
|
||||||
const labels = completions.map(c => c.label);
|
|
||||||
|
|
||||||
// Should show Docker action keys
|
|
||||||
expect(labels).toContain("image");
|
|
||||||
expect(labels).toContain("args");
|
|
||||||
expect(labels).toContain("env");
|
|
||||||
expect(labels).toContain("entrypoint");
|
|
||||||
expect(labels).toContain("pre-entrypoint");
|
|
||||||
expect(labels).toContain("post-entrypoint");
|
|
||||||
|
|
||||||
// Should NOT show Node.js or composite keys
|
|
||||||
expect(labels).not.toContain("main");
|
|
||||||
expect(labels).not.toContain("steps");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prioritizes using when not set", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`name: Test
|
|
||||||
description: Test
|
|
||||||
runs:
|
|
||||||
|`);
|
|
||||||
const completions = await complete(doc, position);
|
|
||||||
|
|
||||||
// Find the using completion
|
|
||||||
const usingCompletion = completions.find(c => c.label === "using");
|
|
||||||
expect(usingCompletion).toBeDefined();
|
|
||||||
|
|
||||||
// It should have a sortText that makes it sort first
|
|
||||||
expect(usingCompletion?.sortText).toBe("0_using");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("completes step keys inside composite action steps", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`name: Test
|
|
||||||
description: Test
|
|
||||||
runs:
|
|
||||||
using: composite
|
|
||||||
steps:
|
|
||||||
- run: echo hello
|
|
||||||
shell: bash
|
|
||||||
- |`);
|
|
||||||
const completions = await complete(doc, position);
|
|
||||||
const labels = completions.map(c => c.label);
|
|
||||||
|
|
||||||
// Should show step keys, not filtered by runs-level logic
|
|
||||||
expect(labels).toContain("run");
|
|
||||||
expect(labels).toContain("uses");
|
|
||||||
expect(labels).toContain("shell");
|
|
||||||
expect(labels).toContain("id");
|
|
||||||
expect(labels).toContain("name");
|
|
||||||
expect(labels).toContain("if");
|
|
||||||
expect(labels).toContain("env");
|
|
||||||
expect(labels).toContain("working-directory");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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("includes descriptions from schema for completion items", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`|`, "file:///my-repo/action.yml");
|
|
||||||
const completions = await complete(doc, position);
|
|
||||||
|
|
||||||
const authorCompletion = completions.find(c => c.label === "author");
|
|
||||||
expect(authorCompletion).toBeDefined();
|
|
||||||
expect(authorCompletion?.documentation).toBeDefined();
|
|
||||||
expect((authorCompletion?.documentation as {value: string})?.value).toContain("author");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("includes descriptions for branding completion", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`|`, "file:///my-repo/action.yml");
|
|
||||||
const completions = await complete(doc, position);
|
|
||||||
|
|
||||||
const brandingCompletion = completions.find(c => c.label === "branding");
|
|
||||||
expect(brandingCompletion).toBeDefined();
|
|
||||||
expect(brandingCompletion?.documentation).toBeDefined();
|
|
||||||
expect((brandingCompletion?.documentation as {value: string})?.value).toContain("branding");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to type description when property has no description", async () => {
|
|
||||||
// `inputs` uses shorthand form in schema: "inputs": "inputs-strict"
|
|
||||||
// So the property has no description, but the type `inputs-strict` does
|
|
||||||
const [doc, position] = createActionDocument(`|`, "file:///my-repo/action.yml");
|
|
||||||
const completions = await complete(doc, position);
|
|
||||||
|
|
||||||
const inputsCompletion = completions.find(c => c.label === "inputs");
|
|
||||||
expect(inputsCompletion).toBeDefined();
|
|
||||||
expect(inputsCompletion?.documentation).toBeDefined();
|
|
||||||
expect((inputsCompletion?.documentation as {value: string})?.value).toContain("Input parameters");
|
|
||||||
});
|
|
||||||
|
|
||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("action scaffolding snippets", () => {
|
|
||||||
it("offers full scaffolding snippets in empty file", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`|`);
|
|
||||||
const completions = await complete(doc, position, scaffoldingConfig);
|
|
||||||
const labels = completions.map(c => c.label);
|
|
||||||
|
|
||||||
expect(labels).toContain("Node.js Action");
|
|
||||||
expect(labels).toContain("Composite Action");
|
|
||||||
expect(labels).toContain("Docker Action");
|
|
||||||
|
|
||||||
// Verify they are snippets
|
|
||||||
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
|
|
||||||
expect(nodeSnippet?.kind).toBe(15); // CompletionItemKind.Snippet
|
|
||||||
expect(nodeSnippet?.insertTextFormat).toBe(2); // InsertTextFormat.Snippet
|
|
||||||
});
|
|
||||||
|
|
||||||
it("offers full scaffolding snippets when no name or description exists", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`author: me
|
|
||||||
|`);
|
|
||||||
const completions = await complete(doc, position, scaffoldingConfig);
|
|
||||||
|
|
||||||
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
|
|
||||||
expect(nodeSnippet).toBeDefined();
|
|
||||||
// Full snippet should include name:
|
|
||||||
expect((nodeSnippet?.textEdit as {newText: string})?.newText).toContain("name:");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("offers runs-only snippets when name exists", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`name: My Action
|
|
||||||
|`);
|
|
||||||
const completions = await complete(doc, position, scaffoldingConfig);
|
|
||||||
|
|
||||||
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
|
|
||||||
expect(nodeSnippet).toBeDefined();
|
|
||||||
// Runs-only snippet should start with inputs:, not name:
|
|
||||||
expect((nodeSnippet?.textEdit as {newText: string})?.newText).toMatch(/^inputs:/);
|
|
||||||
expect((nodeSnippet?.textEdit as {newText: string})?.newText).toContain("runs:");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("offers runs-only snippets when description exists", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`description: Does something
|
|
||||||
|`);
|
|
||||||
const completions = await complete(doc, position, scaffoldingConfig);
|
|
||||||
|
|
||||||
const compositeSnippet = completions.find(c => c.label === "Composite Action");
|
|
||||||
expect(compositeSnippet).toBeDefined();
|
|
||||||
// Runs-only snippet should start with inputs:, not description:
|
|
||||||
expect((compositeSnippet?.textEdit as {newText: string})?.newText).toMatch(/^inputs:/);
|
|
||||||
expect((compositeSnippet?.textEdit as {newText: string})?.newText).toContain("runs:");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not offer snippets when runs.using already exists", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`name: My Action
|
|
||||||
description: Test
|
|
||||||
runs:
|
|
||||||
using: composite
|
|
||||||
|`);
|
|
||||||
const completions = await complete(doc, position, scaffoldingConfig);
|
|
||||||
const labels = completions.map(c => c.label);
|
|
||||||
|
|
||||||
expect(labels).not.toContain("Node.js Action");
|
|
||||||
expect(labels).not.toContain("Composite Action");
|
|
||||||
expect(labels).not.toContain("Docker Action");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("offers snippets inside runs when using is not set", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`name: My Action
|
|
||||||
description: Test
|
|
||||||
runs:
|
|
||||||
|`);
|
|
||||||
const completions = await complete(doc, position, scaffoldingConfig);
|
|
||||||
const labels = completions.map(c => c.label);
|
|
||||||
|
|
||||||
expect(labels).toContain("Node.js Action");
|
|
||||||
expect(labels).toContain("Composite Action");
|
|
||||||
expect(labels).toContain("Docker Action");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not offer snippets at root level when runs exists", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`name: My Action
|
|
||||||
description: Test
|
|
||||||
runs:
|
|
||||||
steps: []
|
|
||||||
|`);
|
|
||||||
const completions = await complete(doc, position, scaffoldingConfig);
|
|
||||||
const labels = completions.map(c => c.label);
|
|
||||||
|
|
||||||
expect(labels).not.toContain("Node.js Action");
|
|
||||||
expect(labels).not.toContain("Composite Action");
|
|
||||||
expect(labels).not.toContain("Docker Action");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not offer snippets when nested inside runs steps", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`name: My Action
|
|
||||||
description: Test
|
|
||||||
runs:
|
|
||||||
using: composite
|
|
||||||
steps:
|
|
||||||
- |`);
|
|
||||||
const completions = await complete(doc, position, scaffoldingConfig);
|
|
||||||
const labels = completions.map(c => c.label);
|
|
||||||
|
|
||||||
expect(labels).not.toContain("Node.js Action");
|
|
||||||
expect(labels).not.toContain("Composite Action");
|
|
||||||
expect(labels).not.toContain("Docker Action");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Node.js snippet contains expected content", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`|`);
|
|
||||||
const completions = await complete(doc, position, scaffoldingConfig);
|
|
||||||
|
|
||||||
const nodeSnippet = completions.find(c => c.label === "Node.js Action");
|
|
||||||
const text = (nodeSnippet?.textEdit as {newText: string})?.newText;
|
|
||||||
|
|
||||||
expect(text).toContain("using: node24");
|
|
||||||
expect(text).toContain("main:");
|
|
||||||
expect(text).toContain("inputs:");
|
|
||||||
expect(text).toContain("outputs:");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Composite snippet contains expected content", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`|`);
|
|
||||||
const completions = await complete(doc, position, scaffoldingConfig);
|
|
||||||
|
|
||||||
const compositeSnippet = completions.find(c => c.label === "Composite Action");
|
|
||||||
const text = (compositeSnippet?.textEdit as {newText: string})?.newText;
|
|
||||||
|
|
||||||
expect(text).toContain("using: composite");
|
|
||||||
expect(text).toContain("steps:");
|
|
||||||
expect(text).toContain("shell: bash");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("Docker snippet contains expected content", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`|`);
|
|
||||||
const completions = await complete(doc, position, scaffoldingConfig);
|
|
||||||
|
|
||||||
const dockerSnippet = completions.find(c => c.label === "Docker Action");
|
|
||||||
const text = (dockerSnippet?.textEdit as {newText: string})?.newText;
|
|
||||||
|
|
||||||
expect(text).toContain("using: docker");
|
|
||||||
expect(text).toContain("image:");
|
|
||||||
expect(text).toContain("entrypoint:");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not offer snippets when feature flag is disabled", async () => {
|
|
||||||
const [doc, position] = createActionDocument(`|`);
|
|
||||||
const completions = await complete(doc, position);
|
|
||||||
const labels = completions.map(c => c.label);
|
|
||||||
|
|
||||||
expect(labels).not.toContain("Node.js Action");
|
|
||||||
expect(labels).not.toContain("Composite Action");
|
|
||||||
expect(labels).not.toContain("Docker Action");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,468 +0,0 @@
|
|||||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
|
|
||||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
|
||||||
import {Position} from "vscode-languageserver-textdocument";
|
|
||||||
import {CompletionItem, CompletionItemKind, InsertTextFormat, TextEdit} from "vscode-languageserver-types";
|
|
||||||
import {Value} from "./value-providers/config.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Valid keys for each action type under the `runs:` section.
|
|
||||||
* Source: https://github.com/actions/runner/blob/main/src/Runner.Worker/ActionManifestManager.cs
|
|
||||||
*/
|
|
||||||
const ACTION_NODE_KEYS = new Set(["using", "main", "pre", "post", "pre-if", "post-if"]);
|
|
||||||
const ACTION_COMPOSITE_KEYS = new Set(["using", "steps"]);
|
|
||||||
const ACTION_DOCKER_KEYS = new Set([
|
|
||||||
"using",
|
|
||||||
"image",
|
|
||||||
"args",
|
|
||||||
"env",
|
|
||||||
"entrypoint",
|
|
||||||
"pre-entrypoint",
|
|
||||||
"pre-if",
|
|
||||||
"post-entrypoint",
|
|
||||||
"post-if"
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Action scaffolding snippets.
|
|
||||||
*
|
|
||||||
* Full variants include name, description, inputs, outputs, and runs.
|
|
||||||
* Runs-only variants include just the runs block.
|
|
||||||
*/
|
|
||||||
const ACTION_SNIPPET_NODEJS_FULL = `name: '\${1:Action Name}'
|
|
||||||
description: '\${2:What this action does}'
|
|
||||||
|
|
||||||
inputs:
|
|
||||||
name:
|
|
||||||
description: 'Name to greet'
|
|
||||||
required: false
|
|
||||||
default: 'World'
|
|
||||||
|
|
||||||
outputs:
|
|
||||||
greeting:
|
|
||||||
description: 'The greeting message'
|
|
||||||
|
|
||||||
runs:
|
|
||||||
# For more on JavaScript actions (including @actions/toolkit), see:
|
|
||||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action
|
|
||||||
using: node24
|
|
||||||
main: index.js
|
|
||||||
# Sample index.js (vanilla JS, no build required):
|
|
||||||
#
|
|
||||||
# const fs = require('fs');
|
|
||||||
# const name = process.env.INPUT_NAME || 'World';
|
|
||||||
# const greeting = \\\`Hello \\\${name}\\\`;
|
|
||||||
# console.log(greeting);
|
|
||||||
# fs.appendFileSync(process.env.GITHUB_OUTPUT, \\\`greeting=\\\${greeting}\\\\n\\\`);
|
|
||||||
#
|
|
||||||
# For JavaScript actions with @actions/toolkit, see:
|
|
||||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ACTION_SNIPPET_NODEJS_RUNS = `inputs:
|
|
||||||
name:
|
|
||||||
description: 'Name to greet'
|
|
||||||
required: false
|
|
||||||
default: 'World'
|
|
||||||
|
|
||||||
outputs:
|
|
||||||
greeting:
|
|
||||||
description: 'The greeting message'
|
|
||||||
|
|
||||||
runs:
|
|
||||||
# For more on JavaScript actions (including @actions/toolkit), see:
|
|
||||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action
|
|
||||||
using: node24
|
|
||||||
main: index.js
|
|
||||||
# Sample index.js (vanilla JS, no build required):
|
|
||||||
#
|
|
||||||
# const fs = require('fs');
|
|
||||||
# const name = process.env.INPUT_NAME || 'World';
|
|
||||||
# const greeting = \\\`Hello \\\${name}\\\`;
|
|
||||||
# console.log(greeting);
|
|
||||||
# fs.appendFileSync(process.env.GITHUB_OUTPUT, \\\`greeting=\\\${greeting}\\\\n\\\`);
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ACTION_SNIPPET_NODEJS_USING = `# For more on JavaScript actions (including @actions/toolkit), see:
|
|
||||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-javascript-action
|
|
||||||
using: node24
|
|
||||||
main: index.js
|
|
||||||
# Sample index.js (vanilla JS, no build required):
|
|
||||||
#
|
|
||||||
# console.log('Hello World');
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ACTION_SNIPPET_COMPOSITE_FULL = `name: '\${1:Action Name}'
|
|
||||||
description: '\${2:What this action does}'
|
|
||||||
|
|
||||||
inputs:
|
|
||||||
name:
|
|
||||||
description: 'Name to greet'
|
|
||||||
required: false
|
|
||||||
default: 'World'
|
|
||||||
|
|
||||||
outputs:
|
|
||||||
greeting:
|
|
||||||
description: 'The greeting message'
|
|
||||||
value: \\\${{ steps.greet.outputs.greeting }}
|
|
||||||
|
|
||||||
runs:
|
|
||||||
# For more on composite actions, see:
|
|
||||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action
|
|
||||||
using: composite
|
|
||||||
steps:
|
|
||||||
- id: greet
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
INPUT_NAME: \\\${{ inputs.name }}
|
|
||||||
run: |
|
|
||||||
GREETING="Hello $INPUT_NAME"
|
|
||||||
echo "$GREETING"
|
|
||||||
echo "greeting=$GREETING" >> $GITHUB_OUTPUT
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ACTION_SNIPPET_COMPOSITE_RUNS = `inputs:
|
|
||||||
name:
|
|
||||||
description: 'Name to greet'
|
|
||||||
required: false
|
|
||||||
default: 'World'
|
|
||||||
|
|
||||||
outputs:
|
|
||||||
greeting:
|
|
||||||
description: 'The greeting message'
|
|
||||||
value: \\\${{ steps.greet.outputs.greeting }}
|
|
||||||
|
|
||||||
runs:
|
|
||||||
# For more on composite actions, see:
|
|
||||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action
|
|
||||||
using: composite
|
|
||||||
steps:
|
|
||||||
- id: greet
|
|
||||||
shell: bash
|
|
||||||
env:
|
|
||||||
INPUT_NAME: \\\${{ inputs.name }}
|
|
||||||
run: |
|
|
||||||
GREETING="Hello $INPUT_NAME"
|
|
||||||
echo "$GREETING"
|
|
||||||
echo "greeting=$GREETING" >> $GITHUB_OUTPUT
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ACTION_SNIPPET_COMPOSITE_USING = `# For more on composite actions, see:
|
|
||||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-composite-action
|
|
||||||
using: composite
|
|
||||||
steps:
|
|
||||||
- shell: bash
|
|
||||||
run: echo "Hello World"
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ACTION_SNIPPET_DOCKER_FULL = `name: '\${1:Action Name}'
|
|
||||||
description: '\${2:What this action does}'
|
|
||||||
|
|
||||||
inputs:
|
|
||||||
name:
|
|
||||||
description: 'Name to greet'
|
|
||||||
required: false
|
|
||||||
default: 'World'
|
|
||||||
|
|
||||||
outputs:
|
|
||||||
greeting:
|
|
||||||
description: 'The greeting message'
|
|
||||||
|
|
||||||
runs:
|
|
||||||
# For more on Docker actions, see:
|
|
||||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action
|
|
||||||
using: docker
|
|
||||||
# 'docker://image:tag' uses pre-built image, 'Dockerfile' builds locally
|
|
||||||
image: '\${3:docker://alpine:3.20}'
|
|
||||||
env:
|
|
||||||
INPUT_NAME: \\\${{ inputs.name }}
|
|
||||||
entrypoint: '\${4:sh}'
|
|
||||||
args:
|
|
||||||
- -c
|
|
||||||
- |
|
|
||||||
GREETING="Hello $INPUT_NAME"
|
|
||||||
echo "$GREETING"
|
|
||||||
echo "greeting=$GREETING" >> $GITHUB_OUTPUT
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ACTION_SNIPPET_DOCKER_RUNS = `inputs:
|
|
||||||
name:
|
|
||||||
description: 'Name to greet'
|
|
||||||
required: false
|
|
||||||
default: 'World'
|
|
||||||
|
|
||||||
outputs:
|
|
||||||
greeting:
|
|
||||||
description: 'The greeting message'
|
|
||||||
|
|
||||||
runs:
|
|
||||||
# For more on Docker actions, see:
|
|
||||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action
|
|
||||||
using: docker
|
|
||||||
# 'docker://image:tag' uses pre-built image, 'Dockerfile' builds locally
|
|
||||||
image: '\${1:docker://alpine:3.20}'
|
|
||||||
env:
|
|
||||||
INPUT_NAME: \\\${{ inputs.name }}
|
|
||||||
entrypoint: '\${2:sh}'
|
|
||||||
args:
|
|
||||||
- -c
|
|
||||||
- |
|
|
||||||
GREETING="Hello $INPUT_NAME"
|
|
||||||
echo "$GREETING"
|
|
||||||
echo "greeting=$GREETING" >> $GITHUB_OUTPUT
|
|
||||||
`;
|
|
||||||
|
|
||||||
const ACTION_SNIPPET_DOCKER_USING = `# For more on Docker actions, see:
|
|
||||||
# https://docs.github.com/en/actions/sharing-automations/creating-actions/creating-a-docker-container-action
|
|
||||||
using: docker
|
|
||||||
# 'docker://image:tag' uses pre-built image, 'Dockerfile' builds locally
|
|
||||||
image: '\${1:docker://alpine:3.20}'
|
|
||||||
entrypoint: '\${2:sh}'
|
|
||||||
args:
|
|
||||||
- -c
|
|
||||||
- echo "Hello World"
|
|
||||||
`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Filters action.yml `runs:` completions based on the `using:` value.
|
|
||||||
*
|
|
||||||
* When the user is completing keys under `runs:`:
|
|
||||||
* - If `using: node20` is set, only show Node.js action keys
|
|
||||||
* - If `using: composite` is set, only show composite action keys
|
|
||||||
* - If `using: docker` is set, only show Docker action keys
|
|
||||||
* - If `using:` is not set, show all keys but prioritize `using` first
|
|
||||||
*/
|
|
||||||
export function filterActionRunsCompletions(values: Value[], path: TemplateToken[], root: TemplateToken): Value[] {
|
|
||||||
// Find the runs mapping from the root
|
|
||||||
let runsMapping: MappingToken | undefined;
|
|
||||||
if (root instanceof MappingToken) {
|
|
||||||
for (let i = 0; i < root.count; i++) {
|
|
||||||
const {key, value} = root.get(i);
|
|
||||||
if (key.toString().toLowerCase() === "runs" && value instanceof MappingToken) {
|
|
||||||
runsMapping = value;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!runsMapping) {
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the runs mapping is in our path (meaning we're completing inside it)
|
|
||||||
const isInsideRuns = path.some(token => token === runsMapping);
|
|
||||||
if (!isInsideRuns) {
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find where runsMapping is in the path
|
|
||||||
const runsMappingIndex = path.indexOf(runsMapping);
|
|
||||||
if (runsMappingIndex === -1) {
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if there's anything after runsMapping in the path
|
|
||||||
// If so, we're nested deeper (e.g., inside steps sequence or a step mapping)
|
|
||||||
if (runsMappingIndex < path.length - 1) {
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the using value from the runs mapping
|
|
||||||
let usingValue: string | undefined;
|
|
||||||
for (let i = 0; i < runsMapping.count; i++) {
|
|
||||||
const {key, value} = runsMapping.get(i);
|
|
||||||
if (key.toString().toLowerCase() === "using") {
|
|
||||||
usingValue = value.toString();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine which keys to allow
|
|
||||||
let allowedKeys: Set<string>;
|
|
||||||
|
|
||||||
if (!usingValue) {
|
|
||||||
// No using value set - show all keys but prioritize "using"
|
|
||||||
return values.map(v => {
|
|
||||||
if (v.label.toLowerCase() === "using") {
|
|
||||||
return {...v, sortText: "0_using"}; // Sort first
|
|
||||||
}
|
|
||||||
return v;
|
|
||||||
});
|
|
||||||
} else if (usingValue.match(/^node\d+$/i)) {
|
|
||||||
allowedKeys = ACTION_NODE_KEYS;
|
|
||||||
} else if (usingValue.toLowerCase() === "composite") {
|
|
||||||
allowedKeys = ACTION_COMPOSITE_KEYS;
|
|
||||||
} else if (usingValue.toLowerCase() === "docker") {
|
|
||||||
allowedKeys = ACTION_DOCKER_KEYS;
|
|
||||||
} else {
|
|
||||||
// Unknown using value - show all
|
|
||||||
return values;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter to only allowed keys
|
|
||||||
return values.filter(v => allowedKeys.has(v.label.toLowerCase()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets action scaffolding snippet completions for action.yml files.
|
|
||||||
*
|
|
||||||
* Returns snippet completions when `runs.using` is not present, offering
|
|
||||||
* three action types: Node.js, Composite, and Docker.
|
|
||||||
*
|
|
||||||
* Three variants per type:
|
|
||||||
* - "_FULL": Full scaffold with name, description, inputs, outputs, and runs
|
|
||||||
* - "_RUNS": Inputs, outputs, and runs (when name/description already exists)
|
|
||||||
* - "_USING": Minimal runs content (when inside `runs:` mapping)
|
|
||||||
*
|
|
||||||
* Which variant is shown depends on context:
|
|
||||||
* - Inside `runs:` mapping → "_USING" variants
|
|
||||||
* - At root with name/description → "_RUNS" variants
|
|
||||||
* - At root without name/description → "_FULL" variants
|
|
||||||
*/
|
|
||||||
export function getActionScaffoldingSnippets(
|
|
||||||
root: TemplateToken | undefined,
|
|
||||||
path: TemplateToken[],
|
|
||||||
position: Position
|
|
||||||
): CompletionItem[] {
|
|
||||||
// Get the runs mapping from the root, if it exists
|
|
||||||
let runsMapping: MappingToken | undefined;
|
|
||||||
if (root instanceof MappingToken) {
|
|
||||||
for (let i = 0; i < root.count; i++) {
|
|
||||||
const {key, value} = root.get(i);
|
|
||||||
if (key.toString().toLowerCase() === "runs" && value instanceof MappingToken) {
|
|
||||||
runsMapping = value;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if runs.using already exists - if so, no scaffolding needed
|
|
||||||
if (runsMapping) {
|
|
||||||
for (let i = 0; i < runsMapping.count; i++) {
|
|
||||||
const {key} = runsMapping.get(i);
|
|
||||||
if (key.toString().toLowerCase() === "using") {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show "_USING" variants directly inside `runs`
|
|
||||||
const runsMappingIndex = runsMapping ? path.indexOf(runsMapping) : -1;
|
|
||||||
const isDirectlyInsideRuns = runsMappingIndex !== -1 && runsMappingIndex === path.length - 1;
|
|
||||||
if (isDirectlyInsideRuns) {
|
|
||||||
return [
|
|
||||||
createSnippetCompletion(
|
|
||||||
"Node.js Action",
|
|
||||||
"Scaffold a Node.js action",
|
|
||||||
ACTION_SNIPPET_NODEJS_USING,
|
|
||||||
position,
|
|
||||||
"1_nodejs"
|
|
||||||
),
|
|
||||||
createSnippetCompletion(
|
|
||||||
"Composite Action",
|
|
||||||
"Scaffold a composite action",
|
|
||||||
ACTION_SNIPPET_COMPOSITE_USING,
|
|
||||||
position,
|
|
||||||
"2_composite"
|
|
||||||
),
|
|
||||||
createSnippetCompletion(
|
|
||||||
"Docker Action",
|
|
||||||
"Scaffold a Docker action",
|
|
||||||
ACTION_SNIPPET_DOCKER_USING,
|
|
||||||
position,
|
|
||||||
"3_docker"
|
|
||||||
)
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Not at root or `runs` already exists?
|
|
||||||
const isAtRoot = path.length === 0 || (path.length === 1 && path[0] === root);
|
|
||||||
if (!isAtRoot || runsMapping) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine which variant to show based on existing root keys
|
|
||||||
let hasNameOrDescription = false;
|
|
||||||
if (root instanceof MappingToken) {
|
|
||||||
for (let i = 0; i < root.count; i++) {
|
|
||||||
const keyStr = root.get(i).key.toString().toLowerCase();
|
|
||||||
if (keyStr === "name" || keyStr === "description") {
|
|
||||||
hasNameOrDescription = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show "_RUNS" variants (inputs, outputs, and runs block)
|
|
||||||
if (hasNameOrDescription) {
|
|
||||||
return [
|
|
||||||
createSnippetCompletion(
|
|
||||||
"Node.js Action",
|
|
||||||
"Scaffold a Node.js action",
|
|
||||||
ACTION_SNIPPET_NODEJS_RUNS,
|
|
||||||
position,
|
|
||||||
"1_nodejs"
|
|
||||||
),
|
|
||||||
createSnippetCompletion(
|
|
||||||
"Composite Action",
|
|
||||||
"Scaffold a composite action",
|
|
||||||
ACTION_SNIPPET_COMPOSITE_RUNS,
|
|
||||||
position,
|
|
||||||
"2_composite"
|
|
||||||
),
|
|
||||||
createSnippetCompletion(
|
|
||||||
"Docker Action",
|
|
||||||
"Scaffold a Docker action",
|
|
||||||
ACTION_SNIPPET_DOCKER_RUNS,
|
|
||||||
position,
|
|
||||||
"3_docker"
|
|
||||||
)
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show "_FULL" variants (complete scaffold)
|
|
||||||
return [
|
|
||||||
createSnippetCompletion(
|
|
||||||
"Node.js Action",
|
|
||||||
"Scaffold a complete Node.js action",
|
|
||||||
ACTION_SNIPPET_NODEJS_FULL,
|
|
||||||
position,
|
|
||||||
"1_nodejs"
|
|
||||||
),
|
|
||||||
createSnippetCompletion(
|
|
||||||
"Composite Action",
|
|
||||||
"Scaffold a complete composite action",
|
|
||||||
ACTION_SNIPPET_COMPOSITE_FULL,
|
|
||||||
position,
|
|
||||||
"2_composite"
|
|
||||||
),
|
|
||||||
createSnippetCompletion(
|
|
||||||
"Docker Action",
|
|
||||||
"Scaffold a complete Docker action",
|
|
||||||
ACTION_SNIPPET_DOCKER_FULL,
|
|
||||||
position,
|
|
||||||
"3_docker"
|
|
||||||
)
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a snippet completion item.
|
|
||||||
*/
|
|
||||||
function createSnippetCompletion(
|
|
||||||
label: string,
|
|
||||||
description: string,
|
|
||||||
snippetText: string,
|
|
||||||
position: Position,
|
|
||||||
sortText: string
|
|
||||||
): CompletionItem {
|
|
||||||
return {
|
|
||||||
label,
|
|
||||||
kind: CompletionItemKind.Snippet,
|
|
||||||
documentation: {
|
|
||||||
kind: "markdown",
|
|
||||||
value: description
|
|
||||||
},
|
|
||||||
insertTextFormat: InsertTextFormat.Snippet,
|
|
||||||
sortText,
|
|
||||||
textEdit: TextEdit.insert(position, snippetText)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||||
import {CompletionItem, CompletionItemKind} from "vscode-languageserver-types";
|
import {CompletionItem, CompletionItemKind} from "vscode-languageserver-types";
|
||||||
import {complete, getExpressionInput} from "./complete.js";
|
import {complete, getExpressionInput} from "./complete";
|
||||||
import {ContextProviderConfig} from "./context-providers/config.js";
|
import {ContextProviderConfig} from "./context-providers/config";
|
||||||
import {registerLogger} from "./log.js";
|
import {registerLogger} from "./log";
|
||||||
import {getPositionFromCursor} from "./test-utils/cursor-position.js";
|
import {getPositionFromCursor} from "./test-utils/cursor-position";
|
||||||
import {TestLogger} from "./test-utils/logger.js";
|
import {TestLogger} from "./test-utils/logger";
|
||||||
import {testFileProvider} from "./test-utils/test-file-provider.js";
|
import {testFileProvider} from "./test-utils/test-file-provider";
|
||||||
import {clearCache} from "./utils/workflow-cache.js";
|
import {clearCache} from "./utils/workflow-cache";
|
||||||
|
|
||||||
const contextProviderConfig: ContextProviderConfig = {
|
const contextProviderConfig: ContextProviderConfig = {
|
||||||
getContext: (context: string) => {
|
getContext: (context: string) => {
|
||||||
@@ -1110,7 +1110,7 @@ jobs:
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
const result = await complete(...getPositionFromCursor(input), {contextProviderConfig});
|
||||||
expect(result.map(x => x.label)).toEqual(["check_run_id", "container", "services", "status"]);
|
expect(result.map(x => x.label)).toEqual(["container", "services", "status"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("job context is suggested within a job output", async () => {
|
it("job context is suggested within a job output", async () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {complete} from "./complete.js";
|
import {complete} from "./complete";
|
||||||
import {TextDocument} from "vscode-languageserver-textdocument";
|
import {TextDocument} from "vscode-languageserver-textdocument";
|
||||||
import {clearCache} from "./utils/workflow-cache.js";
|
import {clearCache} from "./utils/workflow-cache";
|
||||||
import {getPositionFromCursor} from "./test-utils/cursor-position.js";
|
import {getPositionFromCursor} from "./test-utils/cursor-position";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
clearCache();
|
clearCache();
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {CompletionItem, MarkupContent} from "vscode-languageserver-types";
|
import {CompletionItem, MarkupContent} from "vscode-languageserver-types";
|
||||||
import {complete} from "./complete.js";
|
import {complete} from "./complete";
|
||||||
import {getPositionFromCursor} from "./test-utils/cursor-position.js";
|
import {getPositionFromCursor} from "./test-utils/cursor-position";
|
||||||
import {testFileProvider} from "./test-utils/test-file-provider.js";
|
import {testFileProvider} from "./test-utils/test-file-provider";
|
||||||
import {clearCache} from "./utils/workflow-cache.js";
|
import {clearCache} from "./utils/workflow-cache";
|
||||||
|
|
||||||
function mapResult(result: CompletionItem[]) {
|
function mapResult(result: CompletionItem[]) {
|
||||||
return result.map(x => {
|
return result.map(x => {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
import {MarkupContent, TextEdit} from "vscode-languageserver-types";
|
import {MarkupContent, TextEdit} from "vscode-languageserver-types";
|
||||||
import {complete} from "./complete.js";
|
import {complete} from "./complete";
|
||||||
import {registerLogger} from "./log.js";
|
import {registerLogger} from "./log";
|
||||||
import {getPositionFromCursor} from "./test-utils/cursor-position.js";
|
import {getPositionFromCursor} from "./test-utils/cursor-position";
|
||||||
import {TestLogger} from "./test-utils/logger.js";
|
import {TestLogger} from "./test-utils/logger";
|
||||||
import {clearCache} from "./utils/workflow-cache.js";
|
import {clearCache} from "./utils/workflow-cache";
|
||||||
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config.js";
|
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
|
||||||
|
|
||||||
registerLogger(new TestLogger());
|
registerLogger(new TestLogger());
|
||||||
|
|
||||||
@@ -19,12 +19,9 @@ describe("completion", () => {
|
|||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
|
|
||||||
expect(result).not.toBeUndefined();
|
expect(result).not.toBeUndefined();
|
||||||
// 12 runner labels + 2 escape hatches (switch to list, switch to full syntax)
|
expect(result.length).toEqual(12);
|
||||||
expect(result.length).toEqual(14);
|
|
||||||
const labels = result.map(x => x.label);
|
const labels = result.map(x => x.label);
|
||||||
expect(labels).toContain("macos-latest");
|
expect(labels).toContain("macos-latest");
|
||||||
expect(labels).toContain("(switch to list)");
|
|
||||||
expect(labels).toContain("(switch to mapping)");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("needs", async () => {
|
it("needs", async () => {
|
||||||
@@ -47,7 +44,7 @@ jobs:
|
|||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
|
|
||||||
expect(result).not.toBeUndefined();
|
expect(result).not.toBeUndefined();
|
||||||
expect(result.length).toEqual(13);
|
expect(result.length).toEqual(9);
|
||||||
expect(result[0].label).toEqual("concurrency");
|
expect(result[0].label).toEqual("concurrency");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -73,7 +70,7 @@ jobs:
|
|||||||
|`;
|
|`;
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
expect(result).not.toBeUndefined();
|
expect(result).not.toBeUndefined();
|
||||||
expect(result.length).toEqual(30);
|
expect(result.length).toEqual(21);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("string definition completion in sequence", async () => {
|
it("string definition completion in sequence", async () => {
|
||||||
@@ -98,7 +95,6 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
types: |`;
|
types: |`;
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
// Expect string values plus escape hatch to switch to list form
|
|
||||||
expect(result.map(x => x.label)).toEqual([
|
expect(result.map(x => x.label)).toEqual([
|
||||||
"created",
|
"created",
|
||||||
"deleted",
|
"deleted",
|
||||||
@@ -106,8 +102,7 @@ jobs:
|
|||||||
"prereleased",
|
"prereleased",
|
||||||
"published",
|
"published",
|
||||||
"released",
|
"released",
|
||||||
"unpublished",
|
"unpublished"
|
||||||
"(switch to list)"
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,11 +190,8 @@ jobs:
|
|||||||
const result = await complete(...getPositionFromCursor(input), {valueProviderConfig: config});
|
const result = await complete(...getPositionFromCursor(input), {valueProviderConfig: config});
|
||||||
|
|
||||||
expect(result).not.toBeUndefined();
|
expect(result).not.toBeUndefined();
|
||||||
// Custom value plus escape hatches for list and full syntax
|
expect(result.length).toEqual(1);
|
||||||
expect(result.length).toEqual(3);
|
|
||||||
expect(result[0].label).toEqual("my-custom-label");
|
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 () => {
|
it("custom value providers for sequences", async () => {
|
||||||
@@ -220,9 +212,7 @@ jobs:
|
|||||||
expect(result[0].label).toEqual("my-custom-label");
|
expect(result[0].label).toEqual("my-custom-label");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not show mapping keys or parent sibling keys in Key mode", async () => {
|
it("does not show parent mapping sibling keys", 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
|
const input = `on: push
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -230,21 +220,20 @@ jobs:
|
|||||||
runs-on: ubuntu-latest`;
|
runs-on: ubuntu-latest`;
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
expect(result).not.toBeUndefined();
|
expect(result).not.toBeUndefined();
|
||||||
// Only escape hatch to full syntax (container has mapping form but no sequence)
|
expect(result.length).toEqual(6);
|
||||||
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
|
// 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");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not show mapping keys in Key mode when structure is uncommitted", async () => {
|
it("shows mapping keys within a new map ", 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
|
const input = `on: push
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
concurrency: |`;
|
concurrency: |`;
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
expect(result).not.toBeUndefined();
|
expect(result).not.toBeUndefined();
|
||||||
expect(result.map(x => x.label)).toEqual(["(switch to mapping)"]);
|
expect(result.map(x => x.label).sort()).toEqual(["cancel-in-progress", "group"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("job key", async () => {
|
it("job key", async () => {
|
||||||
@@ -254,7 +243,7 @@ jobs:
|
|||||||
runs-|`;
|
runs-|`;
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
expect(result).not.toBeUndefined();
|
expect(result).not.toBeUndefined();
|
||||||
expect(result).toHaveLength(30);
|
expect(result).toHaveLength(21);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("job key with comment afterwards", async () => {
|
it("job key with comment afterwards", async () => {
|
||||||
@@ -265,7 +254,7 @@ jobs:
|
|||||||
#`;
|
#`;
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
expect(result).not.toBeUndefined();
|
expect(result).not.toBeUndefined();
|
||||||
expect(result).toHaveLength(30);
|
expect(result).toHaveLength(21);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("job key with other values afterwards", async () => {
|
it("job key with other values afterwards", async () => {
|
||||||
@@ -277,10 +266,7 @@ jobs:
|
|||||||
concurrency: 'group-name'`;
|
concurrency: 'group-name'`;
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
expect(result).not.toBeUndefined();
|
expect(result).not.toBeUndefined();
|
||||||
// Verify we get job-level completions, but concurrency is already present so excluded
|
expect(result).toHaveLength(20);
|
||||||
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 () => {
|
it("step key without space after colon", async () => {
|
||||||
@@ -349,9 +335,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
`;
|
`;
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
// Verify we get job-level completions including runs-on variants
|
expect(result).toHaveLength(17);
|
||||||
expect(result.length).toBeGreaterThan(20);
|
|
||||||
expect(result.some(x => x.label === "steps")).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("complete from behind a colon will replace it", async () => {
|
it("complete from behind a colon will replace it", async () => {
|
||||||
@@ -364,8 +348,7 @@ jobs:
|
|||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
`;
|
`;
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
// Verify we get job-level completions
|
expect(result).toHaveLength(17);
|
||||||
expect(result.length).toBeGreaterThan(20);
|
|
||||||
const textEdit = result[0].textEdit as TextEdit;
|
const textEdit = result[0].textEdit as TextEdit;
|
||||||
expect(textEdit.range).toEqual({
|
expect(textEdit.range).toEqual({
|
||||||
start: {line: 5, character: 4},
|
start: {line: 5, character: 4},
|
||||||
@@ -464,9 +447,8 @@ jobs:
|
|||||||
"timeout-minutes: "
|
"timeout-minutes: "
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// One-of (scalar variant)
|
// One-of
|
||||||
const concurrencyScalar = result.find(x => x.label === "concurrency" && x.labelDetails === undefined);
|
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency: "]);
|
||||||
expect(concurrencyScalar?.textEdit?.newText).toEqual("concurrency: ");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("custom indentation", async () => {
|
it("custom indentation", async () => {
|
||||||
@@ -488,21 +470,20 @@ jobs:
|
|||||||
"timeout-minutes: "
|
"timeout-minutes: "
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// One-of (scalar variant)
|
// One-of
|
||||||
const concurrencyScalar = result.find(x => x.label === "concurrency" && x.labelDetails === undefined);
|
expect(result.filter(x => x.label === "concurrency").map(x => x.textEdit?.newText)).toEqual(["concurrency: "]);
|
||||||
expect(concurrencyScalar?.textEdit?.newText).toEqual("concurrency: ");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not show mapping keys in Key mode for one-of with mapping variant", async () => {
|
it("adds a new line and indentation for mapping keys when the key is given", 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 input = "concurrency: |";
|
||||||
|
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
|
|
||||||
expect(result.filter(x => x.label === "cancel-in-progress")).toEqual([]);
|
expect(result.filter(x => x.label === "cancel-in-progress").map(x => x.textEdit?.newText)).toEqual([
|
||||||
expect(result.filter(x => x.label === "group")).toEqual([]);
|
"\n cancel-in-progress: "
|
||||||
|
]);
|
||||||
|
expect(result.filter(x => x.label === "group").map(x => x.textEdit?.newText)).toEqual(["\n group: "]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not add new line if no key in line", async () => {
|
it("does not add new line if no key in line", async () => {
|
||||||
@@ -513,15 +494,12 @@ jobs:
|
|||||||
expect(result.filter(x => x.label === "run-name").map(x => x.textEdit?.newText)).toEqual(["run-name: "]);
|
expect(result.filter(x => x.label === "run-name").map(x => x.textEdit?.newText)).toEqual(["run-name: "]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not show mapping keys when user has started typing a scalar value", async () => {
|
it("adds new line for nested mapping", 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 input = "on:\n workflow_dispatch: in|";
|
||||||
|
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
|
|
||||||
// No mapping keys should be shown since user started typing a scalar
|
expect(result.filter(x => x.label === "inputs").map(x => x.textEdit?.newText)).toEqual(["\n inputs:\n "]);
|
||||||
expect(result.filter(x => x.label === "inputs")).toEqual([]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds : for one-of", async () => {
|
it("adds : for one-of", async () => {
|
||||||
@@ -529,370 +507,14 @@ jobs:
|
|||||||
|
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
|
|
||||||
// Scalar variant inserts "types: "
|
expect(result.filter(x => x.label === "types").map(x => x.textEdit?.newText)).toEqual(["types: "]);
|
||||||
const scalarVariant = result.find(x => x.label === "types" && x.labelDetails === undefined);
|
|
||||||
expect(scalarVariant?.textEdit?.newText).toEqual("types: ");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not show mapping keys for one-of when user has typed a scalar value", async () => {
|
it("does not add : for one-of in key mode", 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 input = "on:\n check_run: ty|";
|
||||||
|
|
||||||
const result = await complete(...getPositionFromCursor(input));
|
const result = await complete(...getPositionFromCursor(input));
|
||||||
|
|
||||||
// check_run's scalar form only accepts null, so typing anything should show no completions
|
expect(result.filter(x => x.label === "types").map(x => x.textEdit?.newText)).toEqual(["types"]);
|
||||||
// (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 at cursor position (for VS Code filtering compatibility)
|
|
||||||
const listEdit = switchToList!.textEdit as TextEdit;
|
|
||||||
const fullEdit = switchToFull!.textEdit as TextEdit;
|
|
||||||
|
|
||||||
// Main textEdit inserts newline and indented content at cursor position
|
|
||||||
expect(listEdit.newText).toEqual("\n - ");
|
|
||||||
expect(fullEdit.newText).toEqual("\n ");
|
|
||||||
|
|
||||||
// TextEdit range should be at cursor position (empty range)
|
|
||||||
expect(listEdit.range.start).toEqual({line: 3, character: 13});
|
|
||||||
expect(listEdit.range.end).toEqual({line: 3, character: 13});
|
|
||||||
expect(fullEdit.range.start).toEqual({line: 3, character: 13});
|
|
||||||
expect(fullEdit.range.end).toEqual({line: 3, character: 13});
|
|
||||||
|
|
||||||
// additionalTextEdits should clean up the key portion
|
|
||||||
expect(switchToList!.additionalTextEdits).toHaveLength(1);
|
|
||||||
expect(switchToList!.additionalTextEdits![0].range.start).toEqual({line: 3, character: 4});
|
|
||||||
expect(switchToList!.additionalTextEdits![0].range.end).toEqual({line: 3, character: 13});
|
|
||||||
expect(switchToList!.additionalTextEdits![0].newText).toEqual("runs-on:");
|
|
||||||
|
|
||||||
expect(switchToFull!.additionalTextEdits).toHaveLength(1);
|
|
||||||
expect(switchToFull!.additionalTextEdits![0].newText).toEqual("runs-on:");
|
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
|
||||||
const additionalEdits = switchToList!.additionalTextEdits!;
|
|
||||||
|
|
||||||
// Main textEdit inserts newline content at cursor
|
|
||||||
expect(textEdit.newText).toEqual("\n - ");
|
|
||||||
|
|
||||||
// additionalTextEdits replaces "runs-on: " with "runs-on:"
|
|
||||||
expect(additionalEdits).toHaveLength(1);
|
|
||||||
expect(additionalEdits[0].newText).toEqual("runs-on:");
|
|
||||||
|
|
||||||
// Combined result when applied: "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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+56
-354
@@ -1,11 +1,7 @@
|
|||||||
import {complete as completeExpression, DescriptionDictionary, FeatureFlags} from "@actions/expressions";
|
import {complete as completeExpression, DescriptionDictionary} from "@actions/expressions";
|
||||||
import {CompletionItem as ExpressionCompletionItem} from "@actions/expressions/completion";
|
import {CompletionItem as ExpressionCompletionItem} from "@actions/expressions/completion";
|
||||||
import {isBasicExpression, isSequence, isString} from "@actions/workflow-parser";
|
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 {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 {OPEN_EXPRESSION} from "@actions/workflow-parser/templates/template-constants";
|
||||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
|
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/index";
|
||||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||||
@@ -13,31 +9,22 @@ import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range"
|
|||||||
import {TokenType} from "@actions/workflow-parser/templates/tokens/types";
|
import {TokenType} from "@actions/workflow-parser/templates/tokens/types";
|
||||||
import {File} from "@actions/workflow-parser/workflows/file";
|
import {File} from "@actions/workflow-parser/workflows/file";
|
||||||
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
||||||
import {getWorkflowSchema} from "@actions/workflow-parser/workflows/workflow-schema";
|
|
||||||
import {Position, TextDocument} from "vscode-languageserver-textdocument";
|
import {Position, TextDocument} from "vscode-languageserver-textdocument";
|
||||||
import {CompletionItem, CompletionItemKind, CompletionItemTag, Range, TextEdit} from "vscode-languageserver-types";
|
import {CompletionItem, CompletionItemKind, CompletionItemTag, Range, TextEdit} from "vscode-languageserver-types";
|
||||||
import {filterActionRunsCompletions, getActionScaffoldingSnippets} from "./complete-action.js";
|
import {ContextProviderConfig} from "./context-providers/config";
|
||||||
import {ContextProviderConfig} from "./context-providers/config.js";
|
import {getContext, Mode} from "./context-providers/default";
|
||||||
import {getActionExpressionContext, getWorkflowExpressionContext, Mode} from "./context-providers/default.js";
|
import {getWorkflowContext, WorkflowContext} from "./context/workflow-context";
|
||||||
import {ActionContext, getActionContext} from "./context/action-context.js";
|
import {validatorFunctions} from "./expression-validation/functions";
|
||||||
import {getWorkflowContext, WorkflowContext} from "./context/workflow-context.js";
|
import {error} from "./log";
|
||||||
import {validatorFunctions} from "./expression-validation/functions.js";
|
import {isPotentiallyExpression} from "./utils/expression-detection";
|
||||||
import {error} from "./log.js";
|
import {findToken} from "./utils/find-token";
|
||||||
import {detectDocumentType} from "./utils/document-type.js";
|
import {guessIndentation} from "./utils/indentation-guesser";
|
||||||
import {isPotentiallyExpression} from "./utils/expression-detection.js";
|
import {mapRange} from "./utils/range";
|
||||||
import {findToken} from "./utils/find-token.js";
|
import {isPlaceholder, transform} from "./utils/transform";
|
||||||
import {guessIndentation} from "./utils/indentation-guesser.js";
|
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache";
|
||||||
import {mapRange} from "./utils/range.js";
|
import {Value, ValueProviderConfig} from "./value-providers/config";
|
||||||
import {isPlaceholder, transform} from "./utils/transform.js";
|
import {defaultValueProviders} from "./value-providers/default";
|
||||||
import {
|
import {DefinitionValueMode, definitionValues} from "./value-providers/definition";
|
||||||
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 {
|
export function getExpressionInput(input: string, pos: number): string {
|
||||||
// Find start marker around the cursor position
|
// Find start marker around the cursor position
|
||||||
@@ -55,7 +42,6 @@ export type CompletionConfig = {
|
|||||||
valueProviderConfig?: ValueProviderConfig;
|
valueProviderConfig?: ValueProviderConfig;
|
||||||
contextProviderConfig?: ContextProviderConfig;
|
contextProviderConfig?: ContextProviderConfig;
|
||||||
fileProvider?: FileProvider;
|
fileProvider?: FileProvider;
|
||||||
featureFlags?: FeatureFlags;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function complete(
|
export async function complete(
|
||||||
@@ -79,96 +65,43 @@ export async function complete(
|
|||||||
content: newDoc.getText()
|
content: newDoc.getText()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine document type - unknown defaults to workflow (backwards compatibility)
|
const parsedWorkflow = fetchOrParseWorkflow(file, textDocument.uri, true);
|
||||||
const isAction = detectDocumentType(textDocument.uri) === "action";
|
if (!parsedWorkflow.value) {
|
||||||
|
|
||||||
// Parse the document
|
|
||||||
const parsedTemplate = isAction
|
|
||||||
? getOrParseAction(file, textDocument.uri, true)
|
|
||||||
: getOrParseWorkflow(file, textDocument.uri, true);
|
|
||||||
if (!parsedTemplate.value) {
|
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const schema = isAction ? getActionSchema() : getWorkflowSchema();
|
const template = await fetchOrConvertWorkflowTemplate(
|
||||||
const {token, keyToken, parent, path} = findToken(newPos, parsedTemplate.value);
|
parsedWorkflow.context,
|
||||||
|
parsedWorkflow.value,
|
||||||
|
textDocument.uri,
|
||||||
|
config,
|
||||||
|
{
|
||||||
|
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
|
||||||
|
errorPolicy: ErrorPolicy.TryConversion
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Build context for position-aware completions (e.g., steps.*, needs.*, inputs.*)
|
const {token, keyToken, parent, path} = findToken(newPos, parsedWorkflow.value);
|
||||||
let workflowContext: WorkflowContext | undefined;
|
const workflowContext = getWorkflowContext(textDocument.uri, template, path);
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expression completions
|
// If we are inside an expression, take a different code-path. The workflow parser does not correctly create
|
||||||
if (token && (isBasicExpression(token) || isPotentiallyExpression(token))) {
|
// expression nodes for invalid expressions and during editing expressions are invalid most of the time.
|
||||||
const allowedContext = token.definitionInfo?.allowedContext || [];
|
if (token) {
|
||||||
const context = isAction
|
if (isBasicExpression(token) || isPotentiallyExpression(token)) {
|
||||||
? getActionExpressionContext(allowedContext, config?.contextProviderConfig, actionContext, Mode.Completion)
|
const allowedContext = token.definitionInfo?.allowedContext || [];
|
||||||
: await getWorkflowExpressionContext(
|
const context = await getContext(allowedContext, config?.contextProviderConfig, workflowContext, Mode.Completion);
|
||||||
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 indentation = guessIndentation(newDoc, 2, true); // Use 2 spaces as default and most common for YAML
|
||||||
const indentString = " ".repeat(indentation.tabSize);
|
const indentString = " ".repeat(indentation.tabSize);
|
||||||
|
|
||||||
// YAML key/value completions
|
const values = await getValues(token, keyToken, parent, config?.valueProviderConfig, workflowContext, indentString);
|
||||||
let values = await getValues(
|
|
||||||
token,
|
|
||||||
keyToken,
|
|
||||||
parent,
|
|
||||||
config?.valueProviderConfig,
|
|
||||||
workflowContext,
|
|
||||||
indentString,
|
|
||||||
schema
|
|
||||||
);
|
|
||||||
|
|
||||||
// Filter action.yml `runs:` completions based on `using:` value
|
|
||||||
if (isAction && parsedTemplate.value) {
|
|
||||||
values = filterActionRunsCompletions(values, path, parsedTemplate.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Offer "(switch to list)" / "(switch to mapping)" when the schema allows alternative forms
|
|
||||||
const escapeHatches = getEscapeHatchCompletions(token, keyToken, indentString, newPos, schema);
|
|
||||||
values.push(...escapeHatches);
|
|
||||||
|
|
||||||
// Get action scaffolding snippets if applicable
|
|
||||||
let actionSnippets: CompletionItem[] = [];
|
|
||||||
if (isAction && config?.featureFlags?.isEnabled("actionScaffoldingSnippets")) {
|
|
||||||
actionSnippets = getActionScaffoldingSnippets(parsedTemplate.value, path, position);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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;
|
let replaceRange: Range | undefined;
|
||||||
if (token?.range) {
|
if (token?.range) {
|
||||||
// Prefer the token's range since it accounts for YAML syntax like quotes
|
|
||||||
replaceRange = mapRange(token.range);
|
replaceRange = mapRange(token.range);
|
||||||
} else if (!token) {
|
} else if (!token) {
|
||||||
// Not a valid token, create a range from the current position
|
// Not a valid token, create a range from the current position
|
||||||
@@ -191,66 +124,30 @@ export async function complete(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert values to LSP CompletionItems
|
return values.map(value => {
|
||||||
const completionItems = values.map(value => {
|
|
||||||
const newText = value.insertText || value.label;
|
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Convert additionalTextEdits if present
|
|
||||||
let additionalTextEdits: TextEdit[] | undefined;
|
|
||||||
if (value.additionalTextEdits) {
|
|
||||||
additionalTextEdits = value.additionalTextEdits.map(edit => TextEdit.replace(edit.range, edit.newText));
|
|
||||||
}
|
|
||||||
|
|
||||||
const item: CompletionItem = {
|
const item: CompletionItem = {
|
||||||
label: value.label,
|
label: value.label,
|
||||||
labelDetails: value.labelDetail ? {description: value.labelDetail} : undefined,
|
|
||||||
filterText: value.filterText,
|
|
||||||
sortText: value.sortText,
|
|
||||||
documentation: value.description && {
|
documentation: value.description && {
|
||||||
kind: "markdown",
|
kind: "markdown",
|
||||||
value: value.description
|
value: value.description
|
||||||
},
|
},
|
||||||
tags: value.deprecated ? [CompletionItemTag.Deprecated] : undefined,
|
tags: value.deprecated ? [CompletionItemTag.Deprecated] : undefined,
|
||||||
textEdit,
|
textEdit: replaceRange ? TextEdit.replace(replaceRange, newText) : TextEdit.insert(position, newText)
|
||||||
additionalTextEdits
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return item;
|
return item;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add action scaffolding snippets if available
|
|
||||||
return [...completionItems, ...actionSnippets];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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(
|
async function getValues(
|
||||||
token: TemplateToken | null,
|
token: TemplateToken | null,
|
||||||
keyToken: TemplateToken | null,
|
keyToken: TemplateToken | null,
|
||||||
parent: TemplateToken | null,
|
parent: TemplateToken | null,
|
||||||
valueProviderConfig: ValueProviderConfig | undefined,
|
valueProviderConfig: ValueProviderConfig | undefined,
|
||||||
workflowContext: WorkflowContext | undefined,
|
workflowContext: WorkflowContext,
|
||||||
indentation: string,
|
indentation: string
|
||||||
schema: TemplateSchema
|
|
||||||
): Promise<Value[]> {
|
): Promise<Value[]> {
|
||||||
if (!parent) {
|
if (!parent) {
|
||||||
return [];
|
return [];
|
||||||
@@ -261,23 +158,20 @@ async function getValues(
|
|||||||
// Use the value providers from the parent if the current key is null
|
// Use the value providers from the parent if the current key is null
|
||||||
const valueProviderToken = keyToken || parent;
|
const valueProviderToken = keyToken || parent;
|
||||||
|
|
||||||
// Value providers require workflow context - only use them for workflows
|
const customValueProvider =
|
||||||
if (workflowContext) {
|
valueProviderToken?.definition?.key && valueProviderConfig?.[valueProviderToken.definition.key];
|
||||||
const customValueProvider =
|
if (customValueProvider) {
|
||||||
valueProviderToken?.definition?.key && valueProviderConfig?.[valueProviderToken.definition.key];
|
const customValues = await customValueProvider.get(workflowContext, existingValues);
|
||||||
if (customValueProvider) {
|
if (customValues) {
|
||||||
const customValues = await customValueProvider.get(workflowContext, existingValues);
|
return filterAndSortCompletionOptions(customValues, existingValues);
|
||||||
if (customValues) {
|
|
||||||
return filterAndSortCompletionOptions(customValues, existingValues);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const defaultValueProvider =
|
const defaultValueProvider =
|
||||||
valueProviderToken?.definition?.key && defaultValueProviders[valueProviderToken.definition.key];
|
valueProviderToken?.definition?.key && defaultValueProviders[valueProviderToken.definition.key];
|
||||||
if (defaultValueProvider) {
|
if (defaultValueProvider) {
|
||||||
const values = await defaultValueProvider.get(workflowContext, existingValues);
|
const values = await defaultValueProvider.get(workflowContext, existingValues);
|
||||||
return filterAndSortCompletionOptions(values, existingValues);
|
return filterAndSortCompletionOptions(values, existingValues);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the definition if there are no value providers
|
// Use the definition if there are no value providers
|
||||||
@@ -286,202 +180,10 @@ async function getValues(
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// When a schema allows multiple formats (e.g., `runs-on` can be a string OR a mapping),
|
const values = definitionValues(def, indentation, keyToken ? DefinitionValueMode.Key : DefinitionValueMode.Parent);
|
||||||
// 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);
|
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 [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// For VS Code compatibility, we use a cursor-position range for the main textEdit
|
|
||||||
// and additionalTextEdits to clean up the key portion. This prevents VS Code from
|
|
||||||
// filtering out escape hatches based on the key text (e.g., "runs-on: ").
|
|
||||||
//
|
|
||||||
// Main textEdit: insert at cursor position (newline + indented content)
|
|
||||||
// additionalTextEdits: replace "key: " with "key:" (removes trailing space)
|
|
||||||
const cursorRange = {
|
|
||||||
start: {line: position.line, character: position.character},
|
|
||||||
end: {line: position.line, character: position.character}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Range from key start to cursor - used to replace "key: " with "key:" in additionalTextEdits
|
|
||||||
const keyToCursorRange = {
|
|
||||||
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",
|
|
||||||
textEdit: {
|
|
||||||
range: cursorRange,
|
|
||||||
newText: `\n${indentation}- `
|
|
||||||
},
|
|
||||||
additionalTextEdits: [
|
|
||||||
{
|
|
||||||
range: keyToCursorRange,
|
|
||||||
newText: `${keyName}:`
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (buckets.mapping) {
|
|
||||||
results.push({
|
|
||||||
label: "(switch to mapping)",
|
|
||||||
sortText: "zzz_switch_2",
|
|
||||||
textEdit: {
|
|
||||||
range: cursorRange,
|
|
||||||
newText: `\n${indentation}`
|
|
||||||
},
|
|
||||||
additionalTextEdits: [
|
|
||||||
{
|
|
||||||
range: keyToCursorRange,
|
|
||||||
newText: `${keyName}:`
|
|
||||||
}
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
export function getExistingValues(token: TemplateToken | null, parent: TemplateToken) {
|
||||||
// For incomplete YAML, we may only have a parent token
|
// For incomplete YAML, we may only have a parent token
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -551,7 +253,7 @@ function getExpressionCompletionItems(
|
|||||||
|
|
||||||
function filterAndSortCompletionOptions(options: Value[], existingValues?: Set<string>) {
|
function filterAndSortCompletionOptions(options: Value[], existingValues?: Set<string>) {
|
||||||
options = options.filter(x => !existingValues?.has(x.label));
|
options = options.filter(x => !existingValues?.has(x.label));
|
||||||
options.sort((a, b) => (a.sortText ?? a.label).localeCompare(b.sortText ?? b.label));
|
options.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
return options;
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {DescriptionDictionary} from "@actions/expressions";
|
import {DescriptionDictionary} from "@actions/expressions";
|
||||||
import {WorkflowContext} from "../context/workflow-context.js";
|
import {WorkflowContext} from "../context/workflow-context";
|
||||||
import {Mode} from "./default.js";
|
import {Mode} from "./default";
|
||||||
|
|
||||||
export type ContextProviderConfig = {
|
export type ContextProviderConfig = {
|
||||||
getContext: (
|
getContext: (
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {DescriptionDictionary} from "@actions/expressions";
|
import {DescriptionDictionary} from "@actions/expressions";
|
||||||
import {WorkflowContext} from "../context/workflow-context.js";
|
import {WorkflowContext} from "../context/workflow-context";
|
||||||
import {getWorkflowExpressionContext, Mode} from "./default.js";
|
import {getContext, Mode} from "./default";
|
||||||
|
|
||||||
describe("getWorkflowExpressionContext", () => {
|
describe("getContext", () => {
|
||||||
const emptyWorkflowContext: WorkflowContext = {
|
const emptyWorkflowContext: WorkflowContext = {
|
||||||
uri: "test.yaml",
|
uri: "test.yaml",
|
||||||
template: undefined
|
template: undefined
|
||||||
@@ -10,7 +10,7 @@ describe("getWorkflowExpressionContext", () => {
|
|||||||
|
|
||||||
describe("when no contextProviderConfig is provided", () => {
|
describe("when no contextProviderConfig is provided", () => {
|
||||||
it("should mark secrets context as incomplete", async () => {
|
it("should mark secrets context as incomplete", async () => {
|
||||||
const result = await getWorkflowExpressionContext(["secrets"], undefined, emptyWorkflowContext, Mode.Validation);
|
const result = await getContext(["secrets"], undefined, emptyWorkflowContext, Mode.Validation);
|
||||||
|
|
||||||
const secretsContext = result.get("secrets") as DescriptionDictionary;
|
const secretsContext = result.get("secrets") as DescriptionDictionary;
|
||||||
expect(secretsContext).toBeDefined();
|
expect(secretsContext).toBeDefined();
|
||||||
@@ -18,7 +18,7 @@ describe("getWorkflowExpressionContext", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should mark vars context as incomplete", async () => {
|
it("should mark vars context as incomplete", async () => {
|
||||||
const result = await getWorkflowExpressionContext(["vars"], undefined, emptyWorkflowContext, Mode.Validation);
|
const result = await getContext(["vars"], undefined, emptyWorkflowContext, Mode.Validation);
|
||||||
|
|
||||||
const varsContext = result.get("vars") as DescriptionDictionary;
|
const varsContext = result.get("vars") as DescriptionDictionary;
|
||||||
expect(varsContext).toBeDefined();
|
expect(varsContext).toBeDefined();
|
||||||
@@ -26,12 +26,7 @@ describe("getWorkflowExpressionContext", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should not mark other contexts as incomplete", async () => {
|
it("should not mark other contexts as incomplete", async () => {
|
||||||
const result = await getWorkflowExpressionContext(
|
const result = await getContext(["env", "github"], undefined, emptyWorkflowContext, Mode.Validation);
|
||||||
["env", "github"],
|
|
||||||
undefined,
|
|
||||||
emptyWorkflowContext,
|
|
||||||
Mode.Validation
|
|
||||||
);
|
|
||||||
|
|
||||||
const envContext = result.get("env") as DescriptionDictionary;
|
const envContext = result.get("env") as DescriptionDictionary;
|
||||||
const githubContext = result.get("github") as DescriptionDictionary;
|
const githubContext = result.get("github") as DescriptionDictionary;
|
||||||
@@ -53,7 +48,7 @@ describe("getWorkflowExpressionContext", () => {
|
|||||||
getContext: () => Promise.resolve(providedContext)
|
getContext: () => Promise.resolve(providedContext)
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await getWorkflowExpressionContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
|
const result = await getContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
|
||||||
|
|
||||||
const secretsContext = result.get("secrets");
|
const secretsContext = result.get("secrets");
|
||||||
expect(secretsContext).toBe(providedContext);
|
expect(secretsContext).toBe(providedContext);
|
||||||
@@ -68,7 +63,7 @@ describe("getWorkflowExpressionContext", () => {
|
|||||||
getContext: () => Promise.resolve(providedContext)
|
getContext: () => Promise.resolve(providedContext)
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await getWorkflowExpressionContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
|
const result = await getContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
|
||||||
|
|
||||||
const varsContext = result.get("vars");
|
const varsContext = result.get("vars");
|
||||||
expect(varsContext).toBe(providedContext);
|
expect(varsContext).toBe(providedContext);
|
||||||
@@ -82,7 +77,7 @@ describe("getWorkflowExpressionContext", () => {
|
|||||||
getContext: () => Promise.resolve(undefined)
|
getContext: () => Promise.resolve(undefined)
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await getWorkflowExpressionContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
|
const result = await getContext(["secrets"], config, emptyWorkflowContext, Mode.Validation);
|
||||||
|
|
||||||
const secretsContext = result.get("secrets") as DescriptionDictionary;
|
const secretsContext = result.get("secrets") as DescriptionDictionary;
|
||||||
expect(secretsContext.complete).toBe(false);
|
expect(secretsContext.complete).toBe(false);
|
||||||
@@ -93,7 +88,7 @@ describe("getWorkflowExpressionContext", () => {
|
|||||||
getContext: () => Promise.resolve(undefined)
|
getContext: () => Promise.resolve(undefined)
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = await getWorkflowExpressionContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
|
const result = await getContext(["vars"], config, emptyWorkflowContext, Mode.Validation);
|
||||||
|
|
||||||
const varsContext = result.get("vars") as DescriptionDictionary;
|
const varsContext = result.get("vars") as DescriptionDictionary;
|
||||||
expect(varsContext.complete).toBe(false);
|
expect(varsContext.complete).toBe(false);
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||||
import {Kind} from "@actions/expressions/data/expressiondata";
|
import {Kind} from "@actions/expressions/data/expressiondata";
|
||||||
import {ActionContext, getActionInputs, getActionStepIdsBefore} from "../context/action-context.js";
|
import {WorkflowContext} from "../context/workflow-context";
|
||||||
import {WorkflowContext} from "../context/workflow-context.js";
|
import {ContextProviderConfig} from "./config";
|
||||||
import {ContextProviderConfig} from "./config.js";
|
import {getDescription, RootContext} from "./descriptions";
|
||||||
import {getDescription, RootContext} from "./descriptions.js";
|
import {getEnvContext} from "./env";
|
||||||
import {getEnvContext} from "./env.js";
|
import {getGithubContext} from "./github";
|
||||||
import {getGithubContext} from "./github.js";
|
import {getInputsContext} from "./inputs";
|
||||||
import {getInputsContext} from "./inputs.js";
|
import {getJobContext} from "./job";
|
||||||
import {getJobContext} from "./job.js";
|
import {getJobsContext} from "./jobs";
|
||||||
import {getJobsContext} from "./jobs.js";
|
import {getMatrixContext} from "./matrix";
|
||||||
import {getMatrixContext} from "./matrix.js";
|
import {getNeedsContext} from "./needs";
|
||||||
import {getNeedsContext} from "./needs.js";
|
import {getSecretsContext} from "./secrets";
|
||||||
import {getSecretsContext} from "./secrets.js";
|
import {getStepsContext} from "./steps";
|
||||||
import {getStepsContext} from "./steps.js";
|
import {getStrategyContext} from "./strategy";
|
||||||
|
|
||||||
// ContextValue is the type of the value returned by a context provider
|
// ContextValue is the type of the value returned by a context provider
|
||||||
// Null indicates that the context provider doesn't have any value to provide
|
// Null indicates that the context provider doesn't have any value to provide
|
||||||
@@ -24,13 +24,10 @@ export enum Mode {
|
|||||||
Hover
|
Hover
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export async function getContext(
|
||||||
* Build expression context for workflow files (e.g., github.*, steps.*, needs.*)
|
|
||||||
*/
|
|
||||||
export async function getWorkflowExpressionContext(
|
|
||||||
names: string[],
|
names: string[],
|
||||||
config: ContextProviderConfig | undefined,
|
config: ContextProviderConfig | undefined,
|
||||||
workflowContext: WorkflowContext | undefined,
|
workflowContext: WorkflowContext,
|
||||||
mode: Mode
|
mode: Mode
|
||||||
): Promise<DescriptionDictionary> {
|
): Promise<DescriptionDictionary> {
|
||||||
const context = new DescriptionDictionary();
|
const context = new DescriptionDictionary();
|
||||||
@@ -44,9 +41,7 @@ export async function getWorkflowExpressionContext(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const remoteValue = workflowContext
|
const remoteValue = await config?.getContext(contextName, value, workflowContext, mode);
|
||||||
? await config?.getContext(contextName, value, workflowContext, mode)
|
|
||||||
: undefined;
|
|
||||||
if (remoteValue) {
|
if (remoteValue) {
|
||||||
value = remoteValue;
|
value = remoteValue;
|
||||||
} else if (contextName === "secrets" || contextName === "vars") {
|
} else if (contextName === "secrets" || contextName === "vars") {
|
||||||
@@ -62,198 +57,61 @@ export async function getWorkflowExpressionContext(
|
|||||||
return context;
|
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) {
|
switch (name) {
|
||||||
case "env":
|
case "env":
|
||||||
return workflowContext ? getEnvContext(workflowContext) : new DescriptionDictionary();
|
return getEnvContext(workflowContext);
|
||||||
|
|
||||||
case "github":
|
case "github":
|
||||||
return getGithubContext(workflowContext, mode);
|
return getGithubContext(workflowContext, mode);
|
||||||
|
|
||||||
case "inputs":
|
case "inputs":
|
||||||
return workflowContext ? getInputsContext(workflowContext) : new DescriptionDictionary();
|
return getInputsContext(workflowContext);
|
||||||
|
|
||||||
case "reusableWorkflowJob":
|
case "reusableWorkflowJob":
|
||||||
case "job":
|
case "job":
|
||||||
return workflowContext ? getJobContext(workflowContext) : new DescriptionDictionary();
|
return getJobContext(workflowContext);
|
||||||
|
|
||||||
case "jobs":
|
case "jobs":
|
||||||
return workflowContext ? getJobsContext(workflowContext) : new DescriptionDictionary();
|
return getJobsContext(workflowContext);
|
||||||
|
|
||||||
case "matrix":
|
case "matrix":
|
||||||
return workflowContext ? getMatrixContext(workflowContext, mode) : new DescriptionDictionary();
|
return getMatrixContext(workflowContext, mode);
|
||||||
|
|
||||||
case "needs":
|
case "needs":
|
||||||
return workflowContext ? getNeedsContext(workflowContext) : new DescriptionDictionary();
|
return getNeedsContext(workflowContext);
|
||||||
|
|
||||||
case "runner":
|
case "runner":
|
||||||
return getRunnerContext();
|
return objectToDictionary({
|
||||||
|
arch: "X64",
|
||||||
|
debug: "1",
|
||||||
|
environment: "github-hosted",
|
||||||
|
name: "GitHub Actions 2",
|
||||||
|
os: "Linux",
|
||||||
|
temp: "/home/runner/work/_temp",
|
||||||
|
tool_cache: "/opt/hostedtoolcache",
|
||||||
|
workspace: "/home/runner/work/repo"
|
||||||
|
});
|
||||||
|
|
||||||
case "secrets":
|
case "secrets":
|
||||||
return workflowContext ? getSecretsContext(workflowContext, mode) : new DescriptionDictionary();
|
return getSecretsContext(workflowContext, mode);
|
||||||
|
|
||||||
case "steps":
|
case "steps":
|
||||||
return workflowContext ? getStepsContext(workflowContext) : new DescriptionDictionary();
|
return getStepsContext(workflowContext);
|
||||||
|
|
||||||
case "strategy":
|
case "strategy":
|
||||||
return getStrategyContext();
|
return getStrategyContext(workflowContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
function objectToDictionary(object: {[key: string]: string}): DescriptionDictionary {
|
||||||
* Returns the strategy context with default values (fail-fast, job-index, etc.)
|
const dictionary = new DescriptionDictionary();
|
||||||
*/
|
|
||||||
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) {
|
||||||
* Returns the runner context with environment info (arch, os, temp, workspace, etc.)
|
dictionary.add(key, new data.StringData(object[key]));
|
||||||
*/
|
|
||||||
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 context;
|
return dictionary;
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "strategy":
|
|
||||||
return getStrategyContext();
|
|
||||||
|
|
||||||
case "matrix":
|
|
||||||
// Actions can access matrix context at runtime
|
|
||||||
return new DescriptionDictionary();
|
|
||||||
}
|
|
||||||
|
|
||||||
return 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,35 +198,6 @@
|
|||||||
"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."
|
"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": {
|
"secrets": {
|
||||||
"GITHUB_TOKEN": {
|
"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).\""
|
"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).\""
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||||
import {isScalar, isString} from "@actions/workflow-parser";
|
import {isScalar, isString} from "@actions/workflow-parser";
|
||||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||||
import {WorkflowContext} from "../context/workflow-context.js";
|
import {WorkflowContext} from "../context/workflow-context";
|
||||||
|
|
||||||
export function getEnvContext(workflowContext: WorkflowContext): DescriptionDictionary {
|
export function getEnvContext(workflowContext: WorkflowContext): DescriptionDictionary {
|
||||||
const d = new DescriptionDictionary();
|
const d = new DescriptionDictionary();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {DescriptionDictionary} from "@actions/expressions";
|
import {DescriptionDictionary} from "@actions/expressions";
|
||||||
import {getEventPayload, getSupportedEventTypes} from "./eventPayloads.js";
|
import {getEventPayload, getSupportedEventTypes} from "./eventPayloads";
|
||||||
|
|
||||||
describe("eventPayloads", () => {
|
describe("eventPayloads", () => {
|
||||||
describe("getSupportedEventTypes", () => {
|
describe("getSupportedEventTypes", () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
|
import {DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions/.";
|
||||||
import {testGetWorkflowContext} from "../test-utils/test-workflow-context.js";
|
import {testGetWorkflowContext} from "../test-utils/test-workflow-context";
|
||||||
import {Mode} from "./default.js";
|
import {Mode} from "./default";
|
||||||
import {getGithubContext} from "./github.js";
|
import {getGithubContext} from "./github";
|
||||||
|
|
||||||
describe("github context", () => {
|
describe("github context", () => {
|
||||||
it("single event", async () => {
|
it("single event", async () => {
|
||||||
|
|||||||
@@ -1,16 +1,13 @@
|
|||||||
import {data, DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
|
import {data, DescriptionDictionary, isDescriptionDictionary} from "@actions/expressions";
|
||||||
import {ExpressionData} from "@actions/expressions/data/expressiondata";
|
import {ExpressionData} from "@actions/expressions/data/expressiondata";
|
||||||
import {TypesFilterConfig} from "@actions/workflow-parser/model/workflow-template";
|
import {TypesFilterConfig} from "@actions/workflow-parser/model/workflow-template";
|
||||||
import {WorkflowContext} from "../context/workflow-context.js";
|
import {WorkflowContext} from "../context/workflow-context";
|
||||||
import {Mode} from "./default.js";
|
import {Mode} from "./default";
|
||||||
import {getDescription} from "./descriptions.js";
|
import {getDescription} from "./descriptions";
|
||||||
import {getEventPayload, getSupportedEventTypes} from "./events/eventPayloads.js";
|
import {getEventPayload, getSupportedEventTypes} from "./events/eventPayloads";
|
||||||
import {getInputsContext} from "./inputs.js";
|
import {getInputsContext} from "./inputs";
|
||||||
|
|
||||||
/**
|
export function getGithubContext(workflowContext: WorkflowContext, mode: Mode): DescriptionDictionary {
|
||||||
* 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
|
// https://docs.github.com/en/actions/learn-github-actions/contexts#github-context
|
||||||
const keys = [
|
const keys = [
|
||||||
"action",
|
"action",
|
||||||
@@ -76,10 +73,7 @@ export function getGithubContext(workflowContext: WorkflowContext | undefined, m
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
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 d = new DescriptionDictionary();
|
||||||
const eventsConfig = workflowContext?.template?.events;
|
const eventsConfig = workflowContext?.template?.events;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||||
import {InputConfig} from "@actions/workflow-parser/model/workflow-template";
|
import {InputConfig} from "@actions/workflow-parser/model/workflow-template";
|
||||||
import {WorkflowContext} from "../context/workflow-context.js";
|
import {WorkflowContext} from "../context/workflow-context";
|
||||||
|
|
||||||
export function getInputsContext(workflowContext: WorkflowContext): DescriptionDictionary {
|
export function getInputsContext(workflowContext: WorkflowContext): DescriptionDictionary {
|
||||||
const d = new DescriptionDictionary();
|
const d = new DescriptionDictionary();
|
||||||
|
|||||||
@@ -1,176 +0,0 @@
|
|||||||
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,12 +1,8 @@
|
|||||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||||
import {isMapping, isSequence} from "@actions/workflow-parser";
|
import {isMapping, isSequence} from "@actions/workflow-parser";
|
||||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||||
import {WorkflowContext} from "../context/workflow-context.js";
|
import {WorkflowContext} from "../context/workflow-context";
|
||||||
import {getDescription} from "./descriptions.js";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the job context with container, services, status, and check_run_id.
|
|
||||||
*/
|
|
||||||
export function getJobContext(workflowContext: WorkflowContext): DescriptionDictionary {
|
export function getJobContext(workflowContext: WorkflowContext): DescriptionDictionary {
|
||||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
|
// https://docs.github.com/en/actions/learn-github-actions/contexts#job-context
|
||||||
const jobContext = new DescriptionDictionary();
|
const jobContext = new DescriptionDictionary();
|
||||||
@@ -19,7 +15,7 @@ export function getJobContext(workflowContext: WorkflowContext): DescriptionDict
|
|||||||
const jobContainer = job.container;
|
const jobContainer = job.container;
|
||||||
if (jobContainer && isMapping(jobContainer)) {
|
if (jobContainer && isMapping(jobContainer)) {
|
||||||
const containerContext = createContainerContext(jobContainer, false);
|
const containerContext = createContainerContext(jobContainer, false);
|
||||||
jobContext.add("container", containerContext, getDescription("job", "container"));
|
jobContext.add("container", containerContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
@@ -33,48 +29,39 @@ export function getJobContext(workflowContext: WorkflowContext): DescriptionDict
|
|||||||
const serviceContext = createContainerContext(service.value, true);
|
const serviceContext = createContainerContext(service.value, true);
|
||||||
servicesContext.add(service.key.toString(), serviceContext);
|
servicesContext.add(service.key.toString(), serviceContext);
|
||||||
}
|
}
|
||||||
jobContext.add("services", servicesContext, getDescription("job", "services"));
|
jobContext.add("services", servicesContext);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Status
|
// Status
|
||||||
jobContext.add("status", new data.StringData(""), getDescription("job", "status"));
|
jobContext.add("status", new data.Null());
|
||||||
|
|
||||||
// Check run ID
|
|
||||||
jobContext.add("check_run_id", new data.StringData(""), getDescription("job", "check_run_id"));
|
|
||||||
|
|
||||||
return jobContext;
|
return jobContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createContainerContext(container: MappingToken, isServices: boolean): DescriptionDictionary {
|
function createContainerContext(container: MappingToken, isServices: boolean): data.Dictionary {
|
||||||
const containerContext = new DescriptionDictionary();
|
const containerContext = new data.Dictionary();
|
||||||
|
for (const {key, value} of container) {
|
||||||
// id and network are always available
|
if (isSequence(value)) {
|
||||||
containerContext.add(
|
// service ports are the only thing that is part of the job context
|
||||||
"id",
|
if (key.toString() !== "ports") {
|
||||||
new data.StringData(""),
|
continue;
|
||||||
getDescription("job", isServices ? "services.<service_id>.id" : "container.id")
|
}
|
||||||
);
|
const ports = new data.Dictionary();
|
||||||
containerContext.add(
|
for (const item of value) {
|
||||||
"network",
|
// We can determine the context mapping fully only if the port is defined
|
||||||
new data.StringData(""),
|
// as a mapping (i.e. <port1>:<port2>), single ports are assigned randomly
|
||||||
getDescription("job", isServices ? "services.<service_id>.network" : "container.network")
|
const portParts = item.toString().split(":");
|
||||||
);
|
if (isServices && portParts.length === 2) {
|
||||||
|
ports.add(portParts[1], new data.StringData(portParts[0]));
|
||||||
// ports are only available for service containers (not job container)
|
} else {
|
||||||
if (isServices) {
|
// If the port isn't a mapping, just use null
|
||||||
const ports = new DescriptionDictionary();
|
ports.add(portParts[0], new data.Null());
|
||||||
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;
|
return containerContext;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {data, DescriptionDictionary} from "@actions/expressions";
|
import {data, DescriptionDictionary} from "@actions/expressions";
|
||||||
import {StringData} from "@actions/expressions/data/string";
|
import {StringData} from "@actions/expressions/data/string";
|
||||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||||
import {WorkflowContext} from "../context/workflow-context.js";
|
import {WorkflowContext} from "../context/workflow-context";
|
||||||
import {getDescription} from "./descriptions.js";
|
import {getDescription} from "./descriptions";
|
||||||
|
|
||||||
export function getJobsContext(workflowContext: WorkflowContext): DescriptionDictionary {
|
export function getJobsContext(workflowContext: WorkflowContext): DescriptionDictionary {
|
||||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#jobs-context
|
// https://docs.github.com/en/actions/learn-github-actions/contexts#jobs-context
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-to
|
|||||||
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
|
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
|
||||||
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
|
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
|
||||||
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
||||||
import {WorkflowContext} from "../context/workflow-context.js";
|
import {WorkflowContext} from "../context/workflow-context";
|
||||||
import {Mode} from "./default.js";
|
import {Mode} from "./default";
|
||||||
import {getMatrixContext} from "./matrix.js";
|
import {getMatrixContext} from "./matrix";
|
||||||
|
|
||||||
type MatrixMap = {
|
type MatrixMap = {
|
||||||
[key: string]: Array<string> | Array<{[key: string]: string}>;
|
[key: string]: Array<string> | Array<{[key: string]: string}>;
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import {isBasicExpression, isMapping, isSequence, isString} from "@actions/workf
|
|||||||
import {KeyValuePair} from "@actions/workflow-parser/templates/tokens/key-value-pair";
|
import {KeyValuePair} from "@actions/workflow-parser/templates/tokens/key-value-pair";
|
||||||
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
import {MappingToken} from "@actions/workflow-parser/templates/tokens/mapping-token";
|
||||||
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
|
import {SequenceToken} from "@actions/workflow-parser/templates/tokens/sequence-token";
|
||||||
import {WorkflowContext} from "../context/workflow-context.js";
|
import {WorkflowContext} from "../context/workflow-context";
|
||||||
import {ContextValue, Mode} from "./default.js";
|
import {ContextValue, Mode} from "./default";
|
||||||
|
|
||||||
export function getMatrixContext(workflowContext: WorkflowContext, mode: Mode): ContextValue {
|
export function getMatrixContext(workflowContext: WorkflowContext, mode: Mode): ContextValue {
|
||||||
// https://docs.github.com/en/actions/learn-github-actions/contexts#matrix-context
|
// https://docs.github.com/en/actions/learn-github-actions/contexts#matrix-context
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {DescriptionDictionary} from "@actions/expressions";
|
import {DescriptionDictionary} from "@actions/expressions";
|
||||||
import {StringData} from "@actions/expressions/data/string";
|
import {StringData} from "@actions/expressions/data/string";
|
||||||
import {WorkflowContext} from "../context/workflow-context.js";
|
import {WorkflowContext} from "../context/workflow-context";
|
||||||
import {testGetWorkflowContext} from "../test-utils/test-workflow-context.js";
|
import {testGetWorkflowContext} from "../test-utils/test-workflow-context";
|
||||||
import {getNeedsContext} from "./needs.js";
|
import {getNeedsContext} from "./needs";
|
||||||
|
|
||||||
describe("needs context", () => {
|
describe("needs context", () => {
|
||||||
describe("invalid workflow context", () => {
|
describe("invalid workflow context", () => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user