This commit is contained in:
Francesco Renzi
2026-01-14 15:04:10 +00:00
parent 54404aa9ff
commit b9f6ffbb5d
5 changed files with 178 additions and 0 deletions
+5
View File
@@ -7,6 +7,7 @@ import {TTLCache} from "./utils/cache.js";
import {getActionInputValues} from "./value-providers/action-inputs.js";
import {getEnvironments} from "./value-providers/job-environment.js";
import {getRunnerLabels} from "./value-providers/runs-on.js";
import {getStepUsesValues} from "./value-providers/step-uses.js";
export function valueProviders(
client: Octokit | undefined,
@@ -35,6 +36,10 @@ export function valueProviders(
"step-with": {
kind: ValueProviderKind.AllowedValues,
get: (context: WorkflowContext) => getActionInputValues(client, cache, context)
},
"step-uses": {
kind: ValueProviderKind.SuggestedValues,
get: (context: WorkflowContext) => getStepUsesValues(context)
}
};
}
@@ -0,0 +1,161 @@
import {WorkflowContext} from "@actions/languageservice/context/workflow-context";
import {Value} from "@actions/languageservice/value-providers/config";
const MARKETPLACE_SEARCH_URL = "https://github.com/editor/actions/marketplace-search";
const MARKETPLACE_DETAIL_URL = "https://github.com/editor/actions/marketplace";
const MIN_QUERY_LENGTH = 3;
const COMPLETION_KIND_MODULE = 9; // CompletionItemKind.Module
interface MarketplaceAction {
id: string;
title: string;
owner: string;
stars: number;
description: string;
}
/**
* Parses star count strings like "1.3k" or "205" into numbers
*/
function parseStarsCount(starsStr: string): number {
const trimmed = starsStr.trim().toLowerCase();
if (trimmed.endsWith("k")) {
return Math.round(parseFloat(trimmed.slice(0, -1)) * 1000);
}
return parseInt(trimmed, 10) || 0;
}
/**
* Parses the search results HTML to extract action metadata
*/
function parseSearchResults(html: string): MarketplaceAction[] {
const actions: MarketplaceAction[] = [];
// Split by action item markers and process each chunk
const chunks = html.split('js-marketplace-action-search-item"');
// Skip the first chunk (before any action items)
for (let i = 1; i < chunks.length; i++) {
const itemHtml = chunks[i];
// Extract marketplace ID from the sidebar URL
const idMatch = itemHtml.match(/data-workflow-editor-sidebar-url="[^"]*\/marketplace\/(\d+)/);
if (!idMatch) continue;
const id = idMatch[1];
// Extract title from aria-label on first button
const titleMatch = itemHtml.match(/aria-label="([^"]+)"/);
const title = titleMatch ? titleMatch[1] : "";
// Extract owner - look for "By <span...>owner</span>"
const ownerMatch = itemHtml.match(/By\s*<span[^>]*>([^<]+)<\/span>/);
const owner = ownerMatch ? ownerMatch[1].trim() : "";
// Extract stars - in the float-right span
const starsMatch = itemHtml.match(
/<span class="float-right text-small color-fg-muted[^"]*">\s*<svg[^>]*>[\s\S]*?<\/svg>\s*([^<]+)/
);
const stars = starsMatch ? parseStarsCount(starsMatch[1]) : 0;
// Extract description - the last paragraph with ws-normal class
const descMatch = itemHtml.match(
/<p class="color-fg-muted lh-condensed mb-0 ws-normal">([^<]+)<\/p>/
);
const description = descMatch ? descMatch[1].trim() : "";
actions.push({id, title, owner, stars, description});
}
return actions;
}
/**
* Parses the action detail page to extract the uses value (e.g., "owner/repo@version")
* Prefers version tags over commit SHAs
*/
function parseActionDetail(html: string): string | undefined {
// Look for uses: lines - there are typically two formats:
// 1. SHA: uses: owner/repo@abc123...
// 2. Version: uses: owner/repo@v1.2.3
// We prefer the version format (shorter, contains 'v')
const usesMatches = html.match(/uses:\s*([\w-]+\/[\w._-]+@[\w.+-]+)/g);
if (!usesMatches || usesMatches.length === 0) {
return undefined;
}
// Find the first version tag (contains @v) or fall back to the first match
for (const match of usesMatches) {
const value = match.replace(/^uses:\s*/, "");
if (value.includes("@v")) {
return value;
}
}
// Fall back to first match (likely SHA)
return usesMatches[0].replace(/^uses:\s*/, "");
}
/**
* Fetches marketplace search results and detail pages to build completion values
*/
export async function getStepUsesValues(context: WorkflowContext): Promise<Value[]> {
const query = context.currentValue?.trim() || "";
// Don't search with very short queries
if (query.length < MIN_QUERY_LENGTH) {
return [];
}
try {
// Fetch search results
const searchUrl = `${MARKETPLACE_SEARCH_URL}?query=${encodeURIComponent(query)}`;
const searchResponse = await fetch(searchUrl);
if (!searchResponse.ok) {
return [];
}
const searchHtml = await searchResponse.text();
const actions = parseSearchResults(searchHtml);
if (actions.length === 0) {
return [];
}
// Fetch detail pages in parallel to get the actual uses values
const detailPromises = actions.map(async (action): Promise<Value | null> => {
try {
const detailUrl = `${MARKETPLACE_DETAIL_URL}/${action.id}`;
const detailResponse = await fetch(detailUrl);
if (!detailResponse.ok) {
return null;
}
const detailHtml = await detailResponse.text();
const usesValue = parseActionDetail(detailHtml);
if (!usesValue) {
return null;
}
// Create sort text that puts higher star counts first
// Pad to 10 digits so sorting works correctly
const invertedStars = 10000000000 - action.stars;
const sortText = invertedStars.toString().padStart(10, "0");
return {
label: usesValue,
description: action.description,
kind: COMPLETION_KIND_MODULE,
sortText
};
} catch {
return null;
}
});
const results = await Promise.all(detailPromises);
// Filter out nulls and return
return results.filter((v): v is Value => v !== null);
} catch {
return [];
}
}
+6
View File
@@ -136,6 +136,11 @@ export async function complete(
workflowContext = workflowTemplate ? getWorkflowContext(textDocument.uri, workflowTemplate, path) : undefined;
}
// Populate currentValue for value providers that need the query (e.g., step-uses marketplace search)
if (workflowContext && token && isString(token)) {
workflowContext.currentValue = token.value;
}
// Expression completions
if (token && (isBasicExpression(token) || isPotentiallyExpression(token))) {
const allowedContext = token.definitionInfo?.allowedContext || [];
@@ -224,6 +229,7 @@ export async function complete(
const item: CompletionItem = {
label: value.label,
labelDetails: value.labelDetail ? {description: value.labelDetail} : undefined,
kind: value.kind as CompletionItemKind,
filterText: value.filterText,
sortText: value.sortText,
documentation: value.description && {
@@ -23,6 +23,9 @@ export interface WorkflowContext {
/** If the context is for a position within a step, this will be the step */
step?: Step;
/** The current value being typed at the cursor position (for value providers that need the query) */
currentValue?: string;
}
/**
@@ -22,6 +22,9 @@ export interface Value {
/** Sort text to control ordering, if not given `label` will be used for sorting */
sortText?: string;
/** CompletionItemKind (as number to avoid LSP dependency). E.g., 9 = Module */
kind?: number;
/** Custom text edit with specific range, overrides default range calculation */
textEdit?: {
range: {start: {line: number; character: number}; end: {line: number; character: number}};