Rename folders
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
import {Octokit} from "@octokit/rest";
|
||||
import fetchMock from "fetch-mock";
|
||||
import {fetchActionMetadata} from "./action-metadata";
|
||||
import {TTLCache} from "./cache";
|
||||
|
||||
// A simplified version of the action.yml file from actions/checkout
|
||||
const actionMetadataContent = `
|
||||
name: 'Checkout'
|
||||
description: 'Checkout a Git repository at a particular version'
|
||||
inputs:
|
||||
repository:
|
||||
description: Repository name with owner. For example, actions/checkout
|
||||
default: \${{ github.repository }}
|
||||
runs:
|
||||
using: node16
|
||||
main: dist/index.js
|
||||
post: dist/index.js
|
||||
`;
|
||||
|
||||
// Based on https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3
|
||||
const actionMetadata = {
|
||||
name: "action.yml",
|
||||
path: "action.yml",
|
||||
sha: "cab09ebd3a964aba67b57f9727f5f6fff1372b04",
|
||||
size: 3649,
|
||||
url: "https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3",
|
||||
html_url: "https://github.com/actions/checkout/blob/v3/action.yml",
|
||||
git_url: "https://api.github.com/repos/actions/checkout/git/blobs/cab09ebd3a964aba67b57f9727f5f6fff1372b04",
|
||||
download_url: "https://raw.githubusercontent.com/actions/checkout/v3/action.yml",
|
||||
type: "file",
|
||||
content: Buffer.from(actionMetadataContent).toString("base64"),
|
||||
encoding: "base64",
|
||||
_links: {
|
||||
self: "https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3",
|
||||
git: "https://api.github.com/repos/actions/checkout/git/blobs/cab09ebd3a964aba67b57f9727f5f6fff1372b04",
|
||||
html: "https://github.com/actions/checkout/blob/v3/action.yml"
|
||||
}
|
||||
};
|
||||
|
||||
async function fetchActionWithMock(mock: fetchMock.FetchMockSandbox, cache?: TTLCache) {
|
||||
return await fetchActionMetadata(
|
||||
new Octokit({
|
||||
request: {
|
||||
fetch: mock
|
||||
}
|
||||
}),
|
||||
cache || new TTLCache(),
|
||||
{
|
||||
owner: "actions",
|
||||
name: "checkout",
|
||||
ref: "v3"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
describe("fetchActionMetadata", () => {
|
||||
it("fetches action metadata", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", actionMetadata);
|
||||
|
||||
const metadata = await fetchActionWithMock(mock);
|
||||
|
||||
expect(metadata?.inputs?.repository?.description).toEqual(
|
||||
"Repository name with owner. For example, actions/checkout"
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches action metadata at a path", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/some-path%2Faction.yml?ref=v3", actionMetadata);
|
||||
|
||||
const metadata = await fetchActionMetadata(
|
||||
new Octokit({
|
||||
request: {
|
||||
fetch: mock
|
||||
}
|
||||
}),
|
||||
new TTLCache(),
|
||||
{
|
||||
owner: "actions",
|
||||
name: "checkout",
|
||||
ref: "v3",
|
||||
path: "some-path"
|
||||
}
|
||||
);
|
||||
|
||||
expect(metadata?.inputs?.repository?.description).toEqual(
|
||||
"Repository name with owner. For example, actions/checkout"
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to .yaml extension on 404", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", 404)
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yaml?ref=v3", actionMetadata);
|
||||
|
||||
const metadata = await fetchActionWithMock(mock);
|
||||
|
||||
expect(metadata?.inputs?.repository?.description).toEqual(
|
||||
"Repository name with owner. For example, actions/checkout"
|
||||
);
|
||||
});
|
||||
|
||||
it("fetches action metadata at a path with a .yaml extension", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/some-path%2Faction.yml?ref=v3", 404)
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/some-path%2Faction.yaml?ref=v3", actionMetadata);
|
||||
|
||||
const metadata = await fetchActionMetadata(
|
||||
new Octokit({
|
||||
request: {
|
||||
fetch: mock
|
||||
}
|
||||
}),
|
||||
new TTLCache(),
|
||||
{
|
||||
owner: "actions",
|
||||
name: "checkout",
|
||||
ref: "v3",
|
||||
path: "some-path"
|
||||
}
|
||||
);
|
||||
|
||||
expect(metadata?.inputs?.repository?.description).toEqual(
|
||||
"Repository name with owner. For example, actions/checkout"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not fall back for other errors", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", 403);
|
||||
|
||||
const metadata = await fetchActionWithMock(mock);
|
||||
|
||||
expect(metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles invalid actions", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", 404)
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yaml?ref=v3", 404);
|
||||
|
||||
const metadata = await fetchActionWithMock(mock);
|
||||
|
||||
expect(metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it("caches action metadata", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", actionMetadata);
|
||||
|
||||
const cache = new TTLCache();
|
||||
|
||||
const metadata = await fetchActionWithMock(mock, cache);
|
||||
expect(metadata?.inputs?.repository?.description).toEqual(
|
||||
"Repository name with owner. For example, actions/checkout"
|
||||
);
|
||||
|
||||
const cachedMetadata = await fetchActionWithMock(mock, cache);
|
||||
expect(cachedMetadata?.inputs?.repository?.description).toEqual(
|
||||
"Repository name with owner. For example, actions/checkout"
|
||||
);
|
||||
});
|
||||
|
||||
it("caches action metadata", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", actionMetadata);
|
||||
|
||||
const cache = new TTLCache();
|
||||
|
||||
const metadata = await fetchActionWithMock(mock, cache);
|
||||
expect(metadata?.inputs?.repository?.description).toEqual(
|
||||
"Repository name with owner. For example, actions/checkout"
|
||||
);
|
||||
|
||||
const cachedMetadata = await fetchActionWithMock(mock, cache);
|
||||
expect(cachedMetadata?.inputs?.repository?.description).toEqual(
|
||||
"Repository name with owner. For example, actions/checkout"
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores directories", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", [actionMetadata]);
|
||||
|
||||
const metadata = await fetchActionWithMock(mock);
|
||||
|
||||
expect(metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores non-files", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", {
|
||||
type: "not-a-file",
|
||||
content: Buffer.from(actionMetadataContent).toString("base64")
|
||||
});
|
||||
|
||||
const metadata = await fetchActionWithMock(mock);
|
||||
|
||||
expect(metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores responses without content", async () => {
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", {
|
||||
type: "file"
|
||||
});
|
||||
|
||||
const metadata = await fetchActionWithMock(mock);
|
||||
|
||||
expect(metadata).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles emojis in action descriptions", async () => {
|
||||
const actionMetadataContent = `
|
||||
name: 'Checkout'
|
||||
description: 'Checkout a Git repository at a particular version'
|
||||
inputs:
|
||||
repository:
|
||||
description: 📦 Repository 📦 name with owner. For example, actions/checkout
|
||||
default: \${{ github.repository }}
|
||||
runs:
|
||||
using: node16
|
||||
main: dist/index.js
|
||||
post: dist/index.js
|
||||
`;
|
||||
const mock = fetchMock
|
||||
.sandbox()
|
||||
.getOnce("https://api.github.com/repos/actions/checkout/contents/action.yml?ref=v3", {
|
||||
type: "file",
|
||||
content: Buffer.from(actionMetadataContent).toString("base64")
|
||||
});
|
||||
|
||||
const metadata = await fetchActionWithMock(mock);
|
||||
|
||||
expect(metadata?.inputs?.repository?.description).toEqual(
|
||||
"📦 Repository 📦 name with owner. For example, actions/checkout"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import {ActionReference, ActionMetadata, actionIdentifier} from "@github/actions-languageservice/action";
|
||||
import {error} from "@github/actions-languageservice/log";
|
||||
import {Octokit, RestEndpointMethodTypes} from "@octokit/rest";
|
||||
import {parse} from "yaml";
|
||||
import {TTLCache} from "./cache";
|
||||
|
||||
export async function fetchActionMetadata(
|
||||
client: Octokit,
|
||||
cache: TTLCache,
|
||||
action: ActionReference
|
||||
): Promise<ActionMetadata | undefined> {
|
||||
const metadata = await cache.get(`${actionIdentifier(action)}/action-metadata`, undefined, () =>
|
||||
getActionMetadata(client, action)
|
||||
);
|
||||
if (!metadata) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// https://docs.github.com/actions/creating-actions/metadata-syntax-for-github-actions
|
||||
return parse(metadata);
|
||||
}
|
||||
|
||||
async function getActionMetadata(client: Octokit, action: ActionReference): Promise<string | undefined> {
|
||||
let resp: RestEndpointMethodTypes["repos"]["getContent"]["response"];
|
||||
try {
|
||||
resp = await fetchAction(client, action);
|
||||
} catch (e: any) {
|
||||
error(`Failed to fetch action metadata for ${actionIdentifier(action)}: '${e?.message || "<no details>"}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
// https://docs.github.com/rest/repos/contents?apiVersion=2022-11-28
|
||||
// Ignore directories (array of files) and non-file content
|
||||
if (resp.data === undefined || Array.isArray(resp.data) || resp.data.type !== "file") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (resp.data.content === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Buffer.from(resp.data.content, "base64").toString("utf8");
|
||||
}
|
||||
|
||||
async function fetchAction(client: Octokit, action: ActionReference) {
|
||||
try {
|
||||
return await client.repos.getContent({
|
||||
owner: action.owner,
|
||||
repo: action.name,
|
||||
ref: action.ref,
|
||||
path: action.path ? `${action.path}/action.yml` : "action.yml"
|
||||
});
|
||||
} catch (e: any) {
|
||||
// If action.yml doesn't exist, try action.yaml
|
||||
if (e.status === 404) {
|
||||
return await client.repos.getContent({
|
||||
owner: action.owner,
|
||||
repo: action.name,
|
||||
ref: action.ref,
|
||||
path: action.path ? `${action.path}/action.yaml` : "action.yaml"
|
||||
});
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// From https://github.com/cschleiden/github-actions-parser/blob/a81dec9b7462dbcff08fbad0792f5ad549d9de7d/src/lib/workflowschema/workflowSchema.ts
|
||||
interface CacheEntry<T> {
|
||||
cachedAt: number;
|
||||
content: T;
|
||||
}
|
||||
|
||||
export class TTLCache {
|
||||
private cache = new Map<string, CacheEntry<unknown>>();
|
||||
|
||||
constructor(private defaultTTLinMS: number = 10 * 60 * 1000) {}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param key Key to cache value under
|
||||
* @param ttlInMS How long is the content valid. If optional, default value will be used
|
||||
* @param getter Function to retrieve content if not in cache
|
||||
*/
|
||||
async get<T>(key: string, ttlInMS: number | undefined, getter: () => Promise<T>): Promise<T> {
|
||||
const hasEntry = this.cache.has(key);
|
||||
const e = hasEntry && this.cache.get(key);
|
||||
if (hasEntry && e && e.cachedAt > Date.now() - (ttlInMS || this.defaultTTLinMS)) {
|
||||
return e.content as T;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await getter();
|
||||
|
||||
this.cache.set(key, {
|
||||
cachedAt: Date.now(),
|
||||
content
|
||||
});
|
||||
|
||||
return content;
|
||||
} catch (e) {
|
||||
this.cache.delete(key);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import {log} from "@github/actions-languageservice/log";
|
||||
|
||||
export async function timeOperation<T>(name: string, f: () => T): Promise<T> {
|
||||
const start = Date.now();
|
||||
const result = f();
|
||||
if (result instanceof Promise) {
|
||||
await result;
|
||||
}
|
||||
|
||||
const end = Date.now();
|
||||
|
||||
log(`${name} took ${end - start}ms`);
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user