From de38cfbae149826ca4da25b4c6c1fc36fbb8e473 Mon Sep 17 00:00:00 2001 From: ZiuChen Date: Fri, 10 Apr 2026 17:07:31 +0800 Subject: [PATCH 1/2] feat: Add actions version in hover panel - Add quick fix when new version avaliable --- src/extension.ts | 14 ++ src/hover/actionVersionCodeActionProvider.ts | 142 +++++++++++++++++++ src/hover/actionVersionHoverProvider.ts | 128 +++++++++++++++++ 3 files changed, 284 insertions(+) create mode 100644 src/hover/actionVersionCodeActionProvider.ts create mode 100644 src/hover/actionVersionHoverProvider.ts diff --git a/src/extension.ts b/src/extension.ts index 210c9548..cb878b7f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -34,6 +34,9 @@ import {initResources} from "./treeViews/icons"; import {initTreeViews} from "./treeViews/treeViews"; import {deactivateLanguageServer, initLanguageServer} from "./workflow/languageServer"; import {registerSignIn} from "./commands/signIn"; +import {ActionVersionHoverProvider} from "./hover/actionVersionHoverProvider"; +import {ActionVersionCodeActionProvider} from "./hover/actionVersionCodeActionProvider"; +import {WorkflowSelector, ActionSelector} from "./workflow/documentSelector"; export async function activate(context: vscode.ExtensionContext) { initLogger(); @@ -113,6 +116,17 @@ export async function activate(context: vscode.ExtensionContext) { // Editing features await initLanguageServer(context); + // Action version hover and code actions + const documentSelectors = [WorkflowSelector, ActionSelector]; + context.subscriptions.push( + vscode.languages.registerHoverProvider(documentSelectors, new ActionVersionHoverProvider()) + ); + context.subscriptions.push( + vscode.languages.registerCodeActionsProvider(documentSelectors, new ActionVersionCodeActionProvider(), { + providedCodeActionKinds: ActionVersionCodeActionProvider.providedCodeActionKinds + }) + ); + log("...initialized"); if (!PRODUCTION) { diff --git a/src/hover/actionVersionCodeActionProvider.ts b/src/hover/actionVersionCodeActionProvider.ts new file mode 100644 index 00000000..c2574653 --- /dev/null +++ b/src/hover/actionVersionCodeActionProvider.ts @@ -0,0 +1,142 @@ +import * as vscode from "vscode"; + +import {TTLCache} from "@actions/languageserver/utils/cache"; + +import {getSession} from "../auth/auth"; +import {getClient} from "../api/api"; + +const USES_PATTERN = /uses:\s*(['"]?)([^@\s'"]+)@([^\s'"#]+)/; +const CACHE_TTL_MS = 5 * 60 * 1000; + +const cache = new TTLCache(CACHE_TTL_MS); + +interface ActionVersionInfo { + latest: string; + latestMajor?: string; +} + +function parseUsesReference( + line: string +): {owner: string; name: string; actionPath: string; currentRef: string; refStart: number; refEnd: number} | undefined { + const match = USES_PATTERN.exec(line); + if (!match) { + return undefined; + } + + const actionPath = match[2]; + const currentRef = match[3]; + + const [owner, name] = actionPath.split("/"); + if (!owner || !name) { + return undefined; + } + + // Find the position of the @ref part + const fullMatchStart = match.index + match[0].indexOf(match[2]); + const refStart = fullMatchStart + actionPath.length + 1; // +1 for @ + const refEnd = refStart + currentRef.length; + + return {owner, name, actionPath, currentRef, refStart, refEnd}; +} + +function extractMajorTag(tag: string): string | undefined { + const match = /^(v?\d+)[\.\d]*/.exec(tag); + return match ? match[1] : undefined; +} + +async function fetchLatestVersion(owner: string, name: string): Promise { + const session = await getSession(true); + if (!session) { + return undefined; + } + + const cacheKey = `action-latest-version:${owner}/${name}`; + return cache.get(cacheKey, undefined, async () => { + const client = getClient(session.accessToken); + + try { + const {data} = await client.repos.getLatestRelease({owner, repo: name}); + if (data.tag_name) { + const major = extractMajorTag(data.tag_name); + return {latest: data.tag_name, latestMajor: major}; + } + } catch { + // No release found + } + + try { + const {data} = await client.repos.listTags({owner, repo: name, per_page: 10}); + if (data.length > 0) { + const semverTag = data.find(t => /^v?\d+\.\d+/.test(t.name)); + const tag = semverTag || data[0]; + const major = extractMajorTag(tag.name); + return {latest: tag.name, latestMajor: major}; + } + } catch { + // Ignore + } + + return undefined; + }); +} + +export class ActionVersionCodeActionProvider implements vscode.CodeActionProvider { + static readonly providedCodeActionKinds = [vscode.CodeActionKind.QuickFix]; + + async provideCodeActions( + document: vscode.TextDocument, + range: vscode.Range | vscode.Selection, + _context: vscode.CodeActionContext, + _token: vscode.CancellationToken + ): Promise { + const actions: vscode.CodeAction[] = []; + + for (let lineNum = range.start.line; lineNum <= range.end.line; lineNum++) { + const line = document.lineAt(lineNum).text; + const ref = parseUsesReference(line); + if (!ref) { + continue; + } + + const versionInfo = await fetchLatestVersion(ref.owner, ref.name); + if (!versionInfo) { + continue; + } + + const isCurrentLatest = ref.currentRef === versionInfo.latest || ref.currentRef === versionInfo.latestMajor; + + if (isCurrentLatest) { + continue; + } + + const refRange = new vscode.Range(lineNum, ref.refStart, lineNum, ref.refEnd); + + // Offer update to latest full version + const updateToLatest = new vscode.CodeAction( + `Update ${ref.actionPath} to ${versionInfo.latest}`, + vscode.CodeActionKind.QuickFix + ); + updateToLatest.edit = new vscode.WorkspaceEdit(); + updateToLatest.edit.replace(document.uri, refRange, versionInfo.latest); + updateToLatest.isPreferred = true; + actions.push(updateToLatest); + + // Offer update to latest major version tag if different + if ( + versionInfo.latestMajor && + versionInfo.latestMajor !== versionInfo.latest && + versionInfo.latestMajor !== ref.currentRef + ) { + const updateToMajor = new vscode.CodeAction( + `Update ${ref.actionPath} to ${versionInfo.latestMajor}`, + vscode.CodeActionKind.QuickFix + ); + updateToMajor.edit = new vscode.WorkspaceEdit(); + updateToMajor.edit.replace(document.uri, refRange, versionInfo.latestMajor); + actions.push(updateToMajor); + } + } + + return actions.length > 0 ? actions : undefined; + } +} diff --git a/src/hover/actionVersionHoverProvider.ts b/src/hover/actionVersionHoverProvider.ts new file mode 100644 index 00000000..0a388925 --- /dev/null +++ b/src/hover/actionVersionHoverProvider.ts @@ -0,0 +1,128 @@ +import * as vscode from "vscode"; + +import {TTLCache} from "@actions/languageserver/utils/cache"; + +import {getSession} from "../auth/auth"; +import {getClient} from "../api/api"; + +const USES_PATTERN = /uses:\s*(['"]?)([^@\s'"]+)@([^\s'"#]+)/; +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +const cache = new TTLCache(CACHE_TTL_MS); + +interface ActionVersionInfo { + latest: string; + /** The latest major version tag, e.g. "v4" */ + latestMajor?: string; +} + +/** + * Parses the `uses:` value from a workflow line and returns owner, name, and current ref. + */ +function parseUsesReference( + line: string +): {owner: string; name: string; currentRef: string; valueStart: number; valueEnd: number} | undefined { + const match = USES_PATTERN.exec(line); + if (!match) { + return undefined; + } + + const actionPath = match[2]; // e.g. "actions/checkout" or "actions/cache/restore" + const currentRef = match[3]; + + const [owner, name] = actionPath.split("/"); + if (!owner || !name) { + return undefined; + } + + const valueStart = match.index + match[0].indexOf(match[2]); + const valueEnd = valueStart + actionPath.length + 1 + currentRef.length; // +1 for @ + + return {owner, name, currentRef, valueStart, valueEnd}; +} + +async function fetchLatestVersion(owner: string, name: string): Promise { + const session = await getSession(true); + if (!session) { + return undefined; + } + + const cacheKey = `action-latest-version:${owner}/${name}`; + return cache.get(cacheKey, undefined, async () => { + const client = getClient(session.accessToken); + + // Try latest release first + try { + const {data} = await client.repos.getLatestRelease({owner, repo: name}); + if (data.tag_name) { + const major = extractMajorTag(data.tag_name); + return {latest: data.tag_name, latestMajor: major}; + } + } catch { + // No release found, fallback to tags + } + + // Fallback: list tags and find latest semver + try { + const {data} = await client.repos.listTags({owner, repo: name, per_page: 10}); + if (data.length > 0) { + // Find the latest semver-like tag + const semverTag = data.find(t => /^v?\d+\.\d+/.test(t.name)); + const tag = semverTag || data[0]; + const major = extractMajorTag(tag.name); + return {latest: tag.name, latestMajor: major}; + } + } catch { + // Ignore + } + + return undefined; + }); +} + +function extractMajorTag(tag: string): string | undefined { + const match = /^(v?\d+)[\.\d]*/.exec(tag); + return match ? match[1] : undefined; +} + +export class ActionVersionHoverProvider implements vscode.HoverProvider { + async provideHover( + document: vscode.TextDocument, + position: vscode.Position, + _token: vscode.CancellationToken + ): Promise { + const line = document.lineAt(position).text; + const ref = parseUsesReference(line); + if (!ref) { + return undefined; + } + + // Ensure cursor is within the action reference range + if (position.character < ref.valueStart || position.character > ref.valueEnd) { + return undefined; + } + + const versionInfo = await fetchLatestVersion(ref.owner, ref.name); + if (!versionInfo) { + return undefined; + } + + const md = new vscode.MarkdownString(); + md.isTrusted = true; + + const isCurrentLatest = ref.currentRef === versionInfo.latest || ref.currentRef === versionInfo.latestMajor; + + if (isCurrentLatest) { + md.appendMarkdown(`**Latest version:** \`${versionInfo.latest}\` ✓`); + } else { + md.appendMarkdown(`**Latest version:** \`${versionInfo.latest}\``); + if (versionInfo.latestMajor && ref.currentRef !== versionInfo.latestMajor) { + md.appendMarkdown(` (major: \`${versionInfo.latestMajor}\`)`); + } + } + + const range = new vscode.Range(position.line, ref.valueStart, position.line, ref.valueEnd); + + return new vscode.Hover(md, range); + } +} From 817dda8b7dd2dc956560b352b0db88208f185f8c Mon Sep 17 00:00:00 2001 From: ZiuChen Date: Fri, 10 Apr 2026 17:37:34 +0800 Subject: [PATCH 2/2] feat: address code review issues in action version hover/quick fix - Extract shared module (actionVersionUtils.ts) to eliminate duplicated parsing, caching, and API logic between hover and code action providers - Use non-interactive getSession() to prevent auth prompts during passive hover/code-action triggers - Skip dynamic refs containing `${{` expressions to avoid corrupting YAML with incorrect replacements - Suppress Quick Fix for SHA-pinned action refs to preserve supply-chain security posture - Remove unnecessary `md.isTrusted = true` on hover MarkdownString - Respect CancellationToken in both providers to abort early on stale requests - Fix misleading "latest semver" comment to match actual date-sorted tag selection behavior --- src/hover/actionVersionCodeActionProvider.ts | 95 +++------------- src/hover/actionVersionHoverProvider.ts | 94 ++-------------- src/hover/actionVersionUtils.ts | 112 +++++++++++++++++++ 3 files changed, 134 insertions(+), 167 deletions(-) create mode 100644 src/hover/actionVersionUtils.ts diff --git a/src/hover/actionVersionCodeActionProvider.ts b/src/hover/actionVersionCodeActionProvider.ts index c2574653..b16611a8 100644 --- a/src/hover/actionVersionCodeActionProvider.ts +++ b/src/hover/actionVersionCodeActionProvider.ts @@ -1,84 +1,6 @@ import * as vscode from "vscode"; -import {TTLCache} from "@actions/languageserver/utils/cache"; - -import {getSession} from "../auth/auth"; -import {getClient} from "../api/api"; - -const USES_PATTERN = /uses:\s*(['"]?)([^@\s'"]+)@([^\s'"#]+)/; -const CACHE_TTL_MS = 5 * 60 * 1000; - -const cache = new TTLCache(CACHE_TTL_MS); - -interface ActionVersionInfo { - latest: string; - latestMajor?: string; -} - -function parseUsesReference( - line: string -): {owner: string; name: string; actionPath: string; currentRef: string; refStart: number; refEnd: number} | undefined { - const match = USES_PATTERN.exec(line); - if (!match) { - return undefined; - } - - const actionPath = match[2]; - const currentRef = match[3]; - - const [owner, name] = actionPath.split("/"); - if (!owner || !name) { - return undefined; - } - - // Find the position of the @ref part - const fullMatchStart = match.index + match[0].indexOf(match[2]); - const refStart = fullMatchStart + actionPath.length + 1; // +1 for @ - const refEnd = refStart + currentRef.length; - - return {owner, name, actionPath, currentRef, refStart, refEnd}; -} - -function extractMajorTag(tag: string): string | undefined { - const match = /^(v?\d+)[\.\d]*/.exec(tag); - return match ? match[1] : undefined; -} - -async function fetchLatestVersion(owner: string, name: string): Promise { - const session = await getSession(true); - if (!session) { - return undefined; - } - - const cacheKey = `action-latest-version:${owner}/${name}`; - return cache.get(cacheKey, undefined, async () => { - const client = getClient(session.accessToken); - - try { - const {data} = await client.repos.getLatestRelease({owner, repo: name}); - if (data.tag_name) { - const major = extractMajorTag(data.tag_name); - return {latest: data.tag_name, latestMajor: major}; - } - } catch { - // No release found - } - - try { - const {data} = await client.repos.listTags({owner, repo: name, per_page: 10}); - if (data.length > 0) { - const semverTag = data.find(t => /^v?\d+\.\d+/.test(t.name)); - const tag = semverTag || data[0]; - const major = extractMajorTag(tag.name); - return {latest: tag.name, latestMajor: major}; - } - } catch { - // Ignore - } - - return undefined; - }); -} +import {parseUsesReference, fetchLatestVersion, isShaRef} from "./actionVersionUtils"; export class ActionVersionCodeActionProvider implements vscode.CodeActionProvider { static readonly providedCodeActionKinds = [vscode.CodeActionKind.QuickFix]; @@ -87,17 +9,30 @@ export class ActionVersionCodeActionProvider implements vscode.CodeActionProvide document: vscode.TextDocument, range: vscode.Range | vscode.Selection, _context: vscode.CodeActionContext, - _token: vscode.CancellationToken + token: vscode.CancellationToken ): Promise { const actions: vscode.CodeAction[] = []; for (let lineNum = range.start.line; lineNum <= range.end.line; lineNum++) { + if (token.isCancellationRequested) { + return actions.length > 0 ? actions : undefined; + } + const line = document.lineAt(lineNum).text; const ref = parseUsesReference(line); if (!ref) { continue; } + // Don't offer to replace SHA-pinned refs — it would change the security posture + if (isShaRef(ref.currentRef)) { + continue; + } + + if (token.isCancellationRequested) { + return actions.length > 0 ? actions : undefined; + } + const versionInfo = await fetchLatestVersion(ref.owner, ref.name); if (!versionInfo) { continue; diff --git a/src/hover/actionVersionHoverProvider.ts b/src/hover/actionVersionHoverProvider.ts index 0a388925..89fcfdd1 100644 --- a/src/hover/actionVersionHoverProvider.ts +++ b/src/hover/actionVersionHoverProvider.ts @@ -1,95 +1,12 @@ import * as vscode from "vscode"; -import {TTLCache} from "@actions/languageserver/utils/cache"; - -import {getSession} from "../auth/auth"; -import {getClient} from "../api/api"; - -const USES_PATTERN = /uses:\s*(['"]?)([^@\s'"]+)@([^\s'"#]+)/; -const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes - -const cache = new TTLCache(CACHE_TTL_MS); - -interface ActionVersionInfo { - latest: string; - /** The latest major version tag, e.g. "v4" */ - latestMajor?: string; -} - -/** - * Parses the `uses:` value from a workflow line and returns owner, name, and current ref. - */ -function parseUsesReference( - line: string -): {owner: string; name: string; currentRef: string; valueStart: number; valueEnd: number} | undefined { - const match = USES_PATTERN.exec(line); - if (!match) { - return undefined; - } - - const actionPath = match[2]; // e.g. "actions/checkout" or "actions/cache/restore" - const currentRef = match[3]; - - const [owner, name] = actionPath.split("/"); - if (!owner || !name) { - return undefined; - } - - const valueStart = match.index + match[0].indexOf(match[2]); - const valueEnd = valueStart + actionPath.length + 1 + currentRef.length; // +1 for @ - - return {owner, name, currentRef, valueStart, valueEnd}; -} - -async function fetchLatestVersion(owner: string, name: string): Promise { - const session = await getSession(true); - if (!session) { - return undefined; - } - - const cacheKey = `action-latest-version:${owner}/${name}`; - return cache.get(cacheKey, undefined, async () => { - const client = getClient(session.accessToken); - - // Try latest release first - try { - const {data} = await client.repos.getLatestRelease({owner, repo: name}); - if (data.tag_name) { - const major = extractMajorTag(data.tag_name); - return {latest: data.tag_name, latestMajor: major}; - } - } catch { - // No release found, fallback to tags - } - - // Fallback: list tags and find latest semver - try { - const {data} = await client.repos.listTags({owner, repo: name, per_page: 10}); - if (data.length > 0) { - // Find the latest semver-like tag - const semverTag = data.find(t => /^v?\d+\.\d+/.test(t.name)); - const tag = semverTag || data[0]; - const major = extractMajorTag(tag.name); - return {latest: tag.name, latestMajor: major}; - } - } catch { - // Ignore - } - - return undefined; - }); -} - -function extractMajorTag(tag: string): string | undefined { - const match = /^(v?\d+)[\.\d]*/.exec(tag); - return match ? match[1] : undefined; -} +import {parseUsesReference, fetchLatestVersion} from "./actionVersionUtils"; export class ActionVersionHoverProvider implements vscode.HoverProvider { async provideHover( document: vscode.TextDocument, position: vscode.Position, - _token: vscode.CancellationToken + token: vscode.CancellationToken ): Promise { const line = document.lineAt(position).text; const ref = parseUsesReference(line); @@ -102,13 +19,16 @@ export class ActionVersionHoverProvider implements vscode.HoverProvider { return undefined; } + if (token.isCancellationRequested) { + return undefined; + } + const versionInfo = await fetchLatestVersion(ref.owner, ref.name); - if (!versionInfo) { + if (!versionInfo || token.isCancellationRequested) { return undefined; } const md = new vscode.MarkdownString(); - md.isTrusted = true; const isCurrentLatest = ref.currentRef === versionInfo.latest || ref.currentRef === versionInfo.latestMajor; diff --git a/src/hover/actionVersionUtils.ts b/src/hover/actionVersionUtils.ts new file mode 100644 index 00000000..714d664f --- /dev/null +++ b/src/hover/actionVersionUtils.ts @@ -0,0 +1,112 @@ +import {TTLCache} from "@actions/languageserver/utils/cache"; + +import {getSession} from "../auth/auth"; +import {getClient} from "../api/api"; + +const USES_PATTERN = /uses:\s*(['"]?)([^@\s'"]+)@([^\s'"#]+)/; +const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes + +const cache = new TTLCache(CACHE_TTL_MS); + +export interface ActionVersionInfo { + latest: string; + /** The latest major version tag, e.g. "v4" */ + latestMajor?: string; +} + +export interface UsesReference { + owner: string; + name: string; + actionPath: string; + currentRef: string; + /** Start of the full "owner/repo@ref" value */ + valueStart: number; + /** End of the full "owner/repo@ref" value */ + valueEnd: number; + /** Start of just the ref part after @ */ + refStart: number; + /** End of just the ref part after @ */ + refEnd: number; +} + +/** + * Parses the `uses:` value from a workflow line and returns owner, name, and current ref. + * Returns undefined for dynamic refs containing expression syntax like `${{`. + */ +export function parseUsesReference(line: string): UsesReference | undefined { + const match = USES_PATTERN.exec(line); + if (!match) { + return undefined; + } + + const actionPath = match[2]; // e.g. "actions/checkout" or "actions/cache/restore" + const currentRef = match[3]; + + // Skip dynamic refs (e.g. ${{ github.ref }}) + if (currentRef.includes("${{")) { + return undefined; + } + + const [owner, name] = actionPath.split("/"); + if (!owner || !name) { + return undefined; + } + + const fullMatchStart = match.index + match[0].indexOf(match[2]); + const valueStart = fullMatchStart; + const refStart = fullMatchStart + actionPath.length + 1; // +1 for @ + const refEnd = refStart + currentRef.length; + const valueEnd = refEnd; + + return {owner, name, actionPath, currentRef, valueStart, valueEnd, refStart, refEnd}; +} + +export function extractMajorTag(tag: string): string | undefined { + const match = /^(v?\d+)[\.\d]*/.exec(tag); + return match ? match[1] : undefined; +} + +/** + * Returns true if the ref looks like a commit SHA (40-char hex string). + */ +export function isShaRef(ref: string): boolean { + return /^[0-9a-f]{40}$/i.test(ref); +} + +export async function fetchLatestVersion(owner: string, name: string): Promise { + const session = await getSession(); + if (!session) { + return undefined; + } + + const cacheKey = `action-latest-version:${owner}/${name}`; + return cache.get(cacheKey, undefined, async () => { + const client = getClient(session.accessToken); + + // Try latest release first + try { + const {data} = await client.repos.getLatestRelease({owner, repo: name}); + if (data.tag_name) { + const major = extractMajorTag(data.tag_name); + return {latest: data.tag_name, latestMajor: major}; + } + } catch { + // No release found, fallback to tags + } + + // Fallback: list tags and pick the first semver-like tag (tags are returned in creation-date order) + try { + const {data} = await client.repos.listTags({owner, repo: name, per_page: 10}); + if (data.length > 0) { + const semverTag = data.find(t => /^v?\d+\.\d+/.test(t.name)); + const tag = semverTag || data[0]; + const major = extractMajorTag(tag.name); + return {latest: tag.name, latestMajor: major}; + } + } catch { + // Ignore + } + + return undefined; + }); +}