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:
committed by
pullfrog[bot]
parent
cac9b0e645
commit
071e885d63
+3
-1
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 };
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Generated
+3
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,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,11 +4,11 @@ import { encode as toonEncode } from "@toon-format/toon";
|
||||
import { ghPullfrogMcpName, type PayloadEvent } from "../external.ts";
|
||||
import type { Mode } from "../modes.ts";
|
||||
import type { ResolvedPayload } from "./payload.ts";
|
||||
import type { RepoData } from "./repoData.ts";
|
||||
import type { RunContextData } from "./runContextData.ts";
|
||||
|
||||
interface InstructionsContext {
|
||||
payload: ResolvedPayload;
|
||||
repoData: RepoData;
|
||||
repo: RunContextData["repo"];
|
||||
modes: Mode[];
|
||||
}
|
||||
|
||||
@@ -33,8 +33,8 @@ function buildRuntimeContext(ctx: InstructionsContext): string {
|
||||
|
||||
const data: Record<string, unknown> = {
|
||||
...payloadRest,
|
||||
repo: `${ctx.repoData.owner}/${ctx.repoData.name}`,
|
||||
default_branch: ctx.repoData.repo.default_branch,
|
||||
repo: `${ctx.repo.owner}/${ctx.repo.name}`,
|
||||
default_branch: ctx.repo.data.default_branch,
|
||||
working_directory: process.cwd(),
|
||||
log_level: process.env.LOG_LEVEL,
|
||||
git_status: gitStatus,
|
||||
@@ -150,7 +150,7 @@ You are careful, to-the-point, and kind. You only say things you know to be true
|
||||
You do not break up sentences with hyphens. You use emdashes.
|
||||
You have a strong bias toward minimalism: no dead code, no premature abstractions, no speculative features, and no comments that merely restate what the code does.
|
||||
Your code is focused, elegant, and production-ready.
|
||||
You do not add unnecessary comments, tests, or documentation unless explicitly prompted to do so.
|
||||
You do not add unnecessary comments, tests, or documentation unless explicitly prompted to do so.
|
||||
You adapt your writing style to match existing patterns in the codebase (commit messages, PR descriptions, code comments) while never being unprofessional.
|
||||
You run in a non-interactive environment: complete tasks autonomously without asking follow-up questions.
|
||||
You are running inside a GitHub Actions ephemeral environment. All processes and resources will be cleaned up at the end of the run.
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -134,10 +134,8 @@ export async function setupGit(params: SetupGitParams): Promise<void> {
|
||||
// - restricted/disabled: workflow token (limited by permissions block)
|
||||
// this protects the base repo while allowing fork PR edits via fork remote
|
||||
const originToken =
|
||||
params.bashPermission === "enabled"
|
||||
? params.token
|
||||
: (params.originalToken || params.token);
|
||||
|
||||
params.bashPermission === "enabled" ? params.token : params.originalToken || params.token;
|
||||
|
||||
// non-PR events: set up origin with token, stay on default branch
|
||||
if (params.event.is_pr !== true || !params.event.issue_number) {
|
||||
const originUrl = `https://x-access-token:${originToken}@github.com/${params.owner}/${params.name}.git`;
|
||||
|
||||
+30
-34
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user