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;
} }
+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 * 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";
+5 -5
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,
@@ -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 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. 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. 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 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 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. 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 { 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,
};
}
+2 -4
View File
@@ -134,10 +134,8 @@ 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) {
const originUrl = `https://x-access-token:${originToken}@github.com/${params.owner}/${params.name}.git`; 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(() => { 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.`
); );
} }
} }