Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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) {
Expand Down
142 changes: 142 additions & 0 deletions src/hover/actionVersionCodeActionProvider.ts
Original file line number Diff line number Diff line change
@@ -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;

Comment thread
ZiuChen marked this conversation as resolved.
Outdated
const cache = new TTLCache(CACHE_TTL_MS);

Comment thread
ZiuChen marked this conversation as resolved.
Outdated
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<ActionVersionInfo | undefined> {
const session = await getSession(true);
if (!session) {
return undefined;
}

Comment thread
ZiuChen marked this conversation as resolved.
Outdated
const cacheKey = `action-latest-version:${owner}/${name}`;
return cache.get<ActionVersionInfo | undefined>(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);
Comment thread
ZiuChen marked this conversation as resolved.
Outdated
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<vscode.CodeAction[] | undefined> {
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;
}

Comment thread
ZiuChen marked this conversation as resolved.
Outdated
const versionInfo = await fetchLatestVersion(ref.owner, ref.name);
if (!versionInfo) {
continue;
}

const isCurrentLatest = ref.currentRef === versionInfo.latest || ref.currentRef === versionInfo.latestMajor;

if (isCurrentLatest) {
continue;
}

Comment thread
ZiuChen marked this conversation as resolved.
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;
}
}
128 changes: 128 additions & 0 deletions src/hover/actionVersionHoverProvider.ts
Original file line number Diff line number Diff line change
@@ -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

Comment thread
ZiuChen marked this conversation as resolved.
Outdated
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<ActionVersionInfo | undefined> {
const session = await getSession(true);
Comment thread
ZiuChen marked this conversation as resolved.
Outdated
if (!session) {
return undefined;
}

const cacheKey = `action-latest-version:${owner}/${name}`;
return cache.get<ActionVersionInfo | undefined>(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<vscode.Hover | undefined> {
Comment thread
ZiuChen marked this conversation as resolved.
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;
Comment thread
ZiuChen marked this conversation as resolved.
Outdated

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);
}
}
Loading