325 lines
10 KiB
TypeScript
325 lines
10 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();
|
|
|
|
toolContext = {
|
|
agentId,
|
|
repo: repoContext,
|
|
payload,
|
|
gitea,
|
|
gitToken: botToken,
|
|
modes,
|
|
postCheckoutScript: repoSettings.postCheckoutScript,
|
|
prepushScript: repoSettings.prepushScript,
|
|
prApproveEnabled: repoSettings.prApproveEnabled,
|
|
modeInstructions: repoSettings.modeInstructions,
|
|
toolState,
|
|
mcpServerUrl: "",
|
|
tmpdir,
|
|
};
|
|
|
|
let defaultBranch: string | undefined;
|
|
try {
|
|
const repoData = await gitea.rest.repository.repoGet({
|
|
owner: repoContext.owner,
|
|
repo: repoContext.name,
|
|
});
|
|
defaultBranch = repoData.data.default_branch;
|
|
} catch {
|
|
defaultBranch = "main";
|
|
}
|
|
|
|
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 ? { 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);
|
|
}
|
|
}
|