/** * Pipeline service — declarative completion rules. * * Uses workflow config to determine transitions and side effects. */ import type { PluginRuntime } from "openclaw/plugin-sdk"; import type { StateLabel, IssueProvider } from "../providers/provider.js"; import { deactivateWorker } from "../projects.js"; import { runCommand } from "../run-command.js"; import { notify, getNotificationConfig } from "../notify.js"; import { log as auditLog } from "../audit.js"; import { loadConfig } from "../config/index.js"; import { DEFAULT_WORKFLOW, Action, getCompletionRule, getNextStateDescription, getCompletionEmoji, type CompletionRule, type WorkflowConfig, } from "../workflow.js"; export type { CompletionRule }; export type CompletionOutput = { labelTransition: string; announcement: string; nextState: string; prUrl?: string; issueUrl?: string; issueClosed?: boolean; issueReopened?: boolean; }; /** * Get completion rule for a role:result pair. * Uses workflow config when available. */ export function getRule( role: string, result: string, workflow: WorkflowConfig = DEFAULT_WORKFLOW, ): CompletionRule | undefined { return getCompletionRule(workflow, role, result) ?? undefined; } /** * Execute the completion side-effects for a role:result pair. */ export async function executeCompletion(opts: { workspaceDir: string; groupId: string; role: string; result: string; issueId: number; summary?: string; prUrl?: string; provider: IssueProvider; repoPath: string; projectName: string; channel?: string; pluginConfig?: Record; /** Plugin runtime for direct API access (avoids CLI subprocess timeouts) */ runtime?: PluginRuntime; /** Workflow config (defaults to DEFAULT_WORKFLOW) */ workflow?: WorkflowConfig; }): Promise { const { workspaceDir, groupId, role, result, issueId, summary, provider, repoPath, projectName, channel, pluginConfig, runtime, workflow = DEFAULT_WORKFLOW, } = opts; const key = `${role}:${result}`; const rule = getCompletionRule(workflow, role, result); if (!rule) throw new Error(`No completion rule for ${key}`); const { timeouts } = await loadConfig(workspaceDir, projectName); let prUrl = opts.prUrl; // Execute pre-notification actions for (const action of rule.actions) { switch (action) { case Action.GIT_PULL: try { await runCommand(["git", "pull"], { timeoutMs: timeouts.gitPullMs, cwd: repoPath }); } catch (err) { auditLog(workspaceDir, "pipeline_warning", { step: "gitPull", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {}); } break; case Action.DETECT_PR: if (!prUrl) { try { prUrl = await provider.getMergedMRUrl(issueId) ?? undefined; } catch (err) { auditLog(workspaceDir, "pipeline_warning", { step: "detectPr", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {}); } } break; } } // Get issue early (for URL in notification) const issue = await provider.getIssue(issueId); // Get next state description from workflow const nextState = getNextStateDescription(workflow, role, result); // Send notification early (before deactivation and label transition which can fail) const notifyConfig = getNotificationConfig(pluginConfig); notify( { type: "workerComplete", project: projectName, groupId, issueId, issueUrl: issue.web_url, role, result: result as "done" | "pass" | "fail" | "refine" | "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(() => {}); }); // Deactivate worker + transition label await deactivateWorker(workspaceDir, groupId, role); await provider.transitionLabel(issueId, rule.from as StateLabel, rule.to as StateLabel); // Execute post-transition actions for (const action of rule.actions) { switch (action) { case Action.CLOSE_ISSUE: await provider.closeIssue(issueId); break; case Action.REOPEN_ISSUE: await provider.reopenIssue(issueId); break; } } // Build announcement using workflow-derived emoji const emoji = getCompletionEmoji(role, result); const label = key.replace(":", " ").toUpperCase(); let announcement = `${emoji} ${label} #${issueId}`; if (summary) announcement += ` — ${summary}`; announcement += `\nšŸ“‹ Issue: ${issue.web_url}`; if (prUrl) announcement += `\nšŸ”— PR: ${prUrl}`; announcement += `\n${nextState}.`; return { labelTransition: `${rule.from} → ${rule.to}`, announcement, nextState, prUrl, issueUrl: issue.web_url, issueClosed: rule.actions.includes(Action.CLOSE_ISSUE), issueReopened: rule.actions.includes(Action.REOPEN_ISSUE), }; }