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 { 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 | 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; 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((_, 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); } }