feat: Lifecycle hooks (#219)
* flatten lifecycle hooks into RepoSettings string fields replace the separate LifecycleHook model with setupScript and postCheckoutScript string fields directly on RepoSettings. move the UI into the Agent settings section alongside environment variables and custom instructions. delete the standalone lifecycle-hooks API route, component, and schema since the existing settings PATCH endpoint handles the new fields automatically. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: pass env to lifecycle hook spawn so scripts can use package managers Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Colin McDonnell <colinmcd94@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
pullfrog[bot]
parent
3a7145db1a
commit
1d59fd3d21
@@ -140528,6 +140528,251 @@ function $git(subcommand, args3, options) {
|
||||
};
|
||||
}
|
||||
|
||||
// lifecycle.ts
|
||||
var LIFECYCLE_HOOK_TIMEOUT_MS = 12e4;
|
||||
|
||||
// utils/subprocess.ts
|
||||
import { spawn as nodeSpawn } from "node:child_process";
|
||||
|
||||
// utils/activity.ts
|
||||
var DEFAULT_ACTIVITY_TIMEOUT_MS = 6e4;
|
||||
var DEFAULT_ACTIVITY_CHECK_INTERVAL_MS = 5e3;
|
||||
var _lastActivity = Date.now();
|
||||
function markActivity() {
|
||||
_lastActivity = Date.now();
|
||||
}
|
||||
function getIdleMs() {
|
||||
return Date.now() - _lastActivity;
|
||||
}
|
||||
function wrapWrite(original, onActivity) {
|
||||
const wrapped = (chunk, encodingOrCb, cb) => {
|
||||
onActivity();
|
||||
if (typeof encodingOrCb === "function") {
|
||||
return original(chunk, encodingOrCb);
|
||||
}
|
||||
return original(chunk, encodingOrCb, cb);
|
||||
};
|
||||
return wrapped;
|
||||
}
|
||||
function startProcessOutputMonitor(ctx) {
|
||||
let timedOut = false;
|
||||
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
||||
const originalStderrWrite = process.stderr.write.bind(process.stderr);
|
||||
process.stdout.write = wrapWrite(originalStdoutWrite, markActivity);
|
||||
process.stderr.write = wrapWrite(originalStderrWrite, markActivity);
|
||||
const intervalId = setInterval(() => {
|
||||
const idleMs = getIdleMs();
|
||||
if (timedOut || idleMs <= ctx.timeoutMs) return;
|
||||
timedOut = true;
|
||||
ctx.onTimeout(idleMs);
|
||||
}, ctx.checkIntervalMs);
|
||||
function stop() {
|
||||
clearInterval(intervalId);
|
||||
process.stdout.write = originalStdoutWrite;
|
||||
process.stderr.write = originalStderrWrite;
|
||||
}
|
||||
return { stop };
|
||||
}
|
||||
function createProcessOutputActivityTimeout(ctx) {
|
||||
markActivity();
|
||||
let rejectFn = null;
|
||||
const promise2 = new Promise((_, reject) => {
|
||||
rejectFn = reject;
|
||||
});
|
||||
let monitor = null;
|
||||
monitor = startProcessOutputMonitor({
|
||||
timeoutMs: ctx.timeoutMs,
|
||||
checkIntervalMs: ctx.checkIntervalMs,
|
||||
onTimeout: (idleMs) => {
|
||||
if (!rejectFn) return;
|
||||
const idleSec = Math.round(idleMs / 1e3);
|
||||
if (monitor) {
|
||||
monitor.stop();
|
||||
}
|
||||
rejectFn(new Error(`activity timeout: no output for ${idleSec}s`));
|
||||
}
|
||||
});
|
||||
return {
|
||||
promise: promise2,
|
||||
stop: monitor.stop
|
||||
};
|
||||
}
|
||||
|
||||
// utils/subprocess.ts
|
||||
var activeChildren = /* @__PURE__ */ new Map();
|
||||
var externalSignalHandler = null;
|
||||
function trackChild(options) {
|
||||
activeChildren.set(options.child, options.killGroup ?? false);
|
||||
}
|
||||
function untrackChild(child) {
|
||||
activeChildren.delete(child);
|
||||
}
|
||||
function killTrackedChildren() {
|
||||
const count = activeChildren.size;
|
||||
for (const entry of activeChildren) {
|
||||
const child = entry[0];
|
||||
const killGroup = entry[1];
|
||||
if (killGroup && child.pid) {
|
||||
try {
|
||||
process.kill(-child.pid, "SIGKILL");
|
||||
continue;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
return count;
|
||||
}
|
||||
function cleanupAndExit(signal) {
|
||||
const count = killTrackedChildren();
|
||||
if (count > 0) {
|
||||
log.info(`\xBB received ${signal}, killing ${count} subprocess(es)...`);
|
||||
}
|
||||
setTimeout(() => process.exit(1), 500).unref();
|
||||
process.exit(1);
|
||||
}
|
||||
function handleSignal(signal) {
|
||||
if (externalSignalHandler) {
|
||||
externalSignalHandler(signal);
|
||||
return;
|
||||
}
|
||||
cleanupAndExit(signal);
|
||||
}
|
||||
var handlersInstalled = false;
|
||||
function installSignalHandlers() {
|
||||
if (handlersInstalled) return;
|
||||
handlersInstalled = true;
|
||||
process.on("SIGINT", () => handleSignal("SIGINT"));
|
||||
process.on("SIGTERM", () => handleSignal("SIGTERM"));
|
||||
}
|
||||
async function spawn2(options) {
|
||||
const { cmd, args: args3, env: env3, input, timeout, cwd, stdio, onStdout, onStderr } = options;
|
||||
const activityTimeoutMs = options.activityTimeout ?? DEFAULT_ACTIVITY_TIMEOUT_MS;
|
||||
installSignalHandlers();
|
||||
const startTime = Date.now();
|
||||
let stdoutBuffer = "";
|
||||
let stderrBuffer = "";
|
||||
return new Promise((resolve2, reject) => {
|
||||
const child = nodeSpawn(cmd, args3, {
|
||||
env: env3 || {
|
||||
PATH: process.env.PATH || "",
|
||||
HOME: process.env.HOME || ""
|
||||
},
|
||||
stdio: stdio || ["pipe", "pipe", "pipe"],
|
||||
cwd: cwd || process.cwd()
|
||||
});
|
||||
trackChild({ child });
|
||||
let timeoutId;
|
||||
let activityCheckIntervalId;
|
||||
let isTimedOut = false;
|
||||
let isActivityTimedOut = false;
|
||||
let lastActivityTime = Date.now();
|
||||
if (timeout) {
|
||||
timeoutId = setTimeout(() => {
|
||||
isTimedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, 5e3);
|
||||
}, timeout);
|
||||
}
|
||||
if (activityTimeoutMs > 0) {
|
||||
activityCheckIntervalId = setInterval(() => {
|
||||
const idleMs = Date.now() - lastActivityTime;
|
||||
if (idleMs > activityTimeoutMs) {
|
||||
isActivityTimedOut = true;
|
||||
const idleSec = Math.round(idleMs / 1e3);
|
||||
log.error(`no output for ${idleSec}s, killing process`);
|
||||
child.kill("SIGKILL");
|
||||
clearInterval(activityCheckIntervalId);
|
||||
}
|
||||
}, DEFAULT_ACTIVITY_CHECK_INTERVAL_MS);
|
||||
}
|
||||
function updateActivity() {
|
||||
lastActivityTime = Date.now();
|
||||
}
|
||||
if (child.stdout) {
|
||||
child.stdout.on("data", (data) => {
|
||||
updateActivity();
|
||||
const chunk = data.toString();
|
||||
stdoutBuffer += chunk;
|
||||
onStdout?.(chunk);
|
||||
});
|
||||
}
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", (data) => {
|
||||
updateActivity();
|
||||
const chunk = data.toString();
|
||||
stderrBuffer += chunk;
|
||||
onStderr?.(chunk);
|
||||
});
|
||||
}
|
||||
child.on("close", (exitCode) => {
|
||||
const durationMs = Date.now() - startTime;
|
||||
untrackChild(child);
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (activityCheckIntervalId) clearInterval(activityCheckIntervalId);
|
||||
if (isTimedOut) {
|
||||
reject(new Error(`process timed out after ${timeout}ms`));
|
||||
return;
|
||||
}
|
||||
if (isActivityTimedOut) {
|
||||
const idleSec = Math.round((Date.now() - lastActivityTime) / 1e3);
|
||||
reject(new Error(`activity timeout: no output for ${idleSec}s`));
|
||||
return;
|
||||
}
|
||||
resolve2({
|
||||
stdout: stdoutBuffer,
|
||||
stderr: stderrBuffer,
|
||||
exitCode: exitCode || 0,
|
||||
durationMs
|
||||
});
|
||||
});
|
||||
child.on("error", (error49) => {
|
||||
const durationMs = Date.now() - startTime;
|
||||
untrackChild(child);
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (activityCheckIntervalId) clearInterval(activityCheckIntervalId);
|
||||
console.error(`[spawn] process spawn error: ${error49.message}`);
|
||||
resolve2({
|
||||
stdout: stdoutBuffer,
|
||||
stderr: stderrBuffer,
|
||||
exitCode: 1,
|
||||
durationMs
|
||||
});
|
||||
});
|
||||
if (input && child.stdin && stdio?.[0] !== "ignore") {
|
||||
child.stdin.write(input);
|
||||
child.stdin.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// utils/lifecycle.ts
|
||||
async function executeLifecycleHook(params) {
|
||||
if (!params.script) return;
|
||||
log.info(`\xBB executing ${params.event} lifecycle hook...`);
|
||||
const result = await spawn2({
|
||||
cmd: "bash",
|
||||
args: ["-c", params.script],
|
||||
env: process.env,
|
||||
timeout: LIFECYCLE_HOOK_TIMEOUT_MS,
|
||||
activityTimeout: 0,
|
||||
onStdout: (chunk) => process.stdout.write(chunk),
|
||||
onStderr: (chunk) => process.stderr.write(chunk)
|
||||
});
|
||||
if (result.exitCode !== 0) {
|
||||
const output = result.stderr || result.stdout;
|
||||
throw new Error(
|
||||
`lifecycle hook '${params.event}' failed with exit code ${result.exitCode}:
|
||||
${output}`
|
||||
);
|
||||
}
|
||||
log.info(`\xBB ${params.event} lifecycle hook completed successfully`);
|
||||
}
|
||||
|
||||
// utils/shell.ts
|
||||
import { spawnSync as spawnSync3 } from "node:child_process";
|
||||
function $(cmd, args3, options) {
|
||||
@@ -140661,8 +140906,8 @@ async function fetchAndFormatPrDiff(params) {
|
||||
});
|
||||
return formatFilesWithLineNumbers(filesResponse.data);
|
||||
}
|
||||
async function checkoutPrBranch(params) {
|
||||
const { octokit, owner, name, gitToken, pullNumber, toolState, restricted } = params;
|
||||
async function checkoutPrBranch(pullNumber, params) {
|
||||
const { octokit, owner, name, gitToken, toolState, restricted } = params;
|
||||
log.info(`\xBB checking out PR #${pullNumber}...`);
|
||||
const pr = await octokit.rest.pulls.get({
|
||||
owner,
|
||||
@@ -140723,6 +140968,10 @@ async function checkoutPrBranch(params) {
|
||||
if (isFork) {
|
||||
toolState.pushUrl = `https://github.com/${headRepo.full_name}.git`;
|
||||
}
|
||||
await executeLifecycleHook({
|
||||
event: "post-checkout",
|
||||
script: params.postCheckoutScript
|
||||
});
|
||||
return {
|
||||
prNumber: pullNumber,
|
||||
isFork,
|
||||
@@ -140735,14 +140984,14 @@ function CheckoutPrTool(ctx) {
|
||||
description: "Checkout a pull request branch locally. This fetches the PR branch and sets up push configuration for fork PRs. Returns diffPath pointing to the formatted diff file.",
|
||||
parameters: CheckoutPr,
|
||||
execute: execute(async ({ pull_number }) => {
|
||||
const result = await checkoutPrBranch({
|
||||
await checkoutPrBranch(pull_number, {
|
||||
octokit: ctx.octokit,
|
||||
owner: ctx.repo.owner,
|
||||
name: ctx.repo.name,
|
||||
gitToken: ctx.gitToken,
|
||||
pullNumber: pull_number,
|
||||
toolState: ctx.toolState,
|
||||
restricted: ctx.payload.bash === "restricted"
|
||||
restricted: ctx.payload.bash === "restricted",
|
||||
postCheckoutScript: ctx.postCheckoutScript
|
||||
});
|
||||
const pr = await ctx.octokit.rest.pulls.get({
|
||||
owner: ctx.repo.owner,
|
||||
@@ -141777,225 +142026,6 @@ function isMetadataYarnClassic(metadataPath) {
|
||||
return metadataPath.endsWith(".yarn_integrity");
|
||||
}
|
||||
|
||||
// utils/subprocess.ts
|
||||
import { spawn as nodeSpawn } from "node:child_process";
|
||||
|
||||
// utils/activity.ts
|
||||
var DEFAULT_ACTIVITY_TIMEOUT_MS = 6e4;
|
||||
var DEFAULT_ACTIVITY_CHECK_INTERVAL_MS = 5e3;
|
||||
var _lastActivity = Date.now();
|
||||
function markActivity() {
|
||||
_lastActivity = Date.now();
|
||||
}
|
||||
function getIdleMs() {
|
||||
return Date.now() - _lastActivity;
|
||||
}
|
||||
function wrapWrite(original, onActivity) {
|
||||
const wrapped = (chunk, encodingOrCb, cb) => {
|
||||
onActivity();
|
||||
if (typeof encodingOrCb === "function") {
|
||||
return original(chunk, encodingOrCb);
|
||||
}
|
||||
return original(chunk, encodingOrCb, cb);
|
||||
};
|
||||
return wrapped;
|
||||
}
|
||||
function startProcessOutputMonitor(ctx) {
|
||||
let timedOut = false;
|
||||
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
||||
const originalStderrWrite = process.stderr.write.bind(process.stderr);
|
||||
process.stdout.write = wrapWrite(originalStdoutWrite, markActivity);
|
||||
process.stderr.write = wrapWrite(originalStderrWrite, markActivity);
|
||||
const intervalId = setInterval(() => {
|
||||
const idleMs = getIdleMs();
|
||||
if (timedOut || idleMs <= ctx.timeoutMs) return;
|
||||
timedOut = true;
|
||||
ctx.onTimeout(idleMs);
|
||||
}, ctx.checkIntervalMs);
|
||||
function stop() {
|
||||
clearInterval(intervalId);
|
||||
process.stdout.write = originalStdoutWrite;
|
||||
process.stderr.write = originalStderrWrite;
|
||||
}
|
||||
return { stop };
|
||||
}
|
||||
function createProcessOutputActivityTimeout(ctx) {
|
||||
markActivity();
|
||||
let rejectFn = null;
|
||||
const promise2 = new Promise((_, reject) => {
|
||||
rejectFn = reject;
|
||||
});
|
||||
let monitor = null;
|
||||
monitor = startProcessOutputMonitor({
|
||||
timeoutMs: ctx.timeoutMs,
|
||||
checkIntervalMs: ctx.checkIntervalMs,
|
||||
onTimeout: (idleMs) => {
|
||||
if (!rejectFn) return;
|
||||
const idleSec = Math.round(idleMs / 1e3);
|
||||
if (monitor) {
|
||||
monitor.stop();
|
||||
}
|
||||
rejectFn(new Error(`activity timeout: no output for ${idleSec}s`));
|
||||
}
|
||||
});
|
||||
return {
|
||||
promise: promise2,
|
||||
stop: monitor.stop
|
||||
};
|
||||
}
|
||||
|
||||
// utils/subprocess.ts
|
||||
var activeChildren = /* @__PURE__ */ new Map();
|
||||
var externalSignalHandler = null;
|
||||
function trackChild(options) {
|
||||
activeChildren.set(options.child, options.killGroup ?? false);
|
||||
}
|
||||
function untrackChild(child) {
|
||||
activeChildren.delete(child);
|
||||
}
|
||||
function killTrackedChildren() {
|
||||
const count = activeChildren.size;
|
||||
for (const entry of activeChildren) {
|
||||
const child = entry[0];
|
||||
const killGroup = entry[1];
|
||||
if (killGroup && child.pid) {
|
||||
try {
|
||||
process.kill(-child.pid, "SIGKILL");
|
||||
continue;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
return count;
|
||||
}
|
||||
function cleanupAndExit(signal) {
|
||||
const count = killTrackedChildren();
|
||||
if (count > 0) {
|
||||
log.info(`\xBB received ${signal}, killing ${count} subprocess(es)...`);
|
||||
}
|
||||
setTimeout(() => process.exit(1), 500).unref();
|
||||
process.exit(1);
|
||||
}
|
||||
function handleSignal(signal) {
|
||||
if (externalSignalHandler) {
|
||||
externalSignalHandler(signal);
|
||||
return;
|
||||
}
|
||||
cleanupAndExit(signal);
|
||||
}
|
||||
var handlersInstalled = false;
|
||||
function installSignalHandlers() {
|
||||
if (handlersInstalled) return;
|
||||
handlersInstalled = true;
|
||||
process.on("SIGINT", () => handleSignal("SIGINT"));
|
||||
process.on("SIGTERM", () => handleSignal("SIGTERM"));
|
||||
}
|
||||
async function spawn2(options) {
|
||||
const { cmd, args: args3, env: env3, input, timeout, cwd, stdio, onStdout, onStderr } = options;
|
||||
const activityTimeoutMs = options.activityTimeout ?? DEFAULT_ACTIVITY_TIMEOUT_MS;
|
||||
installSignalHandlers();
|
||||
const startTime = Date.now();
|
||||
let stdoutBuffer = "";
|
||||
let stderrBuffer = "";
|
||||
return new Promise((resolve2, reject) => {
|
||||
const child = nodeSpawn(cmd, args3, {
|
||||
env: env3 || {
|
||||
PATH: process.env.PATH || "",
|
||||
HOME: process.env.HOME || ""
|
||||
},
|
||||
stdio: stdio || ["pipe", "pipe", "pipe"],
|
||||
cwd: cwd || process.cwd()
|
||||
});
|
||||
trackChild({ child });
|
||||
let timeoutId;
|
||||
let activityCheckIntervalId;
|
||||
let isTimedOut = false;
|
||||
let isActivityTimedOut = false;
|
||||
let lastActivityTime = Date.now();
|
||||
if (timeout) {
|
||||
timeoutId = setTimeout(() => {
|
||||
isTimedOut = true;
|
||||
child.kill("SIGTERM");
|
||||
setTimeout(() => {
|
||||
if (!child.killed) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, 5e3);
|
||||
}, timeout);
|
||||
}
|
||||
if (activityTimeoutMs > 0) {
|
||||
activityCheckIntervalId = setInterval(() => {
|
||||
const idleMs = Date.now() - lastActivityTime;
|
||||
if (idleMs > activityTimeoutMs) {
|
||||
isActivityTimedOut = true;
|
||||
const idleSec = Math.round(idleMs / 1e3);
|
||||
log.error(`no output for ${idleSec}s, killing process`);
|
||||
child.kill("SIGKILL");
|
||||
clearInterval(activityCheckIntervalId);
|
||||
}
|
||||
}, DEFAULT_ACTIVITY_CHECK_INTERVAL_MS);
|
||||
}
|
||||
function updateActivity() {
|
||||
lastActivityTime = Date.now();
|
||||
}
|
||||
if (child.stdout) {
|
||||
child.stdout.on("data", (data) => {
|
||||
updateActivity();
|
||||
const chunk = data.toString();
|
||||
stdoutBuffer += chunk;
|
||||
onStdout?.(chunk);
|
||||
});
|
||||
}
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", (data) => {
|
||||
updateActivity();
|
||||
const chunk = data.toString();
|
||||
stderrBuffer += chunk;
|
||||
onStderr?.(chunk);
|
||||
});
|
||||
}
|
||||
child.on("close", (exitCode) => {
|
||||
const durationMs = Date.now() - startTime;
|
||||
untrackChild(child);
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (activityCheckIntervalId) clearInterval(activityCheckIntervalId);
|
||||
if (isTimedOut) {
|
||||
reject(new Error(`process timed out after ${timeout}ms`));
|
||||
return;
|
||||
}
|
||||
if (isActivityTimedOut) {
|
||||
const idleSec = Math.round((Date.now() - lastActivityTime) / 1e3);
|
||||
reject(new Error(`activity timeout: no output for ${idleSec}s`));
|
||||
return;
|
||||
}
|
||||
resolve2({
|
||||
stdout: stdoutBuffer,
|
||||
stderr: stderrBuffer,
|
||||
exitCode: exitCode || 0,
|
||||
durationMs
|
||||
});
|
||||
});
|
||||
child.on("error", (error49) => {
|
||||
const durationMs = Date.now() - startTime;
|
||||
untrackChild(child);
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
if (activityCheckIntervalId) clearInterval(activityCheckIntervalId);
|
||||
console.error(`[spawn] process spawn error: ${error49.message}`);
|
||||
resolve2({
|
||||
stdout: stdoutBuffer,
|
||||
stderr: stderrBuffer,
|
||||
exitCode: 1,
|
||||
durationMs
|
||||
});
|
||||
});
|
||||
if (input && child.stdin && stdio?.[0] !== "ignore") {
|
||||
child.stdin.write(input);
|
||||
child.stdin.end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// prep/installNodeDependencies.ts
|
||||
var nodePackageManagers = {
|
||||
npm: ["echo", "npm is already installed"],
|
||||
@@ -146102,6 +146132,8 @@ function handleAgentResult(result) {
|
||||
var defaultSettings = {
|
||||
defaultAgent: null,
|
||||
modes: [],
|
||||
setupScript: null,
|
||||
postCheckoutScript: null,
|
||||
repoInstructions: "",
|
||||
web: "enabled",
|
||||
search: "enabled",
|
||||
@@ -146138,7 +146170,14 @@ async function fetchRunContext(params) {
|
||||
return defaultRunContext;
|
||||
}
|
||||
return {
|
||||
settings: data.settings ?? defaultSettings,
|
||||
settings: {
|
||||
...defaultSettings,
|
||||
...data.settings,
|
||||
// ensure arrays are never undefined (API may omit new fields for existing repos)
|
||||
modes: data.settings?.modes ?? [],
|
||||
setupScript: data.settings?.setupScript ?? null,
|
||||
postCheckoutScript: data.settings?.postCheckoutScript ?? null
|
||||
},
|
||||
apiToken: data.apiToken
|
||||
};
|
||||
} catch {
|
||||
@@ -146233,15 +146272,7 @@ async function setupGit(params) {
|
||||
return;
|
||||
}
|
||||
const prNumber = params.event.issue_number;
|
||||
await checkoutPrBranch({
|
||||
octokit: params.octokit,
|
||||
owner: params.owner,
|
||||
name: params.name,
|
||||
gitToken: params.gitToken,
|
||||
pullNumber: prNumber,
|
||||
toolState: params.toolState,
|
||||
restricted: params.restricted
|
||||
});
|
||||
await checkoutPrBranch(prNumber, params);
|
||||
}
|
||||
|
||||
// utils/time.ts
|
||||
@@ -146357,9 +146388,15 @@ async function main() {
|
||||
event: payload.event,
|
||||
octokit,
|
||||
toolState,
|
||||
restricted: payload.bash === "restricted"
|
||||
restricted: payload.bash === "restricted",
|
||||
postCheckoutScript: runContext.repoSettings.postCheckoutScript
|
||||
});
|
||||
timer.checkpoint("git");
|
||||
await executeLifecycleHook({
|
||||
event: "setup",
|
||||
script: runContext.repoSettings.setupScript
|
||||
});
|
||||
timer.checkpoint("lifecycleHooks::setup");
|
||||
const modes2 = [...computeModes(), ...runContext.repoSettings.modes];
|
||||
const mcpHttpServer = __using(_stack, await startMcpHttpServer({
|
||||
repo: runContext.repo,
|
||||
@@ -146370,6 +146407,7 @@ async function main() {
|
||||
apiToken: runContext.apiToken,
|
||||
agent: agent2,
|
||||
modes: modes2,
|
||||
postCheckoutScript: runContext.repoSettings.postCheckoutScript,
|
||||
toolState,
|
||||
runId: runInfo.runId,
|
||||
jobId: runInfo.jobId
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** timeout for lifecycle hook scripts */
|
||||
export const LIFECYCLE_HOOK_TIMEOUT_MS = 12e4; // 2 minutes
|
||||
@@ -16,6 +16,7 @@ import { setupExitHandler } from "./utils/exitHandler.ts";
|
||||
import { resolveGit } from "./utils/gitAuth.ts";
|
||||
import { createOctokit } from "./utils/github.ts";
|
||||
import { resolveInstructions } from "./utils/instructions.ts";
|
||||
import { executeLifecycleHook } from "./utils/lifecycle.ts";
|
||||
import { normalizeEnv } from "./utils/normalizeEnv.ts";
|
||||
import { resolvePayload, resolvePromptInput } from "./utils/payload.ts";
|
||||
import { handleAgentResult } from "./utils/run.ts";
|
||||
@@ -126,9 +127,17 @@ export async function main(): Promise<MainResult> {
|
||||
octokit,
|
||||
toolState,
|
||||
restricted: payload.bash === "restricted",
|
||||
postCheckoutScript: runContext.repoSettings.postCheckoutScript,
|
||||
});
|
||||
timer.checkpoint("git");
|
||||
|
||||
// execute setup lifecycle hook (runs once at initialization)
|
||||
await executeLifecycleHook({
|
||||
event: "setup",
|
||||
script: runContext.repoSettings.setupScript,
|
||||
});
|
||||
timer.checkpoint("lifecycleHooks::setup");
|
||||
|
||||
const modes = [...computeModes(), ...runContext.repoSettings.modes];
|
||||
|
||||
await using mcpHttpServer = await startMcpHttpServer({
|
||||
@@ -140,6 +149,7 @@ export async function main(): Promise<MainResult> {
|
||||
apiToken: runContext.apiToken,
|
||||
agent,
|
||||
modes,
|
||||
postCheckoutScript: runContext.repoSettings.postCheckoutScript,
|
||||
toolState,
|
||||
runId: runInfo.runId,
|
||||
jobId: runInfo.jobId,
|
||||
|
||||
+15
-14
@@ -4,8 +4,9 @@ import type { Octokit, RestEndpointMethodTypes } from "@octokit/rest";
|
||||
import { type } from "arktype";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { $git } from "../utils/gitAuth.ts";
|
||||
import { executeLifecycleHook } from "../utils/lifecycle.ts";
|
||||
import { $ } from "../utils/shell.ts";
|
||||
import type { ToolContext, ToolState } from "./server.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
type PullFile = RestEndpointMethodTypes["pulls"]["listFiles"]["response"]["data"][number];
|
||||
@@ -162,16 +163,9 @@ export async function fetchAndFormatPrDiff(params: FetchPrDiffParams): Promise<F
|
||||
return formatFilesWithLineNumbers(filesResponse.data);
|
||||
}
|
||||
|
||||
interface CheckoutPrBranchParams {
|
||||
octokit: Octokit;
|
||||
owner: string;
|
||||
name: string;
|
||||
gitToken: string;
|
||||
pullNumber: number;
|
||||
toolState: ToolState;
|
||||
// restricted bash mode: disables git hooks to prevent token exfiltration
|
||||
restricted: boolean;
|
||||
}
|
||||
import type { GitContext } from "../utils/setup.ts";
|
||||
|
||||
type CheckoutPrBranchParams = GitContext;
|
||||
|
||||
interface CheckoutPrBranchResult {
|
||||
prNumber: number;
|
||||
@@ -185,9 +179,10 @@ interface CheckoutPrBranchResult {
|
||||
* Updates toolState.issueNumber and toolState.pushUrl (for fork PRs).
|
||||
*/
|
||||
export async function checkoutPrBranch(
|
||||
pullNumber: number,
|
||||
params: CheckoutPrBranchParams
|
||||
): Promise<CheckoutPrBranchResult> {
|
||||
const { octokit, owner, name, gitToken, pullNumber, toolState, restricted } = params;
|
||||
const { octokit, owner, name, gitToken, toolState, restricted } = params;
|
||||
log.info(`» checking out PR #${pullNumber}...`);
|
||||
|
||||
// fetch PR metadata
|
||||
@@ -288,6 +283,12 @@ export async function checkoutPrBranch(
|
||||
toolState.pushUrl = `https://github.com/${headRepo.full_name}.git`;
|
||||
}
|
||||
|
||||
// execute post-checkout lifecycle hook
|
||||
await executeLifecycleHook({
|
||||
event: "post-checkout",
|
||||
script: params.postCheckoutScript,
|
||||
});
|
||||
|
||||
return {
|
||||
prNumber: pullNumber,
|
||||
isFork,
|
||||
@@ -303,14 +304,14 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
||||
"Returns diffPath pointing to the formatted diff file.",
|
||||
parameters: CheckoutPr,
|
||||
execute: execute(async ({ pull_number }) => {
|
||||
const result = await checkoutPrBranch({
|
||||
await checkoutPrBranch(pull_number, {
|
||||
octokit: ctx.octokit,
|
||||
owner: ctx.repo.owner,
|
||||
name: ctx.repo.name,
|
||||
gitToken: ctx.gitToken,
|
||||
pullNumber: pull_number,
|
||||
toolState: ctx.toolState,
|
||||
restricted: ctx.payload.bash === "restricted",
|
||||
postCheckoutScript: ctx.postCheckoutScript,
|
||||
});
|
||||
|
||||
// fetch PR metadata to return result
|
||||
|
||||
@@ -67,6 +67,7 @@ export interface ToolContext {
|
||||
apiToken: string;
|
||||
agent: Agent;
|
||||
modes: Mode[];
|
||||
postCheckoutScript: string | null;
|
||||
toolState: ToolState;
|
||||
runId: string;
|
||||
jobId: string | undefined;
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { LIFECYCLE_HOOK_TIMEOUT_MS } from "../lifecycle.ts";
|
||||
import { log } from "./cli.ts";
|
||||
import { spawn } from "./subprocess.ts";
|
||||
|
||||
export interface ExecuteLifecycleHookParams {
|
||||
event: string;
|
||||
script: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* execute a lifecycle hook script if one is configured.
|
||||
* runs the script in a bash shell with a timeout.
|
||||
*/
|
||||
export async function executeLifecycleHook(params: ExecuteLifecycleHookParams): Promise<void> {
|
||||
if (!params.script) return;
|
||||
|
||||
log.info(`» executing ${params.event} lifecycle hook...`);
|
||||
|
||||
const result = await spawn({
|
||||
cmd: "bash",
|
||||
args: ["-c", params.script],
|
||||
env: process.env,
|
||||
timeout: LIFECYCLE_HOOK_TIMEOUT_MS,
|
||||
activityTimeout: 0,
|
||||
onStdout: (chunk) => process.stdout.write(chunk),
|
||||
onStderr: (chunk) => process.stderr.write(chunk),
|
||||
});
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
const output = result.stderr || result.stdout;
|
||||
throw new Error(
|
||||
`lifecycle hook '${params.event}' failed with exit code ${result.exitCode}:\n${output}`
|
||||
);
|
||||
}
|
||||
|
||||
log.info(`» ${params.event} lifecycle hook completed successfully`);
|
||||
}
|
||||
+12
-1
@@ -11,6 +11,8 @@ export interface Mode {
|
||||
export interface RepoSettings {
|
||||
defaultAgent: AgentName | null;
|
||||
modes: Mode[];
|
||||
setupScript: string | null;
|
||||
postCheckoutScript: string | null;
|
||||
repoInstructions: string;
|
||||
web: ToolPermission;
|
||||
search: ToolPermission;
|
||||
@@ -26,6 +28,8 @@ export interface RunContext {
|
||||
const defaultSettings: RepoSettings = {
|
||||
defaultAgent: null,
|
||||
modes: [],
|
||||
setupScript: null,
|
||||
postCheckoutScript: null,
|
||||
repoInstructions: "",
|
||||
web: "enabled",
|
||||
search: "enabled",
|
||||
@@ -81,7 +85,14 @@ export async function fetchRunContext(params: {
|
||||
}
|
||||
|
||||
return {
|
||||
settings: data.settings ?? defaultSettings,
|
||||
settings: {
|
||||
...defaultSettings,
|
||||
...data.settings,
|
||||
// ensure arrays are never undefined (API may omit new fields for existing repos)
|
||||
modes: data.settings?.modes ?? [],
|
||||
setupScript: data.settings?.setupScript ?? null,
|
||||
postCheckoutScript: data.settings?.postCheckoutScript ?? null,
|
||||
},
|
||||
apiToken: data.apiToken,
|
||||
};
|
||||
} catch {
|
||||
|
||||
+8
-12
@@ -45,15 +45,19 @@ export function setupTestRepo(options: SetupOptions): void {
|
||||
}
|
||||
}
|
||||
|
||||
interface SetupGitParams {
|
||||
export interface GitContext {
|
||||
gitToken: string;
|
||||
owner: string;
|
||||
name: string;
|
||||
event: PayloadEvent;
|
||||
octokit: OctokitWithPlugins;
|
||||
toolState: ToolState;
|
||||
// restricted bash mode: disables git hooks to prevent token exfiltration
|
||||
restricted: boolean;
|
||||
postCheckoutScript: string | null;
|
||||
}
|
||||
|
||||
export interface SetupGitParams extends GitContext {
|
||||
event: PayloadEvent;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -149,15 +153,7 @@ export async function setupGit(params: SetupGitParams): Promise<void> {
|
||||
// PR event: checkout PR branch using shared helper
|
||||
const prNumber = params.event.issue_number;
|
||||
|
||||
// use shared checkout helper (handles fork remotes, push config, etc.)
|
||||
// use shared checkout helper (handles fork remotes, push config, post-checkout hook)
|
||||
// this updates toolState.pushUrl for fork PRs and sets toolState.issueNumber
|
||||
await checkoutPrBranch({
|
||||
octokit: params.octokit,
|
||||
owner: params.owner,
|
||||
name: params.name,
|
||||
gitToken: params.gitToken,
|
||||
pullNumber: prNumber,
|
||||
toolState: params.toolState,
|
||||
restricted: params.restricted,
|
||||
});
|
||||
await checkoutPrBranch(prNumber, params);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user