Add upload tool and related APIs (#187)

* Add utils for r2 upload

* Add the tool and new routes

* fix auth issue

* sign headers

* add comment

* use our own API key to auth signed uploads

* Restructure things slightly

* tweak

* tweak

* add comments

* tweak

* revert a thing

* twaek

* drop mime type filtering

* new incarnation of mime type filtering

* jsut allow all octet-streams

* simplify further

* tweak

* update lockfile

---------

Co-authored-by: Colin McDonnell <colinmcd94@gmail.com>
This commit is contained in:
Mateusz Burzyński
2026-01-28 05:34:58 +00:00
committed by pullfrog[bot]
parent cac9b0e645
commit 071e885d63
27 changed files with 4777 additions and 690 deletions
+3 -1
View File
@@ -62,7 +62,9 @@ ${mcpServerSections.join("\n\n")}
`.trim() + "\n"
);
log.info(`» Codex config written to ${configPath} (shell: ${bash === "enabled" ? "enabled" : "disabled"})`);
log.info(
`» Codex config written to ${configPath} (shell: ${bash === "enabled" ? "enabled" : "disabled"})`
);
return codexDir;
}
+4519 -520
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -3,7 +3,7 @@
* This exports the main function for programmatic usage
*/
export type { Agent, AgentRunContext, AgentResult } from "./agents/shared.ts";
export type { Agent, AgentResult, AgentRunContext } from "./agents/shared.ts";
export {
type Inputs as ExecutionInputs,
type MainResult,
+21 -16
View File
@@ -2,7 +2,7 @@
import { initToolState, startMcpHttpServer } from "./mcp/server.ts";
import { computeModes } from "./modes.ts";
import { resolveAgent } from "./utils/agent.ts";
import { validateApiKey } from "./utils/apiKeys.ts";
import { validateAgentApiKey } from "./utils/apiKeys.ts";
import { resolveBody } from "./utils/body.ts";
import { log, writeSummary } from "./utils/cli.ts";
import { reportErrorToComment } from "./utils/errorReport.ts";
@@ -11,8 +11,8 @@ import { createOctokit } from "./utils/github.ts";
import { resolveInstructions } from "./utils/instructions.ts";
import { normalizeEnv } from "./utils/normalizeEnv.ts";
import { resolvePayload } from "./utils/payload.ts";
import { resolveRepoData } from "./utils/repoData.ts";
import { handleAgentResult } from "./utils/run.ts";
import { resolveRunContextData } from "./utils/runContextData.ts";
import { createTempDirectory, setupGit } from "./utils/setup.ts";
import { Timer } from "./utils/timer.ts";
import { resolveInstallationToken } from "./utils/token.ts";
@@ -44,12 +44,12 @@ export async function main(): Promise<MainResult> {
setupExitHandler(toolState);
try {
const repo = await resolveRepoData({ octokit, token: tokenRef.token });
timer.checkpoint("repoData");
const runContext = await resolveRunContextData({ octokit, token: tokenRef.token });
timer.checkpoint("runContextData");
// resolve payload after repoData so permissions can use DB settings
// resolve payload after runContextData so permissions can use DB settings
// precedence: action inputs > json payload > repoSettings > fallbacks
const payload = resolvePayload(repo.repoSettings);
const payload = resolvePayload(runContext.repoSettings);
if (payload.cwd && process.cwd() !== payload.cwd) {
process.chdir(payload.cwd);
}
@@ -57,7 +57,11 @@ export async function main(): Promise<MainResult> {
// resolve body - fetches body_html and converts to markdown if images present
// this ensures agents receive markdown with working signed image URLs
const originalBody = payload.event.body;
const resolvedBody = await resolveBody({ event: payload.event, octokit, repo });
const resolvedBody = await resolveBody({
event: payload.event,
octokit,
repo: runContext.repo,
});
if (resolvedBody !== originalBody) {
payload.event.body = resolvedBody;
// also update prompt if original body was included there
@@ -68,33 +72,34 @@ export async function main(): Promise<MainResult> {
const tmpdir = createTempDirectory();
const agent = resolveAgent({ payload, repoSettings: repo.repoSettings });
const agent = resolveAgent({ payload, repoSettings: runContext.repoSettings });
validateApiKey({
validateAgentApiKey({
agent,
owner: repo.owner,
name: repo.name,
owner: runContext.repo.owner,
name: runContext.repo.name,
});
await setupGit({
token: tokenRef.token,
originalToken: process.env.ORIGINAL_GITHUB_TOKEN,
bashPermission: payload.bash,
owner: repo.owner,
name: repo.name,
owner: runContext.repo.owner,
name: runContext.repo.name,
event: payload.event,
octokit,
toolState,
});
timer.checkpoint("git");
const modes = [...computeModes(), ...repo.repoSettings.modes];
const modes = [...computeModes(), ...runContext.repoSettings.modes];
await using mcpHttpServer = await startMcpHttpServer({
repo,
repo: runContext.repo,
payload,
octokit,
githubInstallationToken: tokenRef.token,
apiToken: runContext.apiToken,
agent,
modes,
toolState,
@@ -106,7 +111,7 @@ export async function main(): Promise<MainResult> {
const instructions = resolveInstructions({
payload,
repoData: repo,
repo: runContext.repo,
modes,
});
+1 -1
View File
@@ -49,7 +49,7 @@ function spawnBash(params: SpawnParams): ChildProcess {
// return useNamespaceIsolation
// ? spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", params.command], spawnOpts)
// : spawn("bash", ["-c", params.command], spawnOpts);
return spawn("bash", ["-c", params.command], spawnOpts);
return spawn("bash", ["-c", params.command], spawnOpts);
}
/** kill process and its entire process group */
+1 -1
View File
@@ -1,7 +1,7 @@
import { type } from "arktype";
import type { ToolContext } from "./server.ts";
import type { PrepResult } from "../prep/index.ts";
import { runPrepPhase } from "../prep/index.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
// empty schema for tools with no parameters
+3 -3
View File
@@ -6,7 +6,7 @@ import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
export function CreateBranchTool(ctx: ToolContext) {
const defaultBranch = ctx.repo.repo.default_branch || "main";
const defaultBranch = ctx.repo.data.default_branch || "main";
const CreateBranch = type({
branchName: type.string.describe(
@@ -24,7 +24,7 @@ export function CreateBranchTool(ctx: ToolContext) {
parameters: CreateBranch,
execute: execute(async ({ branchName, baseBranch }) => {
// baseBranch should always be defined due to default, but TypeScript needs help
const resolvedBaseBranch = baseBranch || ctx.repo.repo.default_branch || "main";
const resolvedBaseBranch = baseBranch || ctx.repo.data.default_branch || "main";
// validate branch name for secrets
if (containsSecrets(branchName)) {
@@ -142,7 +142,7 @@ export const PushBranch = type({
});
export function PushBranchTool(ctx: ToolContext) {
const defaultBranch = ctx.repo.repo.default_branch || "main";
const defaultBranch = ctx.repo.data.default_branch || "main";
return tool({
name: "push_branch",
+5 -2
View File
@@ -8,7 +8,6 @@ import type { Mode } from "../modes.ts";
import type { PrepResult } from "../prep/index.ts";
import type { OctokitWithPlugins } from "../utils/github.ts";
import type { ResolvedPayload } from "../utils/payload.ts";
import type { RepoData } from "../utils/repoData.ts";
export type BackgroundProcess = {
pid: number;
@@ -53,10 +52,11 @@ export function initToolState(ctx: InitToolStateParams): ToolState {
}
export interface ToolContext {
repo: RepoData;
repo: RunContextData["repo"];
payload: ResolvedPayload;
octokit: OctokitWithPlugins;
githubInstallationToken: string;
apiToken: string;
agent: Agent;
modes: Mode[];
toolState: ToolState;
@@ -64,6 +64,7 @@ export interface ToolContext {
jobId: string | undefined;
}
import type { RunContextData } from "../utils/runContextData.ts";
import { BashTool, KillBackgroundTool } from "./bash.ts";
import { CheckoutPrTool } from "./checkout.ts";
import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
@@ -90,6 +91,7 @@ import { CreatePullRequestReviewTool } from "./review.ts";
import { GetReviewCommentsTool, ListPullRequestReviewsTool } from "./reviewComments.ts";
import { SelectModeTool } from "./selectMode.ts";
import { addTools } from "./shared.ts";
import { UploadFileTool } from "./upload.ts";
/**
* Find an available port starting from the given port
@@ -176,6 +178,7 @@ export async function startMcpHttpServer(
CreateBranchTool(ctx),
CommitFilesTool(ctx),
PushBranchTool(ctx),
UploadFileTool(ctx),
];
// only add BashTool when bash is "restricted"
+69
View File
@@ -0,0 +1,69 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { type } from "arktype";
import { fileTypeFromBuffer } from "file-type";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts";
const UploadFileParams = type({
path: type.string.describe("absolute path to file to upload"),
});
export function UploadFileTool(ctx: ToolContext) {
return tool({
name: "upload_file",
description:
"upload a file to get a permanent public URL. use for screenshots, artifacts, or any files you want to reference in PRs/comments. max 10MB, images/text/archives allowed.",
parameters: UploadFileParams,
execute: execute(async (params) => {
// read file from disk eagerly on purpose to avoid its content being changed by the time it's uploaded
const buffer = fs.readFileSync(params.path);
const filename = path.basename(params.path);
const contentLength = buffer.length;
const fileType = await fileTypeFromBuffer(buffer);
const contentType = fileType?.mime || "application/octet-stream";
const apiUrl = process.env.API_URL || "https://pullfrog.com";
const response = await fetch(`${apiUrl}/api/upload/signed-url`, {
method: "POST",
headers: {
Authorization: `Bearer ${ctx.apiToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
filename,
contentType,
contentLength,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`failed to get upload URL: ${error}`);
}
const { uploadUrl, publicUrl } = (await response.json()) as {
uploadUrl: string;
publicUrl: string;
};
const uploadResponse = await fetch(uploadUrl, {
method: "PUT",
headers: {
"Content-Type": contentType,
// should be set automatically, but given this header is signed it's better to be explicit
"Content-Length": String(contentLength),
},
body: buffer,
});
if (!uploadResponse.ok) {
throw new Error(`failed to upload file: ${uploadResponse.statusText}`);
}
return { success: true, publicUrl, filename, contentLength, contentType };
}),
});
}
+1
View File
@@ -42,6 +42,7 @@
"dotenv": "^17.2.3",
"execa": "^9.6.0",
"fastmcp": "^3.26.8",
"file-type": "^21.3.0",
"package-manager-detector": "^1.6.0",
"semver": "^7.7.3",
"table": "^6.9.0",
+3
View File
@@ -56,6 +56,9 @@ importers:
fastmcp:
specifier: ^3.26.8
version: 3.26.8(arktype@2.1.28)(hono@4.11.3)
file-type:
specifier: ^21.3.0
version: 21.3.0
package-manager-detector:
specifier: ^1.6.0
version: 1.6.0
+1 -1
View File
@@ -1,5 +1,5 @@
import type { AgentResult, ValidationCheck } from "./utils.ts";
import { generateAgentUuids, defineFixture, getAgentOutput, runTests } from "./utils.ts";
import { defineFixture, generateAgentUuids, getAgentOutput, runTests } from "./utils.ts";
/**
* nobash test - validates agents respect bash=disabled setting.
+1 -1
View File
@@ -1,5 +1,5 @@
import type { AgentResult, ValidationCheck } from "./utils.ts";
import { generateAgentUuids, defineFixture, getAgentOutput, runTests } from "./utils.ts";
import { defineFixture, generateAgentUuids, getAgentOutput, runTests } from "./utils.ts";
/**
* restricted test - validates bash=restricted environment filtering.
+1 -1
View File
@@ -1,5 +1,5 @@
import { randomUUID } from "node:crypto";
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { config } from "dotenv";
+1 -1
View File
@@ -2,7 +2,7 @@ import { type Agent, agents } from "../agents/index.ts";
import type { AgentName } from "../external.ts";
import { log } from "./cli.ts";
import type { ResolvedPayload } from "./payload.ts";
import type { RepoSettings } from "./repoSettings.ts";
import type { RepoSettings } from "./runContext.ts";
/**
* Check if an agent has API keys available (from process.env)
+1 -1
View File
@@ -56,7 +56,7 @@ function collectApiKeys(agent: Agent): Record<string, string> {
return apiKeys;
}
export function validateApiKey(params: { agent: Agent; owner: string; name: string }): void {
export function validateAgentApiKey(params: { agent: Agent; owner: string; name: string }): void {
const apiKeys = collectApiKeys(params.agent);
if (Object.keys(apiKeys).length === 0) {
+2 -2
View File
@@ -2,7 +2,7 @@ import TurndownService from "turndown";
import type { PayloadEvent } from "../external.ts";
import { log } from "./cli.ts";
import type { OctokitWithPlugins } from "./github.ts";
import type { RepoData } from "./repoData.ts";
import type { RunContextData } from "./runContextData.ts";
const turndown = new TurndownService();
@@ -14,7 +14,7 @@ function hasImages(body: string | null | undefined): boolean {
interface ResolveBodyContext {
event: PayloadEvent;
octokit: OctokitWithPlugins;
repo: RepoData;
repo: RunContextData["repo"];
}
/**
-1
View File
@@ -1,4 +1,3 @@
import assert from "node:assert/strict";
import { createSign } from "node:crypto";
import * as core from "@actions/core";
import { throttling } from "@octokit/plugin-throttling";
+5 -5
View File
@@ -4,11 +4,11 @@ import { encode as toonEncode } from "@toon-format/toon";
import { ghPullfrogMcpName, type PayloadEvent } from "../external.ts";
import type { Mode } from "../modes.ts";
import type { ResolvedPayload } from "./payload.ts";
import type { RepoData } from "./repoData.ts";
import type { RunContextData } from "./runContextData.ts";
interface InstructionsContext {
payload: ResolvedPayload;
repoData: RepoData;
repo: RunContextData["repo"];
modes: Mode[];
}
@@ -33,8 +33,8 @@ function buildRuntimeContext(ctx: InstructionsContext): string {
const data: Record<string, unknown> = {
...payloadRest,
repo: `${ctx.repoData.owner}/${ctx.repoData.name}`,
default_branch: ctx.repoData.repo.default_branch,
repo: `${ctx.repo.owner}/${ctx.repo.name}`,
default_branch: ctx.repo.data.default_branch,
working_directory: process.cwd(),
log_level: process.env.LOG_LEVEL,
git_status: gitStatus,
@@ -150,7 +150,7 @@ You are careful, to-the-point, and kind. You only say things you know to be true
You do not break up sentences with hyphens. You use emdashes.
You have a strong bias toward minimalism: no dead code, no premature abstractions, no speculative features, and no comments that merely restate what the code does.
Your code is focused, elegant, and production-ready.
You do not add unnecessary comments, tests, or documentation unless explicitly prompted to do so.
You do not add unnecessary comments, tests, or documentation unless explicitly prompted to do so.
You adapt your writing style to match existing patterns in the codebase (commit messages, PR descriptions, code comments) while never being unprofessional.
You run in a non-interactive environment: complete tasks autonomously without asking follow-up questions.
You are running inside a GitHub Actions ephemeral environment. All processes and resources will be cleaned up at the end of the run.
+1 -1
View File
@@ -3,7 +3,7 @@ import * as core from "@actions/core";
import { type } from "arktype";
import { AgentName, type AuthorPermission, Effort, type PayloadEvent } from "../external.ts";
import packageJson from "../package.json" with { type: "json" };
import type { RepoSettings } from "./repoSettings.ts";
import type { RepoSettings } from "./runContext.ts";
import { validateCompatibility } from "./versioning.ts";
// tool permission enum types for inputs
-42
View File
@@ -1,42 +0,0 @@
import type { Octokit } from "@octokit/rest";
import packageJson from "../package.json" with { type: "json" };
import { log } from "./cli.ts";
import { createOctokit, type OctokitWithPlugins, parseRepoContext } from "./github.ts";
import { fetchRepoSettings, type RepoSettings } from "./repoSettings.ts";
export interface RepoData {
owner: string;
name: string;
repo: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
repoSettings: RepoSettings;
}
interface ResolveRepoDataParams {
octokit: OctokitWithPlugins;
token: string;
}
/**
* Initialize repo data: parse context, fetch repo info and settings
*/
export async function resolveRepoData(params: ResolveRepoDataParams): Promise<RepoData> {
log.info(`» running Pullfrog v${packageJson.version}...`);
const { owner, name } = parseRepoContext();
// fetch repo data and settings in parallel
const [repoResponse, repoSettings] = await Promise.all([
params.octokit.repos.get({ owner, repo: name }),
fetchRepoSettings({ token: params.token, repoContext: { owner, name } }),
]);
return {
owner,
name,
repo: repoResponse.data,
repoSettings,
};
}
// re-export for convenience
export { createOctokit };
+39 -35
View File
@@ -18,14 +18,35 @@ export interface RepoSettings {
bash: BashPermission;
}
export interface RunContext {
settings: RepoSettings;
apiToken: string;
}
const defaultSettings: RepoSettings = {
defaultAgent: null,
modes: [],
repoInstructions: "",
web: "enabled",
search: "enabled",
write: "enabled",
bash: "restricted",
};
const defaultRunContext: RunContext = {
settings: defaultSettings,
apiToken: "",
};
/**
* Fetch repository settings from the Pullfrog API
* Returns defaults if repo doesn't exist or fetch fails
* fetch run context from Pullfrog API
* returns settings + API token for subsequent calls
* returns defaults if fetch fails
*/
export async function fetchRepoSettings(params: {
export async function fetchRunContext(params: {
token: string;
repoContext: RepoContext;
}): Promise<RepoSettings> {
}): Promise<RunContext> {
const apiUrl = process.env.API_URL || "https://pullfrog.com";
const timeoutMs = 30000;
const controller = new AbortController();
@@ -33,7 +54,7 @@ export async function fetchRepoSettings(params: {
try {
const response = await fetch(
`${apiUrl}/api/repo/${params.repoContext.owner}/${params.repoContext.name}/settings`,
`${apiUrl}/api/repo/${params.repoContext.owner}/${params.repoContext.name}/run-context`,
{
method: "GET",
headers: {
@@ -47,41 +68,24 @@ export async function fetchRepoSettings(params: {
clearTimeout(timeoutId);
if (!response.ok) {
return {
defaultAgent: null,
modes: [],
repoInstructions: "",
web: "enabled",
search: "enabled",
write: "enabled",
bash: "restricted",
};
return defaultRunContext;
}
const settings = (await response.json()) as RepoSettings | null;
if (settings === null) {
return {
defaultAgent: null,
modes: [],
repoInstructions: "",
web: "enabled",
search: "enabled",
write: "enabled",
bash: "restricted",
};
const data = (await response.json()) as {
settings: RepoSettings | null;
apiToken: string;
} | null;
if (data === null) {
return defaultRunContext;
}
return settings;
return {
settings: data.settings ?? defaultSettings,
apiToken: data.apiToken,
};
} catch {
clearTimeout(timeoutId);
return {
defaultAgent: null,
modes: [],
repoInstructions: "",
web: "enabled",
search: "enabled",
write: "enabled",
bash: "restricted",
};
return defaultRunContext;
}
}
+46
View File
@@ -0,0 +1,46 @@
import type { Octokit } from "@octokit/rest";
import packageJson from "../package.json" with { type: "json" };
import { log } from "./cli.ts";
import { type OctokitWithPlugins, parseRepoContext } from "./github.ts";
import { fetchRunContext, type RepoSettings } from "./runContext.ts";
export interface RunContextData {
repo: {
owner: string;
name: string;
data: Awaited<ReturnType<Octokit["repos"]["get"]>>["data"];
};
repoSettings: RepoSettings;
apiToken: string;
}
interface ResolveRunContextDataParams {
octokit: OctokitWithPlugins;
token: string;
}
/**
* initialize run context data: parse context, fetch repo info and settings
*/
export async function resolveRunContextData(
params: ResolveRunContextDataParams
): Promise<RunContextData> {
log.info(`» running Pullfrog v${packageJson.version}...`);
const repoContext = parseRepoContext();
const [repoResponse, runContext] = await Promise.all([
params.octokit.repos.get({ owner: repoContext.owner, repo: repoContext.name }),
fetchRunContext({ token: params.token, repoContext }),
]);
return {
repo: {
owner: repoContext.owner,
name: repoContext.name,
data: repoResponse.data,
},
repoSettings: runContext.settings,
apiToken: runContext.apiToken,
};
}
+2 -4
View File
@@ -134,10 +134,8 @@ export async function setupGit(params: SetupGitParams): Promise<void> {
// - restricted/disabled: workflow token (limited by permissions block)
// this protects the base repo while allowing fork PR edits via fork remote
const originToken =
params.bashPermission === "enabled"
? params.token
: (params.originalToken || params.token);
params.bashPermission === "enabled" ? params.token : params.originalToken || params.token;
// non-PR events: set up origin with token, stay on default branch
if (params.event.is_pr !== true || !params.event.issue_number) {
const originUrl = `https://x-access-token:${originToken}@github.com/${params.owner}/${params.name}.git`;
+30 -34
View File
@@ -1,9 +1,9 @@
import { Timer } from './timer.ts';
import * as cli from './cli.ts';
import * as cli from "./cli.ts";
import { Timer } from "./timer.ts";
describe('Timer', () => {
describe("Timer", () => {
beforeEach(() => {
vi.spyOn(cli.log, 'debug');
vi.spyOn(cli.log, "debug");
// Mock Date.now to have predictable timestamps
vi.useFakeTimers();
});
@@ -13,34 +13,32 @@ describe('Timer', () => {
vi.useRealTimers();
});
describe('constructor', () => {
it('should initialize with current timestamp', () => {
describe("constructor", () => {
it("should initialize with current timestamp", () => {
const mockTime = 1000000;
vi.setSystemTime(mockTime);
const timer = new Timer();
timer.checkpoint('test');
timer.checkpoint("test");
expect(cli.log.debug).toHaveBeenCalledWith(
expect.stringContaining('test')
);
expect(cli.log.debug).toHaveBeenCalledWith(expect.stringContaining("test"));
});
});
describe('checkpoint', () => {
it('should log duration from initial timestamp on first checkpoint', () => {
describe("checkpoint", () => {
it("should log duration from initial timestamp on first checkpoint", () => {
const startTime = 1000000;
vi.setSystemTime(startTime);
const timer = new Timer();
const checkpointTime = startTime + 100;
vi.setSystemTime(checkpointTime);
timer.checkpoint('first');
timer.checkpoint("first");
expect(cli.log.debug).toHaveBeenCalledWith('» first: 100ms');
expect(cli.log.debug).toHaveBeenCalledWith("» first: 100ms");
});
it('should log duration from last checkpoint on subsequent checkpoints', () => {
it("should log duration from last checkpoint on subsequent checkpoints", () => {
const startTime = 1000000;
vi.setSystemTime(startTime);
const timer = new Timer();
@@ -48,63 +46,61 @@ describe('Timer', () => {
// First checkpoint
const firstCheckpointTime = startTime + 50;
vi.setSystemTime(firstCheckpointTime);
timer.checkpoint('first');
timer.checkpoint("first");
// Second checkpoint
const secondCheckpointTime = firstCheckpointTime + 75;
vi.setSystemTime(secondCheckpointTime);
timer.checkpoint('second');
timer.checkpoint("second");
expect(cli.log.debug).toHaveBeenCalledTimes(2);
expect(cli.log.debug).toHaveBeenNthCalledWith(1, '» first: 50ms');
expect(cli.log.debug).toHaveBeenNthCalledWith(2, '» second: 75ms');
expect(cli.log.debug).toHaveBeenNthCalledWith(1, "» first: 50ms");
expect(cli.log.debug).toHaveBeenNthCalledWith(2, "» second: 75ms");
});
it('should handle multiple checkpoints correctly', () => {
it("should handle multiple checkpoints correctly", () => {
const startTime = 1000000;
vi.setSystemTime(startTime);
const timer = new Timer();
// First checkpoint
vi.setSystemTime(startTime + 10);
timer.checkpoint('step1');
timer.checkpoint("step1");
// Second checkpoint
vi.setSystemTime(startTime + 25);
timer.checkpoint('step2');
timer.checkpoint("step2");
// Third checkpoint
vi.setSystemTime(startTime + 45);
timer.checkpoint('step3');
timer.checkpoint("step3");
expect(cli.log.debug).toHaveBeenCalledTimes(3);
expect(cli.log.debug).toHaveBeenNthCalledWith(1, '» step1: 10ms');
expect(cli.log.debug).toHaveBeenNthCalledWith(2, '» step2: 15ms');
expect(cli.log.debug).toHaveBeenNthCalledWith(3, '» step3: 20ms');
expect(cli.log.debug).toHaveBeenNthCalledWith(1, "» step1: 10ms");
expect(cli.log.debug).toHaveBeenNthCalledWith(2, "» step2: 15ms");
expect(cli.log.debug).toHaveBeenNthCalledWith(3, "» step3: 20ms");
});
it('should handle zero duration correctly', () => {
it("should handle zero duration correctly", () => {
const startTime = 1000000;
vi.setSystemTime(startTime);
const timer = new Timer();
// Checkpoint immediately
timer.checkpoint('immediate');
timer.checkpoint("immediate");
expect(cli.log.debug).toHaveBeenCalledWith('» immediate: 0ms');
expect(cli.log.debug).toHaveBeenCalledWith("» immediate: 0ms");
});
it('should handle custom checkpoint names', () => {
it("should handle custom checkpoint names", () => {
const startTime = 1000000;
vi.setSystemTime(startTime);
const timer = new Timer();
vi.setSystemTime(startTime + 200);
timer.checkpoint('Custom Checkpoint Name');
timer.checkpoint("Custom Checkpoint Name");
expect(cli.log.debug).toHaveBeenCalledWith(
'» Custom Checkpoint Name: 200ms'
);
expect(cli.log.debug).toHaveBeenCalledWith("» Custom Checkpoint Name: 200ms");
});
});
});
+10 -8
View File
@@ -1,9 +1,9 @@
import { describe, it, expect } from 'vitest';
import { validateCompatibility } from './versioning.ts';
import { describe, expect, it } from "vitest";
import { validateCompatibility } from "./versioning.ts";
describe('validateCompatibility', () => {
it('should throw if payload version is invalid', () => {
expect(() => validateCompatibility('invalid', '1.0.0')).toThrow(/not a valid semantic version/);
describe("validateCompatibility", () => {
it("should throw if payload version is invalid", () => {
expect(() => validateCompatibility("invalid", "1.0.0")).toThrow(/not a valid semantic version/);
});
it.each([
@@ -17,7 +17,7 @@ describe('validateCompatibility', () => {
["1.0.0", "1.1.0"], // action has a new feature (backward compatible)
["1.0.1", "1.0.0"], // payload is newer (patch)
["1.1.0", "1.0.0"], // payload is newer (feature is backward compatible)
])('should accept compatible payload %#', (payloadVersion, actionVersion) => {
])("should accept compatible payload %#", (payloadVersion, actionVersion) => {
expect(() => validateCompatibility(payloadVersion, actionVersion)).not.toThrow();
});
@@ -26,7 +26,9 @@ describe('validateCompatibility', () => {
["0.2.0", "0.1.0"], // payload had breaking changes during active development
["2.0.0", "1.0.0"], // payload is majorly newer
["1.0.0", "2.0.0"], // action had breaking changes
])('should reject incompatible payload %#', (payloadVersion, actionVersion) => {
expect(() => validateCompatibility(payloadVersion, actionVersion)).toThrow(/is incompatible with action version/);
])("should reject incompatible payload %#", (payloadVersion, actionVersion) => {
expect(() => validateCompatibility(payloadVersion, actionVersion)).toThrow(
/is incompatible with action version/
);
});
});
+10 -8
View File
@@ -1,4 +1,4 @@
import semver from 'semver';
import semver from "semver";
type CompatibilityPolicy =
/**
@@ -6,13 +6,13 @@ type CompatibilityPolicy =
* @example Payload version 1.2.3 => ^1.2.0 range of action versions supported
* @example Payload version 0.1.55 => ^0.1.55 range of action versions supported
*/
| 'same-features'
| "same-features"
/**
* Loose policy: the action must have no breaking changes compared to the payload version
* @example Payload version 1.2.3 => ^1.0.0 range of action versions supported
* @example Payload version 0.1.55 => ^0.1.0 range of action versions supported
*/
| 'non-breaking';
| "non-breaking";
const COMPATIBILITY_POLICY: CompatibilityPolicy = "non-breaking";
@@ -24,19 +24,21 @@ const COMPATIBILITY_POLICY: CompatibilityPolicy = "non-breaking";
*/
export function validateCompatibility(payloadVersion: string, actionVersion: string): void {
const payloadSemVer = semver.parse(payloadVersion);
if (!payloadSemVer) throw new Error(`Payload version ${payloadVersion} is not a valid semantic version.`);
if (!payloadSemVer)
throw new Error(`Payload version ${payloadVersion} is not a valid semantic version.`);
const major = payloadSemVer.major;
const minor = payloadSemVer.minor;
const patch = payloadSemVer.patch;
const compatibilityRange = COMPATIBILITY_POLICY === 'same-features'
? `^${major}.${minor}.${major === 0 ? patch : 0}`
: `^${major}.${major === 0 ? minor : 0}.${major === 0 ? "x" : 0}`; // non-breaking
const compatibilityRange =
COMPATIBILITY_POLICY === "same-features"
? `^${major}.${minor}.${major === 0 ? patch : 0}`
: `^${major}.${major === 0 ? minor : 0}.${major === 0 ? "x" : 0}`; // non-breaking
if (!semver.satisfies(actionVersion, compatibilityRange)) {
throw new Error(
`Payload version ${payloadVersion} is incompatible with action version ${actionVersion}. ` +
`Please update your workflow to use at least ${semver.minVersion(compatibilityRange)} version of the action.`
`Please update your workflow to use at least ${semver.minVersion(compatibilityRange)} version of the action.`
);
}
}