Skip to content

Commit de38cfb

Browse files
committed
feat: Add actions version in hover panel
- Add quick fix when new version avaliable
1 parent 7f1f776 commit de38cfb

File tree

3 files changed

+284
-0
lines changed

3 files changed

+284
-0
lines changed

src/extension.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@ import {initResources} from "./treeViews/icons";
3434
import {initTreeViews} from "./treeViews/treeViews";
3535
import {deactivateLanguageServer, initLanguageServer} from "./workflow/languageServer";
3636
import {registerSignIn} from "./commands/signIn";
37+
import {ActionVersionHoverProvider} from "./hover/actionVersionHoverProvider";
38+
import {ActionVersionCodeActionProvider} from "./hover/actionVersionCodeActionProvider";
39+
import {WorkflowSelector, ActionSelector} from "./workflow/documentSelector";
3740

3841
export async function activate(context: vscode.ExtensionContext) {
3942
initLogger();
@@ -113,6 +116,17 @@ export async function activate(context: vscode.ExtensionContext) {
113116
// Editing features
114117
await initLanguageServer(context);
115118

119+
// Action version hover and code actions
120+
const documentSelectors = [WorkflowSelector, ActionSelector];
121+
context.subscriptions.push(
122+
vscode.languages.registerHoverProvider(documentSelectors, new ActionVersionHoverProvider())
123+
);
124+
context.subscriptions.push(
125+
vscode.languages.registerCodeActionsProvider(documentSelectors, new ActionVersionCodeActionProvider(), {
126+
providedCodeActionKinds: ActionVersionCodeActionProvider.providedCodeActionKinds
127+
})
128+
);
129+
116130
log("...initialized");
117131

118132
if (!PRODUCTION) {
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import * as vscode from "vscode";
2+
3+
import {TTLCache} from "@actions/languageserver/utils/cache";
4+
5+
import {getSession} from "../auth/auth";
6+
import {getClient} from "../api/api";
7+
8+
const USES_PATTERN = /uses:\s*(['"]?)([^@\s'"]+)@([^\s'"#]+)/;
9+
const CACHE_TTL_MS = 5 * 60 * 1000;
10+
11+
const cache = new TTLCache(CACHE_TTL_MS);
12+
13+
interface ActionVersionInfo {
14+
latest: string;
15+
latestMajor?: string;
16+
}
17+
18+
function parseUsesReference(
19+
line: string
20+
): {owner: string; name: string; actionPath: string; currentRef: string; refStart: number; refEnd: number} | undefined {
21+
const match = USES_PATTERN.exec(line);
22+
if (!match) {
23+
return undefined;
24+
}
25+
26+
const actionPath = match[2];
27+
const currentRef = match[3];
28+
29+
const [owner, name] = actionPath.split("/");
30+
if (!owner || !name) {
31+
return undefined;
32+
}
33+
34+
// Find the position of the @ref part
35+
const fullMatchStart = match.index + match[0].indexOf(match[2]);
36+
const refStart = fullMatchStart + actionPath.length + 1; // +1 for @
37+
const refEnd = refStart + currentRef.length;
38+
39+
return {owner, name, actionPath, currentRef, refStart, refEnd};
40+
}
41+
42+
function extractMajorTag(tag: string): string | undefined {
43+
const match = /^(v?\d+)[\.\d]*/.exec(tag);
44+
return match ? match[1] : undefined;
45+
}
46+
47+
async function fetchLatestVersion(owner: string, name: string): Promise<ActionVersionInfo | undefined> {
48+
const session = await getSession(true);
49+
if (!session) {
50+
return undefined;
51+
}
52+
53+
const cacheKey = `action-latest-version:${owner}/${name}`;
54+
return cache.get<ActionVersionInfo | undefined>(cacheKey, undefined, async () => {
55+
const client = getClient(session.accessToken);
56+
57+
try {
58+
const {data} = await client.repos.getLatestRelease({owner, repo: name});
59+
if (data.tag_name) {
60+
const major = extractMajorTag(data.tag_name);
61+
return {latest: data.tag_name, latestMajor: major};
62+
}
63+
} catch {
64+
// No release found
65+
}
66+
67+
try {
68+
const {data} = await client.repos.listTags({owner, repo: name, per_page: 10});
69+
if (data.length > 0) {
70+
const semverTag = data.find(t => /^v?\d+\.\d+/.test(t.name));
71+
const tag = semverTag || data[0];
72+
const major = extractMajorTag(tag.name);
73+
return {latest: tag.name, latestMajor: major};
74+
}
75+
} catch {
76+
// Ignore
77+
}
78+
79+
return undefined;
80+
});
81+
}
82+
83+
export class ActionVersionCodeActionProvider implements vscode.CodeActionProvider {
84+
static readonly providedCodeActionKinds = [vscode.CodeActionKind.QuickFix];
85+
86+
async provideCodeActions(
87+
document: vscode.TextDocument,
88+
range: vscode.Range | vscode.Selection,
89+
_context: vscode.CodeActionContext,
90+
_token: vscode.CancellationToken
91+
): Promise<vscode.CodeAction[] | undefined> {
92+
const actions: vscode.CodeAction[] = [];
93+
94+
for (let lineNum = range.start.line; lineNum <= range.end.line; lineNum++) {
95+
const line = document.lineAt(lineNum).text;
96+
const ref = parseUsesReference(line);
97+
if (!ref) {
98+
continue;
99+
}
100+
101+
const versionInfo = await fetchLatestVersion(ref.owner, ref.name);
102+
if (!versionInfo) {
103+
continue;
104+
}
105+
106+
const isCurrentLatest = ref.currentRef === versionInfo.latest || ref.currentRef === versionInfo.latestMajor;
107+
108+
if (isCurrentLatest) {
109+
continue;
110+
}
111+
112+
const refRange = new vscode.Range(lineNum, ref.refStart, lineNum, ref.refEnd);
113+
114+
// Offer update to latest full version
115+
const updateToLatest = new vscode.CodeAction(
116+
`Update ${ref.actionPath} to ${versionInfo.latest}`,
117+
vscode.CodeActionKind.QuickFix
118+
);
119+
updateToLatest.edit = new vscode.WorkspaceEdit();
120+
updateToLatest.edit.replace(document.uri, refRange, versionInfo.latest);
121+
updateToLatest.isPreferred = true;
122+
actions.push(updateToLatest);
123+
124+
// Offer update to latest major version tag if different
125+
if (
126+
versionInfo.latestMajor &&
127+
versionInfo.latestMajor !== versionInfo.latest &&
128+
versionInfo.latestMajor !== ref.currentRef
129+
) {
130+
const updateToMajor = new vscode.CodeAction(
131+
`Update ${ref.actionPath} to ${versionInfo.latestMajor}`,
132+
vscode.CodeActionKind.QuickFix
133+
);
134+
updateToMajor.edit = new vscode.WorkspaceEdit();
135+
updateToMajor.edit.replace(document.uri, refRange, versionInfo.latestMajor);
136+
actions.push(updateToMajor);
137+
}
138+
}
139+
140+
return actions.length > 0 ? actions : undefined;
141+
}
142+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import * as vscode from "vscode";
2+
3+
import {TTLCache} from "@actions/languageserver/utils/cache";
4+
5+
import {getSession} from "../auth/auth";
6+
import {getClient} from "../api/api";
7+
8+
const USES_PATTERN = /uses:\s*(['"]?)([^@\s'"]+)@([^\s'"#]+)/;
9+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
10+
11+
const cache = new TTLCache(CACHE_TTL_MS);
12+
13+
interface ActionVersionInfo {
14+
latest: string;
15+
/** The latest major version tag, e.g. "v4" */
16+
latestMajor?: string;
17+
}
18+
19+
/**
20+
* Parses the `uses:` value from a workflow line and returns owner, name, and current ref.
21+
*/
22+
function parseUsesReference(
23+
line: string
24+
): {owner: string; name: string; currentRef: string; valueStart: number; valueEnd: number} | undefined {
25+
const match = USES_PATTERN.exec(line);
26+
if (!match) {
27+
return undefined;
28+
}
29+
30+
const actionPath = match[2]; // e.g. "actions/checkout" or "actions/cache/restore"
31+
const currentRef = match[3];
32+
33+
const [owner, name] = actionPath.split("/");
34+
if (!owner || !name) {
35+
return undefined;
36+
}
37+
38+
const valueStart = match.index + match[0].indexOf(match[2]);
39+
const valueEnd = valueStart + actionPath.length + 1 + currentRef.length; // +1 for @
40+
41+
return {owner, name, currentRef, valueStart, valueEnd};
42+
}
43+
44+
async function fetchLatestVersion(owner: string, name: string): Promise<ActionVersionInfo | undefined> {
45+
const session = await getSession(true);
46+
if (!session) {
47+
return undefined;
48+
}
49+
50+
const cacheKey = `action-latest-version:${owner}/${name}`;
51+
return cache.get<ActionVersionInfo | undefined>(cacheKey, undefined, async () => {
52+
const client = getClient(session.accessToken);
53+
54+
// Try latest release first
55+
try {
56+
const {data} = await client.repos.getLatestRelease({owner, repo: name});
57+
if (data.tag_name) {
58+
const major = extractMajorTag(data.tag_name);
59+
return {latest: data.tag_name, latestMajor: major};
60+
}
61+
} catch {
62+
// No release found, fallback to tags
63+
}
64+
65+
// Fallback: list tags and find latest semver
66+
try {
67+
const {data} = await client.repos.listTags({owner, repo: name, per_page: 10});
68+
if (data.length > 0) {
69+
// Find the latest semver-like tag
70+
const semverTag = data.find(t => /^v?\d+\.\d+/.test(t.name));
71+
const tag = semverTag || data[0];
72+
const major = extractMajorTag(tag.name);
73+
return {latest: tag.name, latestMajor: major};
74+
}
75+
} catch {
76+
// Ignore
77+
}
78+
79+
return undefined;
80+
});
81+
}
82+
83+
function extractMajorTag(tag: string): string | undefined {
84+
const match = /^(v?\d+)[\.\d]*/.exec(tag);
85+
return match ? match[1] : undefined;
86+
}
87+
88+
export class ActionVersionHoverProvider implements vscode.HoverProvider {
89+
async provideHover(
90+
document: vscode.TextDocument,
91+
position: vscode.Position,
92+
_token: vscode.CancellationToken
93+
): Promise<vscode.Hover | undefined> {
94+
const line = document.lineAt(position).text;
95+
const ref = parseUsesReference(line);
96+
if (!ref) {
97+
return undefined;
98+
}
99+
100+
// Ensure cursor is within the action reference range
101+
if (position.character < ref.valueStart || position.character > ref.valueEnd) {
102+
return undefined;
103+
}
104+
105+
const versionInfo = await fetchLatestVersion(ref.owner, ref.name);
106+
if (!versionInfo) {
107+
return undefined;
108+
}
109+
110+
const md = new vscode.MarkdownString();
111+
md.isTrusted = true;
112+
113+
const isCurrentLatest = ref.currentRef === versionInfo.latest || ref.currentRef === versionInfo.latestMajor;
114+
115+
if (isCurrentLatest) {
116+
md.appendMarkdown(`**Latest version:** \`${versionInfo.latest}\` ✓`);
117+
} else {
118+
md.appendMarkdown(`**Latest version:** \`${versionInfo.latest}\``);
119+
if (versionInfo.latestMajor && ref.currentRef !== versionInfo.latestMajor) {
120+
md.appendMarkdown(` (major: \`${versionInfo.latestMajor}\`)`);
121+
}
122+
}
123+
124+
const range = new vscode.Range(position.line, ref.valueStart, position.line, ref.valueEnd);
125+
126+
return new vscode.Hover(md, range);
127+
}
128+
}

0 commit comments

Comments
 (0)