diff --git a/languageserver/src/value-providers.ts b/languageserver/src/value-providers.ts index 4e442f2..43d0392 100644 --- a/languageserver/src/value-providers.ts +++ b/languageserver/src/value-providers.ts @@ -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) } }; } diff --git a/languageserver/src/value-providers/step-uses.ts b/languageserver/src/value-providers/step-uses.ts new file mode 100644 index 0000000..959054f --- /dev/null +++ b/languageserver/src/value-providers/step-uses.ts @@ -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 owner" + const ownerMatch = itemHtml.match(/By\s*]*>([^<]+)<\/span>/); + const owner = ownerMatch ? ownerMatch[1].trim() : ""; + + // Extract stars - in the float-right span + const starsMatch = itemHtml.match( + /\s*]*>[\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>/ + ); + 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 { + 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 => { + 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 []; + } +} diff --git a/languageservice/src/complete.ts b/languageservice/src/complete.ts index b71da41..a1ef738 100644 --- a/languageservice/src/complete.ts +++ b/languageservice/src/complete.ts @@ -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 && { diff --git a/languageservice/src/context/workflow-context.ts b/languageservice/src/context/workflow-context.ts index cf9d6c5..4aee450 100644 --- a/languageservice/src/context/workflow-context.ts +++ b/languageservice/src/context/workflow-context.ts @@ -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; } /** diff --git a/languageservice/src/value-providers/config.ts b/languageservice/src/value-providers/config.ts index 2998dc5..2a317f0 100644 --- a/languageservice/src/value-providers/config.ts +++ b/languageservice/src/value-providers/config.ts @@ -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}};