Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| caa3cf4d4b | |||
| 8e53ce4e6b | |||
| 95c1a5757e | |||
| ee100354da | |||
| 70f1c47a28 | |||
| 4ee1ae89a5 | |||
| 185ca7a832 | |||
| 4ecff49b72 | |||
| df3ec6b815 | |||
| 4a9d83b102 | |||
| 57537d1a95 | |||
| 9948c08e7d | |||
| 3bf2f8596f | |||
| 510f2c96f9 | |||
| df13253d48 | |||
| eb22433760 | |||
| b6658ddbc1 | |||
| 37dcea86b9 | |||
| 97937f46f7 | |||
| e45c4a84a2 | |||
| 9a1f3bdb0a | |||
| b80c78bdbe | |||
| 8fd2b6aacb | |||
| 6ac428ee2b | |||
| 375e8e4455 | |||
| 593a956665 | |||
| 80ab5bad34 | |||
| 6313b09e30 | |||
| b753c67d0a | |||
| 4789a2b5e3 | |||
| 06683c1e0a | |||
| 796c56a0c2 | |||
| 002f550e56 | |||
| 0e1f1ccbb7 | |||
| 8a64742ddf | |||
| 8037c118cc | |||
| 6f108237d4 | |||
| d5508d99bb | |||
| 6a77ea6612 |
@@ -32,6 +32,8 @@ jobs:
|
||||
with:
|
||||
prompt: ${{ inputs.prompt }}
|
||||
env:
|
||||
API_URL: ${{ secrets.API_URL }}
|
||||
VERCEL_AUTOMATION_BYPASS_SECRET: ${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}
|
||||
# add any additional keys your agent(s) need
|
||||
# optionally, comment out any you won't use
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
node-version: "24"
|
||||
cache: "pnpm"
|
||||
|
||||
- run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm test
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
fail-fast: true
|
||||
matrix:
|
||||
agent: [claude, codex, cursor, gemini, opencode]
|
||||
test:
|
||||
@@ -37,6 +37,8 @@ jobs:
|
||||
CURSOR_API_KEY: ${{ secrets.CURSOR_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GEMINI_MODEL: ${{ vars.GEMINI_MODEL }}
|
||||
OPENCODE_MODEL: ${{ vars.OPENCODE_MODEL }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
@@ -45,7 +47,7 @@ jobs:
|
||||
node-version: "24"
|
||||
cache: "pnpm"
|
||||
|
||||
- run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm runtest ${{ matrix.test }} ${{ matrix.agent }}
|
||||
|
||||
agnostic:
|
||||
@@ -55,7 +57,7 @@ jobs:
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
fail-fast: true
|
||||
matrix:
|
||||
test:
|
||||
[
|
||||
@@ -85,5 +87,5 @@ jobs:
|
||||
node-version: "24"
|
||||
cache: "pnpm"
|
||||
|
||||
- run: pnpm install --frozen-lockfile --ignore-scripts
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm runtest ${{ matrix.test }}
|
||||
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
# sync action lockfile when action/package.json changes
|
||||
if git diff --cached --name-only | grep -q "^action/package.json$"; then
|
||||
echo "🔒 syncing action/pnpm-lock.yaml..."
|
||||
pnpm --ignore-workspace -C action install --no-frozen-lockfile
|
||||
# note: pnpm -C action install will *not* treat "action" as a monorepo root if run from repo root;
|
||||
# to install with action/ as the workspace root (and search upwards), cd into action first:
|
||||
(cd action && pnpm install --no-frozen-lockfile)
|
||||
git add action/pnpm-lock.yaml
|
||||
fi
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 pullfrog
|
||||
Copyright (c) 2026 Pullfrog, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- test preview system --> <!-- trivial touch -->
|
||||
<!-- test preview system --> <!-- test bypass 2 -->
|
||||
<p align="center">
|
||||
<h1 align="center">
|
||||
<picture>
|
||||
|
||||
+20
-12
@@ -63,7 +63,7 @@ function writeMcpConfig(ctx: AgentRunContext): string {
|
||||
};
|
||||
|
||||
writeFileSync(configPath, JSON.stringify(mcpConfig, null, 2), "utf-8");
|
||||
log.info(`» MCP config written to ${configPath}`);
|
||||
log.debug(`» MCP config written to ${configPath}`);
|
||||
return configPath;
|
||||
}
|
||||
|
||||
@@ -86,12 +86,12 @@ export const claude = agent({
|
||||
// select model and effort level
|
||||
const model = claudeEffortModels[ctx.payload.effort];
|
||||
const effortLevel = claudeEffortLevels[ctx.payload.effort];
|
||||
log.info(`» using model: ${model}${effortLevel ? ` (effort: ${effortLevel})` : ""}`);
|
||||
log.info(`» model: ${model}${effortLevel ? ` (effort: ${effortLevel})` : ""}`);
|
||||
|
||||
// build disallowedTools based on tool permissions
|
||||
const disallowedTools = buildDisallowedTools(ctx);
|
||||
if (disallowedTools.length > 0) {
|
||||
log.info(`» disallowed tools: ${disallowedTools.join(", ")}`);
|
||||
log.debug(`» disallowed built-ins: ${JSON.stringify(disallowedTools)}`);
|
||||
}
|
||||
|
||||
// write MCP config file
|
||||
@@ -173,8 +173,7 @@ export const claude = agent({
|
||||
onStderr: (chunk) => {
|
||||
const trimmed = chunk.trim();
|
||||
if (trimmed) {
|
||||
log.debug(`[claude stderr] ${trimmed}`);
|
||||
log.warning(trimmed);
|
||||
log.info(`[claude stderr] ${trimmed}`);
|
||||
finalOutput += trimmed + "\n";
|
||||
}
|
||||
},
|
||||
@@ -239,10 +238,13 @@ const messageHandlers: SDKMessageHandlers = {
|
||||
user: (data, bashToolIds, thinkingTimer) => {
|
||||
if (data.message?.content) {
|
||||
for (const content of data.message.content) {
|
||||
if (typeof content === "string") {
|
||||
continue;
|
||||
}
|
||||
if (content.type === "tool_result") {
|
||||
thinkingTimer.markToolResult();
|
||||
|
||||
const toolUseId = (content as any).tool_use_id;
|
||||
const toolUseId = content.tool_use_id;
|
||||
const isBashTool = toolUseId && bashToolIds.has(toolUseId);
|
||||
|
||||
const outputContent =
|
||||
@@ -250,7 +252,13 @@ const messageHandlers: SDKMessageHandlers = {
|
||||
? content.content
|
||||
: Array.isArray(content.content)
|
||||
? content.content
|
||||
.map((c: any) => (typeof c === "string" ? c : c.text || JSON.stringify(c)))
|
||||
.map((entry: unknown) =>
|
||||
typeof entry === "string"
|
||||
? entry
|
||||
: typeof entry === "object" && entry !== null && "text" in entry
|
||||
? String(entry.text)
|
||||
: JSON.stringify(entry)
|
||||
)
|
||||
.join("\n")
|
||||
: String(content.content);
|
||||
|
||||
@@ -258,7 +266,7 @@ const messageHandlers: SDKMessageHandlers = {
|
||||
// Log bash output in a collapsed group
|
||||
log.startGroup(`bash output`);
|
||||
if (content.is_error) {
|
||||
log.warning(outputContent);
|
||||
log.info(outputContent);
|
||||
} else {
|
||||
log.info(outputContent);
|
||||
}
|
||||
@@ -266,7 +274,7 @@ const messageHandlers: SDKMessageHandlers = {
|
||||
// Clean up the tracked ID
|
||||
bashToolIds.delete(toolUseId);
|
||||
} else if (content.is_error) {
|
||||
log.warning(`Tool error: ${outputContent}`);
|
||||
log.info(`Tool error: ${outputContent}`);
|
||||
} else {
|
||||
// log successful non-bash tool result at debug level
|
||||
log.debug(`tool output: ${outputContent}`);
|
||||
@@ -301,11 +309,11 @@ const messageHandlers: SDKMessageHandlers = {
|
||||
],
|
||||
]);
|
||||
} else if (data.subtype === "error_max_turns") {
|
||||
log.error(`Max turns reached: ${JSON.stringify(data)}`);
|
||||
log.info(`Max turns reached: ${JSON.stringify(data)}`);
|
||||
} else if (data.subtype === "error_during_execution") {
|
||||
log.error(`Execution error: ${JSON.stringify(data)}`);
|
||||
log.info(`Execution error: ${JSON.stringify(data)}`);
|
||||
} else {
|
||||
log.error(`Failed: ${JSON.stringify(data)}`);
|
||||
log.info(`Failed: ${JSON.stringify(data)}`);
|
||||
}
|
||||
},
|
||||
system: () => {},
|
||||
|
||||
+11
-13
@@ -29,7 +29,7 @@ const FALLBACK_MODEL = "gpt-5.2-codex";
|
||||
|
||||
function getCodexEffortConfig(model: string): Record<Effort, CodexEffortConfig> {
|
||||
return {
|
||||
mini: { model: "gpt-5.1-codex", reasoningEffort: "low" },
|
||||
mini: { model: "gpt-5.2-codex", reasoningEffort: "low" },
|
||||
auto: { model },
|
||||
max: { model, reasoningEffort: "high" },
|
||||
};
|
||||
@@ -43,7 +43,7 @@ async function isModelAvailable(ctx: { apiKey: string; model: string }): Promise
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!response.ok) {
|
||||
log.warning(
|
||||
log.info(
|
||||
`failed to list models (HTTP ${response.status}), falling back to ${FALLBACK_MODEL}`
|
||||
);
|
||||
return false;
|
||||
@@ -51,7 +51,7 @@ async function isModelAvailable(ctx: { apiKey: string; model: string }): Promise
|
||||
const body = (await response.json()) as { data: Array<{ id: string }> };
|
||||
return body.data.some((m) => m.id === ctx.model);
|
||||
} catch (err) {
|
||||
log.warning(`failed to list models: ${err}, falling back to ${FALLBACK_MODEL}`);
|
||||
log.info(`failed to list models: ${err}, falling back to ${FALLBACK_MODEL}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -155,10 +155,9 @@ export const codex = agent({
|
||||
|
||||
// get model and reasoning effort based on effort level
|
||||
const effortConfig = getCodexEffortConfig(model)[ctx.payload.effort];
|
||||
log.info(`» using model: ${effortConfig.model} (effort: ${ctx.payload.effort})`);
|
||||
if (effortConfig.reasoningEffort) {
|
||||
log.info(`» using modelReasoningEffort: ${effortConfig.reasoningEffort}`);
|
||||
}
|
||||
log.info(
|
||||
`» model: ${effortConfig.model}${effortConfig.reasoningEffort ? ` (reasoningEffort: ${effortConfig.reasoningEffort})` : ""}`
|
||||
);
|
||||
|
||||
// determine sandbox mode based on push permission
|
||||
// push: "disabled" → read-only sandbox, otherwise workspace-write.
|
||||
@@ -263,8 +262,7 @@ export const codex = agent({
|
||||
onStderr: (chunk) => {
|
||||
const trimmed = chunk.trim();
|
||||
if (trimmed) {
|
||||
log.debug(`[codex stderr] ${trimmed}`);
|
||||
log.warning(trimmed);
|
||||
log.info(`[codex stderr] ${trimmed}`);
|
||||
finalOutput += trimmed + "\n";
|
||||
}
|
||||
},
|
||||
@@ -320,7 +318,7 @@ const messageHandlers: {
|
||||
]);
|
||||
},
|
||||
"turn.failed": (event) => {
|
||||
log.error(`Turn failed: ${event.error.message}`);
|
||||
log.info(`Turn failed: ${event.error.message}`);
|
||||
},
|
||||
"item.started": (event, commandExecutionIds, thinkingTimer) => {
|
||||
const item = event.item;
|
||||
@@ -363,7 +361,7 @@ const messageHandlers: {
|
||||
thinkingTimer.markToolResult();
|
||||
log.startGroup(`bash output`);
|
||||
if (item.status === "failed" || (item.exit_code !== undefined && item.exit_code !== 0)) {
|
||||
log.warning(item.aggregated_output || "Command failed");
|
||||
log.info(item.aggregated_output || "Command failed");
|
||||
} else {
|
||||
log.info(item.aggregated_output || "");
|
||||
}
|
||||
@@ -373,7 +371,7 @@ const messageHandlers: {
|
||||
} else if (item.type === "mcp_tool_call") {
|
||||
thinkingTimer.markToolResult();
|
||||
if (item.status === "failed" && item.error) {
|
||||
log.warning(`MCP tool call failed: ${item.error.message}`);
|
||||
log.info(`MCP tool call failed: ${item.error.message}`);
|
||||
} else if ((item as any).output) {
|
||||
// log successful MCP tool call output so it appears in captured output
|
||||
const output = (item as any).output;
|
||||
@@ -389,6 +387,6 @@ const messageHandlers: {
|
||||
}
|
||||
},
|
||||
error: (event) => {
|
||||
log.error(`Error: ${event.message}`);
|
||||
log.info(`Error: ${event.message}`);
|
||||
},
|
||||
};
|
||||
|
||||
+9
-7
@@ -134,7 +134,7 @@ export const cursor = agent({
|
||||
try {
|
||||
const projectConfig = JSON.parse(readFileSync(projectCliConfigPath, "utf-8"));
|
||||
if (projectConfig.model) {
|
||||
log.info(`» using model from project .cursor/cli.json: ${projectConfig.model}`);
|
||||
log.info(`» model: ${projectConfig.model} (from .cursor/cli.json)`);
|
||||
} else {
|
||||
modelOverride = cursorEffortModels[ctx.payload.effort];
|
||||
}
|
||||
@@ -146,9 +146,9 @@ export const cursor = agent({
|
||||
}
|
||||
|
||||
if (modelOverride) {
|
||||
log.info(`» using model: ${modelOverride}, effort=${ctx.payload.effort}`);
|
||||
log.info(`» model: ${modelOverride}`);
|
||||
} else if (!existsSync(projectCliConfigPath)) {
|
||||
log.info(`» using default model, effort=${ctx.payload.effort}`);
|
||||
log.info(`» model: default`);
|
||||
}
|
||||
|
||||
// track logged model_call_ids to avoid duplicates
|
||||
@@ -210,7 +210,7 @@ export const cursor = agent({
|
||||
const result = event.tool_call?.mcpToolCall?.result?.success;
|
||||
const isError = result?.isError;
|
||||
if (isError) {
|
||||
log.warning("Tool call failed");
|
||||
log.info("Tool call failed");
|
||||
} else {
|
||||
// log successful tool result so it appears in output
|
||||
// handle both formats: { text: string } or { text: { text: string } }
|
||||
@@ -320,12 +320,12 @@ export const cursor = agent({
|
||||
const text = data.toString();
|
||||
stderr += text;
|
||||
process.stderr.write(text);
|
||||
log.warning(text);
|
||||
log.info(text);
|
||||
});
|
||||
|
||||
child.on("close", async (code, signal) => {
|
||||
if (signal) {
|
||||
log.warning(`Cursor CLI terminated by signal: ${signal}`);
|
||||
log.info(`Cursor CLI terminated by signal: ${signal}`);
|
||||
}
|
||||
|
||||
const duration = ((performance.now() - startTime) / 1000).toFixed(1);
|
||||
@@ -439,5 +439,7 @@ function configureCursorTools(ctx: AgentRunContext): void {
|
||||
}
|
||||
|
||||
writeFileSync(cliConfigPath, JSON.stringify(config, null, 2), "utf-8");
|
||||
log.info(`» CLI config written to ${cliConfigPath}`, JSON.stringify(config, null, 2));
|
||||
log.info(`» CLI config written to ${cliConfigPath}`);
|
||||
log.debug(`» disallowed built-ins: ${JSON.stringify(deny)}`);
|
||||
log.debug(`» CLI config contents: ${JSON.stringify(config, null, 2)}`);
|
||||
}
|
||||
|
||||
+6
-7
@@ -149,7 +149,7 @@ const messageHandlers = {
|
||||
if (event.status === "error") {
|
||||
const errorMsg =
|
||||
typeof event.output === "string" ? event.output : JSON.stringify(event.output);
|
||||
log.warning(`Tool call failed: ${errorMsg}`);
|
||||
log.info(`Tool call failed: ${errorMsg}`);
|
||||
} else if (event.output) {
|
||||
// log successful tool result so it appears in output
|
||||
const outputStr =
|
||||
@@ -270,8 +270,7 @@ export const gemini = agent({
|
||||
onStderr: (chunk) => {
|
||||
const trimmed = chunk.trim();
|
||||
if (trimmed) {
|
||||
log.debug(`[gemini stderr] ${trimmed}`);
|
||||
log.warning(trimmed);
|
||||
log.info(`[gemini stderr] ${trimmed}`);
|
||||
finalOutput += trimmed + "\n";
|
||||
}
|
||||
},
|
||||
@@ -286,7 +285,7 @@ export const gemini = agent({
|
||||
|
||||
// retry on transient API errors (500, 503, INTERNAL, etc.)
|
||||
if (attempt < MAX_ATTEMPTS && isTransientApiError(errorMessage)) {
|
||||
log.warning(
|
||||
log.info(
|
||||
`» transient Gemini API error on attempt ${attempt}/${MAX_ATTEMPTS}, retrying in ${RETRY_DELAY_MS / 1000}s...`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
|
||||
@@ -313,7 +312,7 @@ export const gemini = agent({
|
||||
|
||||
// retry on transient API errors from spawn exceptions too
|
||||
if (attempt < MAX_ATTEMPTS && isTransientApiError(errorMessage)) {
|
||||
log.warning(
|
||||
log.info(
|
||||
`» transient Gemini API error on attempt ${attempt}/${MAX_ATTEMPTS}, retrying in ${RETRY_DELAY_MS / 1000}s...`
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
|
||||
@@ -345,7 +344,7 @@ function configureGeminiSettings(ctx: AgentRunContext): string {
|
||||
// allow env var override for tests (e.g., to avoid flash RPD quota limits)
|
||||
const model = process.env.GEMINI_MODEL ?? effortConfig.model;
|
||||
const thinkingLevel = effortConfig.thinkingLevel;
|
||||
log.info(`» using model: ${model}, thinkingLevel: ${thinkingLevel}`);
|
||||
log.info(`» model: ${model} (thinkingLevel: ${thinkingLevel})`);
|
||||
|
||||
const realHome = homedir();
|
||||
const geminiConfigDir = join(realHome, ".gemini");
|
||||
@@ -413,7 +412,7 @@ function configureGeminiSettings(ctx: AgentRunContext): string {
|
||||
writeFileSync(settingsPath, JSON.stringify(newSettings, null, 2), "utf-8");
|
||||
log.info(`» Gemini settings written to ${settingsPath}`);
|
||||
if (exclude.length > 0) {
|
||||
log.info(`» excluded tools: ${exclude.join(", ")}`);
|
||||
log.debug(`» disallowed built-ins: ${JSON.stringify(exclude)}`);
|
||||
}
|
||||
|
||||
return model;
|
||||
|
||||
+35
-37
@@ -72,7 +72,9 @@ export const opencode = agent({
|
||||
const modelOverride = process.env.OPENCODE_MODEL;
|
||||
if (modelOverride) {
|
||||
args.push("--model", modelOverride);
|
||||
log.info(`» using model override: ${modelOverride}`);
|
||||
log.info(`» model: ${modelOverride} (override)`);
|
||||
} else {
|
||||
log.info(`» model: auto-selected by OpenCode`);
|
||||
}
|
||||
|
||||
process.env.HOME = tempHome;
|
||||
@@ -152,7 +154,7 @@ export const opencode = agent({
|
||||
activeToolCalls > 0
|
||||
? ` (waiting for ${activeToolCalls} tool call${activeToolCalls > 1 ? "s" : ""})`
|
||||
: " (OpenCode may be processing internally - LLM calls, planning, etc.)";
|
||||
log.warning(
|
||||
log.info(
|
||||
`» no activity for ${(timeSinceLastActivity / 1000).toFixed(1)}s${toolCallInfo} (${eventCount} events processed so far)`
|
||||
);
|
||||
}
|
||||
@@ -184,15 +186,12 @@ export const opencode = agent({
|
||||
const providerError = detectProviderError(trimmed);
|
||||
if (providerError) {
|
||||
lastProviderError = providerError;
|
||||
log.error(`» provider error detected (${providerError}): ${trimmed.substring(0, 500)}`);
|
||||
log.info(`» provider error detected (${providerError}): ${trimmed.substring(0, 500)}`);
|
||||
} else {
|
||||
// try to parse as JSON for structured logging, fall back to warning
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
log.debug(JSON.stringify(parsed, null, 2));
|
||||
} catch {
|
||||
log.warning(trimmed);
|
||||
}
|
||||
// OpenCode's --print-logs output goes to stderr. demote internal
|
||||
// INFO/DEBUG bus traffic to debug so it doesn't drown out tool
|
||||
// call logs in the GitHub Actions step output.
|
||||
log.debug(trimmed);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -208,9 +207,9 @@ export const opencode = agent({
|
||||
const diagnosis = lastProviderError
|
||||
? `provider error: ${lastProviderError}`
|
||||
: "unknown cause (no stdout events received)";
|
||||
log.error(`» OpenCode produced 0 events (${diagnosis})`);
|
||||
log.info(`» OpenCode produced 0 events (${diagnosis})`);
|
||||
if (stderrContext) {
|
||||
log.error(`» last stderr output:\n${stderrContext}`);
|
||||
log.info(`» last stderr output:\n${stderrContext}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,12 +263,12 @@ export const opencode = agent({
|
||||
? "OpenCode produced 0 stdout events - check if the model provider is reachable"
|
||||
: `${eventCount} events were processed before the hang`;
|
||||
|
||||
log.error(
|
||||
log.info(
|
||||
`» OpenCode ${isActivityTimeout ? "hung" : "failed"} after ${(duration / 1000).toFixed(1)}s: ${errorMessage}`
|
||||
);
|
||||
log.error(`» diagnosis: ${diagnosis}`);
|
||||
log.info(`» diagnosis: ${diagnosis}`);
|
||||
if (stderrContext) {
|
||||
log.error(
|
||||
log.info(
|
||||
`» recent stderr (last ${Math.min(recentStderr.length, 10)} lines):\n${stderrContext}`
|
||||
);
|
||||
}
|
||||
@@ -325,9 +324,7 @@ function configureOpenCode(ctx: AgentRunContext): void {
|
||||
}
|
||||
|
||||
log.info(`» OpenCode config written to ${configPath}`);
|
||||
log.info(
|
||||
`» OpenCode permissions: edit=${permission.edit}, bash=${permission.bash}, webfetch=${permission.webfetch}`
|
||||
);
|
||||
log.debug(`» disallowed built-ins: ${JSON.stringify(permission)}`);
|
||||
log.debug(`OpenCode config contents:\n${configJson}`);
|
||||
}
|
||||
|
||||
@@ -558,27 +555,28 @@ const messageHandlers = {
|
||||
const status = event.part?.state?.status;
|
||||
const output = event.part?.state?.output;
|
||||
|
||||
// debug log all tool_use events to diagnose missing bash/MCP tool calls
|
||||
if (!toolName || !toolId) {
|
||||
log.debug(`» tool_use event missing toolName or toolId: ${JSON.stringify(event)}`);
|
||||
// surface dropped tool_use events visibly so missing tool calls are diagnosable
|
||||
log.info(
|
||||
`» tool_use event missing toolName or toolId: ${JSON.stringify(event).substring(0, 500)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolName && toolId) {
|
||||
// track tool call in current step
|
||||
if (stepHistory.length > 0) {
|
||||
stepHistory[stepHistory.length - 1].toolCalls.push(toolName);
|
||||
}
|
||||
// track tool call in current step
|
||||
if (stepHistory.length > 0) {
|
||||
stepHistory[stepHistory.length - 1].toolCalls.push(toolName);
|
||||
}
|
||||
|
||||
thinkingTimer.markToolCall();
|
||||
log.toolCall({
|
||||
toolName,
|
||||
input: parameters || {},
|
||||
});
|
||||
thinkingTimer.markToolCall();
|
||||
log.toolCall({
|
||||
toolName,
|
||||
input: parameters || {},
|
||||
});
|
||||
|
||||
// if tool already completed (status in same event), log output
|
||||
if (status === "completed" && output) {
|
||||
log.debug(` output: ${output}`);
|
||||
}
|
||||
// if tool already completed (status in same event), log output
|
||||
if (status === "completed" && output) {
|
||||
log.debug(` output: ${output}`);
|
||||
}
|
||||
},
|
||||
tool_result: (event: OpenCodeToolResultEvent, thinkingTimer: ThinkingTimer) => {
|
||||
@@ -602,7 +600,7 @@ const messageHandlers = {
|
||||
log.debug(` output: ${typeof output === "string" ? output : JSON.stringify(output)}`);
|
||||
}
|
||||
if (toolDuration > 5000) {
|
||||
log.warning(
|
||||
log.info(
|
||||
`» ⚠️ tool call took ${(toolDuration / 1000).toFixed(1)}s - this may indicate network latency or slow processing`
|
||||
);
|
||||
}
|
||||
@@ -610,7 +608,7 @@ const messageHandlers = {
|
||||
}
|
||||
if (status === "error") {
|
||||
const errorMsg = typeof output === "string" ? output : JSON.stringify(output);
|
||||
log.error(`» ❌ tool call failed: ${errorMsg}`);
|
||||
log.info(`» ❌ tool call failed: ${errorMsg}`);
|
||||
} else if (output) {
|
||||
// log successful tool result so it appears in captured output
|
||||
const outputStr = typeof output === "string" ? output : JSON.stringify(output);
|
||||
@@ -626,7 +624,7 @@ const messageHandlers = {
|
||||
);
|
||||
|
||||
if (event.status === "error") {
|
||||
log.error(`» OpenCode CLI failed: ${JSON.stringify(event)}`);
|
||||
log.info(`» OpenCode CLI failed: ${JSON.stringify(event)}`);
|
||||
} else {
|
||||
// log tokens once at the end (use stats from result if available, otherwise use accumulated from step_finish)
|
||||
const inputTokens = event.stats?.input_tokens || accumulatedTokens.input || 0;
|
||||
|
||||
+9
-19
@@ -28,25 +28,15 @@ export const agent = <const input extends AgentInput>(input: input): defineAgent
|
||||
return {
|
||||
...input,
|
||||
run: async (ctx: AgentRunContext): Promise<AgentResult> => {
|
||||
const bash = ctx.payload.bash;
|
||||
const web = ctx.payload.web;
|
||||
const search = ctx.payload.search;
|
||||
const push = ctx.payload.push;
|
||||
log.info(
|
||||
`» running ${input.name} with effort=${ctx.payload.effort}, timeout=${ctx.payload.timeout}...`
|
||||
);
|
||||
// build log box content: eventInstructions (if any) + user request (if any) + event data
|
||||
const logParts = [
|
||||
ctx.instructions.eventInstructions
|
||||
? `EVENT-LEVEL INSTRUCTIONS:\n${ctx.instructions.eventInstructions}`
|
||||
: null,
|
||||
ctx.instructions.user ? `USER REQUEST:\n${ctx.instructions.user}` : null,
|
||||
ctx.instructions.event,
|
||||
].filter(Boolean);
|
||||
log.box(logParts.join("\n\n---\n\n"), {
|
||||
title: "Instructions",
|
||||
});
|
||||
log.info(`» tool permissions: web=${web}, search=${search}, push=${push}, bash=${bash}`);
|
||||
log.info(`» agent: ${input.name}`);
|
||||
log.info(`» effort: ${ctx.payload.effort}`);
|
||||
if (ctx.payload.timeout) log.info(`» timeout: ${ctx.payload.timeout}`);
|
||||
log.info(`» web: ${ctx.payload.web}`);
|
||||
log.info(`» search: ${ctx.payload.search}`);
|
||||
log.info(`» push: ${ctx.payload.push}`);
|
||||
log.info(`» bash: ${ctx.payload.bash}`);
|
||||
log.debug(`» payload: ${JSON.stringify(ctx.payload, null, 2)}`);
|
||||
|
||||
return input.run(ctx);
|
||||
},
|
||||
...agentsManifest[input.name],
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
|
||||
import * as core from "@actions/core";
|
||||
import { main } from "./main.ts";
|
||||
import { runCleanup } from "./utils/exitHandler.ts";
|
||||
|
||||
async function run(): Promise<void> {
|
||||
try {
|
||||
@@ -22,8 +21,6 @@ async function run(): Promise<void> {
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
|
||||
core.setFailed(`Action failed: ${errorMessage}`);
|
||||
} finally {
|
||||
await runCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+18
-14
@@ -3,6 +3,8 @@
|
||||
import { build } from "esbuild";
|
||||
import { readFileSync, writeFileSync } from "fs";
|
||||
|
||||
const isMainOnlyBuild = process.argv.includes("--main-only");
|
||||
|
||||
// Plugin to strip shebangs from output files
|
||||
/**
|
||||
* @type {import("esbuild").Plugin}
|
||||
@@ -67,20 +69,22 @@ await build({
|
||||
plugins: [stripShebangPlugin],
|
||||
});
|
||||
|
||||
// Build the post cleanup entry bundle
|
||||
await build({
|
||||
...sharedConfig,
|
||||
entryPoints: ["./post.ts"],
|
||||
outfile: "./post",
|
||||
plugins: [stripShebangPlugin],
|
||||
});
|
||||
if (!isMainOnlyBuild) {
|
||||
// Build the post cleanup entry bundle
|
||||
await build({
|
||||
...sharedConfig,
|
||||
entryPoints: ["./post.ts"],
|
||||
outfile: "./post",
|
||||
plugins: [stripShebangPlugin],
|
||||
});
|
||||
|
||||
// Build the get-installation-token action
|
||||
await build({
|
||||
...sharedConfig,
|
||||
entryPoints: ["./get-installation-token/entry.ts"],
|
||||
outfile: "./get-installation-token/entry",
|
||||
plugins: [stripShebangPlugin],
|
||||
});
|
||||
// Build the get-installation-token action
|
||||
await build({
|
||||
...sharedConfig,
|
||||
entryPoints: ["./get-installation-token/entry.ts"],
|
||||
outfile: "./get-installation-token/entry",
|
||||
plugins: [stripShebangPlugin],
|
||||
})
|
||||
}
|
||||
|
||||
console.log("» build completed successfully");
|
||||
|
||||
+24
@@ -61,6 +61,28 @@ export type ToolPermission = "disabled" | "enabled";
|
||||
export type BashPermission = "disabled" | "restricted" | "enabled";
|
||||
export type PushPermission = "disabled" | "restricted" | "enabled";
|
||||
|
||||
// workflow yml permissions for GITHUB_TOKEN
|
||||
export type WorkflowPermissionValue = "read" | "write" | "none";
|
||||
export type WorkflowIdTokenPermissionValue = "write" | "none";
|
||||
|
||||
export interface WorkflowPermissions {
|
||||
actions?: WorkflowPermissionValue;
|
||||
attestations?: WorkflowPermissionValue;
|
||||
checks?: WorkflowPermissionValue;
|
||||
contents?: WorkflowPermissionValue;
|
||||
deployments?: WorkflowPermissionValue;
|
||||
discussions?: WorkflowPermissionValue;
|
||||
"id-token"?: WorkflowIdTokenPermissionValue;
|
||||
issues?: WorkflowPermissionValue;
|
||||
models?: WorkflowPermissionValue;
|
||||
packages?: WorkflowPermissionValue;
|
||||
pages?: WorkflowPermissionValue;
|
||||
"pull-requests"?: WorkflowPermissionValue;
|
||||
"repository-projects"?: WorkflowPermissionValue;
|
||||
"security-events"?: WorkflowPermissionValue;
|
||||
statuses?: WorkflowPermissionValue;
|
||||
}
|
||||
|
||||
// permission level for the author who triggered the event
|
||||
// matches GitHub's permission levels: admin > write > maintain > triage > read > none
|
||||
export type AuthorPermission = "admin" | "maintain" | "write" | "triage" | "read" | "none";
|
||||
@@ -250,6 +272,8 @@ export interface WriteablePayload {
|
||||
agent?: AgentName | undefined;
|
||||
/** the user's actual request (body if @pullfrog tagged) */
|
||||
prompt: string;
|
||||
/** github username of the human who triggered this workflow run */
|
||||
triggeringUser?: string | undefined;
|
||||
/** event-level instructions for this trigger type (flag-expanded server-side) */
|
||||
eventInstructions?: string | undefined;
|
||||
/** repo-level instructions (flag-expanded server-side) */
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# `pullfrog/get-installation-token`
|
||||
|
||||
Get a GitHub App installation token in a workflow job. This convenience action makes it easier to integrate Pullfrog into existing CI workflows.
|
||||
|
||||
This action:
|
||||
|
||||
- Provides a GitHub App installation token for later workflow steps.
|
||||
- Works for the current repository out of the box.
|
||||
- Can optionally include additional repositories.
|
||||
- Masks the token in logs.
|
||||
- Revokes the token automatically in the post step.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Workflow or job permissions must include `id-token: write`.
|
||||
- The Pullfrog GitHub App must be installed on the target repositories.
|
||||
- If you pass `repos`, each repository must be installed for the same app installation.
|
||||
|
||||
## Inputs
|
||||
|
||||
| Name | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `repos` | no | Comma-separated additional repo names to include, for example: `repo1,repo2`. The current repo is always included. |
|
||||
|
||||
## Outputs
|
||||
|
||||
| Name | Description |
|
||||
| --- | --- |
|
||||
| `token` | GitHub App installation token |
|
||||
|
||||
## Usage
|
||||
|
||||
### Basic (current repo only)
|
||||
|
||||
```yaml
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
example:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Get installation token
|
||||
id: token
|
||||
uses: ./action/get-installation-token
|
||||
|
||||
- name: Call GitHub API with token
|
||||
run: gh api repos/${{ github.repository }}
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.token.outputs.token }}
|
||||
```
|
||||
|
||||
### Include extra repositories
|
||||
|
||||
```yaml
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
example:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get token for current repo plus extra repos
|
||||
id: token
|
||||
uses: ./action/get-installation-token
|
||||
with:
|
||||
repos: pullfrog,app
|
||||
|
||||
- name: Checkout another repo with installation token
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: pullfrog/pullfrog
|
||||
token: ${{ steps.token.outputs.token }}
|
||||
path: action-repo
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- `repos` expects repository names, not `owner/repo`.
|
||||
- Token lifetime is managed by GitHub, but this action also revokes the token during post-run cleanup.
|
||||
- Prefer step output usage (`${{ steps.<id>.outputs.token }}`) rather than writing tokens to files.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- `Error: id-token permission is required`:
|
||||
Add `id-token: write` in workflow or job permissions.
|
||||
- Token works for current repo but not an extra repo:
|
||||
Ensure that repository is listed in `repos` and the app installation has access to it.
|
||||
@@ -399,7 +399,7 @@ var require_tunnel = __commonJS({
|
||||
connectOptions.headers = connectOptions.headers || {};
|
||||
connectOptions.headers["Proxy-Authorization"] = "Basic " + new Buffer(connectOptions.proxyAuth).toString("base64");
|
||||
}
|
||||
debug2("making CONNECT request");
|
||||
debug3("making CONNECT request");
|
||||
var connectReq = self2.request(connectOptions);
|
||||
connectReq.useChunkedEncodingByDefault = false;
|
||||
connectReq.once("response", onResponse);
|
||||
@@ -419,7 +419,7 @@ var require_tunnel = __commonJS({
|
||||
connectReq.removeAllListeners();
|
||||
socket.removeAllListeners();
|
||||
if (res.statusCode !== 200) {
|
||||
debug2(
|
||||
debug3(
|
||||
"tunneling socket could not be established, statusCode=%d",
|
||||
res.statusCode
|
||||
);
|
||||
@@ -431,7 +431,7 @@ var require_tunnel = __commonJS({
|
||||
return;
|
||||
}
|
||||
if (head.length > 0) {
|
||||
debug2("got illegal response body from proxy");
|
||||
debug3("got illegal response body from proxy");
|
||||
socket.destroy();
|
||||
var error2 = new Error("got illegal response body from proxy");
|
||||
error2.code = "ECONNRESET";
|
||||
@@ -439,13 +439,13 @@ var require_tunnel = __commonJS({
|
||||
self2.removeSocket(placeholder);
|
||||
return;
|
||||
}
|
||||
debug2("tunneling connection has established");
|
||||
debug3("tunneling connection has established");
|
||||
self2.sockets[self2.sockets.indexOf(placeholder)] = socket;
|
||||
return cb(socket);
|
||||
}
|
||||
function onError(cause) {
|
||||
connectReq.removeAllListeners();
|
||||
debug2(
|
||||
debug3(
|
||||
"tunneling socket could not be established, cause=%s\n",
|
||||
cause.message,
|
||||
cause.stack
|
||||
@@ -507,9 +507,9 @@ var require_tunnel = __commonJS({
|
||||
}
|
||||
return target;
|
||||
}
|
||||
var debug2;
|
||||
var debug3;
|
||||
if (process.env.NODE_DEBUG && /\btunnel\b/.test(process.env.NODE_DEBUG)) {
|
||||
debug2 = function() {
|
||||
debug3 = function() {
|
||||
var args = Array.prototype.slice.call(arguments);
|
||||
if (typeof args[0] === "string") {
|
||||
args[0] = "TUNNEL: " + args[0];
|
||||
@@ -519,10 +519,10 @@ var require_tunnel = __commonJS({
|
||||
console.error.apply(console, args);
|
||||
};
|
||||
} else {
|
||||
debug2 = function() {
|
||||
debug3 = function() {
|
||||
};
|
||||
}
|
||||
exports.debug = debug2;
|
||||
exports.debug = debug3;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -19733,10 +19733,10 @@ Support boolean input list: \`true | True | TRUE | false | False | FALSE\``);
|
||||
return process.env["RUNNER_DEBUG"] === "1";
|
||||
}
|
||||
exports.isDebug = isDebug2;
|
||||
function debug2(message) {
|
||||
function debug3(message) {
|
||||
(0, command_1.issueCommand)("debug", {}, message);
|
||||
}
|
||||
exports.debug = debug2;
|
||||
exports.debug = debug3;
|
||||
function error2(message, properties = {}) {
|
||||
(0, command_1.issueCommand)("error", (0, utils_1.toCommandProperties)(properties), message instanceof Error ? message.toString() : message);
|
||||
}
|
||||
@@ -25515,7 +25515,12 @@ var isGitHubActions = !!process.env.GITHUB_ACTIONS;
|
||||
var isInsideDocker = existsSync("/.dockerenv");
|
||||
|
||||
// utils/log.ts
|
||||
var isDebugEnabled = () => process.env.LOG_LEVEL === "debug" || process.env.ACTIONS_STEP_DEBUG === "true" || core.isDebug();
|
||||
var isRunnerDebugEnabled = () => core.isDebug();
|
||||
var isLocalDebugEnabled = () => process.env.LOG_LEVEL === "debug" || process.env.ACTIONS_STEP_DEBUG === "true";
|
||||
var isDebugEnabled = () => isLocalDebugEnabled() || isRunnerDebugEnabled();
|
||||
function ts() {
|
||||
return isDebugEnabled() ? `[${(/* @__PURE__ */ new Date()).toISOString()}] ` : "";
|
||||
}
|
||||
function formatArgs(args) {
|
||||
return args.map((arg) => {
|
||||
if (typeof arg === "string") return arg;
|
||||
@@ -25626,19 +25631,16 @@ function separator(length = 50) {
|
||||
const separatorText = "\u2500".repeat(length);
|
||||
core.info(separatorText);
|
||||
}
|
||||
function ts() {
|
||||
return isDebugEnabled() ? `[${(/* @__PURE__ */ new Date()).toISOString()}] ` : "";
|
||||
}
|
||||
var log = {
|
||||
/** Print info message */
|
||||
info: (...args) => {
|
||||
core.info(`${ts()}${formatArgs(args)}`);
|
||||
},
|
||||
/** Print warning message */
|
||||
/** Print a warning message. Use only for warnings that should be displayed in the job summary. */
|
||||
warning: (...args) => {
|
||||
core.warning(`${ts()}${formatArgs(args)}`);
|
||||
},
|
||||
/** Print error message */
|
||||
/** Print an error message. Use only for errors that should be displayed in the job summary. */
|
||||
error: (...args) => {
|
||||
core.error(`${ts()}${formatArgs(args)}`);
|
||||
},
|
||||
@@ -25646,10 +25648,14 @@ var log = {
|
||||
success: (...args) => {
|
||||
core.info(`${ts()}\xBB ${formatArgs(args)}`);
|
||||
},
|
||||
/** Print debug message (only if LOG_LEVEL=debug) */
|
||||
/** Print debug message (only when debug mode is enabled) */
|
||||
debug: (...args) => {
|
||||
if (isDebugEnabled()) {
|
||||
core.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] [DEBUG] ${formatArgs(args)}`);
|
||||
if (isRunnerDebugEnabled()) {
|
||||
core.debug(formatArgs(args));
|
||||
return;
|
||||
}
|
||||
if (isLocalDebugEnabled()) {
|
||||
core.info(`${ts()}[DEBUG] ${formatArgs(args)}`);
|
||||
}
|
||||
},
|
||||
/** Print a formatted box with text */
|
||||
@@ -25681,15 +25687,43 @@ var core2 = __toESM(require_core(), 1);
|
||||
import { createSign } from "node:crypto";
|
||||
|
||||
// utils/apiUrl.ts
|
||||
function getApiUrl() {
|
||||
const url = process.env.API_URL || "https://pullfrog.com";
|
||||
log.debug(`resolved API_URL: ${url}`);
|
||||
return url;
|
||||
function isLocalUrl(url) {
|
||||
return url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
||||
}
|
||||
function getVercelBypassHeaders() {
|
||||
const secret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
|
||||
if (!secret) return {};
|
||||
return { "x-vercel-protection-bypass": secret };
|
||||
function getApiUrl() {
|
||||
const raw = process.env.API_URL || "https://pullfrog.com";
|
||||
const parsed = new URL(raw);
|
||||
if (parsed.protocol !== "https:" && !isLocalUrl(parsed)) {
|
||||
throw new Error(
|
||||
`API_URL must use https:// (got ${parsed.protocol}). only localhost is exempt.`
|
||||
);
|
||||
}
|
||||
log.debug(`resolved API_URL: ${raw}`);
|
||||
return raw;
|
||||
}
|
||||
|
||||
// utils/apiFetch.ts
|
||||
async function apiFetch(options) {
|
||||
const apiUrl = getApiUrl();
|
||||
const url = new URL(options.path, apiUrl);
|
||||
const bypassSecret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
|
||||
if (bypassSecret) {
|
||||
url.searchParams.set("x-vercel-protection-bypass", bypassSecret);
|
||||
}
|
||||
const headers = {
|
||||
...options.headers
|
||||
};
|
||||
if (bypassSecret) {
|
||||
headers["x-vercel-protection-bypass"] = bypassSecret;
|
||||
}
|
||||
log.debug(`api fetch: ${options.method ?? "GET"} ${url.pathname}`);
|
||||
const init = {
|
||||
method: options.method ?? "GET",
|
||||
headers
|
||||
};
|
||||
if (options.body) init.body = options.body;
|
||||
if (options.signal) init.signal = options.signal;
|
||||
return fetch(url.toString(), init);
|
||||
}
|
||||
|
||||
// utils/retry.ts
|
||||
@@ -25712,9 +25746,7 @@ async function retry(fn, options = {}) {
|
||||
throw error2;
|
||||
}
|
||||
const delay = delayMs * attempt;
|
||||
log.warning(
|
||||
`\xBB ${label} failed (attempt ${attempt}/${maxAttempts}), retrying in ${delay}ms...`
|
||||
);
|
||||
log.info(`\xBB ${label} failed (attempt ${attempt}/${maxAttempts}), retrying in ${delay}ms...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
@@ -25729,37 +25761,26 @@ function isOIDCAvailable() {
|
||||
}
|
||||
async function acquireTokenViaOIDC(opts) {
|
||||
const oidcToken = await core2.getIDToken("pullfrog-api");
|
||||
const apiUrl = getApiUrl();
|
||||
const params = new URLSearchParams();
|
||||
const repos = [...opts?.repos ?? []];
|
||||
const targetRepo = process.env.GITHUB_REPOSITORY?.split("/")[1];
|
||||
if (targetRepo) {
|
||||
repos.push(targetRepo);
|
||||
}
|
||||
if (repos.length) {
|
||||
params.set("repos", repos.join(","));
|
||||
}
|
||||
const queryString = params.toString() ? `?${params.toString()}` : "";
|
||||
const reposParam = repos.length ? `?repos=${repos.join(",")}` : "";
|
||||
const timeoutMs = 3e4;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const fetchOptions = {
|
||||
const tokenResponse = await apiFetch({
|
||||
path: `/api/github/installation-token${reposParam}`,
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${oidcToken}`,
|
||||
"Content-Type": "application/json",
|
||||
...getVercelBypassHeaders()
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: opts?.permissions ? JSON.stringify({ permissions: opts.permissions }) : void 0,
|
||||
signal: controller.signal
|
||||
};
|
||||
if (opts?.permissions) {
|
||||
fetchOptions.body = JSON.stringify({ permissions: opts.permissions });
|
||||
}
|
||||
const tokenResponse = await fetch(
|
||||
`${apiUrl}/api/github/installation-token${queryString}`,
|
||||
fetchOptions
|
||||
);
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error(`Token exchange failed: ${tokenResponse.status} ${tokenResponse.statusText}`);
|
||||
@@ -25908,7 +25929,7 @@ async function revokeGitHubInstallationToken(token) {
|
||||
});
|
||||
log.debug("\xBB installation token revoked");
|
||||
} catch (error2) {
|
||||
log.warning(
|
||||
log.info(
|
||||
`Failed to revoke installation token: ${error2 instanceof Error ? error2.message : String(error2)}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Internal entrypoint for the root app.
|
||||
* Re-exports shared types, values, and utilities needed by the Next.js app.
|
||||
*/
|
||||
|
||||
export type {
|
||||
AgentApiKeyName,
|
||||
AgentManifest,
|
||||
AuthorPermission,
|
||||
BashPermission,
|
||||
Payload,
|
||||
PayloadEvent,
|
||||
PushPermission,
|
||||
ToolPermission,
|
||||
WriteablePayload,
|
||||
} from "../external.ts";
|
||||
export {
|
||||
AgentName,
|
||||
agentsManifest,
|
||||
Effort,
|
||||
ghPullfrogMcpName,
|
||||
} from "../external.ts";
|
||||
export type {
|
||||
AgentInfo,
|
||||
BuildPullfrogFooterParams,
|
||||
WorkflowRunFooterInfo,
|
||||
} from "../utils/buildPullfrogFooter.ts";
|
||||
export {
|
||||
buildPullfrogFooter,
|
||||
PULLFROG_DIVIDER,
|
||||
stripExistingFooter,
|
||||
} from "../utils/buildPullfrogFooter.ts";
|
||||
|
||||
export {
|
||||
isValidTimeString,
|
||||
parseTimeString,
|
||||
TIMEOUT_DISABLED,
|
||||
} from "../utils/time.ts";
|
||||
@@ -12,7 +12,6 @@ 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";
|
||||
import { setupExitHandler } from "./utils/exitHandler.ts";
|
||||
import { resolveGit } from "./utils/gitAuth.ts";
|
||||
import { createOctokit } from "./utils/github.ts";
|
||||
import { resolveInstructions } from "./utils/instructions.ts";
|
||||
@@ -52,8 +51,6 @@ export async function main(): Promise<MainResult> {
|
||||
typeof resolvedPromptInput !== "string" ? resolvedPromptInput.progressCommentId : undefined,
|
||||
});
|
||||
|
||||
setupExitHandler(toolState);
|
||||
|
||||
// resolve and fingerprint git binary before any agent code runs
|
||||
resolveGit();
|
||||
|
||||
@@ -167,6 +164,17 @@ export async function main(): Promise<MainResult> {
|
||||
repo: runContext.repo,
|
||||
modes,
|
||||
});
|
||||
// log instructions as soon as they are fully resolved
|
||||
const logParts = [
|
||||
instructions.eventInstructions
|
||||
? `EVENT-LEVEL INSTRUCTIONS:\n${instructions.eventInstructions}`
|
||||
: null,
|
||||
instructions.user ? `USER REQUEST:\n${instructions.user}` : null,
|
||||
instructions.event,
|
||||
].filter(Boolean);
|
||||
log.box(logParts.join("\n\n---\n\n"), {
|
||||
title: "Instructions",
|
||||
});
|
||||
|
||||
// run agent, optionally with timeout enforcement
|
||||
activityTimeout = createProcessOutputActivityTimeout({
|
||||
@@ -236,8 +244,6 @@ export async function main(): Promise<MainResult> {
|
||||
error: errorMessage,
|
||||
};
|
||||
} finally {
|
||||
if (activityTimeout) {
|
||||
activityTimeout.stop();
|
||||
}
|
||||
activityTimeout?.stop();
|
||||
}
|
||||
}
|
||||
|
||||
-206
@@ -1,206 +0,0 @@
|
||||
# gh_pullfrog MCP Tools
|
||||
|
||||
this directory contains the mcp (model context protocol) server tools for interacting with github.
|
||||
|
||||
## available tools
|
||||
|
||||
### check suite tools
|
||||
|
||||
#### `get_check_suite_logs`
|
||||
get workflow run logs for a failed check suite with intelligent log analysis.
|
||||
|
||||
**parameters:**
|
||||
- `check_suite_id` (number): the id from check_suite.id in the webhook payload
|
||||
|
||||
**replaces:** `gh run list` and `gh run view --log`
|
||||
|
||||
**returns:**
|
||||
structured failure information for each failed job:
|
||||
- `_instructions`: explains how to use each field
|
||||
- `failed_jobs[]`: array of failed job results, each containing:
|
||||
- `job_id`, `job_name`, `job_url`: job identification
|
||||
- `failed_steps`: which CI steps failed (e.g., "Step 6: Run tests")
|
||||
- `log_index`: array of interesting lines (errors, warnings, failures) with line numbers
|
||||
- `excerpt`: ~80 line curated window around the last error
|
||||
- `full_log_path`: path to complete log file for deeper investigation
|
||||
|
||||
**log_index types:**
|
||||
- `error`: lines matching `##[error]`, `Error:`, `ERR_`, `exit code N`
|
||||
- `warning`: lines matching `##[warning]`, `WARN`
|
||||
- `failure`: lines matching `N failed`, `FAIL`, `✕`
|
||||
- `trace`: stack trace lines (deduplicated)
|
||||
|
||||
**workflow for using results:**
|
||||
1. scan `log_index` to see where errors/warnings/failures are located in the log
|
||||
2. read `excerpt` for immediate context around the main error
|
||||
3. if excerpt doesn't show what you need, read specific line ranges from `full_log_path`
|
||||
4. check `failed_steps` and read the workflow yml to understand what command failed
|
||||
|
||||
**example:**
|
||||
```typescript
|
||||
// when handling a check_suite_completed webhook
|
||||
const result = await mcp.call("gh_pullfrog/get_check_suite_logs", {
|
||||
check_suite_id: check_suite.id
|
||||
});
|
||||
|
||||
// result.failed_jobs[0].log_index shows:
|
||||
// [
|
||||
// { line: 181, content: "WARN Failed to create bin...", type: "warning" },
|
||||
// { line: 1079, content: "Error: expect(received).toBe(expected)", type: "error" },
|
||||
// ...
|
||||
// ]
|
||||
// use these line numbers to read specific sections from full_log_path
|
||||
```
|
||||
|
||||
### review tools
|
||||
|
||||
#### `get_review_comments`
|
||||
get all line-by-line comments for a specific pull request review, including full thread context for replies.
|
||||
|
||||
**parameters:**
|
||||
- `pull_number` (number): the pull request number
|
||||
- `review_id` (number): the id from review.id in the webhook payload
|
||||
- `approved_by` (string, optional): only return comments this user gave a 👍 to
|
||||
|
||||
**replaces:** `gh api repos/{owner}/{repo}/pulls/{pull_number}/reviews/{review_id}/comments`
|
||||
|
||||
**returns:**
|
||||
- `commentsPath`: path to XML file with full comment details
|
||||
- `reviewer`: github username of the review author
|
||||
- `count`: number of comments to address
|
||||
|
||||
**output format (XML):**
|
||||
```xml
|
||||
<review_comments count="2" reviewer="colinmcd94">
|
||||
|
||||
<summary>
|
||||
<comment id="67890" file="src/utils/auth.ts" line="42">Actually, can you use a type guard...</comment>
|
||||
<comment id="67891" file="src/api/handler.ts" line="15">This should handle the error case</comment>
|
||||
</summary>
|
||||
|
||||
<comment id="67890" file="src/utils/auth.ts" line="42" author="colinmcd94">
|
||||
<thread>
|
||||
<message id="12345" author="colinmcd94">Please add null checking here</message>
|
||||
<message id="23456" author="octocat">What about using optional chaining?</message>
|
||||
</thread>
|
||||
<diff>
|
||||
@@ -40,7 +40,7 @@
|
||||
const user = getUser(id);
|
||||
- return user.name;
|
||||
+ return user?.name;
|
||||
</diff>
|
||||
<body>Actually, can you use a type guard instead?</body>
|
||||
</comment>
|
||||
|
||||
</review_comments>
|
||||
```
|
||||
|
||||
- `<summary>` lists all comments to address with truncated preview
|
||||
- `<thread>` shows parent comments (when replying to existing thread)
|
||||
- `<diff>` contains the diff hunk around the commented line
|
||||
- `<body>` is the actual comment text to address
|
||||
|
||||
**example:**
|
||||
```typescript
|
||||
// when handling a pull_request_review_submitted webhook
|
||||
await mcp.call("gh_pullfrog/get_review_comments", {
|
||||
pull_number: 47,
|
||||
review_id: review.id
|
||||
});
|
||||
```
|
||||
|
||||
#### `list_pull_request_reviews`
|
||||
list all reviews for a pull request.
|
||||
|
||||
**parameters:**
|
||||
- `pull_number` (number): the pull request number
|
||||
|
||||
**replaces:** `gh api repos/{owner}/{repo}/pulls/{pull_number}/reviews`
|
||||
|
||||
**returns:**
|
||||
array of reviews with:
|
||||
- review id, body, state (approved/changes_requested/commented)
|
||||
- user, commit_id, submitted_at, html_url
|
||||
|
||||
**example:**
|
||||
```typescript
|
||||
await mcp.call("gh_pullfrog/list_pull_request_reviews", {
|
||||
pull_number: 47
|
||||
});
|
||||
```
|
||||
|
||||
#### `reply_to_review_comment`
|
||||
reply to a PR review comment thread explaining how the feedback was addressed.
|
||||
|
||||
**parameters:**
|
||||
- `pull_number` (number): the pull request number
|
||||
- `comment_id` (number): the ID of the review comment to reply to
|
||||
- `body` (string): the reply text explaining how the feedback was addressed
|
||||
|
||||
**replaces:** `gh api repos/{owner}/{repo}/pulls/{pull_number}/comments/{comment_id}/replies`
|
||||
|
||||
**returns:**
|
||||
the created reply comment including:
|
||||
- comment id, body, html_url
|
||||
- in_reply_to_id showing it's a reply to the specified comment
|
||||
|
||||
**example:**
|
||||
```typescript
|
||||
// after addressing a review comment
|
||||
await mcp.call("gh_pullfrog/reply_to_review_comment", {
|
||||
pull_number: 47,
|
||||
comment_id: 2567334961,
|
||||
body: "removed the function as requested"
|
||||
});
|
||||
```
|
||||
|
||||
### output tools
|
||||
|
||||
#### `set_output`
|
||||
set the action output for consumption by subsequent workflow steps. useful when pullfrog is used as a step in a user-defined CI workflow (e.g., generating release notes).
|
||||
|
||||
**parameters:**
|
||||
- `value` (string): the output value to expose
|
||||
|
||||
**returns:**
|
||||
- `success`: true on success
|
||||
|
||||
the value will be available as the `result` output of the action, accessible via `${{ steps.<step-id>.outputs.result }}`.
|
||||
|
||||
**example:**
|
||||
```typescript
|
||||
// when generating content for downstream consumption
|
||||
await mcp.call("gh_pullfrog/set_output", {
|
||||
value: "## Release Notes\n\n- Added new feature X\n- Fixed bug Y"
|
||||
});
|
||||
```
|
||||
|
||||
**usage in workflow:**
|
||||
```yaml
|
||||
- uses: pullfrog/pullfrog@v1
|
||||
id: notes
|
||||
with:
|
||||
prompt: "Generate release notes for v2.0.0"
|
||||
|
||||
- uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
body: ${{ steps.notes.outputs.result }}
|
||||
```
|
||||
|
||||
### other tools
|
||||
|
||||
see individual files for documentation on other tools:
|
||||
- `comment.ts` - create, edit, and update comments
|
||||
- `issue.ts` - create issues
|
||||
- `output.ts` - set action output for workflow consumption
|
||||
- `pr.ts` - create pull requests
|
||||
- `prInfo.ts` - get pull request information
|
||||
- `review.ts` - create pull request reviews
|
||||
- `delegate.ts` - delegate task to a subagent with a specific mode and effort level
|
||||
|
||||
## usage in agents
|
||||
|
||||
agents should prefer using the mcp tools provided by this server. the `gh` cli is available as a fallback if needed, but mcp tools handle authentication and provide better integration.
|
||||
|
||||
the agent instructions automatically include guidance on using these tools.
|
||||
|
||||
+3
-3
@@ -78,7 +78,7 @@ function detectSandboxMethod(): SandboxMethod {
|
||||
}
|
||||
|
||||
detectedSandboxMethod = "none";
|
||||
log.warning("PID namespace isolation not available - falling back to env filtering only");
|
||||
log.info("PID namespace isolation not available - falling back to env filtering only");
|
||||
return "none";
|
||||
}
|
||||
|
||||
@@ -243,8 +243,8 @@ Use this tool to:
|
||||
|
||||
const finalExitCode = exitCode ?? (timedOut ? 124 : -1);
|
||||
if (finalExitCode !== 0) {
|
||||
log.error(`bash command failed with exit code ${finalExitCode}: ${params.command}`);
|
||||
if (output) log.error(`output: ${output.trim()}`);
|
||||
log.info(`bash command failed with exit code ${finalExitCode}: ${params.command}`);
|
||||
if (output) log.info(`output: ${output.trim()}`);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
+1
-1
@@ -213,7 +213,7 @@ export function GetCheckSuiteLogsTool(ctx: ToolContext) {
|
||||
|
||||
log.debug(`analyzed logs for job ${job.name}: ${analysis.index.length} indexed lines`);
|
||||
} catch (error) {
|
||||
log.error(`failed to fetch logs for job ${job.id}: ${error}`);
|
||||
log.info(`failed to fetch logs for job ${job.id}: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-3
@@ -132,7 +132,8 @@ export type CheckoutPrResult = {
|
||||
number: number;
|
||||
title: string;
|
||||
base: string;
|
||||
head: string;
|
||||
localBranch: string;
|
||||
remoteBranch: string;
|
||||
isFork: boolean;
|
||||
maintainerCanModify: boolean;
|
||||
url: string;
|
||||
@@ -289,6 +290,15 @@ export async function checkoutPrBranch(
|
||||
toolState.pushUrl = `https://github.com/${headRepo.full_name}.git`;
|
||||
}
|
||||
|
||||
// store push destination so push_branch can use it directly
|
||||
// git config is the primary mechanism, but toolState serves as a reliable fallback
|
||||
// in case git config reads fail in certain environments
|
||||
toolState.pushDest = {
|
||||
remoteName: isFork ? `pr-${pullNumber}` : "origin",
|
||||
remoteBranch: headBranch,
|
||||
localBranch,
|
||||
};
|
||||
|
||||
// execute post-checkout lifecycle hook
|
||||
await executeLifecycleHook({
|
||||
event: "post-checkout",
|
||||
@@ -356,7 +366,8 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
||||
number: pr.data.number,
|
||||
title: pr.data.title,
|
||||
base: pr.data.base.ref,
|
||||
head: pr.data.head.ref,
|
||||
localBranch: `pr-${pull_number}`,
|
||||
remoteBranch: `refs/heads/${pr.data.head.ref}`,
|
||||
isFork: headRepo.full_name !== pr.data.base.repo.full_name,
|
||||
maintainerCanModify: pr.data.maintainer_can_modify,
|
||||
url: pr.data.html_url,
|
||||
@@ -367,7 +378,9 @@ export function CheckoutPrTool(ctx: ToolContext) {
|
||||
`the diff file at diffPath contains a table of contents (TOC) at the top listing every changed file with its line range. ` +
|
||||
`use the line ranges to read specific files from the diff instead of reading the entire file. ` +
|
||||
`for example, if the TOC says "src/foo.ts → lines 5-42", read lines 5-42 from diffPath to see that file's changes. ` +
|
||||
`review files selectively based on relevance rather than reading everything sequentially.`,
|
||||
`review files selectively based on relevance rather than reading everything sequentially. ` +
|
||||
`the local branch is 'localBranch' (pr-{number}), not the remote branch name. ` +
|
||||
`when pushing, omit branchName to use the current branch. do not use remoteBranch as a local branch name.`,
|
||||
} satisfies CheckoutPrResult;
|
||||
}),
|
||||
});
|
||||
|
||||
+16
-5
@@ -148,10 +148,14 @@ export const ReportProgress = type({
|
||||
});
|
||||
|
||||
/**
|
||||
* Standalone function to report progress to GitHub comment.
|
||||
* Can be called directly without going through the MCP tool interface.
|
||||
* Returns result data if successful.
|
||||
* When there's no comment target (no progressCommentId and no issueNumber), returns a "skipped" result.
|
||||
* Report progress to a GitHub comment.
|
||||
*
|
||||
* progressCommentId has three states:
|
||||
* - undefined: no comment yet — will create one if an issue/PR target exists
|
||||
* - number: active comment — will update it in place
|
||||
* - null: deliberately deleted (e.g. after submitting a PR review) — skips silently
|
||||
*
|
||||
* The body is always tracked in lastProgressBody for the job summary regardless of comment state.
|
||||
*/
|
||||
export async function reportProgress(
|
||||
ctx: ToolContext,
|
||||
@@ -201,6 +205,11 @@ export async function reportProgress(
|
||||
};
|
||||
}
|
||||
|
||||
// null = progress comment was deliberately deleted (e.g. by create_pull_request_review)
|
||||
if (existingCommentId === null) {
|
||||
return { body, action: "skipped" };
|
||||
}
|
||||
|
||||
// no existing comment - need an issue/PR to create one on
|
||||
// use fallback chain: dynamically set context > event payload
|
||||
if (issueNumber === undefined) {
|
||||
@@ -288,6 +297,8 @@ export function ReportProgressTool(ctx: ToolContext) {
|
||||
/**
|
||||
* Delete the progress comment if it exists.
|
||||
* Used after submitting a PR review since the review body contains all necessary info.
|
||||
* Sets progressCommentId to null, which prevents future report_progress calls from
|
||||
* creating a new comment (the agent may call report_progress again after this).
|
||||
*/
|
||||
export async function deleteProgressComment(ctx: ToolContext): Promise<boolean> {
|
||||
const existingCommentId = ctx.toolState.progressCommentId;
|
||||
@@ -310,7 +321,7 @@ export async function deleteProgressComment(ctx: ToolContext): Promise<boolean>
|
||||
}
|
||||
}
|
||||
|
||||
// reset state and mark as updated so post script doesn't try to handle it
|
||||
// set to null (not undefined) so report_progress skips instead of creating a new comment
|
||||
ctx.toolState.progressCommentId = null;
|
||||
ctx.toolState.wasUpdated = true;
|
||||
|
||||
|
||||
+4
-3
@@ -12,7 +12,7 @@ export const DelegateParams = type({
|
||||
"the name of the mode to delegate to (e.g., 'Build', 'Plan', 'Review', 'Fix', 'AddressReviews')"
|
||||
),
|
||||
"effort?": Effort.describe(
|
||||
'effort level for the subagent: "mini" (fast), "auto" (default, highly capable), or "max" (maximum capability)'
|
||||
'effort level for the subagent: "mini" (low-effort and fast, only for simple tasks), "auto" (medium-effort, good for typical tasks that don\'t require significant reasoning), or "max" (high-effort, good for PR reviews and complex coding tasks)'
|
||||
),
|
||||
"instructions?": type.string.describe(
|
||||
"optional additional context or instructions for the subagent — use this to pass results from earlier delegations or narrow the subagent's focus"
|
||||
@@ -45,7 +45,8 @@ export function DelegateTool(ctx: ToolContext) {
|
||||
// guard: prevent subagent recursion
|
||||
if (ctx.toolState.delegationActive) {
|
||||
return {
|
||||
error: "delegation is not available inside a delegated subagent",
|
||||
error:
|
||||
"delegation is not available inside a subagent. you are already running as a delegated subagent. complete the task directly using the available tools. do not attempt to delegate further. your last agent message will be passed to your supervisor and it will decide what to do next.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,7 +112,7 @@ export function DelegateTool(ctx: ToolContext) {
|
||||
} catch (err) {
|
||||
// normalize agent crashes into the same return shape as clean failures
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
log.error(`» delegation to ${selectedMode.name} crashed: ${errorMessage}`);
|
||||
log.info(`» delegation to ${selectedMode.name} crashed: ${errorMessage}`);
|
||||
return {
|
||||
success: false,
|
||||
mode: selectedMode.name,
|
||||
|
||||
+103
-88
@@ -9,6 +9,7 @@ import {
|
||||
} from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { type } from "arktype";
|
||||
import type { BashPermission } from "../external.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
@@ -38,59 +39,6 @@ export const ListDirectoryParams = type({
|
||||
path: "string",
|
||||
});
|
||||
|
||||
// resolve path and validate it is within the repository.
|
||||
// uses realpathSync to follow symlinks and prevent symlink-based escapes.
|
||||
//
|
||||
// threat model: the primary scenario where symlink protection matters is a
|
||||
// malicious PR that plants symlinks in the repo (e.g. `secrets -> /etc/shadow`).
|
||||
// git materializes symlinks on linux, so after checkout the working tree contains
|
||||
// live symlinks. without realpathSync, file_read("secrets") would leak host files.
|
||||
//
|
||||
// when bash is enabled the agent can already `cat /etc/shadow` directly, so the
|
||||
// symlink check is defense-in-depth for that case. the check is most meaningful
|
||||
// when bash is disabled — the agent's only filesystem access is through these MCP
|
||||
// tools, and pre-planted symlinks become the sole escape vector.
|
||||
//
|
||||
// the path traversal check (resolve + startsWith) is always useful regardless of
|
||||
// bash — it blocks `../../etc/passwd` style escapes on every configuration.
|
||||
function resolveAndValidatePath(filePath: string): string {
|
||||
const cwd = realpathSync(process.cwd());
|
||||
const resolved = resolve(cwd, filePath);
|
||||
|
||||
// if the target exists, resolve symlinks to get the real location
|
||||
if (existsSync(resolved)) {
|
||||
const real = realpathSync(resolved);
|
||||
if (real !== cwd && !real.startsWith(cwd + "/")) {
|
||||
throw new Error(`path must be within the repository (symlink escape blocked): ${filePath}`);
|
||||
}
|
||||
return real;
|
||||
}
|
||||
|
||||
// target doesn't exist yet (common for writes) — walk up to find
|
||||
// the first existing ancestor and verify it resolves within the repo.
|
||||
// this prevents creating files through symlinked parent directories.
|
||||
let ancestor = dirname(resolved);
|
||||
while (!existsSync(ancestor)) {
|
||||
const parent = dirname(ancestor);
|
||||
if (parent === ancestor) break; // reached filesystem root
|
||||
ancestor = parent;
|
||||
}
|
||||
|
||||
if (existsSync(ancestor)) {
|
||||
const realAncestor = realpathSync(ancestor);
|
||||
if (realAncestor !== cwd && !realAncestor.startsWith(cwd + "/")) {
|
||||
throw new Error(`path must be within the repository (symlink escape blocked): ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
// also verify the resolved path (without symlink resolution) is within cwd
|
||||
if (resolved !== cwd && !resolved.startsWith(cwd + "/")) {
|
||||
throw new Error(`path must be within the repository: ${filePath}`);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// SECURITY: files that git interprets and can trigger code execution.
|
||||
// .gitattributes can define filter drivers (clean/smudge) that execute arbitrary commands.
|
||||
// .gitmodules can reference malicious submodule URLs that execute code on update.
|
||||
@@ -98,27 +46,92 @@ function resolveAndValidatePath(filePath: string): string {
|
||||
// and could write these files via shell, so blocking via MCP is redundant.
|
||||
const GIT_INTERPRETED_FILES = [".gitattributes", ".gitmodules"];
|
||||
|
||||
type BashPermission = "disabled" | "restricted" | "enabled";
|
||||
|
||||
type ValidateWritePathParams = {
|
||||
filePath: string;
|
||||
bashPermission: BashPermission;
|
||||
};
|
||||
|
||||
function validateWritePath(params: ValidateWritePathParams): string {
|
||||
const resolved = resolveAndValidatePath(params.filePath);
|
||||
// resolve and validate a read path. allows:
|
||||
// 1. paths within the repo (with symlink protection to prevent malicious PR symlinks)
|
||||
// 2. paths within PULLFROG_TEMP_DIR (tool result files: diffs, CI logs, review threads, etc.)
|
||||
function resolveReadPath(filePath: string): string {
|
||||
const cwd = realpathSync(process.cwd());
|
||||
const relative = resolved.slice(cwd.length + 1);
|
||||
if (relative === ".git" || relative.startsWith(".git/")) {
|
||||
throw new Error(`writing to .git is not allowed: ${params.filePath}`);
|
||||
const resolved = resolve(cwd, filePath);
|
||||
|
||||
// allow reads from PULLFROG_TEMP_DIR (tool result files)
|
||||
const tempDir = process.env.PULLFROG_TEMP_DIR;
|
||||
if (tempDir && (resolved === tempDir || resolved.startsWith(tempDir + "/"))) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
// block git-interpreted files only when bash is disabled (no shell = no other way to create them)
|
||||
if (params.bashPermission === "disabled") {
|
||||
const basename = relative.split("/").pop() || "";
|
||||
// allow reads from the repo with symlink protection.
|
||||
// threat model: a malicious PR plants symlinks (e.g. `secrets -> /etc/shadow`).
|
||||
// git materializes symlinks on linux, so after checkout the working tree contains
|
||||
// live symlinks. realpathSync catches these and blocks the read.
|
||||
if (existsSync(resolved)) {
|
||||
const real = realpathSync(resolved);
|
||||
if (real === cwd || real.startsWith(cwd + "/")) {
|
||||
return real;
|
||||
}
|
||||
throw new Error(`path must be within the repository (symlink escape blocked): ${filePath}`);
|
||||
}
|
||||
|
||||
// path doesn't exist — check if it's within the repo
|
||||
if (resolved === cwd || resolved.startsWith(cwd + "/")) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
throw new Error(`path must be within the repository or temp directory: ${filePath}`);
|
||||
}
|
||||
|
||||
// resolve and validate a write path. enforces:
|
||||
// - repo-scoping with symlink protection (when bash !== "enabled")
|
||||
// - .git/ always blocked (defense-in-depth)
|
||||
// - .gitattributes/.gitmodules blocked when bash === "disabled"
|
||||
//
|
||||
// when bash=enabled, repo-scoping is dropped — the agent can write anywhere via native
|
||||
// bash, so restricting file_write to the repo would be security theater.
|
||||
function resolveWritePath(filePath: string, bashPermission: BashPermission): string {
|
||||
const cwd = realpathSync(process.cwd());
|
||||
const resolved = resolve(cwd, filePath);
|
||||
|
||||
// repo-scoping: enforced when agent doesn't have full bash
|
||||
if (bashPermission !== "enabled") {
|
||||
if (existsSync(resolved)) {
|
||||
const real = realpathSync(resolved);
|
||||
if (real !== cwd && !real.startsWith(cwd + "/")) {
|
||||
throw new Error(`path must be within the repository (symlink escape blocked): ${filePath}`);
|
||||
}
|
||||
} else {
|
||||
// target doesn't exist yet — walk up to find the first existing ancestor
|
||||
// and verify it resolves within the repo. prevents creating files through
|
||||
// symlinked parent directories.
|
||||
let ancestor = dirname(resolved);
|
||||
while (!existsSync(ancestor)) {
|
||||
const parent = dirname(ancestor);
|
||||
if (parent === ancestor) break;
|
||||
ancestor = parent;
|
||||
}
|
||||
if (existsSync(ancestor)) {
|
||||
const realAncestor = realpathSync(ancestor);
|
||||
if (realAncestor !== cwd && !realAncestor.startsWith(cwd + "/")) {
|
||||
throw new Error(
|
||||
`path must be within the repository (symlink escape blocked): ${filePath}`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (resolved !== cwd && !resolved.startsWith(cwd + "/")) {
|
||||
throw new Error(`path must be within the repository: ${filePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// .git always blocked anywhere in the path (defense-in-depth even with bash=enabled)
|
||||
if (resolved.includes("/.git/") || resolved.endsWith("/.git")) {
|
||||
throw new Error(`writing to .git is not allowed: ${filePath}`);
|
||||
}
|
||||
|
||||
// git-interpreted files blocked anywhere in the path when bash is disabled
|
||||
if (bashPermission === "disabled") {
|
||||
const basename = resolved.split("/").pop() || "";
|
||||
if (GIT_INTERPRETED_FILES.includes(basename)) {
|
||||
throw new Error(
|
||||
`writing to ${basename} is not allowed when bash is ${params.bashPermission} (can trigger code execution via git filter drivers): ${params.filePath}`
|
||||
`writing to ${basename} is not allowed when bash is ${bashPermission} (can trigger code execution via git filter drivers): ${filePath}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -129,10 +142,12 @@ function validateWritePath(params: ValidateWritePathParams): string {
|
||||
export function FileReadTool(_ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "file_read",
|
||||
description: `Read a file from the repository. Path is relative to the repository root. Only paths within the current repository are allowed.`,
|
||||
description:
|
||||
"Read a file. Path is relative to the repository root, or an absolute path " +
|
||||
"to read tool result files (diffs, CI logs, etc.) from the temp directory.",
|
||||
parameters: FileReadParams,
|
||||
execute: execute(async (params) => {
|
||||
const resolved = resolveAndValidatePath(params.path);
|
||||
const resolved = resolveReadPath(params.path);
|
||||
const raw = readFileSync(resolved, "utf-8");
|
||||
const lines = raw.split("\n");
|
||||
|
||||
@@ -156,13 +171,12 @@ export function FileReadTool(_ctx: ToolContext) {
|
||||
export function FileWriteTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "file_write",
|
||||
description: `Write content to a file in the repository. Path is relative to the repository root. Only paths within the current repository are allowed. Writes to .git/ are blocked. Creates parent directories if needed.`,
|
||||
description:
|
||||
"Write content to a file. Path is relative to the repository root. " +
|
||||
"Writes to .git/ are blocked. Creates parent directories if needed.",
|
||||
parameters: FileWriteParams,
|
||||
execute: execute(async (params) => {
|
||||
const resolved = validateWritePath({
|
||||
filePath: params.path,
|
||||
bashPermission: ctx.payload.bash,
|
||||
});
|
||||
const resolved = resolveWritePath(params.path, ctx.payload.bash);
|
||||
const dir = dirname(resolved);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(resolved, params.content, "utf-8");
|
||||
@@ -174,7 +188,10 @@ export function FileWriteTool(ctx: ToolContext) {
|
||||
export function FileEditTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "file_edit",
|
||||
description: `Replace text in a file. old_string must match exactly (including whitespace and indentation). By default replaces a single unique occurrence — set replace_all to replace every occurrence. Path is relative to the repository root. Writes to .git/ are blocked.`,
|
||||
description:
|
||||
"Replace text in a file. old_string must match exactly (including whitespace and indentation). " +
|
||||
"By default replaces a single unique occurrence — set replace_all to replace every occurrence. " +
|
||||
"Path is relative to the repository root. Writes to .git/ are blocked.",
|
||||
parameters: FileEditParams,
|
||||
execute: execute(async (params) => {
|
||||
if (params.old_string.length === 0) {
|
||||
@@ -184,10 +201,7 @@ export function FileEditTool(ctx: ToolContext) {
|
||||
throw new Error("old_string and new_string are identical");
|
||||
}
|
||||
|
||||
const resolved = validateWritePath({
|
||||
filePath: params.path,
|
||||
bashPermission: ctx.payload.bash,
|
||||
});
|
||||
const resolved = resolveWritePath(params.path, ctx.payload.bash);
|
||||
const content = readFileSync(resolved, "utf-8");
|
||||
const count = content.split(params.old_string).length - 1;
|
||||
|
||||
@@ -213,13 +227,12 @@ export function FileEditTool(ctx: ToolContext) {
|
||||
export function FileDeleteTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "file_delete",
|
||||
description: `Delete a file from the repository. Path is relative to the repository root. Only paths within the current repository are allowed. Deletes to .git/ are blocked. Cannot delete directories.`,
|
||||
description:
|
||||
"Delete a file. Path is relative to the repository root. " +
|
||||
"Deletes to .git/ are blocked. Cannot delete directories.",
|
||||
parameters: FileDeleteParams,
|
||||
execute: execute(async (params) => {
|
||||
const resolved = validateWritePath({
|
||||
filePath: params.path,
|
||||
bashPermission: ctx.payload.bash,
|
||||
});
|
||||
const resolved = resolveWritePath(params.path, ctx.payload.bash);
|
||||
unlinkSync(resolved);
|
||||
return { path: params.path, deleted: true };
|
||||
}),
|
||||
@@ -229,10 +242,12 @@ export function FileDeleteTool(ctx: ToolContext) {
|
||||
export function ListDirectoryTool(_ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "list_directory",
|
||||
description: `List files and directories. Path is relative to the repository root. Only paths within the current repository are allowed. Returns entries sorted with directories first, then alphabetically.`,
|
||||
description:
|
||||
"List files and directories. Path is relative to the repository root, or an absolute path " +
|
||||
"to list tool result files from the temp directory. Returns entries sorted with directories first, then alphabetically.",
|
||||
parameters: ListDirectoryParams,
|
||||
execute: execute(async (params) => {
|
||||
const resolved = resolveAndValidatePath(params.path);
|
||||
const resolved = resolveReadPath(params.path);
|
||||
const entries = readdirSync(resolved, { withFileTypes: true });
|
||||
const sorted = entries.sort((a, b) => {
|
||||
if (a.isDirectory() && !b.isDirectory()) return -1;
|
||||
|
||||
+38
-31
@@ -3,7 +3,7 @@ import { type } from "arktype";
|
||||
import { log } from "../utils/cli.ts";
|
||||
import { $git } from "../utils/gitAuth.ts";
|
||||
import { $ } from "../utils/shell.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import type { StoredPushDest, ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
type PushDestination = {
|
||||
@@ -14,37 +14,36 @@ type PushDestination = {
|
||||
|
||||
/**
|
||||
* get where git would actually push this branch.
|
||||
* uses git's native @{push} resolution, falls back to origin if unset.
|
||||
* prefers the stored destination from toolState (set by checkout_pr) when it
|
||||
* matches the current branch, because git config reads can silently fail in
|
||||
* certain environments causing pushes to the wrong remote branch.
|
||||
*
|
||||
* for branches created via checkout_pr: uses configured pushRemote/merge
|
||||
* for new branches (git checkout -b): falls back to origin/<branch>
|
||||
* falls back to reading branch.X.pushRemote and branch.X.merge from git config,
|
||||
* and finally to origin/<branch> for branches created without checkout_pr.
|
||||
*/
|
||||
function getPushDestination(branch: string): PushDestination {
|
||||
// try git's @{push} resolution first (works for checkout_pr branches)
|
||||
function getPushDestination(
|
||||
branch: string,
|
||||
storedDest: StoredPushDest | undefined
|
||||
): PushDestination {
|
||||
// prefer stored destination from checkout_pr when it matches the current branch
|
||||
if (storedDest && storedDest.localBranch === branch) {
|
||||
log.debug(`using stored push destination: ${storedDest.remoteName}/${storedDest.remoteBranch}`);
|
||||
const url = $("git", ["remote", "get-url", "--push", storedDest.remoteName], {
|
||||
log: false,
|
||||
}).trim();
|
||||
return { remoteName: storedDest.remoteName, remoteBranch: storedDest.remoteBranch, url };
|
||||
}
|
||||
|
||||
// fall back to git config (for branches not created by checkout_pr)
|
||||
try {
|
||||
const pushRef = $(
|
||||
"git",
|
||||
["rev-parse", "--abbrev-ref", "--symbolic-full-name", `${branch}@{push}`],
|
||||
{ log: false }
|
||||
).trim();
|
||||
|
||||
// pushRef is like "origin/main" or "pr-123/feature/foo"
|
||||
// parse carefully to handle branch names with slashes
|
||||
const slashIndex = pushRef.indexOf("/");
|
||||
if (slashIndex === -1) {
|
||||
throw new Error(`unexpected push ref format: ${pushRef}`);
|
||||
}
|
||||
const remoteName = pushRef.slice(0, slashIndex);
|
||||
const remoteBranch = pushRef.slice(slashIndex + 1);
|
||||
|
||||
// get the actual URL git would push to (handles remote.X.pushurl)
|
||||
const url = $("git", ["remote", "get-url", "--push", remoteName], { log: false }).trim();
|
||||
|
||||
return { remoteName, remoteBranch, url };
|
||||
const pushRemote = $("git", ["config", `branch.${branch}.pushRemote`], { log: false }).trim();
|
||||
const merge = $("git", ["config", `branch.${branch}.merge`], { log: false }).trim();
|
||||
const remoteBranch = merge.replace(/^refs\/heads\//, "");
|
||||
const url = $("git", ["remote", "get-url", "--push", pushRemote], { log: false }).trim();
|
||||
return { remoteName: pushRemote, remoteBranch, url };
|
||||
} catch {
|
||||
// @{push} not configured - branch was created locally without checkout_pr
|
||||
// fall back to origin with the same branch name
|
||||
log.debug(`no push tracking for ${branch}, falling back to origin/${branch}`);
|
||||
// no push config - branch was created locally without checkout_pr
|
||||
log.debug(`no push config for ${branch}, falling back to origin/${branch}`);
|
||||
const url = $("git", ["remote", "get-url", "--push", "origin"], { log: false }).trim();
|
||||
return { remoteName: "origin", remoteBranch: branch, url };
|
||||
}
|
||||
@@ -60,6 +59,7 @@ function normalizeUrl(url: string): string {
|
||||
type ValidatePushParams = {
|
||||
branch: string;
|
||||
pushUrl: string;
|
||||
storedDest: StoredPushDest | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -67,7 +67,7 @@ type ValidatePushParams = {
|
||||
* pushUrl is set by setupGit (base repo) and updated by checkout_pr (fork repo).
|
||||
*/
|
||||
function validatePushDestination(params: ValidatePushParams): PushDestination {
|
||||
const dest = getPushDestination(params.branch);
|
||||
const dest = getPushDestination(params.branch, params.storedDest);
|
||||
|
||||
if (normalizeUrl(dest.url) !== normalizeUrl(params.pushUrl)) {
|
||||
throw new Error(
|
||||
@@ -95,7 +95,10 @@ export function PushBranchTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
name: "push_branch",
|
||||
description:
|
||||
"Push the current branch (or specified branch) to the remote repository. Git automatically determines the correct remote based on branch config (set by checkout_pr for fork PRs). Never force push unless explicitly requested. Pushes to the default branch are blocked in restricted mode.",
|
||||
"Push the current branch to the remote repository. Omit branchName to push the current branch (recommended). " +
|
||||
"If specifying branchName, use the LOCAL branch name (e.g., 'pr-1'), not the remote branch name. " +
|
||||
"The correct remote and remote branch are determined automatically from branch config set by checkout_pr. " +
|
||||
"Never force push unless explicitly requested. Pushes to the default branch are blocked in restricted mode.",
|
||||
parameters: PushBranch,
|
||||
execute: execute(async ({ branchName, force }) => {
|
||||
// permission check
|
||||
@@ -110,7 +113,11 @@ export function PushBranchTool(ctx: ToolContext) {
|
||||
if (!pushUrl) {
|
||||
throw new Error("pushUrl not set - setupGit must run before push_branch");
|
||||
}
|
||||
const pushDest = validatePushDestination({ branch, pushUrl });
|
||||
const pushDest = validatePushDestination({
|
||||
branch,
|
||||
pushUrl,
|
||||
storedDest: ctx.toolState.pushDest,
|
||||
});
|
||||
|
||||
// block pushes to default branch in restricted mode
|
||||
if (pushPermission === "restricted" && pushDest.remoteBranch === defaultBranch) {
|
||||
|
||||
@@ -44,6 +44,22 @@ export function CreatePullRequestTool(ctx: ToolContext) {
|
||||
base: base,
|
||||
});
|
||||
|
||||
// best-effort: request review from the user who triggered the workflow
|
||||
const reviewer = ctx.payload.triggeringUser;
|
||||
if (reviewer) {
|
||||
try {
|
||||
log.debug(`Requesting review from ${reviewer} on PR #${result.data.number}`);
|
||||
await ctx.octokit.rest.pulls.requestReviewers({
|
||||
owner: ctx.repo.owner,
|
||||
repo: ctx.repo.name,
|
||||
pull_number: result.data.number,
|
||||
reviewers: [reviewer],
|
||||
});
|
||||
} catch {
|
||||
log.info(`failed to request review from ${reviewer} on PR #${result.data.number}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
pullRequestId: result.data.id,
|
||||
|
||||
@@ -290,7 +290,8 @@ function hasThumbsUpFrom(comment: ReviewThreadComment, username: string): boolea
|
||||
if (!comment.reactionGroups) return false;
|
||||
const thumbsUp = comment.reactionGroups.find((g) => g.content === "THUMBS_UP");
|
||||
if (!thumbsUp?.reactors?.nodes) return false;
|
||||
return thumbsUp.reactors.nodes.some((r) => r?.login === username);
|
||||
const usernameNeedle = username.toLowerCase();
|
||||
return thumbsUp.reactors.nodes.some((r) => r?.login?.toLowerCase() === usernameNeedle);
|
||||
}
|
||||
|
||||
function threadHasThumbsUpFrom(thread: ReviewThread, username: string): boolean {
|
||||
@@ -631,7 +632,7 @@ export function ResolveReviewThreadTool(ctx: ToolContext) {
|
||||
const message = isResolved
|
||||
? `thread ${params.thread_id} was already resolved`
|
||||
: `failed to resolve thread ${params.thread_id}: ${errorMessage}`;
|
||||
log.warning(message);
|
||||
log.info(message);
|
||||
|
||||
return {
|
||||
thread_id: params.thread_id,
|
||||
|
||||
+15
-7
@@ -15,10 +15,19 @@ export type BackgroundProcess = {
|
||||
pidPath: string;
|
||||
};
|
||||
|
||||
export type StoredPushDest = {
|
||||
remoteName: string;
|
||||
remoteBranch: string;
|
||||
localBranch: string;
|
||||
};
|
||||
|
||||
export interface ToolState {
|
||||
// where we're allowed to push - base repo initially, fork URL for fork PRs
|
||||
// set by setupGit, updated by checkout_pr. always set before push validation.
|
||||
pushUrl?: string;
|
||||
// push destination set by checkout_pr - used as primary source in push_branch
|
||||
// because git config reads can fail in certain environments
|
||||
pushDest?: StoredPushDest;
|
||||
// issue or PR number (same number space in GitHub)
|
||||
issueNumber?: number;
|
||||
selectedMode?: string;
|
||||
@@ -34,7 +43,8 @@ export interface ToolState {
|
||||
promise: Promise<PrepResult[]> | undefined;
|
||||
results: PrepResult[] | undefined;
|
||||
};
|
||||
progressCommentId: number | null;
|
||||
// undefined = no comment yet, number = active comment, null = deliberately deleted
|
||||
progressCommentId: number | null | undefined;
|
||||
lastProgressBody?: string;
|
||||
wasUpdated?: boolean;
|
||||
output?: string;
|
||||
@@ -45,13 +55,11 @@ interface InitToolStateParams {
|
||||
}
|
||||
|
||||
export function initToolState(params: InitToolStateParams): ToolState {
|
||||
const progressCommentId = params.progressCommentId
|
||||
? parseInt(params.progressCommentId, 10)
|
||||
: null;
|
||||
const resolvedId = Number.isNaN(progressCommentId) ? null : progressCommentId;
|
||||
const parsed = params.progressCommentId ? parseInt(params.progressCommentId, 10) : NaN;
|
||||
const resolvedId = Number.isNaN(parsed) ? undefined : parsed;
|
||||
|
||||
if (progressCommentId) {
|
||||
log.info(`» using pre-created progress comment: ${progressCommentId}`);
|
||||
if (resolvedId) {
|
||||
log.info(`» using pre-created progress comment: ${resolvedId}`);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
+1
-1
@@ -53,7 +53,7 @@ export const execute = <T, R extends Record<string, any> | string>(
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const prefix = toolName ? `[${toolName}]` : "tool";
|
||||
log.error(`${prefix} error: ${errorMessage}`);
|
||||
log.info(`${prefix} error: ${errorMessage}`);
|
||||
log.debug(`${prefix} params: ${formatJsonValue(params)}`);
|
||||
return handleToolError(error);
|
||||
}
|
||||
|
||||
+3
-5
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import { type } from "arktype";
|
||||
import { fileTypeFromBuffer } from "file-type";
|
||||
import { getApiUrl, getVercelBypassHeaders } from "../utils/apiUrl.ts";
|
||||
import { apiFetch } from "../utils/apiFetch.ts";
|
||||
import type { ToolContext } from "./server.ts";
|
||||
import { execute, tool } from "./shared.ts";
|
||||
|
||||
@@ -25,14 +25,12 @@ export function UploadFileTool(ctx: ToolContext) {
|
||||
const fileType = await fileTypeFromBuffer(buffer);
|
||||
const contentType = fileType?.mime || "application/octet-stream";
|
||||
|
||||
const apiUrl = getApiUrl();
|
||||
|
||||
const response = await fetch(`${apiUrl}/api/upload/signed-url`, {
|
||||
const response = await apiFetch({
|
||||
path: "/api/upload/signed-url",
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${ctx.apiToken}`,
|
||||
"Content-Type": "application/json",
|
||||
...getVercelBypassHeaders(),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filename,
|
||||
|
||||
@@ -99,7 +99,7 @@ export function computeModes(): Mode[] {
|
||||
name: "Review",
|
||||
description:
|
||||
"Review code, PRs, or implementations; provide feedback or suggestions; identify issues; or check code quality, style, and correctness",
|
||||
prompt: `Follow these steps to review the PR. Your job is to find problems—assume they exist until you've proven otherwise. Do not submit a clean review without thorough investigation.
|
||||
prompt: `Follow these steps to review the PR. Your job is to find problems—assume they exist until you've proven otherwise. Do not submit a clean review without thorough investigation. **If you have nothing interesting to say, do NOT submit a review at all—use \`report_progress\` instead.**
|
||||
|
||||
1. **CHECKOUT** - Call ${ghPullfrogMcpName}/checkout_pr with the PR number. This should give you all PR metadata you need, including a \`diffPath\`: a path to a temp file containing the PR diff.
|
||||
|
||||
@@ -113,21 +113,17 @@ export function computeModes(): Mode[] {
|
||||
- **Explore failure modes**: What if this throws? What if that returns null? What if the network fails? What if this runs twice?
|
||||
- **Verify assumptions**: If the code assumes X, verify X is actually true. Use grep, read related files, check documentation.
|
||||
- **Consider lifecycle**: Initialization, cleanup, error recovery. Are resources acquired before use? Released after? What happens on cancellation?
|
||||
- **Spot performance issues**: Nested loops over large collections, blocking I/O, memory leaks, excessive object creation in hot paths, inefficient array operations (e.g., repeated \`.find()\` in a loop).
|
||||
- **Check PR consistency**: Does the PR title/description match the actual code changes? Flag significant discrepancies.
|
||||
- Do NOT stop at "this looks reasonable." Dig until you either find a problem or have concrete evidence there isn't one.
|
||||
|
||||
4. **DRAFT LINE-BY-LINE COMMENTS** - For each issue found, draft an inline comment on the specific line. Use the NEW line number from the diff (second column: \`| OLD | NEW | TYPE | CODE\`). If no issues found, skip to step 6.
|
||||
4. **DRAFT LINE-BY-LINE COMMENTS** - Every comment must be actionable: the author should need to change something in response. 2-3 sentences max. Use the NEW line number from the diff (second column: \`| OLD | NEW | TYPE | CODE\`). If no issues found, skip to step 5. Non-actionable comments (praise, style preferences, minor optimizations, documentation nits) must not be drafted. If no comments survive and you have no significant concerns, **do not submit a review**. Use \`${ghPullfrogMcpName}/report_progress\` to note the PR was reviewed and no issues were found.
|
||||
|
||||
5. **FILTER LINE-BY-LINE COMMENTS** - Each inline comment must be actionable. Remove anything that doesn't require action:
|
||||
- **Not actionable → no comment**: Do NOT create inline comments for compliments (e.g., "this looks clean", "nice refactor") or general observations. These waste reviewer attention.
|
||||
- **Actionable by agent → keep**: Bugs, logic errors, missing error handling, security issues, race conditions, resource leaks, incorrect assumptions.
|
||||
- **Requires human decision → keep**: If something needs human judgment (architectural choice, product decision, tradeoff evaluation), create a comment clearly stating what decision is needed and why.
|
||||
- Remove style-only comments (formatting, naming conventions) unless they cause real confusion.
|
||||
5. **WRITE SUMMARY** - Draft a 1-3 sentence summary for the review body. Include urgency level and any concerns about code outside the diff.
|
||||
|
||||
6. **WRITE SUMMARY** - Draft a 1-3 sentence summary for the review body. Include urgency level and any concerns about code outside the diff.
|
||||
|
||||
7. **SUBMIT** — Use ${ghPullfrogMcpName}/create_pull_request_review:
|
||||
- \`body\`: The summary from step 6
|
||||
- \`comments\`: The filtered inline comments from step 5
|
||||
6. **SUBMIT** — Use ${ghPullfrogMcpName}/create_pull_request_review:
|
||||
- \`body\`: The summary from step 5
|
||||
- \`comments\`: The inline comments from step 4
|
||||
|
||||
${permalinkTip}
|
||||
`,
|
||||
|
||||
+6
-3
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@pullfrog/pullfrog",
|
||||
"version": "0.0.162",
|
||||
"version": "0.0.165",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"index.js",
|
||||
@@ -20,7 +20,8 @@
|
||||
"runtest": "node test/run.ts",
|
||||
"scratch": "node scratch.ts",
|
||||
"upDeps": "pnpm up --latest",
|
||||
"lock": "pnpm --ignore-workspace install --no-frozen-lockfile",
|
||||
"lock": "pnpm install --no-frozen-lockfile",
|
||||
"postinstall": "node scripts/generate-proxies.ts",
|
||||
"prepare": "cd .. && husky action/.husky"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -79,7 +80,9 @@
|
||||
"types": "./dist/index.d.cts",
|
||||
"import": "./dist/index.js",
|
||||
"require": "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"./internal": "./dist/internal.js",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a"
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import arg from "arg";
|
||||
import { config } from "dotenv";
|
||||
import type { AgentResult } from "./agents/shared.ts";
|
||||
@@ -10,7 +10,9 @@ import { type Inputs, main } from "./main.ts";
|
||||
import { defineFixture } from "./test/utils.ts";
|
||||
import { log } from "./utils/cli.ts";
|
||||
import { runInDocker } from "./utils/docker.ts";
|
||||
import { ensureGitHubToken } from "./utils/github.ts";
|
||||
import { isInsideDocker } from "./utils/globals.ts";
|
||||
import { runPostCleanup } from "./utils/postCleanup.ts";
|
||||
import { setupTestRepo } from "./utils/setup.ts";
|
||||
|
||||
/**
|
||||
@@ -34,6 +36,8 @@ config();
|
||||
config({ path: join(__dirname, "..", ".env") });
|
||||
|
||||
export async function run(inputsOrPrompt: Inputs | string): Promise<AgentResult> {
|
||||
await ensureGitHubToken();
|
||||
|
||||
// create unique temp directory path in OS temp location for parallel execution
|
||||
// use a parent dir from mkdtemp, then clone into a 'repo' subdirectory
|
||||
const tempParent = await mkdtemp(join(tmpdir(), "pullfrog-play-"));
|
||||
@@ -65,7 +69,13 @@ export async function run(inputsOrPrompt: Inputs | string): Promise<AgentResult>
|
||||
}
|
||||
}
|
||||
|
||||
const result = await main();
|
||||
// wrap main() so post cleanup runs even on failure (mirrors action.yml post-if: "failure() || cancelled()")
|
||||
let result: AgentResult;
|
||||
try {
|
||||
result = await main();
|
||||
} finally {
|
||||
await runPostCleanup();
|
||||
}
|
||||
|
||||
process.chdir(originalCwd);
|
||||
|
||||
@@ -92,7 +102,11 @@ export async function run(inputsOrPrompt: Inputs | string): Promise<AgentResult>
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
const isDirectExecution = process.argv[1]
|
||||
? import.meta.url === pathToFileURL(resolve(process.argv[1])).href
|
||||
: false;
|
||||
|
||||
if (isDirectExecution) {
|
||||
const args = arg({
|
||||
"--help": Boolean,
|
||||
"--raw": String,
|
||||
|
||||
Generated
+38
@@ -4,6 +4,8 @@ settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
packageExtensionsChecksum: sha256-Ae6BTffLg0DiuEWVZSk6skwAhBSw9mfAk50E5Iq3i80=
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@@ -120,6 +122,15 @@ packages:
|
||||
peerDependencies:
|
||||
zod: ^4.0.0
|
||||
|
||||
'@anthropic-ai/sdk@0.77.0':
|
||||
resolution: {integrity: sha512-TivlT6nfidz3sOyMF72T2x5AkmHrpT7JgL2e/0HNdh7b24v7JC8cR+rCY/42jA68xIsjmiGQ5IKMsH9feEKh3A==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
zod: ^3.25.0 || ^4.0.0
|
||||
peerDependenciesMeta:
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
'@ark/fs@0.56.0':
|
||||
resolution: {integrity: sha512-zY/wDDhcvmt6/upQwZM766PAnvIzdEMcgydUGd9pqY9FMGNo9I9uE4RYAfms9AeUUtbZJu2h2Ua0tvFsO5XF4Q==}
|
||||
|
||||
@@ -129,6 +140,10 @@ packages:
|
||||
'@ark/util@0.56.0':
|
||||
resolution: {integrity: sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==}
|
||||
|
||||
'@babel/runtime@7.28.6':
|
||||
resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
|
||||
'@borewit/text-codec@0.1.1':
|
||||
resolution: {integrity: sha512-5L/uBxmjaCIX5h8Z+uu+kA9BQLkc/Wl06UGR5ajNRxu+/XjonB5i8JpgFMrPj3LXTCPA0pv8yxUvbUi+QthGGA==}
|
||||
|
||||
@@ -1182,6 +1197,10 @@ packages:
|
||||
jose@6.1.3:
|
||||
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
|
||||
|
||||
json-schema-to-ts@3.1.1:
|
||||
resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
json-schema-traverse@1.0.0:
|
||||
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||
|
||||
@@ -1454,6 +1473,9 @@ packages:
|
||||
resolution: {integrity: sha512-kh9LVIWH5CnL63Ipf0jhlBIy0UsrMj/NJDfpsy1SqOXlLKEVyXXYrnFxFT1yOOYVGBSApeVnjPw/sBz5BfEjAQ==}
|
||||
engines: {node: '>=14.16'}
|
||||
|
||||
ts-algebra@2.0.0:
|
||||
resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
|
||||
|
||||
tunnel@0.0.6:
|
||||
resolution: {integrity: sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg==}
|
||||
engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'}
|
||||
@@ -1666,6 +1688,7 @@ snapshots:
|
||||
|
||||
'@anthropic-ai/claude-agent-sdk@0.2.39(zod@4.3.5)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.77.0(zod@4.3.5)
|
||||
zod: 4.3.5
|
||||
optionalDependencies:
|
||||
'@img/sharp-darwin-arm64': 0.33.5
|
||||
@@ -1677,6 +1700,12 @@ snapshots:
|
||||
'@img/sharp-linuxmusl-x64': 0.33.5
|
||||
'@img/sharp-win32-x64': 0.33.5
|
||||
|
||||
'@anthropic-ai/sdk@0.77.0(zod@4.3.5)':
|
||||
dependencies:
|
||||
json-schema-to-ts: 3.1.1
|
||||
optionalDependencies:
|
||||
zod: 4.3.5
|
||||
|
||||
'@ark/fs@0.56.0': {}
|
||||
|
||||
'@ark/schema@0.56.0':
|
||||
@@ -1685,6 +1714,8 @@ snapshots:
|
||||
|
||||
'@ark/util@0.56.0': {}
|
||||
|
||||
'@babel/runtime@7.28.6': {}
|
||||
|
||||
'@borewit/text-codec@0.1.1': {}
|
||||
|
||||
'@esbuild/aix-ppc64@0.25.12':
|
||||
@@ -2589,6 +2620,11 @@ snapshots:
|
||||
|
||||
jose@6.1.3: {}
|
||||
|
||||
json-schema-to-ts@3.1.1:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
ts-algebra: 2.0.0
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
|
||||
json-schema-typed@8.0.2: {}
|
||||
@@ -2871,6 +2907,8 @@ snapshots:
|
||||
'@tokenizer/token': 0.3.0
|
||||
ieee754: 1.2.1
|
||||
|
||||
ts-algebra@2.0.0: {}
|
||||
|
||||
tunnel@0.0.6: {}
|
||||
|
||||
turndown@7.2.2:
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
packages: [] # prevent looking upwards for the workspace root
|
||||
|
||||
packageExtensions:
|
||||
"@anthropic-ai/claude-agent-sdk":
|
||||
dependencies:
|
||||
"@anthropic-ai/sdk": "*"
|
||||
@@ -399,7 +399,7 @@ var require_tunnel = __commonJS({
|
||||
connectOptions.headers = connectOptions.headers || {};
|
||||
connectOptions.headers["Proxy-Authorization"] = "Basic " + new Buffer(connectOptions.proxyAuth).toString("base64");
|
||||
}
|
||||
debug("making CONNECT request");
|
||||
debug2("making CONNECT request");
|
||||
var connectReq = self2.request(connectOptions);
|
||||
connectReq.useChunkedEncodingByDefault = false;
|
||||
connectReq.once("response", onResponse);
|
||||
@@ -419,7 +419,7 @@ var require_tunnel = __commonJS({
|
||||
connectReq.removeAllListeners();
|
||||
socket.removeAllListeners();
|
||||
if (res.statusCode !== 200) {
|
||||
debug(
|
||||
debug2(
|
||||
"tunneling socket could not be established, statusCode=%d",
|
||||
res.statusCode
|
||||
);
|
||||
@@ -431,7 +431,7 @@ var require_tunnel = __commonJS({
|
||||
return;
|
||||
}
|
||||
if (head.length > 0) {
|
||||
debug("got illegal response body from proxy");
|
||||
debug2("got illegal response body from proxy");
|
||||
socket.destroy();
|
||||
var error2 = new Error("got illegal response body from proxy");
|
||||
error2.code = "ECONNRESET";
|
||||
@@ -439,13 +439,13 @@ var require_tunnel = __commonJS({
|
||||
self2.removeSocket(placeholder);
|
||||
return;
|
||||
}
|
||||
debug("tunneling connection has established");
|
||||
debug2("tunneling connection has established");
|
||||
self2.sockets[self2.sockets.indexOf(placeholder)] = socket;
|
||||
return cb(socket);
|
||||
}
|
||||
function onError(cause) {
|
||||
connectReq.removeAllListeners();
|
||||
debug(
|
||||
debug2(
|
||||
"tunneling socket could not be established, cause=%s\n",
|
||||
cause.message,
|
||||
cause.stack
|
||||
@@ -507,9 +507,9 @@ var require_tunnel = __commonJS({
|
||||
}
|
||||
return target;
|
||||
}
|
||||
var debug;
|
||||
var debug2;
|
||||
if (process.env.NODE_DEBUG && /\btunnel\b/.test(process.env.NODE_DEBUG)) {
|
||||
debug = function() {
|
||||
debug2 = function() {
|
||||
var args2 = Array.prototype.slice.call(arguments);
|
||||
if (typeof args2[0] === "string") {
|
||||
args2[0] = "TUNNEL: " + args2[0];
|
||||
@@ -519,10 +519,10 @@ var require_tunnel = __commonJS({
|
||||
console.error.apply(console, args2);
|
||||
};
|
||||
} else {
|
||||
debug = function() {
|
||||
debug2 = function() {
|
||||
};
|
||||
}
|
||||
exports.debug = debug;
|
||||
exports.debug = debug2;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -19733,10 +19733,10 @@ Support boolean input list: \`true | True | TRUE | false | False | FALSE\``);
|
||||
return process.env["RUNNER_DEBUG"] === "1";
|
||||
}
|
||||
exports.isDebug = isDebug2;
|
||||
function debug(message) {
|
||||
function debug2(message) {
|
||||
(0, command_1.issueCommand)("debug", {}, message);
|
||||
}
|
||||
exports.debug = debug;
|
||||
exports.debug = debug2;
|
||||
function error2(message, properties = {}) {
|
||||
(0, command_1.issueCommand)("error", (0, utils_1.toCommandProperties)(properties), message instanceof Error ? message.toString() : message);
|
||||
}
|
||||
@@ -25838,7 +25838,7 @@ var require_light = __commonJS({
|
||||
}
|
||||
return this.Events.trigger("scheduled", { args: this.args, options: this.options });
|
||||
}
|
||||
async doExecute(chained, clearGlobalState, run2, free) {
|
||||
async doExecute(chained, clearGlobalState, run, free) {
|
||||
var error2, eventInfo, passed;
|
||||
if (this.retryCount === 0) {
|
||||
this._assertStatus("RUNNING");
|
||||
@@ -25858,10 +25858,10 @@ var require_light = __commonJS({
|
||||
}
|
||||
} catch (error1) {
|
||||
error2 = error1;
|
||||
return this._onFailure(error2, eventInfo, clearGlobalState, run2, free);
|
||||
return this._onFailure(error2, eventInfo, clearGlobalState, run, free);
|
||||
}
|
||||
}
|
||||
doExpire(clearGlobalState, run2, free) {
|
||||
doExpire(clearGlobalState, run, free) {
|
||||
var error2, eventInfo;
|
||||
if (this._states.jobStatus(this.options.id === "RUNNING")) {
|
||||
this._states.next(this.options.id);
|
||||
@@ -25869,9 +25869,9 @@ var require_light = __commonJS({
|
||||
this._assertStatus("EXECUTING");
|
||||
eventInfo = { args: this.args, options: this.options, retryCount: this.retryCount };
|
||||
error2 = new BottleneckError$1(`This job timed out after ${this.options.expiration} ms.`);
|
||||
return this._onFailure(error2, eventInfo, clearGlobalState, run2, free);
|
||||
return this._onFailure(error2, eventInfo, clearGlobalState, run, free);
|
||||
}
|
||||
async _onFailure(error2, eventInfo, clearGlobalState, run2, free) {
|
||||
async _onFailure(error2, eventInfo, clearGlobalState, run, free) {
|
||||
var retry2, retryAfter;
|
||||
if (clearGlobalState()) {
|
||||
retry2 = await this.Events.trigger("failed", error2, eventInfo);
|
||||
@@ -25879,7 +25879,7 @@ var require_light = __commonJS({
|
||||
retryAfter = ~~retry2;
|
||||
this.Events.trigger("retry", `Retrying ${this.options.id} after ${retryAfter} ms`, eventInfo);
|
||||
this.retryCount++;
|
||||
return run2(retryAfter);
|
||||
return run(retryAfter);
|
||||
} else {
|
||||
this.doDone(eventInfo);
|
||||
await free(this.options, eventInfo);
|
||||
@@ -26517,17 +26517,17 @@ var require_light = __commonJS({
|
||||
}
|
||||
}
|
||||
_run(index, job, wait) {
|
||||
var clearGlobalState, free, run2;
|
||||
var clearGlobalState, free, run;
|
||||
job.doRun();
|
||||
clearGlobalState = this._clearGlobalState.bind(this, index);
|
||||
run2 = this._run.bind(this, index, job);
|
||||
run = this._run.bind(this, index, job);
|
||||
free = this._free.bind(this, index, job);
|
||||
return this._scheduled[index] = {
|
||||
timeout: setTimeout(() => {
|
||||
return job.doExecute(this._limiter, clearGlobalState, run2, free);
|
||||
return job.doExecute(this._limiter, clearGlobalState, run, free);
|
||||
}, wait),
|
||||
expiration: job.options.expiration != null ? setTimeout(function() {
|
||||
return job.doExpire(clearGlobalState, run2, free);
|
||||
return job.doExpire(clearGlobalState, run, free);
|
||||
}, wait + job.options.expiration) : void 0,
|
||||
job
|
||||
};
|
||||
@@ -26949,9 +26949,9 @@ var require_constants6 = __commonJS({
|
||||
var require_debug = __commonJS({
|
||||
"node_modules/.pnpm/semver@7.7.3/node_modules/semver/internal/debug.js"(exports, module) {
|
||||
"use strict";
|
||||
var debug = typeof process === "object" && process.env && process.env.NODE_DEBUG && /\bsemver\b/i.test(process.env.NODE_DEBUG) ? (...args2) => console.error("SEMVER", ...args2) : () => {
|
||||
var debug2 = typeof process === "object" && process.env && process.env.NODE_DEBUG && /\bsemver\b/i.test(process.env.NODE_DEBUG) ? (...args2) => console.error("SEMVER", ...args2) : () => {
|
||||
};
|
||||
module.exports = debug;
|
||||
module.exports = debug2;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -26964,7 +26964,7 @@ var require_re = __commonJS({
|
||||
MAX_SAFE_BUILD_LENGTH,
|
||||
MAX_LENGTH
|
||||
} = require_constants6();
|
||||
var debug = require_debug();
|
||||
var debug2 = require_debug();
|
||||
exports = module.exports = {};
|
||||
var re = exports.re = [];
|
||||
var safeRe = exports.safeRe = [];
|
||||
@@ -26987,7 +26987,7 @@ var require_re = __commonJS({
|
||||
var createToken = (name, value2, isGlobal) => {
|
||||
const safe = makeSafeRegex(value2);
|
||||
const index = R++;
|
||||
debug(name, index, value2);
|
||||
debug2(name, index, value2);
|
||||
t[name] = index;
|
||||
src[index] = value2;
|
||||
safeSrc[index] = safe;
|
||||
@@ -27091,7 +27091,7 @@ var require_identifiers = __commonJS({
|
||||
var require_semver = __commonJS({
|
||||
"node_modules/.pnpm/semver@7.7.3/node_modules/semver/classes/semver.js"(exports, module) {
|
||||
"use strict";
|
||||
var debug = require_debug();
|
||||
var debug2 = require_debug();
|
||||
var { MAX_LENGTH, MAX_SAFE_INTEGER } = require_constants6();
|
||||
var { safeRe: re, t } = require_re();
|
||||
var parseOptions = require_parse_options();
|
||||
@@ -27113,7 +27113,7 @@ var require_semver = __commonJS({
|
||||
`version is longer than ${MAX_LENGTH} characters`
|
||||
);
|
||||
}
|
||||
debug("SemVer", version, options);
|
||||
debug2("SemVer", version, options);
|
||||
this.options = options;
|
||||
this.loose = !!options.loose;
|
||||
this.includePrerelease = !!options.includePrerelease;
|
||||
@@ -27161,7 +27161,7 @@ var require_semver = __commonJS({
|
||||
return this.version;
|
||||
}
|
||||
compare(other) {
|
||||
debug("SemVer.compare", this.version, this.options, other);
|
||||
debug2("SemVer.compare", this.version, this.options, other);
|
||||
if (!(other instanceof _SemVer)) {
|
||||
if (typeof other === "string" && other === this.version) {
|
||||
return 0;
|
||||
@@ -27212,7 +27212,7 @@ var require_semver = __commonJS({
|
||||
do {
|
||||
const a = this.prerelease[i];
|
||||
const b = other.prerelease[i];
|
||||
debug("prerelease compare", i, a, b);
|
||||
debug2("prerelease compare", i, a, b);
|
||||
if (a === void 0 && b === void 0) {
|
||||
return 0;
|
||||
} else if (b === void 0) {
|
||||
@@ -27234,7 +27234,7 @@ var require_semver = __commonJS({
|
||||
do {
|
||||
const a = this.build[i];
|
||||
const b = other.build[i];
|
||||
debug("build compare", i, a, b);
|
||||
debug2("build compare", i, a, b);
|
||||
if (a === void 0 && b === void 0) {
|
||||
return 0;
|
||||
} else if (b === void 0) {
|
||||
@@ -27862,21 +27862,21 @@ var require_range = __commonJS({
|
||||
const loose = this.options.loose;
|
||||
const hr = loose ? re[t.HYPHENRANGELOOSE] : re[t.HYPHENRANGE];
|
||||
range2 = range2.replace(hr, hyphenReplace(this.options.includePrerelease));
|
||||
debug("hyphen replace", range2);
|
||||
debug2("hyphen replace", range2);
|
||||
range2 = range2.replace(re[t.COMPARATORTRIM], comparatorTrimReplace);
|
||||
debug("comparator trim", range2);
|
||||
debug2("comparator trim", range2);
|
||||
range2 = range2.replace(re[t.TILDETRIM], tildeTrimReplace);
|
||||
debug("tilde trim", range2);
|
||||
debug2("tilde trim", range2);
|
||||
range2 = range2.replace(re[t.CARETTRIM], caretTrimReplace);
|
||||
debug("caret trim", range2);
|
||||
debug2("caret trim", range2);
|
||||
let rangeList = range2.split(" ").map((comp) => parseComparator(comp, this.options)).join(" ").split(/\s+/).map((comp) => replaceGTE0(comp, this.options));
|
||||
if (loose) {
|
||||
rangeList = rangeList.filter((comp) => {
|
||||
debug("loose invalid filter", comp, this.options);
|
||||
debug2("loose invalid filter", comp, this.options);
|
||||
return !!comp.match(re[t.COMPARATORLOOSE]);
|
||||
});
|
||||
}
|
||||
debug("range list", rangeList);
|
||||
debug2("range list", rangeList);
|
||||
const rangeMap = /* @__PURE__ */ new Map();
|
||||
const comparators = rangeList.map((comp) => new Comparator(comp, this.options));
|
||||
for (const comp of comparators) {
|
||||
@@ -27931,7 +27931,7 @@ var require_range = __commonJS({
|
||||
var cache = new LRU();
|
||||
var parseOptions = require_parse_options();
|
||||
var Comparator = require_comparator();
|
||||
var debug = require_debug();
|
||||
var debug2 = require_debug();
|
||||
var SemVer = require_semver();
|
||||
var {
|
||||
safeRe: re,
|
||||
@@ -27957,15 +27957,15 @@ var require_range = __commonJS({
|
||||
};
|
||||
var parseComparator = (comp, options) => {
|
||||
comp = comp.replace(re[t.BUILD], "");
|
||||
debug("comp", comp, options);
|
||||
debug2("comp", comp, options);
|
||||
comp = replaceCarets(comp, options);
|
||||
debug("caret", comp);
|
||||
debug2("caret", comp);
|
||||
comp = replaceTildes(comp, options);
|
||||
debug("tildes", comp);
|
||||
debug2("tildes", comp);
|
||||
comp = replaceXRanges(comp, options);
|
||||
debug("xrange", comp);
|
||||
debug2("xrange", comp);
|
||||
comp = replaceStars(comp, options);
|
||||
debug("stars", comp);
|
||||
debug2("stars", comp);
|
||||
return comp;
|
||||
};
|
||||
var isX = (id) => !id || id.toLowerCase() === "x" || id === "*";
|
||||
@@ -27975,7 +27975,7 @@ var require_range = __commonJS({
|
||||
var replaceTilde = (comp, options) => {
|
||||
const r = options.loose ? re[t.TILDELOOSE] : re[t.TILDE];
|
||||
return comp.replace(r, (_, M, m, p, pr) => {
|
||||
debug("tilde", comp, _, M, m, p, pr);
|
||||
debug2("tilde", comp, _, M, m, p, pr);
|
||||
let ret;
|
||||
if (isX(M)) {
|
||||
ret = "";
|
||||
@@ -27984,12 +27984,12 @@ var require_range = __commonJS({
|
||||
} else if (isX(p)) {
|
||||
ret = `>=${M}.${m}.0 <${M}.${+m + 1}.0-0`;
|
||||
} else if (pr) {
|
||||
debug("replaceTilde pr", pr);
|
||||
debug2("replaceTilde pr", pr);
|
||||
ret = `>=${M}.${m}.${p}-${pr} <${M}.${+m + 1}.0-0`;
|
||||
} else {
|
||||
ret = `>=${M}.${m}.${p} <${M}.${+m + 1}.0-0`;
|
||||
}
|
||||
debug("tilde return", ret);
|
||||
debug2("tilde return", ret);
|
||||
return ret;
|
||||
});
|
||||
};
|
||||
@@ -27997,11 +27997,11 @@ var require_range = __commonJS({
|
||||
return comp.trim().split(/\s+/).map((c) => replaceCaret(c, options)).join(" ");
|
||||
};
|
||||
var replaceCaret = (comp, options) => {
|
||||
debug("caret", comp, options);
|
||||
debug2("caret", comp, options);
|
||||
const r = options.loose ? re[t.CARETLOOSE] : re[t.CARET];
|
||||
const z = options.includePrerelease ? "-0" : "";
|
||||
return comp.replace(r, (_, M, m, p, pr) => {
|
||||
debug("caret", comp, _, M, m, p, pr);
|
||||
debug2("caret", comp, _, M, m, p, pr);
|
||||
let ret;
|
||||
if (isX(M)) {
|
||||
ret = "";
|
||||
@@ -28014,7 +28014,7 @@ var require_range = __commonJS({
|
||||
ret = `>=${M}.${m}.0${z} <${+M + 1}.0.0-0`;
|
||||
}
|
||||
} else if (pr) {
|
||||
debug("replaceCaret pr", pr);
|
||||
debug2("replaceCaret pr", pr);
|
||||
if (M === "0") {
|
||||
if (m === "0") {
|
||||
ret = `>=${M}.${m}.${p}-${pr} <${M}.${m}.${+p + 1}-0`;
|
||||
@@ -28025,7 +28025,7 @@ var require_range = __commonJS({
|
||||
ret = `>=${M}.${m}.${p}-${pr} <${+M + 1}.0.0-0`;
|
||||
}
|
||||
} else {
|
||||
debug("no pr");
|
||||
debug2("no pr");
|
||||
if (M === "0") {
|
||||
if (m === "0") {
|
||||
ret = `>=${M}.${m}.${p}${z} <${M}.${m}.${+p + 1}-0`;
|
||||
@@ -28036,19 +28036,19 @@ var require_range = __commonJS({
|
||||
ret = `>=${M}.${m}.${p} <${+M + 1}.0.0-0`;
|
||||
}
|
||||
}
|
||||
debug("caret return", ret);
|
||||
debug2("caret return", ret);
|
||||
return ret;
|
||||
});
|
||||
};
|
||||
var replaceXRanges = (comp, options) => {
|
||||
debug("replaceXRanges", comp, options);
|
||||
debug2("replaceXRanges", comp, options);
|
||||
return comp.split(/\s+/).map((c) => replaceXRange(c, options)).join(" ");
|
||||
};
|
||||
var replaceXRange = (comp, options) => {
|
||||
comp = comp.trim();
|
||||
const r = options.loose ? re[t.XRANGELOOSE] : re[t.XRANGE];
|
||||
return comp.replace(r, (ret, gtlt, M, m, p, pr) => {
|
||||
debug("xRange", comp, ret, gtlt, M, m, p, pr);
|
||||
debug2("xRange", comp, ret, gtlt, M, m, p, pr);
|
||||
const xM = isX(M);
|
||||
const xm = xM || isX(m);
|
||||
const xp = xm || isX(p);
|
||||
@@ -28095,16 +28095,16 @@ var require_range = __commonJS({
|
||||
} else if (xp) {
|
||||
ret = `>=${M}.${m}.0${pr} <${M}.${+m + 1}.0-0`;
|
||||
}
|
||||
debug("xRange return", ret);
|
||||
debug2("xRange return", ret);
|
||||
return ret;
|
||||
});
|
||||
};
|
||||
var replaceStars = (comp, options) => {
|
||||
debug("replaceStars", comp, options);
|
||||
debug2("replaceStars", comp, options);
|
||||
return comp.trim().replace(re[t.STAR], "");
|
||||
};
|
||||
var replaceGTE0 = (comp, options) => {
|
||||
debug("replaceGTE0", comp, options);
|
||||
debug2("replaceGTE0", comp, options);
|
||||
return comp.trim().replace(re[options.includePrerelease ? t.GTE0PRE : t.GTE0], "");
|
||||
};
|
||||
var hyphenReplace = (incPr) => ($0, from, fM, fm, fp, fpr, fb, to, tM, tm, tp, tpr) => {
|
||||
@@ -28142,7 +28142,7 @@ var require_range = __commonJS({
|
||||
}
|
||||
if (version.prerelease.length && !options.includePrerelease) {
|
||||
for (let i = 0; i < set.length; i++) {
|
||||
debug(set[i].semver);
|
||||
debug2(set[i].semver);
|
||||
if (set[i].semver === Comparator.ANY) {
|
||||
continue;
|
||||
}
|
||||
@@ -28179,7 +28179,7 @@ var require_comparator = __commonJS({
|
||||
}
|
||||
}
|
||||
comp = comp.trim().split(/\s+/).join(" ");
|
||||
debug("comparator", comp, options);
|
||||
debug2("comparator", comp, options);
|
||||
this.options = options;
|
||||
this.loose = !!options.loose;
|
||||
this.parse(comp);
|
||||
@@ -28188,7 +28188,7 @@ var require_comparator = __commonJS({
|
||||
} else {
|
||||
this.value = this.operator + this.semver.version;
|
||||
}
|
||||
debug("comp", this);
|
||||
debug2("comp", this);
|
||||
}
|
||||
parse(comp) {
|
||||
const r = this.options.loose ? re[t.COMPARATORLOOSE] : re[t.COMPARATOR];
|
||||
@@ -28210,7 +28210,7 @@ var require_comparator = __commonJS({
|
||||
return this.value;
|
||||
}
|
||||
test(version) {
|
||||
debug("Comparator.test", version, this.options.loose);
|
||||
debug2("Comparator.test", version, this.options.loose);
|
||||
if (this.semver === ANY || version === ANY) {
|
||||
return true;
|
||||
}
|
||||
@@ -28267,7 +28267,7 @@ var require_comparator = __commonJS({
|
||||
var parseOptions = require_parse_options();
|
||||
var { safeRe: re, t } = require_re();
|
||||
var cmp = require_cmp();
|
||||
var debug = require_debug();
|
||||
var debug2 = require_debug();
|
||||
var SemVer = require_semver();
|
||||
var Range = require_range();
|
||||
}
|
||||
@@ -28843,6 +28843,184 @@ var require_semver2 = __commonJS({
|
||||
}
|
||||
});
|
||||
|
||||
// utils/log.ts
|
||||
var core = __toESM(require_core(), 1);
|
||||
var import_table = __toESM(require_src(), 1);
|
||||
|
||||
// utils/globals.ts
|
||||
import { existsSync } from "node:fs";
|
||||
var isCloudflareSandbox = !!process.env.CLOUDFLARE_APPLICATION_ID && !!process.env.SANDBOX_VERSION;
|
||||
var isGitHubActions = !!process.env.GITHUB_ACTIONS;
|
||||
var isInsideDocker = existsSync("/.dockerenv");
|
||||
|
||||
// utils/log.ts
|
||||
var isRunnerDebugEnabled = () => core.isDebug();
|
||||
var isLocalDebugEnabled = () => process.env.LOG_LEVEL === "debug" || process.env.ACTIONS_STEP_DEBUG === "true";
|
||||
var isDebugEnabled = () => isLocalDebugEnabled() || isRunnerDebugEnabled();
|
||||
function ts() {
|
||||
return isDebugEnabled() ? `[${(/* @__PURE__ */ new Date()).toISOString()}] ` : "";
|
||||
}
|
||||
function formatArgs(args2) {
|
||||
return args2.map((arg) => {
|
||||
if (typeof arg === "string") return arg;
|
||||
if (arg instanceof Error) return `${arg.message}
|
||||
${arg.stack}`;
|
||||
return JSON.stringify(arg);
|
||||
}).join(" ");
|
||||
}
|
||||
function startGroup2(name) {
|
||||
if (isGitHubActions) {
|
||||
core.startGroup(name);
|
||||
} else {
|
||||
console.group(name);
|
||||
}
|
||||
}
|
||||
function endGroup2() {
|
||||
if (isGitHubActions) {
|
||||
core.endGroup();
|
||||
} else {
|
||||
console.groupEnd();
|
||||
}
|
||||
}
|
||||
function group(name, fn2) {
|
||||
startGroup2(name);
|
||||
fn2();
|
||||
endGroup2();
|
||||
}
|
||||
function boxString(text, options) {
|
||||
const { title, maxWidth = 80, indent: indent2 = "", padding = 1 } = options || {};
|
||||
const lines = text.trim().split("\n");
|
||||
const wrappedLines = [];
|
||||
for (const line of lines) {
|
||||
if (line.length <= maxWidth - padding * 2) {
|
||||
wrappedLines.push(line);
|
||||
} else {
|
||||
const words = line.split(" ");
|
||||
let currentLine = "";
|
||||
for (const word of words) {
|
||||
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
||||
if (testLine.length <= maxWidth - padding * 2) {
|
||||
currentLine = testLine;
|
||||
} else {
|
||||
if (currentLine) {
|
||||
wrappedLines.push(currentLine);
|
||||
currentLine = "";
|
||||
}
|
||||
const maxLineLength2 = maxWidth - padding * 2;
|
||||
let remainingWord = word;
|
||||
while (remainingWord.length > maxLineLength2) {
|
||||
wrappedLines.push(remainingWord.substring(0, maxLineLength2));
|
||||
remainingWord = remainingWord.substring(maxLineLength2);
|
||||
}
|
||||
currentLine = remainingWord;
|
||||
}
|
||||
}
|
||||
if (currentLine) {
|
||||
wrappedLines.push(currentLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
const maxLineLength = Math.max(...wrappedLines.map((line) => line.length));
|
||||
const contentBoxWidth = maxLineLength + padding * 2;
|
||||
const titleLineLength = title ? ` ${title} `.length : 0;
|
||||
const boxWidth = Math.max(contentBoxWidth, titleLineLength);
|
||||
let result = "";
|
||||
if (title) {
|
||||
const titleLine = ` ${title} `;
|
||||
const titlePadding = Math.max(0, boxWidth - titleLine.length);
|
||||
result += `${indent2}\u250C${titleLine}${"\u2500".repeat(titlePadding)}\u2510
|
||||
`;
|
||||
}
|
||||
if (!title) {
|
||||
result += `${indent2}\u250C${"\u2500".repeat(boxWidth)}\u2510
|
||||
`;
|
||||
}
|
||||
for (const line of wrappedLines) {
|
||||
const paddedLine = line.padEnd(maxLineLength);
|
||||
result += `${indent2}\u2502${" ".repeat(padding)}${paddedLine}${" ".repeat(padding)}\u2502
|
||||
`;
|
||||
}
|
||||
result += `${indent2}\u2514${"\u2500".repeat(boxWidth)}\u2518`;
|
||||
return result;
|
||||
}
|
||||
function box(text, options) {
|
||||
const boxContent = boxString(text, options);
|
||||
core.info(boxContent);
|
||||
}
|
||||
function printTable(rows, options) {
|
||||
const { title } = options || {};
|
||||
const tableData = rows.map(
|
||||
(row) => row.map((cell) => {
|
||||
if (typeof cell === "string") {
|
||||
return cell;
|
||||
}
|
||||
return cell.data;
|
||||
})
|
||||
);
|
||||
const formatted = (0, import_table.table)(tableData);
|
||||
if (title) {
|
||||
core.info(`
|
||||
${title}`);
|
||||
}
|
||||
core.info(`
|
||||
${formatted}
|
||||
`);
|
||||
}
|
||||
function separator(length = 50) {
|
||||
const separatorText = "\u2500".repeat(length);
|
||||
core.info(separatorText);
|
||||
}
|
||||
var log = {
|
||||
/** Print info message */
|
||||
info: (...args2) => {
|
||||
core.info(`${ts()}${formatArgs(args2)}`);
|
||||
},
|
||||
/** Print a warning message. Use only for warnings that should be displayed in the job summary. */
|
||||
warning: (...args2) => {
|
||||
core.warning(`${ts()}${formatArgs(args2)}`);
|
||||
},
|
||||
/** Print an error message. Use only for errors that should be displayed in the job summary. */
|
||||
error: (...args2) => {
|
||||
core.error(`${ts()}${formatArgs(args2)}`);
|
||||
},
|
||||
/** Print success message */
|
||||
success: (...args2) => {
|
||||
core.info(`${ts()}\xBB ${formatArgs(args2)}`);
|
||||
},
|
||||
/** Print debug message (only when debug mode is enabled) */
|
||||
debug: (...args2) => {
|
||||
if (isRunnerDebugEnabled()) {
|
||||
core.debug(formatArgs(args2));
|
||||
return;
|
||||
}
|
||||
if (isLocalDebugEnabled()) {
|
||||
core.info(`${ts()}[DEBUG] ${formatArgs(args2)}`);
|
||||
}
|
||||
},
|
||||
/** Print a formatted box with text */
|
||||
box,
|
||||
/** Print a formatted table using the table package */
|
||||
table: printTable,
|
||||
/** Print a separator line */
|
||||
separator,
|
||||
/** Start a collapsed group (GitHub Actions) or regular group (local) */
|
||||
startGroup: startGroup2,
|
||||
/** End a collapsed group */
|
||||
endGroup: endGroup2,
|
||||
/** Run a callback within a collapsed group */
|
||||
group,
|
||||
/** Log tool call information to console with formatted output */
|
||||
toolCall: ({ toolName, input }) => {
|
||||
const inputFormatted = formatJsonValue(input);
|
||||
const output = inputFormatted !== "{}" ? `\xBB ${toolName}(${inputFormatted})` : `\xBB ${toolName}()`;
|
||||
log.info(output.trimEnd());
|
||||
}
|
||||
};
|
||||
function formatJsonValue(value2) {
|
||||
const compact = JSON.stringify(value2);
|
||||
return compact.length > 80 || compact.includes("\n") ? JSON.stringify(value2, null, 2) : compact;
|
||||
}
|
||||
|
||||
// node_modules/.pnpm/@ark+util@0.56.0/node_modules/@ark/util/out/arrays.js
|
||||
var liftArray = (data) => Array.isArray(data) ? data : [data];
|
||||
var spliterate = (arr, predicate) => {
|
||||
@@ -37299,178 +37477,6 @@ var schema = ark.schema;
|
||||
var define2 = ark.define;
|
||||
var declare = ark.declare;
|
||||
|
||||
// utils/log.ts
|
||||
var core = __toESM(require_core(), 1);
|
||||
var import_table = __toESM(require_src(), 1);
|
||||
|
||||
// utils/globals.ts
|
||||
import { existsSync } from "node:fs";
|
||||
var isCloudflareSandbox = !!process.env.CLOUDFLARE_APPLICATION_ID && !!process.env.SANDBOX_VERSION;
|
||||
var isGitHubActions = !!process.env.GITHUB_ACTIONS;
|
||||
var isInsideDocker = existsSync("/.dockerenv");
|
||||
|
||||
// utils/log.ts
|
||||
var isDebugEnabled = () => process.env.LOG_LEVEL === "debug" || process.env.ACTIONS_STEP_DEBUG === "true" || core.isDebug();
|
||||
function formatArgs(args2) {
|
||||
return args2.map((arg) => {
|
||||
if (typeof arg === "string") return arg;
|
||||
if (arg instanceof Error) return `${arg.message}
|
||||
${arg.stack}`;
|
||||
return JSON.stringify(arg);
|
||||
}).join(" ");
|
||||
}
|
||||
function startGroup2(name) {
|
||||
if (isGitHubActions) {
|
||||
core.startGroup(name);
|
||||
} else {
|
||||
console.group(name);
|
||||
}
|
||||
}
|
||||
function endGroup2() {
|
||||
if (isGitHubActions) {
|
||||
core.endGroup();
|
||||
} else {
|
||||
console.groupEnd();
|
||||
}
|
||||
}
|
||||
function group(name, fn2) {
|
||||
startGroup2(name);
|
||||
fn2();
|
||||
endGroup2();
|
||||
}
|
||||
function boxString(text, options) {
|
||||
const { title, maxWidth = 80, indent: indent2 = "", padding = 1 } = options || {};
|
||||
const lines = text.trim().split("\n");
|
||||
const wrappedLines = [];
|
||||
for (const line of lines) {
|
||||
if (line.length <= maxWidth - padding * 2) {
|
||||
wrappedLines.push(line);
|
||||
} else {
|
||||
const words = line.split(" ");
|
||||
let currentLine = "";
|
||||
for (const word of words) {
|
||||
const testLine = currentLine ? `${currentLine} ${word}` : word;
|
||||
if (testLine.length <= maxWidth - padding * 2) {
|
||||
currentLine = testLine;
|
||||
} else {
|
||||
if (currentLine) {
|
||||
wrappedLines.push(currentLine);
|
||||
currentLine = "";
|
||||
}
|
||||
const maxLineLength2 = maxWidth - padding * 2;
|
||||
let remainingWord = word;
|
||||
while (remainingWord.length > maxLineLength2) {
|
||||
wrappedLines.push(remainingWord.substring(0, maxLineLength2));
|
||||
remainingWord = remainingWord.substring(maxLineLength2);
|
||||
}
|
||||
currentLine = remainingWord;
|
||||
}
|
||||
}
|
||||
if (currentLine) {
|
||||
wrappedLines.push(currentLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
const maxLineLength = Math.max(...wrappedLines.map((line) => line.length));
|
||||
const contentBoxWidth = maxLineLength + padding * 2;
|
||||
const titleLineLength = title ? ` ${title} `.length : 0;
|
||||
const boxWidth = Math.max(contentBoxWidth, titleLineLength);
|
||||
let result = "";
|
||||
if (title) {
|
||||
const titleLine = ` ${title} `;
|
||||
const titlePadding = Math.max(0, boxWidth - titleLine.length);
|
||||
result += `${indent2}\u250C${titleLine}${"\u2500".repeat(titlePadding)}\u2510
|
||||
`;
|
||||
}
|
||||
if (!title) {
|
||||
result += `${indent2}\u250C${"\u2500".repeat(boxWidth)}\u2510
|
||||
`;
|
||||
}
|
||||
for (const line of wrappedLines) {
|
||||
const paddedLine = line.padEnd(maxLineLength);
|
||||
result += `${indent2}\u2502${" ".repeat(padding)}${paddedLine}${" ".repeat(padding)}\u2502
|
||||
`;
|
||||
}
|
||||
result += `${indent2}\u2514${"\u2500".repeat(boxWidth)}\u2518`;
|
||||
return result;
|
||||
}
|
||||
function box(text, options) {
|
||||
const boxContent = boxString(text, options);
|
||||
core.info(boxContent);
|
||||
}
|
||||
function printTable(rows, options) {
|
||||
const { title } = options || {};
|
||||
const tableData = rows.map(
|
||||
(row) => row.map((cell) => {
|
||||
if (typeof cell === "string") {
|
||||
return cell;
|
||||
}
|
||||
return cell.data;
|
||||
})
|
||||
);
|
||||
const formatted = (0, import_table.table)(tableData);
|
||||
if (title) {
|
||||
core.info(`
|
||||
${title}`);
|
||||
}
|
||||
core.info(`
|
||||
${formatted}
|
||||
`);
|
||||
}
|
||||
function separator(length = 50) {
|
||||
const separatorText = "\u2500".repeat(length);
|
||||
core.info(separatorText);
|
||||
}
|
||||
function ts() {
|
||||
return isDebugEnabled() ? `[${(/* @__PURE__ */ new Date()).toISOString()}] ` : "";
|
||||
}
|
||||
var log = {
|
||||
/** Print info message */
|
||||
info: (...args2) => {
|
||||
core.info(`${ts()}${formatArgs(args2)}`);
|
||||
},
|
||||
/** Print warning message */
|
||||
warning: (...args2) => {
|
||||
core.warning(`${ts()}${formatArgs(args2)}`);
|
||||
},
|
||||
/** Print error message */
|
||||
error: (...args2) => {
|
||||
core.error(`${ts()}${formatArgs(args2)}`);
|
||||
},
|
||||
/** Print success message */
|
||||
success: (...args2) => {
|
||||
core.info(`${ts()}\xBB ${formatArgs(args2)}`);
|
||||
},
|
||||
/** Print debug message (only if LOG_LEVEL=debug) */
|
||||
debug: (...args2) => {
|
||||
if (isDebugEnabled()) {
|
||||
core.info(`[${(/* @__PURE__ */ new Date()).toISOString()}] [DEBUG] ${formatArgs(args2)}`);
|
||||
}
|
||||
},
|
||||
/** Print a formatted box with text */
|
||||
box,
|
||||
/** Print a formatted table using the table package */
|
||||
table: printTable,
|
||||
/** Print a separator line */
|
||||
separator,
|
||||
/** Start a collapsed group (GitHub Actions) or regular group (local) */
|
||||
startGroup: startGroup2,
|
||||
/** End a collapsed group */
|
||||
endGroup: endGroup2,
|
||||
/** Run a callback within a collapsed group */
|
||||
group,
|
||||
/** Log tool call information to console with formatted output */
|
||||
toolCall: ({ toolName, input }) => {
|
||||
const inputFormatted = formatJsonValue(input);
|
||||
const output = inputFormatted !== "{}" ? `\xBB ${toolName}(${inputFormatted})` : `\xBB ${toolName}()`;
|
||||
log.info(output.trimEnd());
|
||||
}
|
||||
};
|
||||
function formatJsonValue(value2) {
|
||||
const compact = JSON.stringify(value2);
|
||||
return compact.length > 80 || compact.includes("\n") ? JSON.stringify(value2, null, 2) : compact;
|
||||
}
|
||||
|
||||
// utils/buildPullfrogFooter.ts
|
||||
var PULLFROG_DIVIDER = "<!-- PULLFROG_DIVIDER_DO_NOT_REMOVE_PLZ -->";
|
||||
var FROG_LOGO = `<a href="https://pullfrog.com"><picture><source media="(prefers-color-scheme: dark)" srcset="https://pullfrog.com/logos/frog-white-full-18px.png"><img src="https://pullfrog.com/logos/frog-green-full-18px.png" width="9px" height="9px" style="vertical-align: middle; " alt="Pullfrog"></picture></a>`;
|
||||
@@ -41192,37 +41198,8 @@ var ReplyToReviewComment = type({
|
||||
)
|
||||
});
|
||||
|
||||
// utils/token.ts
|
||||
var core3 = __toESM(require_core(), 1);
|
||||
function getJobToken() {
|
||||
const inputToken = core3.getInput("token");
|
||||
if (inputToken) {
|
||||
return inputToken;
|
||||
}
|
||||
const fallbackToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
|
||||
if (fallbackToken) {
|
||||
return fallbackToken;
|
||||
}
|
||||
throw new Error("token input is required");
|
||||
}
|
||||
|
||||
// utils/exitHandler.ts
|
||||
function buildErrorCommentBody(params) {
|
||||
const workflowRunLink = params.runId ? `[workflow run logs](https://github.com/${params.owner}/${params.repo}/actions/runs/${params.runId})` : "workflow run logs";
|
||||
const errorMessage = params.isCancellation ? `This run was cancelled \u{1F6D1}
|
||||
|
||||
The workflow was cancelled before completion. Please check the ${workflowRunLink} for details.` : `This run croaked \u{1F635}
|
||||
|
||||
The workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`;
|
||||
const footer = buildPullfrogFooter({
|
||||
triggeredBy: true,
|
||||
workflowRun: params.runId ? { owner: params.owner, repo: params.repo, runId: params.runId } : void 0
|
||||
});
|
||||
return `${errorMessage}${footer}`;
|
||||
}
|
||||
|
||||
// utils/payload.ts
|
||||
var core4 = __toESM(require_core(), 1);
|
||||
var core3 = __toESM(require_core(), 1);
|
||||
|
||||
// external.ts
|
||||
var agentsManifest = {
|
||||
@@ -41258,7 +41235,7 @@ var Effort = type.enumerated("mini", "auto", "max");
|
||||
// package.json
|
||||
var package_default = {
|
||||
name: "@pullfrog/pullfrog",
|
||||
version: "0.0.162",
|
||||
version: "0.0.165",
|
||||
type: "module",
|
||||
files: [
|
||||
"index.js",
|
||||
@@ -41278,7 +41255,8 @@ var package_default = {
|
||||
runtest: "node test/run.ts",
|
||||
scratch: "node scratch.ts",
|
||||
upDeps: "pnpm up --latest",
|
||||
lock: "pnpm --ignore-workspace install --no-frozen-lockfile",
|
||||
lock: "pnpm install --no-frozen-lockfile",
|
||||
postinstall: "node scripts/generate-proxies.ts",
|
||||
prepare: "cd .. && husky action/.husky"
|
||||
},
|
||||
dependencies: {
|
||||
@@ -41337,7 +41315,9 @@ var package_default = {
|
||||
types: "./dist/index.d.cts",
|
||||
import: "./dist/index.js",
|
||||
require: "./dist/index.cjs"
|
||||
}
|
||||
},
|
||||
"./internal": "./dist/internal.js",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
packageManager: "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a"
|
||||
};
|
||||
@@ -41369,6 +41349,7 @@ var JsonPayload = type({
|
||||
version: "string",
|
||||
"agent?": AgentName.or("undefined"),
|
||||
prompt: "string",
|
||||
"triggeringUser?": "string | undefined",
|
||||
"eventInstructions?": "string",
|
||||
"repoInstructions?": "string",
|
||||
"event?": "object",
|
||||
@@ -41389,7 +41370,7 @@ var Inputs = type({
|
||||
"cwd?": type.string.or("undefined")
|
||||
});
|
||||
function resolvePromptInput() {
|
||||
const prompt = core4.getInput("prompt", { required: true });
|
||||
const prompt = core3.getInput("prompt", { required: true });
|
||||
let parsed2;
|
||||
try {
|
||||
parsed2 = JSON.parse(prompt);
|
||||
@@ -41404,8 +41385,35 @@ function resolvePromptInput() {
|
||||
return jsonPayload;
|
||||
}
|
||||
|
||||
// post.ts
|
||||
// utils/token.ts
|
||||
var core4 = __toESM(require_core(), 1);
|
||||
function getJobToken() {
|
||||
const inputToken = core4.getInput("token");
|
||||
if (inputToken) {
|
||||
return inputToken;
|
||||
}
|
||||
const fallbackToken = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
|
||||
if (fallbackToken) {
|
||||
return fallbackToken;
|
||||
}
|
||||
throw new Error("token input is required");
|
||||
}
|
||||
|
||||
// utils/postCleanup.ts
|
||||
var SHOULD_CHECK_REASON = true;
|
||||
function buildErrorCommentBody(params) {
|
||||
const workflowRunLink = params.runId ? `[workflow run logs](https://github.com/${params.owner}/${params.repo}/actions/runs/${params.runId})` : "workflow run logs";
|
||||
const errorMessage = params.isCancellation ? `This run was cancelled \u{1F6D1}
|
||||
|
||||
The workflow was cancelled before completion. Please check the ${workflowRunLink} for details.` : `This run croaked \u{1F635}
|
||||
|
||||
The workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`;
|
||||
const footer = buildPullfrogFooter({
|
||||
triggeredBy: true,
|
||||
workflowRun: params.runId ? { owner: params.owner, repo: params.repo, runId: params.runId } : void 0
|
||||
});
|
||||
return `${errorMessage}${footer}`;
|
||||
}
|
||||
async function validateStuckProgressComment(promptInput, octokit, owner, repo) {
|
||||
if (!promptInput?.progressCommentId) {
|
||||
log.info("[post] no progressCommentId in prompt input, skipping cleanup");
|
||||
@@ -41427,11 +41435,12 @@ async function validateStuckProgressComment(promptInput, octokit, owner, repo) {
|
||||
return null;
|
||||
} catch (error2) {
|
||||
const errorMessage = error2 instanceof Error ? error2.message : String(error2);
|
||||
log.error(`[post] failed to get comment ${commentId}: ${errorMessage}`);
|
||||
log.info(`[post] failed to get comment ${commentId}: ${errorMessage}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function getIsCancelled(params) {
|
||||
if (!params.runIdStr) return false;
|
||||
try {
|
||||
const { data: jobs } = await params.octokit.rest.actions.listJobsForWorkflowRun({
|
||||
owner: params.repoContext.owner,
|
||||
@@ -41453,7 +41462,7 @@ async function getIsCancelled(params) {
|
||||
}
|
||||
log.info("[post] no cancellation found, assuming failure");
|
||||
} catch (error2) {
|
||||
log.warning(
|
||||
log.info(
|
||||
`[post] failed to get job status: ${error2 instanceof Error ? error2.message : String(error2)}`
|
||||
);
|
||||
}
|
||||
@@ -41462,13 +41471,12 @@ async function getIsCancelled(params) {
|
||||
async function runPostCleanup() {
|
||||
log.info("\xBB [post] starting post cleanup");
|
||||
const runIdStr = process.env.GITHUB_RUN_ID;
|
||||
if (!runIdStr) return log.info("\xBB [post] no GITHUB_RUN_ID available, skipping cleanup");
|
||||
let promptInput = null;
|
||||
try {
|
||||
const resolved = resolvePromptInput();
|
||||
if (typeof resolved !== "string") promptInput = resolved;
|
||||
} catch (error2) {
|
||||
log.warning(
|
||||
log.info(
|
||||
`[post] failed to resolve prompt input: ${error2 instanceof Error ? error2.message : String(error2)}`
|
||||
);
|
||||
}
|
||||
@@ -41499,19 +41507,18 @@ async function runPostCleanup() {
|
||||
log.info("\xBB [post] successfully updated progress comment");
|
||||
} catch (error2) {
|
||||
const errorMessage = error2 instanceof Error ? error2.message : String(error2);
|
||||
log.error(`[post] failed to update comment: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
async function run() {
|
||||
try {
|
||||
await runPostCleanup();
|
||||
} catch (error2) {
|
||||
const message = error2 instanceof Error ? error2.message : String(error2);
|
||||
log.error(`[post] unexpected error: ${message}`);
|
||||
log.info(`[post] failed to update comment: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
// post.ts
|
||||
log.debug(`[post] script started at ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
||||
await run();
|
||||
try {
|
||||
await runPostCleanup();
|
||||
} catch (error2) {
|
||||
const message = error2 instanceof Error ? error2.message : String(error2);
|
||||
log.error(`[post] unexpected error: ${message}`);
|
||||
}
|
||||
/*! Bundled license information:
|
||||
|
||||
undici/lib/fetch/body.js:
|
||||
|
||||
@@ -6,180 +6,14 @@
|
||||
* Searches for Pullfrog comment via GitHub API and updates if stuck on "Leaping into action".
|
||||
*/
|
||||
|
||||
import { LEAPING_INTO_ACTION_PREFIX } from "./mcp/comment.ts";
|
||||
import { log } from "./utils/cli.ts";
|
||||
import { buildErrorCommentBody } from "./utils/exitHandler.ts";
|
||||
import { createOctokit, parseRepoContext } from "./utils/github.ts";
|
||||
import { type ResolvedPromptInput, resolvePromptInput } from "./utils/payload.ts";
|
||||
import { getJobToken } from "./utils/token.ts";
|
||||
|
||||
type JsonPromptInput = Extract<ResolvedPromptInput, object>; // not string
|
||||
|
||||
/**
|
||||
* Controls whether the script should check the reason for the workflow termination.
|
||||
* It can be either canceled or failed.
|
||||
* YAML file cannot supply it (not in ENV), so an extra request is required to check it.
|
||||
* */
|
||||
const SHOULD_CHECK_REASON = true;
|
||||
|
||||
/**
|
||||
* Validate that the progress comment is stuck on "Leaping into action"
|
||||
* Fetches the comment by ID and checks if it starts with LEAPING_INTO_ACTION_PREFIX
|
||||
* Returns the comment ID if stuck, null otherwise
|
||||
*/
|
||||
async function validateStuckProgressComment(
|
||||
promptInput: JsonPromptInput | null,
|
||||
octokit: ReturnType<typeof createOctokit>,
|
||||
owner: string,
|
||||
repo: string
|
||||
): Promise<number | null> {
|
||||
if (!promptInput?.progressCommentId) {
|
||||
log.info("[post] no progressCommentId in prompt input, skipping cleanup");
|
||||
return null;
|
||||
}
|
||||
|
||||
const commentId = parseInt(promptInput.progressCommentId, 10);
|
||||
log.info(`[post] validating progressCommentId from prompt input: ${commentId}`);
|
||||
|
||||
try {
|
||||
const { data: comment } = await octokit.rest.issues.getComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
});
|
||||
|
||||
// check if comment is stuck on "Leaping into action"
|
||||
if (comment.body?.startsWith(LEAPING_INTO_ACTION_PREFIX)) {
|
||||
log.info(`[post] comment ${commentId} is stuck on "Leaping into action"`);
|
||||
return commentId;
|
||||
}
|
||||
|
||||
log.info(`[post] comment ${commentId} is not stuck (already updated or different content)`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.error(`[post] failed to get comment ${commentId}: ${errorMessage}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if the workflow or its steps is cancelled.
|
||||
* While the job is still in_progress, the individual steps may have their conclusions set.
|
||||
*/
|
||||
async function getIsCancelled(params: {
|
||||
repoContext: ReturnType<typeof parseRepoContext>;
|
||||
octokit: ReturnType<typeof createOctokit>;
|
||||
runIdStr: string;
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
const { data: jobs } = await params.octokit.rest.actions.listJobsForWorkflowRun({
|
||||
owner: params.repoContext.owner,
|
||||
repo: params.repoContext.name,
|
||||
run_id: Number.parseInt(params.runIdStr, 10),
|
||||
});
|
||||
|
||||
// find current job by matching GITHUB_JOB env var
|
||||
// Note: GITHUB_JOB is the job ID (yaml key), but job.name is the display name
|
||||
// For matrix jobs, the name includes matrix values like "build (ubuntu-latest, node-18)"
|
||||
// So we match jobs that START with the job ID
|
||||
const currentJobName = process.env.GITHUB_JOB;
|
||||
const currentJob = currentJobName
|
||||
? jobs.jobs.find((j) => j.name === currentJobName || j.name.startsWith(`${currentJobName} (`))
|
||||
: jobs.jobs[0]; // fallback to first job
|
||||
|
||||
if (!currentJob) {
|
||||
log.warning("[post] could not find current job");
|
||||
return false;
|
||||
}
|
||||
|
||||
log.info(`[post] job status: ${currentJob.status}, conclusion: ${currentJob.conclusion}`);
|
||||
if (currentJob.conclusion === "cancelled") return true; // whole job explicit cancellation
|
||||
|
||||
// but if it's still null, check steps for cancellation:
|
||||
const cancelledStep = currentJob.steps?.find((step) => step.conclusion === "cancelled");
|
||||
if (cancelledStep) {
|
||||
log.info(`[post] found cancelled step: ${cancelledStep.name}`);
|
||||
return true;
|
||||
}
|
||||
log.info("[post] no cancellation found, assuming failure");
|
||||
} catch (error) {
|
||||
log.warning(
|
||||
`[post] failed to get job status: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
return false; // assuming failure
|
||||
}
|
||||
|
||||
async function runPostCleanup(): Promise<void> {
|
||||
log.info("» [post] starting post cleanup");
|
||||
|
||||
const runIdStr = process.env.GITHUB_RUN_ID;
|
||||
|
||||
if (!runIdStr) return log.info("» [post] no GITHUB_RUN_ID available, skipping cleanup");
|
||||
|
||||
// resolve prompt input once and use it for both issue number and comment ID extraction
|
||||
// only use the object form (JSON payload), not plain string prompts
|
||||
let promptInput: JsonPromptInput | null = null;
|
||||
try {
|
||||
const resolved = resolvePromptInput();
|
||||
if (typeof resolved !== "string") promptInput = resolved;
|
||||
} catch (error) {
|
||||
log.warning(
|
||||
`[post] failed to resolve prompt input: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
|
||||
// get job token for API calls
|
||||
const token = getJobToken();
|
||||
const repoContext = parseRepoContext();
|
||||
const octokit = createOctokit(token);
|
||||
|
||||
// validate that progressCommentId from prompt input is stuck on "Leaping into action"
|
||||
const commentId = await validateStuckProgressComment(
|
||||
promptInput,
|
||||
octokit,
|
||||
repoContext.owner,
|
||||
repoContext.name
|
||||
);
|
||||
|
||||
if (!commentId) return log.info("» [post] no stuck progress comment to update, skipping cleanup");
|
||||
|
||||
log.info(`» [post] validated stuck comment: ${commentId}, updating with error message`);
|
||||
|
||||
try {
|
||||
const body = buildErrorCommentBody({
|
||||
owner: repoContext.owner,
|
||||
repo: repoContext.name,
|
||||
runId: runIdStr,
|
||||
isCancellation: SHOULD_CHECK_REASON
|
||||
? await getIsCancelled({ octokit, repoContext, runIdStr })
|
||||
: false,
|
||||
});
|
||||
|
||||
await octokit.rest.issues.updateComment({
|
||||
owner: repoContext.owner,
|
||||
repo: repoContext.name,
|
||||
comment_id: commentId,
|
||||
body,
|
||||
});
|
||||
|
||||
log.info("» [post] successfully updated progress comment");
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.error(`[post] failed to update comment: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function run(): Promise<void> {
|
||||
try {
|
||||
await runPostCleanup();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error(`[post] unexpected error: ${message}`);
|
||||
// don't fail the post script - best effort cleanup
|
||||
}
|
||||
}
|
||||
import { runPostCleanup } from "./utils/postCleanup.ts";
|
||||
|
||||
log.debug(`[post] script started at ${new Date().toISOString()}`);
|
||||
await run();
|
||||
try {
|
||||
await runPostCleanup();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log.error(`[post] unexpected error: ${message}`);
|
||||
// don't fail the post script - best effort cleanup
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
|
||||
const proxies = [
|
||||
{ dest: "dist/index.js", source: "../index.ts" },
|
||||
{ dest: "dist/internal.js", source: "../internal/index.ts" },
|
||||
];
|
||||
|
||||
mkdirSync("dist", { recursive: true });
|
||||
|
||||
for (const proxy of proxies) {
|
||||
writeFileSync(proxy.dest, `export * from "${proxy.source}";\n`);
|
||||
writeFileSync(proxy.dest.replace(/\.js$/, ".d.ts"), `export * from "${proxy.source}";\n`);
|
||||
}
|
||||
@@ -30,11 +30,11 @@ function validator(result: AgentResult): ValidationCheck[] {
|
||||
const setOutputCalled = output !== null;
|
||||
const correctValue = setOutputCalled && /EFFORT_TEST_PASSED/i.test(output);
|
||||
|
||||
// the orchestrator runs at auto (effort=auto in its log line).
|
||||
// the delegate tool should spawn the subagent at mini (effort=mini in its log line).
|
||||
// if effort override works, we should see BOTH effort=auto AND effort=mini in the output.
|
||||
const orchestratorEffort = /running \w+ with effort=auto/i.test(agentOutput);
|
||||
const subagentEffort = /running \w+ with effort=mini/i.test(agentOutput);
|
||||
// the orchestrator runs at auto (» effort: auto in its log line).
|
||||
// the delegate tool should spawn the subagent at mini (» effort: mini in its log line).
|
||||
// if effort override works, we should see BOTH effort values in the output.
|
||||
const orchestratorEffort = /» effort:\s+auto/i.test(agentOutput);
|
||||
const subagentEffort = /» effort:\s+mini/i.test(agentOutput);
|
||||
|
||||
return [
|
||||
{ name: "set_output", passed: setOutputCalled },
|
||||
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env bash
|
||||
# determines which agents need testing based on changed files.
|
||||
# reads changed file paths from stdin (JSON array or newline-delimited).
|
||||
# outputs a JSON array of agent names to stdout.
|
||||
#
|
||||
# only agents whose harness file changed are included.
|
||||
# shared.ts/index.ts and other non-harness action changes fall back to claude as a canary.
|
||||
set -euo pipefail
|
||||
|
||||
# read stdin - auto-detect JSON array vs newline-delimited
|
||||
input=$(cat)
|
||||
if echo "$input" | jq -e 'type == "array"' > /dev/null 2>&1; then
|
||||
files=$(echo "$input" | jq -r '.[]')
|
||||
else
|
||||
files="$input"
|
||||
fi
|
||||
|
||||
# find which agent harness files changed
|
||||
changed_agents=()
|
||||
has_non_agent_change=false
|
||||
|
||||
while IFS= read -r file; do
|
||||
[[ -z "$file" ]] && continue
|
||||
case "$file" in
|
||||
action/agents/shared.ts|action/agents/index.ts)
|
||||
has_non_agent_change=true
|
||||
;;
|
||||
action/agents/*.ts)
|
||||
changed_agents+=("$(basename "$file" .ts)")
|
||||
;;
|
||||
action/*)
|
||||
has_non_agent_change=true
|
||||
;;
|
||||
esac
|
||||
done <<< "$files"
|
||||
|
||||
# output agents based on change type.
|
||||
# non-agent action changes run claude as a canary.
|
||||
if [[ ${#changed_agents[@]} -gt 0 ]]; then
|
||||
printf '%s\n' "${changed_agents[@]}" | sort -u | jq -R . | jq -sc .
|
||||
elif $has_non_agent_change; then
|
||||
echo '["claude"]'
|
||||
else
|
||||
echo '[]'
|
||||
fi
|
||||
+32
-11
@@ -1,9 +1,10 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
import { agentsManifest } from "../external.ts";
|
||||
import { agentsManifest, type WorkflowPermissions } from "../external.ts";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const actionDir = join(__dirname, "..");
|
||||
@@ -12,7 +13,7 @@ const rootDir = join(actionDir, "..");
|
||||
type WorkflowJob = {
|
||||
"runs-on": string;
|
||||
"timeout-minutes"?: number;
|
||||
permissions?: Record<string, string>;
|
||||
permissions?: WorkflowPermissions;
|
||||
strategy?: { "fail-fast": boolean; matrix: Record<string, string[]> };
|
||||
env?: Record<string, string>;
|
||||
steps?: unknown[];
|
||||
@@ -57,11 +58,14 @@ const expectedAgents = Object.keys(agentsManifest).sort();
|
||||
const crossagentTests = getTestNamesFromDir("crossagent");
|
||||
const agnosticTests = getTestNamesFromDir("agnostic");
|
||||
const adhocTests = getTestNamesFromDir("adhoc");
|
||||
const dynamicAgentsExpression = "$" + "{{ fromJSON(needs.changes.outputs.agents) }}";
|
||||
|
||||
// all API key names from all agents + GITHUB_TOKEN
|
||||
// all API key names from all agents + GITHUB_TOKEN + model overrides
|
||||
const expectedAgentEnvVars = [
|
||||
"GITHUB_TOKEN",
|
||||
...new Set(Object.values(agentsManifest).flatMap((a) => a.apiKeyNames)),
|
||||
"GEMINI_MODEL",
|
||||
"OPENCODE_MODEL",
|
||||
].sort();
|
||||
|
||||
// agnostic tests only run with claude
|
||||
@@ -82,8 +86,25 @@ describe("ci workflow consistency", () => {
|
||||
const rootJob = rootWorkflow.jobs["action-agents"];
|
||||
const actionJob = actionWorkflow.jobs.agents;
|
||||
|
||||
it("root agent matrix matches agentsManifest", () => {
|
||||
expect([...rootJob.strategy!.matrix.agent].sort()).toEqual(expectedAgents);
|
||||
it("root agent matrix uses dynamic output from changes job", () => {
|
||||
expect(rootJob.strategy!.matrix.agent).toBe(dynamicAgentsExpression);
|
||||
});
|
||||
|
||||
it("changed-agents.sh falls back to claude when shared agent code changed", () => {
|
||||
const input = JSON.stringify(["action/agents/shared.ts"]);
|
||||
const output = execFileSync("bash", [join(__dirname, "changed-agents.sh")], {
|
||||
input,
|
||||
encoding: "utf-8",
|
||||
});
|
||||
expect(JSON.parse(output)).toEqual(["claude"]);
|
||||
});
|
||||
|
||||
it("changed-agents.sh falls back to claude for non-agent action changes", () => {
|
||||
const output = execFileSync("bash", [join(__dirname, "changed-agents.sh")], {
|
||||
input: JSON.stringify(["action/mcp/delegate.ts"]),
|
||||
encoding: "utf-8",
|
||||
});
|
||||
expect(JSON.parse(output)).toEqual(["claude"]);
|
||||
});
|
||||
|
||||
it("action agent matrix matches agentsManifest", () => {
|
||||
@@ -114,9 +135,9 @@ describe("ci workflow consistency", () => {
|
||||
expect(getEnvVarNames(rootJob)).toEqual(expectedAgentEnvVars);
|
||||
});
|
||||
|
||||
it("fail-fast is disabled in both", () => {
|
||||
expect(rootJob.strategy!["fail-fast"]).toBe(false);
|
||||
expect(actionJob.strategy!["fail-fast"]).toBe(false);
|
||||
it("fail-fast is enabled in both", () => {
|
||||
expect(rootJob.strategy!["fail-fast"]).toBe(true);
|
||||
expect(actionJob.strategy!["fail-fast"]).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -148,9 +169,9 @@ describe("ci workflow consistency", () => {
|
||||
expect(getEnvVarNames(rootJob)).toEqual(expectedAgnosticEnvVars);
|
||||
});
|
||||
|
||||
it("fail-fast is disabled in both", () => {
|
||||
expect(rootJob.strategy!["fail-fast"]).toBe(false);
|
||||
expect(actionJob.strategy!["fail-fast"]).toBe(false);
|
||||
it("fail-fast is enabled in both", () => {
|
||||
expect(rootJob.strategy!["fail-fast"]).toBe(true);
|
||||
expect(actionJob.strategy!["fail-fast"]).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ const secret = randomUUID();
|
||||
|
||||
const fixture = defineFixture(
|
||||
{
|
||||
prompt: `Call the get_test_value tool from the robinMCP server, then call set_output with the exact value returned.`,
|
||||
prompt: `Call the get_test_value tool from the robinMCP server. It returns a JSON object with a "value" field. Extract that inner value string and pass it to set_output.`,
|
||||
bash: "disabled",
|
||||
effort: "mini",
|
||||
},
|
||||
|
||||
@@ -13,9 +13,11 @@ import {
|
||||
|
||||
const fixture = defineFixture(
|
||||
{
|
||||
prompt: `${buildBashToolPrompt("echo $PULLFROG_DIAGNOSTIC_ID")}
|
||||
prompt: `This is a test to determine token visibility in bash tool calls.
|
||||
|
||||
${buildBashToolPrompt("echo $PULLFROG_TEST_VALUE")}
|
||||
|
||||
Then also run: echo $PULLFROG_FILTER_TOKEN
|
||||
Then also run: echo $PULLFROG_TEST_TOKEN
|
||||
|
||||
Then call set_output with the exact output of each command, one per line:
|
||||
DIAGNOSTIC_ID=<value or "empty">
|
||||
@@ -27,14 +29,11 @@ FILTER_TOKEN=<value or "empty">`,
|
||||
{ localOnly: true }
|
||||
);
|
||||
|
||||
const { getUuid, agentEnv } = generateAgentUuids([
|
||||
"PULLFROG_DIAGNOSTIC_ID",
|
||||
"PULLFROG_FILTER_TOKEN",
|
||||
]);
|
||||
const { getUuid, agentEnv } = generateAgentUuids(["PULLFROG_TEST_VALUE", "PULLFROG_TEST_TOKEN"]);
|
||||
|
||||
function validator(result: AgentResult): ValidationCheck[] {
|
||||
const safeMarker = getUuid(result.agent, "PULLFROG_DIAGNOSTIC_ID");
|
||||
const filteredMarker = getUuid(result.agent, "PULLFROG_FILTER_TOKEN");
|
||||
const safeMarker = getUuid(result.agent, "PULLFROG_TEST_VALUE");
|
||||
const filteredMarker = getUuid(result.agent, "PULLFROG_TEST_TOKEN");
|
||||
|
||||
// require structured output from set_output tool
|
||||
const output = getStructuredOutput(result);
|
||||
|
||||
+7
-18
@@ -3,13 +3,9 @@ import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { config } from "dotenv";
|
||||
import { runInDocker } from "../utils/docker.ts";
|
||||
import { acquireNewToken } from "../utils/github.ts";
|
||||
import { ensureGitHubToken } from "../utils/github.ts";
|
||||
import { isInsideDocker } from "../utils/globals.ts";
|
||||
import {
|
||||
installSignalHandlers,
|
||||
killTrackedChildren,
|
||||
setSignalHandler,
|
||||
} from "../utils/subprocess.ts";
|
||||
import { killTrackedChildren, setSignalHandler } from "../utils/subprocess.ts";
|
||||
import {
|
||||
type AgentResult,
|
||||
agents,
|
||||
@@ -311,14 +307,14 @@ async function runTestForAgent(ctx: RunContext): Promise<ValidationResult> {
|
||||
env.PULLFROG_TEST_REPO_SETUP = testConfig.repoSetup;
|
||||
}
|
||||
|
||||
// opencode: override to google/gemini-3-pro-preview to avoid flash's tight RPD quota limits
|
||||
// opencode: use anthropic sonnet to avoid google quota issues and gemini doom-looping
|
||||
if (ctx.agent === "opencode") {
|
||||
env.OPENCODE_MODEL = "google/gemini-3-pro-preview";
|
||||
env.OPENCODE_MODEL ??= "anthropic/claude-sonnet-4-5";
|
||||
}
|
||||
|
||||
// gemini: override to pro for all tests (including mini-effort) to avoid flash's tight RPD quota limits
|
||||
// gemini: use flash for all tests (including mini-effort) to avoid pro quota limits
|
||||
if (ctx.agent === "gemini") {
|
||||
env.GEMINI_MODEL = "gemini-3-pro-preview";
|
||||
env.GEMINI_MODEL ??= "gemini-3-flash-preview";
|
||||
}
|
||||
|
||||
// build file-based env vars for MCP servers that don't inherit parent env
|
||||
@@ -388,13 +384,7 @@ async function main(): Promise<void> {
|
||||
// run in Docker unless already inside
|
||||
if (!isInsideDocker) {
|
||||
// acquire token for docker if needed
|
||||
if (!process.env.GITHUB_TOKEN && !process.env.GH_TOKEN) {
|
||||
if (process.env.GITHUB_APP_ID && process.env.GITHUB_PRIVATE_KEY) {
|
||||
console.log("» acquiring github installation token...");
|
||||
const token = await acquireNewToken();
|
||||
process.env.GITHUB_TOKEN = token;
|
||||
}
|
||||
}
|
||||
await ensureGitHubToken();
|
||||
runTestsInDocker(args);
|
||||
}
|
||||
|
||||
@@ -488,7 +478,6 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
setSignalHandler(handleCancel);
|
||||
installSignalHandlers();
|
||||
|
||||
// run tests with limited concurrency to avoid overwhelming agent APIs
|
||||
const maxConcurrency = 5;
|
||||
|
||||
+1
-3
@@ -5,7 +5,7 @@ import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { agentsManifest } from "../external.ts";
|
||||
import type { Inputs } from "../main.ts";
|
||||
import { installSignalHandlers, trackChild, untrackChild } from "../utils/subprocess.ts";
|
||||
import { trackChild, untrackChild } from "../utils/subprocess.ts";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
@@ -165,8 +165,6 @@ const DEFAULT_TEST_TIMEOUT = "10m";
|
||||
// run agent and stream output with prefix labels
|
||||
// note: activity timeout is enforced in action main and subprocess utils
|
||||
export async function runAgentStreaming(options: RunStreamingOptions): Promise<AgentResult> {
|
||||
installSignalHandlers();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const chunks: Buffer[] = [];
|
||||
const prefix = getPrefix({ test: options.test, agent: options.agent });
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { getApiUrl } from "./apiUrl.ts";
|
||||
import { log } from "./cli.ts";
|
||||
|
||||
type ApiFetchOptions = {
|
||||
path: string;
|
||||
method?: string | undefined;
|
||||
headers?: Record<string, string> | undefined;
|
||||
body?: string | undefined;
|
||||
signal?: AbortSignal | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* fetch wrapper for hitting the Pullfrog API with Vercel deployment protection bypass.
|
||||
*
|
||||
* adds the bypass secret as BOTH a query parameter and a header for maximum reliability.
|
||||
* the server-side forwarding code uses query params, and the Vercel docs say both work,
|
||||
* so we do both as belt-and-suspenders.
|
||||
*
|
||||
* the query param approach is the primary bypass mechanism (matches server-side forwarding).
|
||||
* the header is added as a fallback.
|
||||
*/
|
||||
export async function apiFetch(options: ApiFetchOptions): Promise<Response> {
|
||||
const apiUrl = getApiUrl();
|
||||
const url = new URL(options.path, apiUrl);
|
||||
|
||||
const bypassSecret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
|
||||
if (bypassSecret) {
|
||||
url.searchParams.set("x-vercel-protection-bypass", bypassSecret);
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
// also add as header for belt-and-suspenders
|
||||
if (bypassSecret) {
|
||||
headers["x-vercel-protection-bypass"] = bypassSecret;
|
||||
}
|
||||
|
||||
log.debug(`api fetch: ${options.method ?? "GET"} ${url.pathname}`);
|
||||
|
||||
const init: RequestInit = {
|
||||
method: options.method ?? "GET",
|
||||
headers,
|
||||
};
|
||||
if (options.body) init.body = options.body;
|
||||
if (options.signal) init.signal = options.signal;
|
||||
|
||||
return fetch(url.toString(), init);
|
||||
}
|
||||
+16
-13
@@ -1,24 +1,27 @@
|
||||
import { log } from "./cli.ts";
|
||||
|
||||
function isLocalUrl(url: URL): boolean {
|
||||
return url.hostname === "localhost" || url.hostname === "127.0.0.1";
|
||||
}
|
||||
|
||||
/**
|
||||
* resolve the Pullfrog API base URL.
|
||||
*
|
||||
* in the action: API_URL is not explicitly set, so this falls back to https://pullfrog.com.
|
||||
* in local dev: API_URL=http://localhost:3000 (from .env).
|
||||
*
|
||||
* enforces https:// for non-local URLs to prevent cleartext credential transmission.
|
||||
*/
|
||||
export function getApiUrl(): string {
|
||||
const url = process.env.API_URL || "https://pullfrog.com";
|
||||
log.debug(`resolved API_URL: ${url}`);
|
||||
return url;
|
||||
}
|
||||
const raw = process.env.API_URL || "https://pullfrog.com";
|
||||
const parsed = new URL(raw);
|
||||
|
||||
/**
|
||||
* returns headers needed to bypass Vercel deployment protection on preview deployments.
|
||||
* when VERCEL_AUTOMATION_BYPASS_SECRET is set (preview repos), includes the bypass header.
|
||||
* otherwise returns an empty object (production / local dev).
|
||||
*/
|
||||
export function getVercelBypassHeaders(): Record<string, string> {
|
||||
const secret = process.env.VERCEL_AUTOMATION_BYPASS_SECRET;
|
||||
if (!secret) return {};
|
||||
return { "x-vercel-protection-bypass": secret };
|
||||
if (parsed.protocol !== "https:" && !isLocalUrl(parsed)) {
|
||||
throw new Error(
|
||||
`API_URL must use https:// (got ${parsed.protocol}). only localhost is exempt.`
|
||||
);
|
||||
}
|
||||
|
||||
log.debug(`resolved API_URL: ${raw}`);
|
||||
return raw;
|
||||
}
|
||||
|
||||
@@ -120,6 +120,7 @@ const testEnvAllowList = new Set([
|
||||
"GOOGLE_GENERATIVE_AI_API_KEY",
|
||||
"CURSOR_API_KEY",
|
||||
"OPENCODE_MODEL", // override OpenCode model (e.g. google/gemini-3-flash-preview) for tests or user preference
|
||||
"GEMINI_MODEL", // override Gemini model (e.g. gemini-3-pro-preview) for tests or user preference
|
||||
"LOG_LEVEL",
|
||||
"DEBUG",
|
||||
"NODE_ENV",
|
||||
|
||||
+22
-106
@@ -1,119 +1,35 @@
|
||||
import { LEAPING_INTO_ACTION_PREFIX } from "../mcp/comment.ts";
|
||||
import type { ToolState } from "../mcp/server.ts";
|
||||
import { buildPullfrogFooter } from "./buildPullfrogFooter.ts";
|
||||
import { log } from "./cli.ts";
|
||||
import { createOctokit, parseRepoContext } from "./github.ts";
|
||||
import { revokeGitHubInstallationToken } from "./token.ts";
|
||||
import os from "node:os";
|
||||
|
||||
type ExitSignalHandler = (signal: "SIGINT" | "SIGTERM") => void | Promise<void>;
|
||||
|
||||
const handlers = new Set<ExitSignalHandler>();
|
||||
let installed = false;
|
||||
|
||||
/**
|
||||
* Build error comment body with error message and footer
|
||||
* Register a handler to run when the process receives SIGINT or SIGTERM.
|
||||
* Returns a dispose function that removes the handler.
|
||||
*/
|
||||
export function buildErrorCommentBody(params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
runId: string | undefined;
|
||||
isCancellation: boolean;
|
||||
}): string {
|
||||
const workflowRunLink = params.runId
|
||||
? `[workflow run logs](https://github.com/${params.owner}/${params.repo}/actions/runs/${params.runId})`
|
||||
: "workflow run logs";
|
||||
const errorMessage = params.isCancellation
|
||||
? `This run was cancelled 🛑\n\nThe workflow was cancelled before completion. Please check the ${workflowRunLink} for details.`
|
||||
: `This run croaked 😵\n\nThe workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`;
|
||||
const footer = buildPullfrogFooter({
|
||||
triggeredBy: true,
|
||||
workflowRun: params.runId
|
||||
? { owner: params.owner, repo: params.repo, runId: params.runId }
|
||||
: undefined,
|
||||
});
|
||||
return `${errorMessage}${footer}`;
|
||||
export function onExitSignal(handler: ExitSignalHandler): () => void {
|
||||
installSignalHandlers();
|
||||
handlers.add(handler);
|
||||
return () => {
|
||||
handlers.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
let cleanupFn: ((isCancellation: boolean) => Promise<void>) | undefined;
|
||||
function installSignalHandlers(): void {
|
||||
if (installed) return;
|
||||
installed = true;
|
||||
|
||||
export function setupExitHandler(toolState: ToolState): void {
|
||||
let hasCleanedUp = false;
|
||||
|
||||
async function cleanup(isCancellation: boolean): Promise<void> {
|
||||
if (hasCleanedUp) {
|
||||
return;
|
||||
}
|
||||
hasCleanedUp = true;
|
||||
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const commentId = toolState.progressCommentId;
|
||||
const wasUpdated = toolState.wasUpdated === true;
|
||||
|
||||
// update progress comment if it was never updated (still shows "leaping into action")
|
||||
if (token && commentId && !wasUpdated) {
|
||||
try {
|
||||
const repoContext = parseRepoContext();
|
||||
const octokit = createOctokit(token);
|
||||
|
||||
const existingComment = await octokit.rest.issues.getComment({
|
||||
owner: repoContext.owner,
|
||||
repo: repoContext.name,
|
||||
comment_id: commentId,
|
||||
});
|
||||
|
||||
const commentBody = existingComment.data.body || "";
|
||||
|
||||
// only update if comment still shows the initial "leaping into action" message
|
||||
if (commentBody.startsWith(LEAPING_INTO_ACTION_PREFIX)) {
|
||||
const runId = process.env.GITHUB_RUN_ID;
|
||||
|
||||
const body = buildErrorCommentBody({
|
||||
owner: repoContext.owner,
|
||||
repo: repoContext.name,
|
||||
runId,
|
||||
isCancellation,
|
||||
});
|
||||
|
||||
await octokit.rest.issues.updateComment({
|
||||
owner: repoContext.owner,
|
||||
repo: repoContext.name,
|
||||
comment_id: commentId,
|
||||
body,
|
||||
});
|
||||
|
||||
log.info("» updated progress comment with error message");
|
||||
}
|
||||
} catch {
|
||||
// ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// revoke token
|
||||
if (token) {
|
||||
try {
|
||||
await revokeGitHubInstallationToken(token);
|
||||
log.debug("» installation token revoked");
|
||||
} catch {
|
||||
// ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// store cleanup function for runCleanup()
|
||||
cleanupFn = cleanup;
|
||||
|
||||
// handle cancellation signals
|
||||
function handleSignal(): void {
|
||||
log.info("» workflow cancelled, cleaning up...");
|
||||
cleanup(true).finally(() => process.exit(1));
|
||||
async function handleSignal(signal: "SIGINT" | "SIGTERM") {
|
||||
await Promise.allSettled([...handlers].map((h) => Promise.try(h, signal)));
|
||||
exitWithSignal(signal);
|
||||
}
|
||||
|
||||
process.on("SIGINT", handleSignal);
|
||||
process.on("SIGTERM", handleSignal);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run cleanup explicitly. Called from entry.ts in finally block.
|
||||
*/
|
||||
export async function runCleanup(): Promise<void> {
|
||||
try {
|
||||
await cleanupFn?.(false);
|
||||
} catch {
|
||||
// ignore errors during cleanup
|
||||
}
|
||||
export function exitWithSignal(signal: "SIGINT" | "SIGTERM") {
|
||||
process.exit(128 + os.constants.signals[signal]);
|
||||
}
|
||||
|
||||
+1
-1
@@ -161,7 +161,7 @@ export function $git(
|
||||
|
||||
if (result.status !== 0) {
|
||||
const stderr = result.stderr?.trim() ?? "";
|
||||
log.error(`git ${subcommand} failed: ${stderr}`);
|
||||
log.info(`git ${subcommand} failed: ${stderr}`);
|
||||
throw new Error(`git ${subcommand} failed: ${stderr}`);
|
||||
}
|
||||
|
||||
|
||||
+36
-28
@@ -2,7 +2,7 @@ import { createSign } from "node:crypto";
|
||||
import * as core from "@actions/core";
|
||||
import { throttling } from "@octokit/plugin-throttling";
|
||||
import { Octokit } from "@octokit/rest";
|
||||
import { getApiUrl, getVercelBypassHeaders } from "./apiUrl.ts";
|
||||
import { apiFetch } from "./apiFetch.ts";
|
||||
import { retry } from "./retry.ts";
|
||||
|
||||
export interface InstallationToken {
|
||||
@@ -53,73 +53,63 @@ function isOIDCAvailable(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// github installation token permission levels
|
||||
type ReadWrite = "read" | "write";
|
||||
type WriteOnly = "write";
|
||||
type ReadOnly = "read";
|
||||
|
||||
// permission names use underscores (API format)
|
||||
type InstallationTokenPermissions = {
|
||||
/**
|
||||
* GitHub App installation access token permissions.
|
||||
* passed to `POST /app/installations/{id}/access_tokens` to scope the token.
|
||||
* fields and allowed values come from the `app-permissions` OpenAPI schema.
|
||||
* @see https://docs.github.com/en/rest/apps/installations#create-an-installation-access-token-for-an-app
|
||||
* @see https://github.com/github/rest-api-description — components.schemas.app-permissions
|
||||
*/
|
||||
type GitHubAppPermissions = {
|
||||
actions?: ReadWrite;
|
||||
artifact_metadata?: ReadWrite;
|
||||
attestations?: ReadWrite;
|
||||
checks?: ReadWrite;
|
||||
contents?: ReadWrite;
|
||||
deployments?: ReadWrite;
|
||||
id_token?: WriteOnly;
|
||||
issues?: ReadWrite;
|
||||
models?: ReadOnly;
|
||||
discussions?: ReadWrite;
|
||||
issues?: ReadWrite;
|
||||
packages?: ReadWrite;
|
||||
pages?: ReadWrite;
|
||||
pull_requests?: ReadWrite;
|
||||
security_events?: ReadWrite;
|
||||
statuses?: ReadWrite;
|
||||
workflows?: WriteOnly;
|
||||
};
|
||||
|
||||
type AcquireTokenOptions = {
|
||||
repos?: string[];
|
||||
permissions?: InstallationTokenPermissions;
|
||||
permissions?: GitHubAppPermissions;
|
||||
};
|
||||
|
||||
async function acquireTokenViaOIDC(opts?: AcquireTokenOptions): Promise<string> {
|
||||
const oidcToken = await core.getIDToken("pullfrog-api");
|
||||
|
||||
const apiUrl = getApiUrl();
|
||||
const params = new URLSearchParams();
|
||||
|
||||
// ensure the token covers GITHUB_REPOSITORY (may differ from OIDC claims repo)
|
||||
const repos = [...(opts?.repos ?? [])];
|
||||
const targetRepo = process.env.GITHUB_REPOSITORY?.split("/")[1];
|
||||
if (targetRepo) {
|
||||
repos.push(targetRepo);
|
||||
}
|
||||
if (repos.length) {
|
||||
params.set("repos", repos.join(","));
|
||||
}
|
||||
const queryString = params.toString() ? `?${params.toString()}` : "";
|
||||
const reposParam = repos.length ? `?repos=${repos.join(",")}` : "";
|
||||
|
||||
const timeoutMs = 30000;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const fetchOptions: RequestInit = {
|
||||
const tokenResponse = await apiFetch({
|
||||
path: `/api/github/installation-token${reposParam}`,
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${oidcToken}`,
|
||||
"Content-Type": "application/json",
|
||||
...getVercelBypassHeaders(),
|
||||
},
|
||||
body: opts?.permissions ? JSON.stringify({ permissions: opts.permissions }) : undefined,
|
||||
signal: controller.signal,
|
||||
};
|
||||
if (opts?.permissions) {
|
||||
fetchOptions.body = JSON.stringify({ permissions: opts.permissions });
|
||||
}
|
||||
const tokenResponse = await fetch(
|
||||
`${apiUrl}/api/github/installation-token${queryString}`,
|
||||
fetchOptions
|
||||
);
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
@@ -228,7 +218,7 @@ const checkRepositoryAccess = async (
|
||||
const createInstallationToken = async (
|
||||
jwt: string,
|
||||
installationId: number,
|
||||
permissions?: InstallationTokenPermissions
|
||||
permissions?: GitHubAppPermissions
|
||||
): Promise<string> => {
|
||||
const requestOpts: { method: string; headers: Record<string, string>; body?: string } = {
|
||||
method: "POST",
|
||||
@@ -287,6 +277,24 @@ async function acquireTokenViaGitHubApp(opts?: AcquireTokenOptions): Promise<str
|
||||
return await createInstallationToken(jwt, installationId, opts?.permissions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a GitHub token is available in the environment.
|
||||
*
|
||||
* If neither `GITHUB_TOKEN` nor `GH_TOKEN` is set, attempts to acquire an
|
||||
* installation token using `GITHUB_APP_ID` / `GITHUB_PRIVATE_KEY`.
|
||||
*
|
||||
* **Not intended for production use** — this is a convenience for local
|
||||
* development and test harnesses where tokens aren't pre-provisioned.
|
||||
*/
|
||||
export async function ensureGitHubToken(): Promise<void> {
|
||||
if (!process.env.GITHUB_TOKEN && !process.env.GH_TOKEN) {
|
||||
if (process.env.GITHUB_APP_ID && process.env.GITHUB_PRIVATE_KEY) {
|
||||
const token = await acquireNewToken();
|
||||
process.env.GITHUB_TOKEN = token;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function acquireNewToken(opts?: AcquireTokenOptions): Promise<string> {
|
||||
if (isOIDCAvailable()) {
|
||||
return await retry(() => acquireTokenViaOIDC(opts), {
|
||||
|
||||
+11
-2
@@ -346,7 +346,10 @@ export function resolveInstructions(ctx: InstructionsContext): ResolvedInstructi
|
||||
|
||||
Call \`delegate\` with a mode, effort level, and optional instructions:
|
||||
- \`mode\`: The workflow to run (see available modes below)
|
||||
- \`effort\`: \`"auto"\` (default, most capable), \`"mini"\` (fast, for simple tasks), or \`"max"\` (maximum capability)
|
||||
- \`effort\`:
|
||||
- \`"mini"\`: low-effort and fast, for simple tasks
|
||||
- \`"auto"\`: medium-effort, good for typical tasks that don't require significant reasoning
|
||||
- \`"max"\`: high-effort, good for PR reviews and complex coding tasks.
|
||||
- \`instructions\`: Optional additional context for the subagent. Use this to pass results from earlier delegations or narrow the subagent's focus.
|
||||
|
||||
### Single vs. multi-phase delegation
|
||||
@@ -419,7 +422,13 @@ export function resolveSubagentInstructions(
|
||||
): ResolvedInstructions {
|
||||
const inputs = buildCommonInputs(ctx);
|
||||
|
||||
const subagentTaskSection = `You are operating in **${ctx.mode.name}** mode.
|
||||
const subagentTaskSection = `You are operating in **${ctx.mode.name}** mode as a delegated subagent. An orchestrator spawned you and will read your final output to decide what to do next.
|
||||
|
||||
### Delegation rules
|
||||
|
||||
- The \`delegate\` tool is NOT available to you — complete your task directly using the available tools.
|
||||
- When you finish, end with a clear, concise summary: what you did, what succeeded, what failed, and any blockers or next steps. The orchestrator uses this to decide whether to delegate again or report final results.
|
||||
- If you encounter an error you cannot resolve, report it clearly — do not attempt to delegate or re-run yourself.
|
||||
|
||||
${ctx.mode.prompt}`;
|
||||
|
||||
|
||||
+20
-12
@@ -6,8 +6,17 @@ import * as core from "@actions/core";
|
||||
import { table } from "table";
|
||||
import { isGitHubActions, isInsideDocker } from "./globals.ts";
|
||||
|
||||
const isDebugEnabled = () =>
|
||||
process.env.LOG_LEVEL === "debug" || process.env.ACTIONS_STEP_DEBUG === "true" || core.isDebug();
|
||||
const isRunnerDebugEnabled = () => core.isDebug();
|
||||
|
||||
const isLocalDebugEnabled = () =>
|
||||
process.env.LOG_LEVEL === "debug" || process.env.ACTIONS_STEP_DEBUG === "true";
|
||||
|
||||
const isDebugEnabled = () => isLocalDebugEnabled() || isRunnerDebugEnabled();
|
||||
|
||||
/** timestamp prefix for debug mode — empty string when debug is off */
|
||||
function ts(): string {
|
||||
return isDebugEnabled() ? `[${new Date().toISOString()}] ` : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Format arguments into a single string for logging
|
||||
@@ -206,23 +215,18 @@ function separator(length: number = 50): void {
|
||||
/**
|
||||
* Main logging utility object - import this once and access all utilities
|
||||
*/
|
||||
/** timestamp prefix for debug mode — empty string when debug is off */
|
||||
function ts(): string {
|
||||
return isDebugEnabled() ? `[${new Date().toISOString()}] ` : "";
|
||||
}
|
||||
|
||||
export const log = {
|
||||
/** Print info message */
|
||||
info: (...args: unknown[]): void => {
|
||||
core.info(`${ts()}${formatArgs(args)}`);
|
||||
},
|
||||
|
||||
/** Print warning message */
|
||||
/** Print a warning message. Use only for warnings that should be displayed in the job summary. */
|
||||
warning: (...args: unknown[]): void => {
|
||||
core.warning(`${ts()}${formatArgs(args)}`);
|
||||
},
|
||||
|
||||
/** Print error message */
|
||||
/** Print an error message. Use only for errors that should be displayed in the job summary. */
|
||||
error: (...args: unknown[]): void => {
|
||||
core.error(`${ts()}${formatArgs(args)}`);
|
||||
},
|
||||
@@ -232,10 +236,14 @@ export const log = {
|
||||
core.info(`${ts()}» ${formatArgs(args)}`);
|
||||
},
|
||||
|
||||
/** Print debug message (only if LOG_LEVEL=debug) */
|
||||
/** Print debug message (only when debug mode is enabled) */
|
||||
debug: (...args: unknown[]): void => {
|
||||
if (isDebugEnabled()) {
|
||||
core.info(`[${new Date().toISOString()}] [DEBUG] ${formatArgs(args)}`);
|
||||
if (isRunnerDebugEnabled()) {
|
||||
core.debug(formatArgs(args));
|
||||
return;
|
||||
}
|
||||
if (isLocalDebugEnabled()) {
|
||||
core.info(`${ts()}[DEBUG] ${formatArgs(args)}`);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ export const JsonPayload = type({
|
||||
version: "string",
|
||||
"agent?": AgentName.or("undefined"),
|
||||
prompt: "string",
|
||||
"triggeringUser?": "string | undefined",
|
||||
"eventInstructions?": "string",
|
||||
"repoInstructions?": "string",
|
||||
"event?": "object",
|
||||
@@ -108,6 +109,11 @@ function resolveNonPromptInputs() {
|
||||
});
|
||||
}
|
||||
|
||||
const isPullfrog = (actor: string | null | undefined): boolean => {
|
||||
actor = actor?.replace("[bot]", "");
|
||||
return !!actor && (actor === "pullfrog" || actor === "pullfrogdev");
|
||||
};
|
||||
|
||||
export function resolvePayload(
|
||||
resolvedPromptInput: ResolvedPromptInput,
|
||||
repoSettings: RepoSettings
|
||||
@@ -161,6 +167,10 @@ export function resolvePayload(
|
||||
version: jsonPayload?.version ?? packageJson.version,
|
||||
agent: resolvedAgent,
|
||||
prompt,
|
||||
triggeringUser:
|
||||
jsonPayload?.triggeringUser ??
|
||||
// it's not a common use case but GITHUB_ACTOR can be a user when the workflow is manually triggered by a user through GitHub Actions UI
|
||||
(!isPullfrog(process.env.GITHUB_ACTOR) ? process.env.GITHUB_ACTOR : undefined),
|
||||
eventInstructions: jsonPayload?.eventInstructions,
|
||||
repoInstructions: jsonPayload?.repoInstructions,
|
||||
event,
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { LEAPING_INTO_ACTION_PREFIX } from "../mcp/comment.ts";
|
||||
import { buildPullfrogFooter } from "./buildPullfrogFooter.ts";
|
||||
import { log } from "./cli.ts";
|
||||
import { createOctokit, parseRepoContext } from "./github.ts";
|
||||
import { type ResolvedPromptInput, resolvePromptInput } from "./payload.ts";
|
||||
import { getJobToken } from "./token.ts";
|
||||
|
||||
type JsonPromptInput = Extract<ResolvedPromptInput, object>; // not string
|
||||
|
||||
/**
|
||||
* Controls whether the script should check the reason for the workflow termination.
|
||||
* It can be either canceled or failed.
|
||||
* YAML file cannot supply it (not in ENV), so an extra request is required to check it.
|
||||
* */
|
||||
const SHOULD_CHECK_REASON = true;
|
||||
|
||||
/**
|
||||
* Build error comment body with error message and footer
|
||||
*/
|
||||
function buildErrorCommentBody(params: {
|
||||
owner: string;
|
||||
repo: string;
|
||||
runId: string | undefined;
|
||||
isCancellation: boolean;
|
||||
}): string {
|
||||
const workflowRunLink = params.runId
|
||||
? `[workflow run logs](https://github.com/${params.owner}/${params.repo}/actions/runs/${params.runId})`
|
||||
: "workflow run logs";
|
||||
const errorMessage = params.isCancellation
|
||||
? `This run was cancelled 🛑\n\nThe workflow was cancelled before completion. Please check the ${workflowRunLink} for details.`
|
||||
: `This run croaked 😵\n\nThe workflow encountered an error before any progress could be reported. Please check the ${workflowRunLink} for details.`;
|
||||
const footer = buildPullfrogFooter({
|
||||
triggeredBy: true,
|
||||
workflowRun: params.runId
|
||||
? { owner: params.owner, repo: params.repo, runId: params.runId }
|
||||
: undefined,
|
||||
});
|
||||
return `${errorMessage}${footer}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that the progress comment is stuck on "Leaping into action"
|
||||
* Fetches the comment by ID and checks if it starts with LEAPING_INTO_ACTION_PREFIX
|
||||
* Returns the comment ID if stuck, null otherwise
|
||||
*/
|
||||
async function validateStuckProgressComment(
|
||||
promptInput: JsonPromptInput | null,
|
||||
octokit: ReturnType<typeof createOctokit>,
|
||||
owner: string,
|
||||
repo: string
|
||||
): Promise<number | null> {
|
||||
if (!promptInput?.progressCommentId) {
|
||||
log.info("[post] no progressCommentId in prompt input, skipping cleanup");
|
||||
return null;
|
||||
}
|
||||
|
||||
const commentId = parseInt(promptInput.progressCommentId, 10);
|
||||
log.info(`[post] validating progressCommentId from prompt input: ${commentId}`);
|
||||
|
||||
try {
|
||||
const { data: comment } = await octokit.rest.issues.getComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: commentId,
|
||||
});
|
||||
|
||||
// check if comment is stuck on "Leaping into action"
|
||||
if (comment.body?.startsWith(LEAPING_INTO_ACTION_PREFIX)) {
|
||||
log.info(`[post] comment ${commentId} is stuck on "Leaping into action"`);
|
||||
return commentId;
|
||||
}
|
||||
|
||||
log.info(`[post] comment ${commentId} is not stuck (already updated or different content)`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.info(`[post] failed to get comment ${commentId}: ${errorMessage}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if the workflow or its steps is cancelled.
|
||||
* While the job is still in_progress, the individual steps may have their conclusions set.
|
||||
*/
|
||||
async function getIsCancelled(params: {
|
||||
repoContext: ReturnType<typeof parseRepoContext>;
|
||||
octokit: ReturnType<typeof createOctokit>;
|
||||
runIdStr: string | undefined;
|
||||
}): Promise<boolean> {
|
||||
if (!params.runIdStr) return false; // can't check without a run ID — assume failure
|
||||
try {
|
||||
const { data: jobs } = await params.octokit.rest.actions.listJobsForWorkflowRun({
|
||||
owner: params.repoContext.owner,
|
||||
repo: params.repoContext.name,
|
||||
run_id: Number.parseInt(params.runIdStr, 10),
|
||||
});
|
||||
|
||||
// find current job by matching GITHUB_JOB env var
|
||||
// Note: GITHUB_JOB is the job ID (yaml key), but job.name is the display name
|
||||
// For matrix jobs, the name includes matrix values like "build (ubuntu-latest, node-18)"
|
||||
// So we match jobs that START with the job ID
|
||||
const currentJobName = process.env.GITHUB_JOB;
|
||||
const currentJob = currentJobName
|
||||
? jobs.jobs.find((j) => j.name === currentJobName || j.name.startsWith(`${currentJobName} (`))
|
||||
: jobs.jobs[0]; // fallback to first job
|
||||
|
||||
if (!currentJob) {
|
||||
log.warning("[post] could not find current job");
|
||||
return false;
|
||||
}
|
||||
|
||||
log.info(`[post] job status: ${currentJob.status}, conclusion: ${currentJob.conclusion}`);
|
||||
if (currentJob.conclusion === "cancelled") return true; // whole job explicit cancellation
|
||||
|
||||
// but if it's still null, check steps for cancellation:
|
||||
const cancelledStep = currentJob.steps?.find((step) => step.conclusion === "cancelled");
|
||||
if (cancelledStep) {
|
||||
log.info(`[post] found cancelled step: ${cancelledStep.name}`);
|
||||
return true;
|
||||
}
|
||||
log.info("[post] no cancellation found, assuming failure");
|
||||
} catch (error) {
|
||||
log.info(
|
||||
`[post] failed to get job status: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
return false; // assuming failure
|
||||
}
|
||||
|
||||
export async function runPostCleanup(): Promise<void> {
|
||||
log.info("» [post] starting post cleanup");
|
||||
|
||||
const runIdStr = process.env.GITHUB_RUN_ID;
|
||||
|
||||
// resolve prompt input once and use it for both issue number and comment ID extraction
|
||||
// only use the object form (JSON payload), not plain string prompts
|
||||
let promptInput: JsonPromptInput | null = null;
|
||||
try {
|
||||
const resolved = resolvePromptInput();
|
||||
if (typeof resolved !== "string") promptInput = resolved;
|
||||
} catch (error) {
|
||||
log.info(
|
||||
`[post] failed to resolve prompt input: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
|
||||
// get job token for API calls
|
||||
const token = getJobToken();
|
||||
const repoContext = parseRepoContext();
|
||||
const octokit = createOctokit(token);
|
||||
|
||||
// validate that progressCommentId from prompt input is stuck on "Leaping into action"
|
||||
const commentId = await validateStuckProgressComment(
|
||||
promptInput,
|
||||
octokit,
|
||||
repoContext.owner,
|
||||
repoContext.name
|
||||
);
|
||||
|
||||
if (!commentId) return log.info("» [post] no stuck progress comment to update, skipping cleanup");
|
||||
|
||||
log.info(`» [post] validated stuck comment: ${commentId}, updating with error message`);
|
||||
|
||||
try {
|
||||
const body = buildErrorCommentBody({
|
||||
owner: repoContext.owner,
|
||||
repo: repoContext.name,
|
||||
runId: runIdStr,
|
||||
isCancellation: SHOULD_CHECK_REASON
|
||||
? await getIsCancelled({ octokit, repoContext, runIdStr })
|
||||
: false,
|
||||
});
|
||||
|
||||
await octokit.rest.issues.updateComment({
|
||||
owner: repoContext.owner,
|
||||
repo: repoContext.name,
|
||||
comment_id: commentId,
|
||||
body,
|
||||
});
|
||||
|
||||
log.info("» [post] successfully updated progress comment");
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log.info(`[post] failed to update comment: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
+1
-3
@@ -37,9 +37,7 @@ export async function retry<T>(fn: () => Promise<T>, options: RetryOptions = {})
|
||||
}
|
||||
|
||||
const delay = delayMs * attempt;
|
||||
log.warning(
|
||||
`» ${label} failed (attempt ${attempt}/${maxAttempts}), retrying in ${delay}ms...`
|
||||
);
|
||||
log.info(`» ${label} failed (attempt ${attempt}/${maxAttempts}), retrying in ${delay}ms...`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
+9
-14
@@ -1,5 +1,5 @@
|
||||
import type { AgentName, BashPermission, PushPermission, ToolPermission } from "../external.ts";
|
||||
import { getApiUrl, getVercelBypassHeaders } from "./apiUrl.ts";
|
||||
import { apiFetch } from "./apiFetch.ts";
|
||||
import type { RepoContext } from "./github.ts";
|
||||
|
||||
export interface Mode {
|
||||
@@ -52,24 +52,19 @@ export async function fetchRunContext(params: {
|
||||
token: string;
|
||||
repoContext: RepoContext;
|
||||
}): Promise<RunContext> {
|
||||
const apiUrl = getApiUrl();
|
||||
const timeoutMs = 30000;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${apiUrl}/api/repo/${params.repoContext.owner}/${params.repoContext.name}/run-context`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.token}`,
|
||||
"Content-Type": "application/json",
|
||||
...getVercelBypassHeaders(),
|
||||
},
|
||||
signal: controller.signal,
|
||||
}
|
||||
);
|
||||
const response = await apiFetch({
|
||||
path: `/api/repo/${params.repoContext.owner}/${params.repoContext.name}/run-context`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${params.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
|
||||
+5
-20
@@ -3,7 +3,6 @@
|
||||
* Redacts actual secret values rather than using pattern matching
|
||||
*/
|
||||
|
||||
import { agentsManifest } from "../external.ts";
|
||||
import { getGitHubInstallationToken } from "./token.ts";
|
||||
|
||||
// patterns for sensitive env var names
|
||||
@@ -52,28 +51,14 @@ export function resolveEnv(mode: EnvMode | undefined): Record<string, string | u
|
||||
function getAllSecrets(): string[] {
|
||||
const secrets: string[] = [];
|
||||
|
||||
// get all API key values from agent manifest
|
||||
for (const agent of Object.values(agentsManifest)) {
|
||||
for (const keyName of agent.apiKeyNames) {
|
||||
const envKey = keyName.toUpperCase();
|
||||
const value = process.env[envKey];
|
||||
if (value) {
|
||||
secrets.push(value);
|
||||
}
|
||||
// collect all env var values matching SENSITIVE_PATTERNS
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value && isSensitiveEnvName(key)) {
|
||||
secrets.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
// for OpenCode: also scan all API_KEY environment variables (since apiKeyNames is empty)
|
||||
const opencodeAgent = agentsManifest.opencode;
|
||||
if (opencodeAgent && opencodeAgent.apiKeyNames.length === 0) {
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value && typeof value === "string" && key.includes("API_KEY")) {
|
||||
secrets.push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add GitHub installation token
|
||||
// add GitHub installation token (stored in memory, not in env)
|
||||
try {
|
||||
const token = getGitHubInstallationToken();
|
||||
if (token) {
|
||||
|
||||
+2
-4
@@ -69,7 +69,7 @@ export interface SetupGitParams extends GitContext {
|
||||
* - sets up authentication via gitToken (minimal contents:write)
|
||||
* - for PR events, checks out the PR branch using shared helper
|
||||
*
|
||||
* gitToken is a minimal-permission token (contents:write only) used for git operations.
|
||||
* gitToken is a minimal-permission token (contents + workflows) used for git operations.
|
||||
* it is assumed to be potentially exfiltratable, so it has limited scope.
|
||||
*/
|
||||
export async function setupGit(params: SetupGitParams): Promise<void> {
|
||||
@@ -121,9 +121,7 @@ export async function setupGit(params: SetupGitParams): Promise<void> {
|
||||
} catch (error) {
|
||||
// If git config fails, log warning but don't fail the action
|
||||
// This can happen if we're not in a git repo or git isn't available
|
||||
log.warning(
|
||||
`Failed to set git config: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
log.info(`Failed to set git config: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
// 2. setup authentication
|
||||
|
||||
+19
-27
@@ -2,6 +2,7 @@ import { type ChildProcess, spawn as nodeSpawn } from "node:child_process";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { DEFAULT_ACTIVITY_CHECK_INTERVAL_MS, DEFAULT_ACTIVITY_TIMEOUT_MS } from "./activity.ts";
|
||||
import { log } from "./cli.ts";
|
||||
import { onExitSignal } from "./exitHandler.ts";
|
||||
|
||||
export type TrackChildOptions = {
|
||||
child: ChildProcess;
|
||||
@@ -18,6 +19,9 @@ let externalSignalHandler: SignalHandler | null = null;
|
||||
|
||||
// track a child process for cleanup on Ctrl+C
|
||||
export function trackChild(options: TrackChildOptions): void {
|
||||
// the signal handler cleans up all tracked children
|
||||
// so we only have to install it once some child gets tracked
|
||||
installSignalHandler();
|
||||
activeChildren.set(options.child, options.killGroup ?? false);
|
||||
}
|
||||
|
||||
@@ -32,8 +36,7 @@ export function setSignalHandler(handler: SignalHandler | null): void {
|
||||
}
|
||||
|
||||
// kill all tracked children without exiting
|
||||
export function killTrackedChildren(): number {
|
||||
const count = activeChildren.size;
|
||||
export function killTrackedChildren() {
|
||||
for (const entry of activeChildren) {
|
||||
const child = entry[0];
|
||||
const killGroup = entry[1];
|
||||
@@ -47,35 +50,24 @@ export function killTrackedChildren(): number {
|
||||
}
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
// cleanup handler for SIGINT/SIGTERM - kills all tracked children
|
||||
function cleanupAndExit(signal: string): void {
|
||||
const count = killTrackedChildren();
|
||||
if (count > 0) {
|
||||
log.info(`» received ${signal}, killing ${count} subprocess(es)...`);
|
||||
}
|
||||
// force exit after a short delay if process is stuck
|
||||
setTimeout(() => process.exit(1), 500).unref();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function handleSignal(signal: NodeJS.Signals): void {
|
||||
if (externalSignalHandler) {
|
||||
externalSignalHandler(signal);
|
||||
return;
|
||||
}
|
||||
cleanupAndExit(signal);
|
||||
}
|
||||
|
||||
// install signal handlers once (call early in process lifecycle)
|
||||
let handlersInstalled = false;
|
||||
export function installSignalHandlers(): void {
|
||||
function installSignalHandler(): void {
|
||||
if (handlersInstalled) return;
|
||||
handlersInstalled = true;
|
||||
process.on("SIGINT", () => handleSignal("SIGINT"));
|
||||
process.on("SIGTERM", () => handleSignal("SIGTERM"));
|
||||
onExitSignal((signal) => {
|
||||
if (externalSignalHandler) {
|
||||
externalSignalHandler(signal);
|
||||
return;
|
||||
}
|
||||
const count = activeChildren.size;
|
||||
if (count > 0) {
|
||||
log.info(`» received ${signal}, killing ${count} subprocess(es)...`);
|
||||
}
|
||||
killTrackedChildren();
|
||||
});
|
||||
}
|
||||
|
||||
export interface SpawnOptions {
|
||||
@@ -106,7 +98,7 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
const { cmd, args, env, input, timeout, cwd, stdio, onStdout, onStderr } = options;
|
||||
const activityTimeoutMs = options.activityTimeout ?? DEFAULT_ACTIVITY_TIMEOUT_MS;
|
||||
|
||||
installSignalHandlers();
|
||||
installSignalHandler();
|
||||
|
||||
const startTime = performance.now();
|
||||
let stdoutBuffer = "";
|
||||
@@ -157,7 +149,7 @@ export async function spawn(options: SpawnOptions): Promise<SpawnResult> {
|
||||
if (idleMs > activityTimeoutMs) {
|
||||
isActivityTimedOut = true;
|
||||
const idleSec = Math.round(idleMs / 1000);
|
||||
log.error(`no output for ${idleSec}s from pid=${child.pid} (${cmd}), killing process`);
|
||||
log.info(`no output for ${idleSec}s from pid=${child.pid} (${cmd}), killing process`);
|
||||
child.kill("SIGKILL");
|
||||
clearInterval(activityCheckIntervalId);
|
||||
}
|
||||
|
||||
+36
-10
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import * as core from "@actions/core";
|
||||
import type { PushPermission } from "../external.ts";
|
||||
import { log } from "./cli.ts";
|
||||
import { onExitSignal } from "./exitHandler.ts";
|
||||
import { acquireNewToken } from "./github.ts";
|
||||
import { isGitHubActions } from "./globals.ts";
|
||||
|
||||
@@ -105,12 +106,20 @@ export async function resolveTokens(params: ResolveTokensParams): Promise<TokenR
|
||||
|
||||
// create git token based on push permission (assumed exfiltratable)
|
||||
// disabled = read-only, restricted/enabled = write (MCP tools enforce branch restrictions)
|
||||
const gitContents = params.push === "disabled" ? "read" : "write";
|
||||
const gitToken = await acquireNewToken({ permissions: { contents: gitContents } });
|
||||
// workflows permission is write-only in the API, so only requested when pushing is allowed
|
||||
const gitPermissions =
|
||||
params.push === "disabled"
|
||||
? { contents: "read" as const }
|
||||
: { contents: "write" as const, workflows: "write" as const };
|
||||
const gitToken = await acquireNewToken({ permissions: gitPermissions });
|
||||
if (isGitHubActions) {
|
||||
core.setSecret(gitToken);
|
||||
}
|
||||
log.info(`» acquired git token (contents:${gitContents})`);
|
||||
log.info(
|
||||
`» acquired git token (${Object.entries(gitPermissions)
|
||||
.map((e) => e.join(":"))
|
||||
.join(", ")})`
|
||||
);
|
||||
|
||||
// create full MCP token - not exfiltratable (only accessible via MCP tools)
|
||||
const mcpToken = await acquireNewToken();
|
||||
@@ -123,18 +132,35 @@ export async function resolveTokens(params: ResolveTokensParams): Promise<TokenR
|
||||
const revertGithubToken = setEnvironmentVariable("GITHUB_TOKEN", mcpToken);
|
||||
mcpTokenValue = mcpToken;
|
||||
|
||||
return {
|
||||
gitToken,
|
||||
mcpToken,
|
||||
async [Symbol.asyncDispose]() {
|
||||
let disposingRef: PromiseWithResolvers<void> | undefined;
|
||||
|
||||
const dispose = async () => {
|
||||
if (disposingRef) {
|
||||
// this can happen if the signal arrives when disposing tokens
|
||||
// we make sure to wait for the current dispose to complete
|
||||
return disposingRef.promise;
|
||||
}
|
||||
disposingRef = Promise.withResolvers();
|
||||
try {
|
||||
mcpTokenValue = undefined;
|
||||
revertGithubToken();
|
||||
// revoke both tokens
|
||||
await Promise.all([
|
||||
revokeGitHubInstallationToken(gitToken),
|
||||
revokeGitHubInstallationToken(mcpToken),
|
||||
]);
|
||||
},
|
||||
} finally {
|
||||
removeSignalHandler();
|
||||
disposingRef.resolve();
|
||||
disposingRef = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const removeSignalHandler = onExitSignal(dispose);
|
||||
|
||||
return {
|
||||
gitToken,
|
||||
mcpToken,
|
||||
[Symbol.asyncDispose]: dispose,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -161,7 +187,7 @@ export async function revokeGitHubInstallationToken(token: string): Promise<void
|
||||
});
|
||||
log.debug("» installation token revoked");
|
||||
} catch (error) {
|
||||
log.warning(
|
||||
log.info(
|
||||
`Failed to revoke installation token: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export interface WorkflowRunInfo {
|
||||
progressCommentId: string | null;
|
||||
}
|
||||
+2
-4
@@ -1,9 +1,7 @@
|
||||
import { resolve } from "node:path";
|
||||
import { config } from "dotenv";
|
||||
import { ensureGitHubToken } from "./utils/github.ts";
|
||||
|
||||
config({ path: resolve(import.meta.dirname, "../.env") });
|
||||
|
||||
// alias GITHUB_TOKEN to GH_TOKEN for tests
|
||||
if (!process.env.GH_TOKEN && process.env.GITHUB_TOKEN) {
|
||||
process.env.GH_TOKEN = process.env.GITHUB_TOKEN;
|
||||
}
|
||||
await ensureGitHubToken();
|
||||
|
||||
Reference in New Issue
Block a user