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" `.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; 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 * 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 { export {
type Inputs as ExecutionInputs, type Inputs as ExecutionInputs,
type MainResult, type MainResult,
+21 -16
View File
@@ -2,7 +2,7 @@
import { initToolState, startMcpHttpServer } from "./mcp/server.ts"; import { initToolState, startMcpHttpServer } from "./mcp/server.ts";
import { computeModes } from "./modes.ts"; import { computeModes } from "./modes.ts";
import { resolveAgent } from "./utils/agent.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 { resolveBody } from "./utils/body.ts";
import { log, writeSummary } from "./utils/cli.ts"; import { log, writeSummary } from "./utils/cli.ts";
import { reportErrorToComment } from "./utils/errorReport.ts"; import { reportErrorToComment } from "./utils/errorReport.ts";
@@ -11,8 +11,8 @@ import { createOctokit } from "./utils/github.ts";
import { resolveInstructions } from "./utils/instructions.ts"; import { resolveInstructions } from "./utils/instructions.ts";
import { normalizeEnv } from "./utils/normalizeEnv.ts"; import { normalizeEnv } from "./utils/normalizeEnv.ts";
import { resolvePayload } from "./utils/payload.ts"; import { resolvePayload } from "./utils/payload.ts";
import { resolveRepoData } from "./utils/repoData.ts";
import { handleAgentResult } from "./utils/run.ts"; import { handleAgentResult } from "./utils/run.ts";
import { resolveRunContextData } from "./utils/runContextData.ts";
import { createTempDirectory, setupGit } from "./utils/setup.ts"; import { createTempDirectory, setupGit } from "./utils/setup.ts";
import { Timer } from "./utils/timer.ts"; import { Timer } from "./utils/timer.ts";
import { resolveInstallationToken } from "./utils/token.ts"; import { resolveInstallationToken } from "./utils/token.ts";
@@ -44,12 +44,12 @@ export async function main(): Promise<MainResult> {
setupExitHandler(toolState); setupExitHandler(toolState);
try { try {
const repo = await resolveRepoData({ octokit, token: tokenRef.token }); const runContext = await resolveRunContextData({ octokit, token: tokenRef.token });
timer.checkpoint("repoData"); 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 // precedence: action inputs > json payload > repoSettings > fallbacks
const payload = resolvePayload(repo.repoSettings); const payload = resolvePayload(runContext.repoSettings);
if (payload.cwd && process.cwd() !== payload.cwd) { if (payload.cwd && process.cwd() !== payload.cwd) {
process.chdir(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 // resolve body - fetches body_html and converts to markdown if images present
// this ensures agents receive markdown with working signed image URLs // this ensures agents receive markdown with working signed image URLs
const originalBody = payload.event.body; 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) { if (resolvedBody !== originalBody) {
payload.event.body = resolvedBody; payload.event.body = resolvedBody;
// also update prompt if original body was included there // also update prompt if original body was included there
@@ -68,33 +72,34 @@ export async function main(): Promise<MainResult> {
const tmpdir = createTempDirectory(); const tmpdir = createTempDirectory();
const agent = resolveAgent({ payload, repoSettings: repo.repoSettings }); const agent = resolveAgent({ payload, repoSettings: runContext.repoSettings });
validateApiKey({ validateAgentApiKey({
agent, agent,
owner: repo.owner, owner: runContext.repo.owner,
name: repo.name, name: runContext.repo.name,
}); });
await setupGit({ await setupGit({
token: tokenRef.token, token: tokenRef.token,
originalToken: process.env.ORIGINAL_GITHUB_TOKEN, originalToken: process.env.ORIGINAL_GITHUB_TOKEN,
bashPermission: payload.bash, bashPermission: payload.bash,
owner: repo.owner, owner: runContext.repo.owner,
name: repo.name, name: runContext.repo.name,
event: payload.event, event: payload.event,
octokit, octokit,
toolState, toolState,
}); });
timer.checkpoint("git"); timer.checkpoint("git");
const modes = [...computeModes(), ...repo.repoSettings.modes]; const modes = [...computeModes(), ...runContext.repoSettings.modes];
await using mcpHttpServer = await startMcpHttpServer({ await using mcpHttpServer = await startMcpHttpServer({
repo, repo: runContext.repo,
payload, payload,
octokit, octokit,
githubInstallationToken: tokenRef.token, githubInstallationToken: tokenRef.token,
apiToken: runContext.apiToken,
agent, agent,
modes, modes,
toolState, toolState,
@@ -106,7 +111,7 @@ export async function main(): Promise<MainResult> {
const instructions = resolveInstructions({ const instructions = resolveInstructions({
payload, payload,
repoData: repo, repo: runContext.repo,
modes, modes,
}); });
+1 -1
View File
@@ -49,7 +49,7 @@ function spawnBash(params: SpawnParams): ChildProcess {
// return useNamespaceIsolation // return useNamespaceIsolation
// ? spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", params.command], spawnOpts) // ? spawn("unshare", ["--pid", "--fork", "--mount-proc", "bash", "-c", params.command], spawnOpts)
// : spawn("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 */ /** kill process and its entire process group */
+1 -1
View File
@@ -1,7 +1,7 @@
import { type } from "arktype"; import { type } from "arktype";
import type { ToolContext } from "./server.ts";
import type { PrepResult } from "../prep/index.ts"; import type { PrepResult } from "../prep/index.ts";
import { runPrepPhase } from "../prep/index.ts"; import { runPrepPhase } from "../prep/index.ts";
import type { ToolContext } from "./server.ts";
import { execute, tool } from "./shared.ts"; import { execute, tool } from "./shared.ts";
// empty schema for tools with no parameters // 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"; import { execute, tool } from "./shared.ts";
export function CreateBranchTool(ctx: ToolContext) { export function CreateBranchTool(ctx: ToolContext) {
const defaultBranch = ctx.repo.repo.default_branch || "main"; const defaultBranch = ctx.repo.data.default_branch || "main";
const CreateBranch = type({ const CreateBranch = type({
branchName: type.string.describe( branchName: type.string.describe(
@@ -24,7 +24,7 @@ export function CreateBranchTool(ctx: ToolContext) {
parameters: CreateBranch, parameters: CreateBranch,
execute: execute(async ({ branchName, baseBranch }) => { execute: execute(async ({ branchName, baseBranch }) => {
// baseBranch should always be defined due to default, but TypeScript needs help // 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 // validate branch name for secrets
if (containsSecrets(branchName)) { if (containsSecrets(branchName)) {
@@ -142,7 +142,7 @@ export const PushBranch = type({
}); });
export function PushBranchTool(ctx: ToolContext) { export function PushBranchTool(ctx: ToolContext) {
const defaultBranch = ctx.repo.repo.default_branch || "main"; const defaultBranch = ctx.repo.data.default_branch || "main";
return tool({ return tool({
name: "push_branch", 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 { PrepResult } from "../prep/index.ts";
import type { OctokitWithPlugins } from "../utils/github.ts"; import type { OctokitWithPlugins } from "../utils/github.ts";
import type { ResolvedPayload } from "../utils/payload.ts"; import type { ResolvedPayload } from "../utils/payload.ts";
import type { RepoData } from "../utils/repoData.ts";
export type BackgroundProcess = { export type BackgroundProcess = {
pid: number; pid: number;
@@ -53,10 +52,11 @@ export function initToolState(ctx: InitToolStateParams): ToolState {
} }
export interface ToolContext { export interface ToolContext {
repo: RepoData; repo: RunContextData["repo"];
payload: ResolvedPayload; payload: ResolvedPayload;
octokit: OctokitWithPlugins; octokit: OctokitWithPlugins;
githubInstallationToken: string; githubInstallationToken: string;
apiToken: string;
agent: Agent; agent: Agent;
modes: Mode[]; modes: Mode[];
toolState: ToolState; toolState: ToolState;
@@ -64,6 +64,7 @@ export interface ToolContext {
jobId: string | undefined; jobId: string | undefined;
} }
import type { RunContextData } from "../utils/runContextData.ts";
import { BashTool, KillBackgroundTool } from "./bash.ts"; import { BashTool, KillBackgroundTool } from "./bash.ts";
import { CheckoutPrTool } from "./checkout.ts"; import { CheckoutPrTool } from "./checkout.ts";
import { GetCheckSuiteLogsTool } from "./checkSuite.ts"; import { GetCheckSuiteLogsTool } from "./checkSuite.ts";
@@ -90,6 +91,7 @@ import { CreatePullRequestReviewTool } from "./review.ts";
import { GetReviewCommentsTool, ListPullRequestReviewsTool } from "./reviewComments.ts"; import { GetReviewCommentsTool, ListPullRequestReviewsTool } from "./reviewComments.ts";
import { SelectModeTool } from "./selectMode.ts"; import { SelectModeTool } from "./selectMode.ts";
import { addTools } from "./shared.ts"; import { addTools } from "./shared.ts";
import { UploadFileTool } from "./upload.ts";
/** /**
* Find an available port starting from the given port * Find an available port starting from the given port
@@ -176,6 +178,7 @@ export async function startMcpHttpServer(
CreateBranchTool(ctx), CreateBranchTool(ctx),
CommitFilesTool(ctx), CommitFilesTool(ctx),
PushBranchTool(ctx), PushBranchTool(ctx),
UploadFileTool(ctx),
]; ];
// only add BashTool when bash is "restricted" // 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", "dotenv": "^17.2.3",
"execa": "^9.6.0", "execa": "^9.6.0",
"fastmcp": "^3.26.8", "fastmcp": "^3.26.8",
"file-type": "^21.3.0",
"package-manager-detector": "^1.6.0", "package-manager-detector": "^1.6.0",
"semver": "^7.7.3", "semver": "^7.7.3",
"table": "^6.9.0", "table": "^6.9.0",
+3
View File
@@ -56,6 +56,9 @@ importers:
fastmcp: fastmcp:
specifier: ^3.26.8 specifier: ^3.26.8
version: 3.26.8(arktype@2.1.28)(hono@4.11.3) 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: package-manager-detector:
specifier: ^1.6.0 specifier: ^1.6.0
version: 1.6.0 version: 1.6.0
+1 -1
View File
@@ -1,5 +1,5 @@
import type { AgentResult, ValidationCheck } from "./utils.ts"; 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. * nobash test - validates agents respect bash=disabled setting.
+1 -1
View File
@@ -1,5 +1,5 @@
import type { AgentResult, ValidationCheck } from "./utils.ts"; 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. * 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 { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { config } from "dotenv"; 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 type { AgentName } from "../external.ts";
import { log } from "./cli.ts"; import { log } from "./cli.ts";
import type { ResolvedPayload } from "./payload.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) * 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; 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); const apiKeys = collectApiKeys(params.agent);
if (Object.keys(apiKeys).length === 0) { 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 type { PayloadEvent } from "../external.ts";
import { log } from "./cli.ts"; import { log } from "./cli.ts";
import type { OctokitWithPlugins } from "./github.ts"; import type { OctokitWithPlugins } from "./github.ts";
import type { RepoData } from "./repoData.ts"; import type { RunContextData } from "./runContextData.ts";
const turndown = new TurndownService(); const turndown = new TurndownService();
@@ -14,7 +14,7 @@ function hasImages(body: string | null | undefined): boolean {
interface ResolveBodyContext { interface ResolveBodyContext {
event: PayloadEvent; event: PayloadEvent;
octokit: OctokitWithPlugins; 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 { createSign } from "node:crypto";
import * as core from "@actions/core"; import * as core from "@actions/core";
import { throttling } from "@octokit/plugin-throttling"; 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 { ghPullfrogMcpName, type PayloadEvent } from "../external.ts";
import type { Mode } from "../modes.ts"; import type { Mode } from "../modes.ts";
import type { ResolvedPayload } from "./payload.ts"; import type { ResolvedPayload } from "./payload.ts";
import type { RepoData } from "./repoData.ts"; import type { RunContextData } from "./runContextData.ts";
interface InstructionsContext { interface InstructionsContext {
payload: ResolvedPayload; payload: ResolvedPayload;
repoData: RepoData; repo: RunContextData["repo"];
modes: Mode[]; modes: Mode[];
} }
@@ -33,8 +33,8 @@ function buildRuntimeContext(ctx: InstructionsContext): string {
const data: Record<string, unknown> = { const data: Record<string, unknown> = {
...payloadRest, ...payloadRest,
repo: `${ctx.repoData.owner}/${ctx.repoData.name}`, repo: `${ctx.repo.owner}/${ctx.repo.name}`,
default_branch: ctx.repoData.repo.default_branch, default_branch: ctx.repo.data.default_branch,
working_directory: process.cwd(), working_directory: process.cwd(),
log_level: process.env.LOG_LEVEL, log_level: process.env.LOG_LEVEL,
git_status: gitStatus, git_status: gitStatus,
+1 -1
View File
@@ -3,7 +3,7 @@ import * as core from "@actions/core";
import { type } from "arktype"; import { type } from "arktype";
import { AgentName, type AuthorPermission, Effort, type PayloadEvent } from "../external.ts"; import { AgentName, type AuthorPermission, Effort, type PayloadEvent } from "../external.ts";
import packageJson from "../package.json" with { type: "json" }; 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"; import { validateCompatibility } from "./versioning.ts";
// tool permission enum types for inputs // 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; 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 * fetch run context from Pullfrog API
* Returns defaults if repo doesn't exist or fetch fails * returns settings + API token for subsequent calls
* returns defaults if fetch fails
*/ */
export async function fetchRepoSettings(params: { export async function fetchRunContext(params: {
token: string; token: string;
repoContext: RepoContext; repoContext: RepoContext;
}): Promise<RepoSettings> { }): Promise<RunContext> {
const apiUrl = process.env.API_URL || "https://pullfrog.com"; const apiUrl = process.env.API_URL || "https://pullfrog.com";
const timeoutMs = 30000; const timeoutMs = 30000;
const controller = new AbortController(); const controller = new AbortController();
@@ -33,7 +54,7 @@ export async function fetchRepoSettings(params: {
try { try {
const response = await fetch( 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", method: "GET",
headers: { headers: {
@@ -47,41 +68,24 @@ export async function fetchRepoSettings(params: {
clearTimeout(timeoutId); clearTimeout(timeoutId);
if (!response.ok) { if (!response.ok) {
return { return defaultRunContext;
defaultAgent: null,
modes: [],
repoInstructions: "",
web: "enabled",
search: "enabled",
write: "enabled",
bash: "restricted",
};
} }
const settings = (await response.json()) as RepoSettings | null; const data = (await response.json()) as {
if (settings === null) { settings: RepoSettings | null;
return { apiToken: string;
defaultAgent: null, } | null;
modes: [],
repoInstructions: "", if (data === null) {
web: "enabled", return defaultRunContext;
search: "enabled",
write: "enabled",
bash: "restricted",
};
} }
return settings; return {
settings: data.settings ?? defaultSettings,
apiToken: data.apiToken,
};
} catch { } catch {
clearTimeout(timeoutId); clearTimeout(timeoutId);
return { return defaultRunContext;
defaultAgent: null,
modes: [],
repoInstructions: "",
web: "enabled",
search: "enabled",
write: "enabled",
bash: "restricted",
};
} }
} }
+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) // - restricted/disabled: workflow token (limited by permissions block)
// this protects the base repo while allowing fork PR edits via fork remote // this protects the base repo while allowing fork PR edits via fork remote
const originToken = const originToken =
params.bashPermission === "enabled" params.bashPermission === "enabled" ? params.token : params.originalToken || params.token;
? params.token
: (params.originalToken || params.token);
// non-PR events: set up origin with token, stay on default branch // non-PR events: set up origin with token, stay on default branch
if (params.event.is_pr !== true || !params.event.issue_number) { 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(() => { beforeEach(() => {
vi.spyOn(cli.log, 'debug'); vi.spyOn(cli.log, "debug");
// Mock Date.now to have predictable timestamps // Mock Date.now to have predictable timestamps
vi.useFakeTimers(); vi.useFakeTimers();
}); });
@@ -13,34 +13,32 @@ describe('Timer', () => {
vi.useRealTimers(); vi.useRealTimers();
}); });
describe('constructor', () => { describe("constructor", () => {
it('should initialize with current timestamp', () => { it("should initialize with current timestamp", () => {
const mockTime = 1000000; const mockTime = 1000000;
vi.setSystemTime(mockTime); vi.setSystemTime(mockTime);
const timer = new Timer(); const timer = new Timer();
timer.checkpoint('test'); timer.checkpoint("test");
expect(cli.log.debug).toHaveBeenCalledWith( expect(cli.log.debug).toHaveBeenCalledWith(expect.stringContaining("test"));
expect.stringContaining('test')
);
}); });
}); });
describe('checkpoint', () => { describe("checkpoint", () => {
it('should log duration from initial timestamp on first checkpoint', () => { it("should log duration from initial timestamp on first checkpoint", () => {
const startTime = 1000000; const startTime = 1000000;
vi.setSystemTime(startTime); vi.setSystemTime(startTime);
const timer = new Timer(); const timer = new Timer();
const checkpointTime = startTime + 100; const checkpointTime = startTime + 100;
vi.setSystemTime(checkpointTime); 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; const startTime = 1000000;
vi.setSystemTime(startTime); vi.setSystemTime(startTime);
const timer = new Timer(); const timer = new Timer();
@@ -48,63 +46,61 @@ describe('Timer', () => {
// First checkpoint // First checkpoint
const firstCheckpointTime = startTime + 50; const firstCheckpointTime = startTime + 50;
vi.setSystemTime(firstCheckpointTime); vi.setSystemTime(firstCheckpointTime);
timer.checkpoint('first'); timer.checkpoint("first");
// Second checkpoint // Second checkpoint
const secondCheckpointTime = firstCheckpointTime + 75; const secondCheckpointTime = firstCheckpointTime + 75;
vi.setSystemTime(secondCheckpointTime); vi.setSystemTime(secondCheckpointTime);
timer.checkpoint('second'); timer.checkpoint("second");
expect(cli.log.debug).toHaveBeenCalledTimes(2); expect(cli.log.debug).toHaveBeenCalledTimes(2);
expect(cli.log.debug).toHaveBeenNthCalledWith(1, '» first: 50ms'); expect(cli.log.debug).toHaveBeenNthCalledWith(1, "» first: 50ms");
expect(cli.log.debug).toHaveBeenNthCalledWith(2, '» second: 75ms'); expect(cli.log.debug).toHaveBeenNthCalledWith(2, "» second: 75ms");
}); });
it('should handle multiple checkpoints correctly', () => { it("should handle multiple checkpoints correctly", () => {
const startTime = 1000000; const startTime = 1000000;
vi.setSystemTime(startTime); vi.setSystemTime(startTime);
const timer = new Timer(); const timer = new Timer();
// First checkpoint // First checkpoint
vi.setSystemTime(startTime + 10); vi.setSystemTime(startTime + 10);
timer.checkpoint('step1'); timer.checkpoint("step1");
// Second checkpoint // Second checkpoint
vi.setSystemTime(startTime + 25); vi.setSystemTime(startTime + 25);
timer.checkpoint('step2'); timer.checkpoint("step2");
// Third checkpoint // Third checkpoint
vi.setSystemTime(startTime + 45); vi.setSystemTime(startTime + 45);
timer.checkpoint('step3'); timer.checkpoint("step3");
expect(cli.log.debug).toHaveBeenCalledTimes(3); expect(cli.log.debug).toHaveBeenCalledTimes(3);
expect(cli.log.debug).toHaveBeenNthCalledWith(1, '» step1: 10ms'); expect(cli.log.debug).toHaveBeenNthCalledWith(1, "» step1: 10ms");
expect(cli.log.debug).toHaveBeenNthCalledWith(2, '» step2: 15ms'); expect(cli.log.debug).toHaveBeenNthCalledWith(2, "» step2: 15ms");
expect(cli.log.debug).toHaveBeenNthCalledWith(3, '» step3: 20ms'); expect(cli.log.debug).toHaveBeenNthCalledWith(3, "» step3: 20ms");
}); });
it('should handle zero duration correctly', () => { it("should handle zero duration correctly", () => {
const startTime = 1000000; const startTime = 1000000;
vi.setSystemTime(startTime); vi.setSystemTime(startTime);
const timer = new Timer(); const timer = new Timer();
// Checkpoint immediately // 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; const startTime = 1000000;
vi.setSystemTime(startTime); vi.setSystemTime(startTime);
const timer = new Timer(); const timer = new Timer();
vi.setSystemTime(startTime + 200); vi.setSystemTime(startTime + 200);
timer.checkpoint('Custom Checkpoint Name'); timer.checkpoint("Custom Checkpoint Name");
expect(cli.log.debug).toHaveBeenCalledWith( expect(cli.log.debug).toHaveBeenCalledWith("» Custom Checkpoint Name: 200ms");
'» Custom Checkpoint Name: 200ms'
);
}); });
}); });
}); });
+10 -8
View File
@@ -1,9 +1,9 @@
import { describe, it, expect } from 'vitest'; import { describe, expect, it } from "vitest";
import { validateCompatibility } from './versioning.ts'; import { validateCompatibility } from "./versioning.ts";
describe('validateCompatibility', () => { describe("validateCompatibility", () => {
it('should throw if payload version is invalid', () => { it("should throw if payload version is invalid", () => {
expect(() => validateCompatibility('invalid', '1.0.0')).toThrow(/not a valid semantic version/); expect(() => validateCompatibility("invalid", "1.0.0")).toThrow(/not a valid semantic version/);
}); });
it.each([ it.each([
@@ -17,7 +17,7 @@ describe('validateCompatibility', () => {
["1.0.0", "1.1.0"], // action has a new feature (backward compatible) ["1.0.0", "1.1.0"], // action has a new feature (backward compatible)
["1.0.1", "1.0.0"], // payload is newer (patch) ["1.0.1", "1.0.0"], // payload is newer (patch)
["1.1.0", "1.0.0"], // payload is newer (feature is backward compatible) ["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(); 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 ["0.2.0", "0.1.0"], // payload had breaking changes during active development
["2.0.0", "1.0.0"], // payload is majorly newer ["2.0.0", "1.0.0"], // payload is majorly newer
["1.0.0", "2.0.0"], // action had breaking changes ["1.0.0", "2.0.0"], // action had breaking changes
])('should reject incompatible payload %#', (payloadVersion, actionVersion) => { ])("should reject incompatible payload %#", (payloadVersion, actionVersion) => {
expect(() => validateCompatibility(payloadVersion, actionVersion)).toThrow(/is incompatible with action version/); 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 = 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 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 * @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 * 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 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 * @example Payload version 0.1.55 => ^0.1.0 range of action versions supported
*/ */
| 'non-breaking'; | "non-breaking";
const COMPATIBILITY_POLICY: CompatibilityPolicy = "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 { export function validateCompatibility(payloadVersion: string, actionVersion: string): void {
const payloadSemVer = semver.parse(payloadVersion); 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 major = payloadSemVer.major;
const minor = payloadSemVer.minor; const minor = payloadSemVer.minor;
const patch = payloadSemVer.patch; const patch = payloadSemVer.patch;
const compatibilityRange = COMPATIBILITY_POLICY === 'same-features' const compatibilityRange =
? `^${major}.${minor}.${major === 0 ? patch : 0}` COMPATIBILITY_POLICY === "same-features"
: `^${major}.${major === 0 ? minor : 0}.${major === 0 ? "x" : 0}`; // non-breaking ? `^${major}.${minor}.${major === 0 ? patch : 0}`
: `^${major}.${major === 0 ? minor : 0}.${major === 0 ? "x" : 0}`; // non-breaking
if (!semver.satisfies(actionVersion, compatibilityRange)) { if (!semver.satisfies(actionVersion, compatibilityRange)) {
throw new Error( throw new Error(
`Payload version ${payloadVersion} is incompatible with action version ${actionVersion}. ` + `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.`
); );
} }
} }