Files
devclaw-gitea/lib/tools/work-finish.ts

189 lines
7.2 KiB
TypeScript

/**
* work_finish — Complete a task (DEV done, QA pass/fail/refine/blocked).
*
* Delegates side-effects to pipeline service: label transition, state update,
* issue close/reopen, notifications, and audit logging.
*
* Roles without workflow states (e.g. architect) are handled inline —
* deactivate worker, optionally transition label, and notify.
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js";
import type { StateLabel } from "../providers/provider.js";
import { deactivateWorker, getWorker, resolveRepoPath } from "../projects.js";
import { executeCompletion, getRule } from "../services/pipeline.js";
import { log as auditLog } from "../audit.js";
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
import { getAllRoleIds, isValidResult, getCompletionResults } from "../roles/index.js";
import { loadWorkflow, hasWorkflowStates, getCompletionEmoji } from "../workflow.js";
import { notify, getNotificationConfig } from "../notify.js";
export function createWorkFinishTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({
name: "work_finish",
label: "Work Finish",
description: `Complete a task: Developer done (PR created, goes to review) or blocked. Tester pass/fail/refine/blocked. Reviewer approve/reject/blocked. Architect done/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit logging.`,
parameters: {
type: "object",
required: ["role", "result", "projectGroupId"],
properties: {
role: { type: "string", enum: getAllRoleIds(), description: "Worker role" },
result: { type: "string", enum: ["done", "pass", "fail", "refine", "blocked", "approve", "reject"], description: "Completion result" },
projectGroupId: { type: "string", description: "Project group ID" },
summary: { type: "string", description: "Brief summary" },
prUrl: { type: "string", description: "PR/MR URL (auto-detected if omitted)" },
},
},
async execute(_id: string, params: Record<string, unknown>) {
const role = params.role as string;
const result = params.result as string;
const groupId = params.projectGroupId as string;
const summary = params.summary as string | undefined;
const prUrl = params.prUrl as string | undefined;
const workspaceDir = requireWorkspaceDir(ctx);
// Validate role:result using registry
if (!isValidResult(role, result)) {
const valid = getCompletionResults(role);
throw new Error(`${role.toUpperCase()} cannot complete with "${result}". Valid results: ${valid.join(", ")}`);
}
// Resolve project + worker
const { project } = await resolveProject(workspaceDir, groupId);
const worker = getWorker(project, role);
if (!worker.active) throw new Error(`${role.toUpperCase()} worker not active on ${project.name}`);
const issueId = worker.issueId ? Number(worker.issueId.split(",")[0]) : null;
if (!issueId) throw new Error(`No issueId for active ${role.toUpperCase()} on ${project.name}`);
const { provider } = await resolveProvider(project);
const workflow = await loadWorkflow(workspaceDir, project.name);
// Roles without workflow states (e.g. architect) — handle inline
if (!hasWorkflowStates(workflow, role)) {
return handleStatelessCompletion({
workspaceDir, groupId, role, result, issueId, summary,
provider, projectName: project.name, channel: project.channel,
pluginConfig: getPluginConfig(api), runtime: api.runtime,
});
}
// Standard pipeline completion for roles with workflow states
if (!getRule(role, result))
throw new Error(`Invalid completion: ${role}:${result}`);
const repoPath = resolveRepoPath(project.repo);
const pluginConfig = getPluginConfig(api);
const completion = await executeCompletion({
workspaceDir, groupId, role, result, issueId, summary, prUrl, provider, repoPath,
projectName: project.name,
channel: project.channel,
pluginConfig,
runtime: api.runtime,
workflow,
});
const output: Record<string, unknown> = {
success: true, project: project.name, groupId, issueId, role, result,
...completion,
};
await auditLog(workspaceDir, "work_finish", {
project: project.name, groupId, issue: issueId, role, result,
summary: summary ?? null, labelTransition: completion.labelTransition,
});
return jsonResult(output);
},
});
}
/**
* Handle completion for roles without workflow states (e.g. architect).
*
* - done: deactivate worker, issue stays in current state (Planning)
* - blocked: deactivate worker, transition issue to Refining
*/
async function handleStatelessCompletion(opts: {
workspaceDir: string;
groupId: string;
role: string;
result: string;
issueId: number;
summary?: string;
provider: import("../providers/provider.js").IssueProvider;
projectName: string;
channel?: string;
pluginConfig?: Record<string, unknown>;
runtime?: import("openclaw/plugin-sdk").PluginRuntime;
}): Promise<ReturnType<typeof jsonResult>> {
const {
workspaceDir, groupId, role, result, issueId, summary,
provider, projectName, channel, pluginConfig, runtime,
} = opts;
const issue = await provider.getIssue(issueId);
// Deactivate worker
await deactivateWorker(workspaceDir, groupId, role);
// If blocked, transition to Refining
let labelTransition = "none";
if (result === "blocked") {
const currentLabel = provider.getCurrentStateLabel(issue) ?? "Planning";
await provider.transitionLabel(issueId, currentLabel as StateLabel, "Refining" as StateLabel);
labelTransition = `${currentLabel} → Refining`;
}
// Notification
const nextState = result === "blocked" ? "awaiting human decision" : "awaiting human decision";
const notifyConfig = getNotificationConfig(pluginConfig);
notify(
{
type: "workerComplete",
project: projectName,
groupId,
issueId,
issueUrl: issue.web_url,
role,
result: result as "done" | "blocked",
summary,
nextState,
},
{
workspaceDir,
config: notifyConfig,
groupId,
channel: channel ?? "telegram",
runtime,
},
).catch((err) => {
auditLog(workspaceDir, "pipeline_warning", { step: "notify", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {});
});
// Build announcement
const emoji = getCompletionEmoji(role, result);
const label = `${role} ${result}`.toUpperCase();
let announcement = `${emoji} ${label} #${issueId}`;
if (summary) announcement += `${summary}`;
announcement += `\n📋 Issue: ${issue.web_url}`;
if (result === "blocked") announcement += `\nawaiting human decision.`;
// Audit
await auditLog(workspaceDir, "work_finish", {
project: projectName, groupId, issue: issueId, role, result,
summary: summary ?? null, labelTransition,
});
return jsonResult({
success: true, project: projectName, groupId, issueId, role, result,
labelTransition,
announcement,
nextState,
issueUrl: issue.web_url,
});
}