Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9f6ffbb5d |
@@ -7,6 +7,7 @@ import {TTLCache} from "./utils/cache.js";
|
|||||||
import {getActionInputValues} from "./value-providers/action-inputs.js";
|
import {getActionInputValues} from "./value-providers/action-inputs.js";
|
||||||
import {getEnvironments} from "./value-providers/job-environment.js";
|
import {getEnvironments} from "./value-providers/job-environment.js";
|
||||||
import {getRunnerLabels} from "./value-providers/runs-on.js";
|
import {getRunnerLabels} from "./value-providers/runs-on.js";
|
||||||
|
import {getStepUsesValues} from "./value-providers/step-uses.js";
|
||||||
|
|
||||||
export function valueProviders(
|
export function valueProviders(
|
||||||
client: Octokit | undefined,
|
client: Octokit | undefined,
|
||||||
@@ -35,6 +36,10 @@ export function valueProviders(
|
|||||||
"step-with": {
|
"step-with": {
|
||||||
kind: ValueProviderKind.AllowedValues,
|
kind: ValueProviderKind.AllowedValues,
|
||||||
get: (context: WorkflowContext) => getActionInputValues(client, cache, context)
|
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/[email protected]
|
||||||
|
// 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -136,6 +136,11 @@ export async function complete(
|
|||||||
workflowContext = workflowTemplate ? getWorkflowContext(textDocument.uri, workflowTemplate, path) : undefined;
|
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
|
// Expression completions
|
||||||
if (token && (isBasicExpression(token) || isPotentiallyExpression(token))) {
|
if (token && (isBasicExpression(token) || isPotentiallyExpression(token))) {
|
||||||
const allowedContext = token.definitionInfo?.allowedContext || [];
|
const allowedContext = token.definitionInfo?.allowedContext || [];
|
||||||
@@ -224,6 +229,7 @@ export async function complete(
|
|||||||
const item: CompletionItem = {
|
const item: CompletionItem = {
|
||||||
label: value.label,
|
label: value.label,
|
||||||
labelDetails: value.labelDetail ? {description: value.labelDetail} : undefined,
|
labelDetails: value.labelDetail ? {description: value.labelDetail} : undefined,
|
||||||
|
kind: value.kind as CompletionItemKind,
|
||||||
filterText: value.filterText,
|
filterText: value.filterText,
|
||||||
sortText: value.sortText,
|
sortText: value.sortText,
|
||||||
documentation: value.description && {
|
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 */
|
/** If the context is for a position within a step, this will be the step */
|
||||||
step?: 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 */
|
/** Sort text to control ordering, if not given `label` will be used for sorting */
|
||||||
sortText?: string;
|
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 */
|
/** Custom text edit with specific range, overrides default range calculation */
|
||||||
textEdit?: {
|
textEdit?: {
|
||||||
range: {start: {line: number; character: number}; end: {line: number; character: number}};
|
range: {start: {line: number; character: number}; end: {line: number; character: number}};
|
||||||
|
|||||||
Reference in New Issue
Block a user