Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6641228870 | |||
| c1ad4d14df | |||
| 6a47895521 |
@@ -0,0 +1,59 @@
|
||||
# PR #283 Review: Use property descriptions for completion items
|
||||
|
||||
## Summary
|
||||
|
||||
This PR fixes a bug where completion items for action.yml were missing descriptions. The root cause was that `mappingValues()` only looked at the type definition's description, ignoring property-level descriptions in the schema.
|
||||
|
||||
## Changes Analysis
|
||||
|
||||
### Core Fix ([definition.ts](languageservice/src/value-providers/definition.ts))
|
||||
|
||||
**Before:**
|
||||
```typescript
|
||||
let description: string | undefined;
|
||||
if (value.type) {
|
||||
const typeDef = definitions[value.type];
|
||||
description = typeDef?.description;
|
||||
}
|
||||
```
|
||||
|
||||
**After:**
|
||||
```typescript
|
||||
let description: string | undefined = value.description;
|
||||
if (value.type) {
|
||||
const typeDef = definitions[value.type];
|
||||
if (!description) {
|
||||
description = typeDef?.description;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
✅ **Correct approach** - prioritizes property description, falls back to type description.
|
||||
|
||||
### Test Coverage
|
||||
|
||||
1. **complete-action.test.ts**: Two new tests verify `author` and `branding` completions include documentation
|
||||
2. **hover-action.test.ts**: New test for `author` hover + updated `branding` test to verify "Documentation" link
|
||||
|
||||
## Potential Issues
|
||||
|
||||
### 1. One-of expansion doesn't use property description
|
||||
|
||||
Looking at line 140-142:
|
||||
```typescript
|
||||
const expanded = expandOneOfToCompletions(oneOfDef, definitions, key, description, indentation, mode);
|
||||
```
|
||||
|
||||
This passes `description` to `expandOneOfToCompletions`, but at this point `description` may have been populated from the property. **This is correct** - the property description is passed through.
|
||||
|
||||
### 2. Consistency check
|
||||
|
||||
The PR description mentions this is consistent with hover. Verified: [template-reader.ts#L225](workflow-parser/src/templates/template-reader.ts#L225) shows hover uses `nextPropertyDef.description` when available.
|
||||
|
||||
## Verdict
|
||||
|
||||
✅ **LGTM** - Clean, minimal fix that aligns completion behavior with hover. Good test coverage for the specific cases mentioned.
|
||||
|
||||
## Minor Suggestions (non-blocking)
|
||||
|
||||
1. Could add a test for a property that has NO description but whose type DOES have one, to verify fallback works (e.g., `inputs` which references `inputs-strict` type that has a description)
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/expressions",
|
||||
"version": "0.3.32",
|
||||
"version": "0.3.33",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"source": "./src/index.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageserver",
|
||||
"version": "0.3.32",
|
||||
"version": "0.3.33",
|
||||
"description": "Language server for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -48,8 +48,8 @@
|
||||
"actions-languageserver": "./bin/actions-languageserver"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/languageservice": "^0.3.32",
|
||||
"@actions/workflow-parser": "^0.3.32",
|
||||
"@actions/languageservice": "^0.3.33",
|
||||
"@actions/workflow-parser": "^0.3.33",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@octokit/types": "^9.0.0",
|
||||
"vscode-languageserver": "^8.0.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/languageservice",
|
||||
"version": "0.3.32",
|
||||
"version": "0.3.33",
|
||||
"description": "Language service for GitHub Actions",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -47,8 +47,8 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.32",
|
||||
"@actions/workflow-parser": "^0.3.32",
|
||||
"@actions/expressions": "^0.3.33",
|
||||
"@actions/workflow-parser": "^0.3.33",
|
||||
"vscode-languageserver-textdocument": "^1.0.7",
|
||||
"vscode-languageserver-types": "^3.17.2",
|
||||
"vscode-uri": "^3.0.8",
|
||||
|
||||
@@ -251,6 +251,38 @@ runs:
|
||||
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});
|
||||
|
||||
@@ -723,16 +723,28 @@ jobs:
|
||||
expect(switchToList!.sortText).toEqual("zzz_switch_1");
|
||||
expect(switchToFull!.sortText).toEqual("zzz_switch_2");
|
||||
|
||||
// Escape hatches should have textEdit that restructures the YAML
|
||||
// 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;
|
||||
|
||||
expect(listEdit.newText).toEqual("runs-on:\n - ");
|
||||
expect(fullEdit.newText).toEqual("runs-on:\n ");
|
||||
// Main textEdit inserts newline and indented content at cursor position
|
||||
expect(listEdit.newText).toEqual("\n - ");
|
||||
expect(fullEdit.newText).toEqual("\n ");
|
||||
|
||||
// TextEdit range should cover from key start to cursor position
|
||||
expect(listEdit.range.start).toEqual({line: 3, character: 4});
|
||||
expect(fullEdit.range.start).toEqual({line: 3, character: 4});
|
||||
// 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 () => {
|
||||
@@ -824,9 +836,16 @@ jobs:
|
||||
|
||||
const switchToList = result.find(x => x.label === "(switch to list)");
|
||||
const textEdit = switchToList!.textEdit as TextEdit;
|
||||
const additionalEdits = switchToList!.additionalTextEdits!;
|
||||
|
||||
// Applying this edit to "runs-on: " should produce "runs-on:\n - "
|
||||
expect(textEdit.newText).toEqual("runs-on:\n - ");
|
||||
// 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 - "
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -192,6 +192,12 @@ export async function complete(
|
||||
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 = {
|
||||
label: value.label,
|
||||
labelDetails: value.labelDetail ? {description: value.labelDetail} : undefined,
|
||||
@@ -202,7 +208,8 @@ export async function complete(
|
||||
value: value.description
|
||||
},
|
||||
tags: value.deprecated ? [CompletionItemTag.Deprecated] : undefined,
|
||||
textEdit
|
||||
textEdit,
|
||||
additionalTextEdits
|
||||
};
|
||||
|
||||
return item;
|
||||
@@ -388,9 +395,19 @@ function getEscapeHatchCompletions(
|
||||
return [];
|
||||
}
|
||||
|
||||
// Calculate the range from key start to current position
|
||||
// This covers "key: " so we can replace it with "key:\n - " or "key:\n "
|
||||
const editRange = {
|
||||
// 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}
|
||||
};
|
||||
@@ -400,9 +417,15 @@ function getEscapeHatchCompletions(
|
||||
label: "(switch to list)",
|
||||
sortText: "zzz_switch_1",
|
||||
textEdit: {
|
||||
range: editRange,
|
||||
newText: `${keyName}:\n${indentation}- `
|
||||
}
|
||||
range: cursorRange,
|
||||
newText: `\n${indentation}- `
|
||||
},
|
||||
additionalTextEdits: [
|
||||
{
|
||||
range: keyToCursorRange,
|
||||
newText: `${keyName}:`
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -411,9 +434,15 @@ function getEscapeHatchCompletions(
|
||||
label: "(switch to mapping)",
|
||||
sortText: "zzz_switch_2",
|
||||
textEdit: {
|
||||
range: editRange,
|
||||
newText: `${keyName}:\n${indentation}`
|
||||
}
|
||||
range: cursorRange,
|
||||
newText: `\n${indentation}`
|
||||
},
|
||||
additionalTextEdits: [
|
||||
{
|
||||
range: keyToCursorRange,
|
||||
newText: `${keyName}:`
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -53,6 +53,20 @@ ru|ns:
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.contents).toContain("runs");
|
||||
});
|
||||
|
||||
it("shows description for author key", async () => {
|
||||
const [doc, position] = createActionDocument(`name: My Action
|
||||
description: Test
|
||||
au|thor: Me
|
||||
runs:
|
||||
using: node20
|
||||
main: index.js`);
|
||||
const result = await hover(doc, position);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.contents).toContain("author");
|
||||
expect(result?.contents).toContain("Documentation");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runs properties", () => {
|
||||
@@ -145,6 +159,7 @@ brand|ing:
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result?.contents).toContain("brand");
|
||||
expect(result?.contents).toContain("Documentation");
|
||||
});
|
||||
|
||||
it("shows description for icon key", async () => {
|
||||
|
||||
@@ -27,6 +27,12 @@ export interface Value {
|
||||
range: {start: {line: number; character: number}; end: {line: number; character: number}};
|
||||
newText: string;
|
||||
};
|
||||
|
||||
/** Additional text edits to apply after the main edit (e.g., cleanup edits) */
|
||||
additionalTextEdits?: {
|
||||
range: {start: {line: number; character: number}; end: {line: number; character: number}};
|
||||
newText: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export enum ValueProviderKind {
|
||||
|
||||
@@ -107,10 +107,14 @@ function mappingValues(
|
||||
for (const [key, value] of Object.entries(mappingDefinition.properties)) {
|
||||
let insertText: string | undefined;
|
||||
|
||||
let description: string | undefined;
|
||||
// Prefer the property's own description (from the schema's property definition),
|
||||
// fall back to the type definition's description if the property doesn't have one
|
||||
let description: string | undefined = value.description;
|
||||
if (value.type) {
|
||||
const typeDef = definitions[value.type];
|
||||
description = typeDef?.description;
|
||||
if (!description) {
|
||||
description = typeDef?.description;
|
||||
}
|
||||
|
||||
if (typeDef) {
|
||||
switch (typeDef.definitionType) {
|
||||
|
||||
+1
-1
@@ -6,5 +6,5 @@
|
||||
"languageservice",
|
||||
"languageserver"
|
||||
],
|
||||
"version": "0.3.32"
|
||||
"version": "0.3.33"
|
||||
}
|
||||
Generated
+9
-9
@@ -136,7 +136,7 @@
|
||||
},
|
||||
"expressions": {
|
||||
"name": "@actions/expressions",
|
||||
"version": "0.3.32",
|
||||
"version": "0.3.33",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.0.3",
|
||||
@@ -396,11 +396,11 @@
|
||||
},
|
||||
"languageserver": {
|
||||
"name": "@actions/languageserver",
|
||||
"version": "0.3.32",
|
||||
"version": "0.3.33",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/languageservice": "^0.3.32",
|
||||
"@actions/workflow-parser": "^0.3.32",
|
||||
"@actions/languageservice": "^0.3.33",
|
||||
"@actions/workflow-parser": "^0.3.33",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@octokit/types": "^9.0.0",
|
||||
"vscode-languageserver": "^8.0.2",
|
||||
@@ -940,11 +940,11 @@
|
||||
},
|
||||
"languageservice": {
|
||||
"name": "@actions/languageservice",
|
||||
"version": "0.3.32",
|
||||
"version": "0.3.33",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.32",
|
||||
"@actions/workflow-parser": "^0.3.32",
|
||||
"@actions/expressions": "^0.3.33",
|
||||
"@actions/workflow-parser": "^0.3.33",
|
||||
"vscode-languageserver-textdocument": "^1.0.7",
|
||||
"vscode-languageserver-types": "^3.17.2",
|
||||
"vscode-uri": "^3.0.8",
|
||||
@@ -13345,10 +13345,10 @@
|
||||
},
|
||||
"workflow-parser": {
|
||||
"name": "@actions/workflow-parser",
|
||||
"version": "0.3.32",
|
||||
"version": "0.3.33",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.32",
|
||||
"@actions/expressions": "^0.3.33",
|
||||
"cronstrue": "^2.21.0",
|
||||
"yaml": "^2.0.0-8"
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@actions/workflow-parser",
|
||||
"version": "0.3.32",
|
||||
"version": "0.3.33",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"source": "./src/index.ts",
|
||||
@@ -48,7 +48,7 @@
|
||||
"watch": "tsc --build tsconfig.build.json --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@actions/expressions": "^0.3.32",
|
||||
"@actions/expressions": "^0.3.33",
|
||||
"cronstrue": "^2.21.0",
|
||||
"yaml": "^2.0.0-8"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user