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..b16611a8 --- /dev/null +++ b/src/hover/actionVersionCodeActionProvider.ts @@ -0,0 +1,77 @@ +import * as vscode from "vscode"; + +import {parseUsesReference, fetchLatestVersion, isShaRef} from "./actionVersionUtils"; + +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++) { + 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; + } + + 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..89fcfdd1 --- /dev/null +++ b/src/hover/actionVersionHoverProvider.ts @@ -0,0 +1,48 @@ +import * as vscode from "vscode"; + +import {parseUsesReference, fetchLatestVersion} from "./actionVersionUtils"; + +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; + } + + if (token.isCancellationRequested) { + return undefined; + } + + const versionInfo = await fetchLatestVersion(ref.owner, ref.name); + if (!versionInfo || token.isCancellationRequested) { + return undefined; + } + + const md = new vscode.MarkdownString(); + + 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); + } +} 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; + }); +}