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]
co-authored by Colin McDonnell
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;
}
+4518 -519
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";
+4 -4
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,
+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,
};
}
+1 -3
View File
@@ -134,9 +134,7 @@ 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) {
+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.`
);
}
}