Files
shockbot/main.ts
T

323 lines
11 KiB
TypeScript

import * as core from "@actions/core";
import { agents } from "./agents/index.ts";
import type { PayloadEvent } from "./external.ts";
import { reportProgress } from "./mcp/comment.ts";
import { startInstallation } from "./mcp/dependencies.ts";
import { startMcpHttpServer, type ToolContext } from "./mcp/server.ts";
import { computeModes } from "./modes.ts";
import { initToolState } from "./toolState.ts";
import {
type ActivityTimeout,
createProcessOutputActivityTimeout,
DEFAULT_ACTIVITY_CHECK_INTERVAL_MS,
DEFAULT_ACTIVITY_TIMEOUT_MS,
} from "./utils/activity.ts";
import { recordDiffReadFromToolUse } from "./utils/diffCoverage.ts";
import { onExitSignal } from "./utils/exitHandler.ts";
import { resolveGit, setGitAuthServer } from "./utils/gitAuth.ts";
import { startGitAuthServer } from "./utils/gitAuthServer.ts";
import { createGiteaClient } from "./utils/gitea.ts";
import { resolveInstructions } from "./utils/instructions.ts";
import { executeLifecycleHook } from "./utils/lifecycle.ts";
import { log } from "./utils/cli.ts";
import { normalizeEnv } from "./utils/normalizeEnv.ts";
import { resolveOutputSchema, resolvePayload, resolvePromptInput } from "./utils/payload.ts";
import { defaultRepoSettings } from "./utils/runContext.ts";
import { setupGit, createTempDirectory, wipeRunnerLeakSurface } from "./utils/setup.ts";
import { killTrackedChildren } from "./utils/subprocess.ts";
import { resolveTimeoutMs, TIMEOUT_DISABLED } from "./utils/time.ts";
import { Timer } from "./utils/timer.ts";
import { createTodoTracker } from "./utils/todoTracking.ts";
export interface MainResult {
success: boolean;
output?: string | undefined;
error?: string | undefined;
result?: string | undefined;
}
function parseRepoContext(): { owner: string; name: string } {
const githubRepo = process.env.GITHUB_REPOSITORY;
if (!githubRepo) {
throw new Error("GITHUB_REPOSITORY environment variable is required");
}
const [owner, name] = githubRepo.split("/");
if (!owner || !name) {
throw new Error(`Invalid GITHUB_REPOSITORY format: ${githubRepo}. Expected 'owner/repo'`);
}
return { owner, name };
}
/**
* When the workflow passes a plain-string prompt, infer the event context
* from Gitea Actions environment variables (GITHUB_EVENT_NAME, GITEA_PR_NUMBER).
* Returns null when the env vars aren't set (e.g. local dev run).
*/
function resolveEventFromEnv(): PayloadEvent | null {
const eventName = process.env.GITHUB_EVENT_NAME;
const prNumberRaw = process.env.GITEA_PR_NUMBER;
const prNumber = prNumberRaw ? parseInt(prNumberRaw, 10) : NaN;
if (eventName === "pull_request" && !Number.isNaN(prNumber)) {
return {
trigger: "pull_request_opened",
issue_number: prNumber,
is_pr: true,
title: process.env.GITEA_PR_TITLE ?? "",
body: null,
branch: process.env.GITHUB_HEAD_REF ?? "",
};
}
if (eventName === "issue_comment" && !Number.isNaN(prNumber)) {
return {
trigger: "issue_comment_created",
issue_number: prNumber,
is_pr: true,
comment_id: 0,
comment_type: "issue",
body: null,
};
}
return null;
}
export async function main(): Promise<MainResult> {
normalizeEnv();
const timer = new Timer();
let activityTimeout: ActivityTimeout | null = null;
let safetyNetTimer: NodeJS.Timeout | undefined;
const resolvedPromptInput = resolvePromptInput();
const repoSettings = defaultRepoSettings();
const payload = resolvePayload(resolvedPromptInput, repoSettings);
// When the prompt is a plain string and the event resolved to "unknown",
// patch the event from Gitea Actions environment variables so the agent
// knows which PR to review.
if (payload.event.trigger === "unknown") {
const envEvent = resolveEventFromEnv();
log.info(
`» event resolution: GITHUB_EVENT_NAME=${process.env.GITHUB_EVENT_NAME ?? "(unset)"}, ` +
`GITEA_PR_NUMBER=${process.env.GITEA_PR_NUMBER ?? "(unset)"}, ` +
`resolved=${envEvent ? `${envEvent.trigger} #${(envEvent as { issue_number?: number }).issue_number}` : "null"}`
);
if (envEvent) {
(payload as { event: PayloadEvent }).event = envEvent;
}
}
const toolState = initToolState({
progressComment: payload.progressComment,
});
resolveGit();
const repoContext = parseRepoContext();
const gitea = createGiteaClient();
const tmpdir = createTempDirectory();
toolState.model = payload.model ?? process.env.OLLAMA_MODEL ?? "qwen3.6:35b";
if (payload.event.trigger === "pull_request_synchronize") {
toolState.beforeSha = payload.event.before_sha;
}
wipeRunnerLeakSurface();
const botToken = process.env.BOT_TOKEN;
if (!botToken) {
throw new Error("BOT_TOKEN environment variable is required");
}
let toolContext: ToolContext | undefined;
let progressCallbackDisabled = false;
let todoTracker: ReturnType<typeof createTodoTracker> | undefined;
try {
if (payload.cwd && process.cwd() !== payload.cwd) {
process.chdir(payload.cwd);
}
await using gitAuthServer = await startGitAuthServer(tmpdir);
setGitAuthServer(gitAuthServer);
await setupGit({
gitToken: botToken,
owner: repoContext.owner,
name: repoContext.name,
gitea,
toolState,
shell: payload.shell,
postCheckoutScript: repoSettings.postCheckoutScript,
});
timer.checkpoint("git");
const setupHook = await executeLifecycleHook({
event: "setup",
script: repoSettings.setupScript,
normalizeWorkingTreeAfter: true,
});
if (setupHook.warning) {
throw new Error(setupHook.warning);
}
timer.checkpoint("lifecycleHooks::setup");
const agentId = "ollama" as const;
const modes = computeModes(agentId);
const outputSchema = resolveOutputSchema();
let defaultBranch = "main";
try {
const repoData = await gitea.request(
"GET /repos/{owner}/{repo}",
{ owner: repoContext.owner, repo: repoContext.name }
);
defaultBranch = (repoData.data as { default_branch?: string }).default_branch ?? "main";
} catch { /* keep "main" fallback */ }
toolContext = {
agentId,
repo: { ...repoContext, defaultBranch },
payload,
gitea,
gitToken: botToken,
modes,
postCheckoutScript: repoSettings.postCheckoutScript,
prepushScript: repoSettings.prepushScript,
prApproveEnabled: repoSettings.prApproveEnabled,
modeInstructions: repoSettings.modeInstructions,
toolState,
mcpServerUrl: "",
tmpdir,
};
await using mcpHttpServer = await startMcpHttpServer(toolContext, { outputSchema });
toolContext.mcpServerUrl = mcpHttpServer.url;
log.info(`» MCP server started at ${mcpHttpServer.url}`);
timer.checkpoint("mcpServer");
startInstallation(toolContext);
const instructions = resolveInstructions({
payload,
repo: { owner: repoContext.owner, name: repoContext.name, defaultBranch },
modes,
agentId,
outputSchema,
});
log.info(`» starting shockbot (model: ${toolState.model})`);
activityTimeout = createProcessOutputActivityTimeout({
timeoutMs: DEFAULT_ACTIVITY_TIMEOUT_MS,
checkIntervalMs: DEFAULT_ACTIVITY_CHECK_INTERVAL_MS,
});
activityTimeout.promise.catch(() => {});
todoTracker = createTodoTracker(async (body) => {
if (progressCallbackDisabled || !toolContext) return;
try {
await reportProgress(toolContext, { body });
} catch (err) {
log.debug(`progress update failed: ${err}`);
}
});
toolState.todoTracker = todoTracker;
onExitSignal(() => {
todoTracker?.cancel();
});
let innerTimeoutFired = false;
const onInnerActivityTimeout = () => {
if (innerTimeoutFired) return;
innerTimeoutFired = true;
log.info("» inner activity timeout fired — stopping MCP server");
mcpHttpServer[Symbol.asyncDispose]().catch((err) => {
log.debug(`mcp server stop after inner kill failed: ${err instanceof Error ? err.message : String(err)}`);
});
safetyNetTimer = setTimeout(
() => {
activityTimeout?.forceReject("agent still pending 5min after inner activity kill — forcing exit");
},
5 * 60 * 1000
);
safetyNetTimer.unref?.();
};
const agentPromise = agents.ollama.run({
payload,
model: toolState.model,
mcpServerUrl: mcpHttpServer.url,
tmpdir,
instructions,
todoTracker,
stopScript: repoSettings.stopScript,
toolState,
onActivityTimeout: onInnerActivityTimeout,
onToolUse: (event) => {
const wasTracked = recordDiffReadFromToolUse({
state: toolState.diffCoverage,
toolName: event.toolName,
input: event.input,
cwd: process.cwd(),
});
if (!wasTracked) return;
log.debug(`» diff coverage tracked from tool ${event.toolName}`);
},
});
agentPromise.catch(() => {});
let result: Awaited<typeof agentPromise>;
if (payload.timeout === TIMEOUT_DISABLED) {
result = await Promise.race([agentPromise, activityTimeout.promise]);
} else {
const usable = resolveTimeoutMs(payload.timeout);
if (payload.timeout && usable === null) {
log.warning(`invalid timeout "${payload.timeout}", using 1h`);
}
const timeoutMs = usable ?? 3600000;
const actualTimeout = usable !== null ? payload.timeout : "1h";
let timeoutId: NodeJS.Timeout | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`agent run timed out after ${actualTimeout}`));
}, timeoutMs);
});
timeoutPromise.catch(() => {});
try {
result = await Promise.race([agentPromise, timeoutPromise, activityTimeout.promise]);
} finally {
clearTimeout(timeoutId);
}
}
if (outputSchema && !toolState.output) {
throw new Error(
"output_schema was provided but agent did not call set_output — structured output is required"
);
}
if (result.success) {
core.setOutput("result", result.output ?? "");
log.success("Task complete.");
return { success: true, output: result.output };
} else {
return { success: false, error: result.error };
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "unknown error occurred";
progressCallbackDisabled = true;
todoTracker?.cancel();
killTrackedChildren();
log.error(errorMessage);
return { success: false, error: errorMessage };
} finally {
activityTimeout?.stop();
if (safetyNetTimer) clearTimeout(safetyNetTimer);
}
}