Files
fusion/packages/core/src/task-merge.ts
2026-04-12 15:13:27 -07:00

112 lines
3.3 KiB
TypeScript

import type { Task, WorkflowStepResult } from "./types.js";
const BLOCKING_TASK_STATUSES = new Set([
"failed",
"awaiting-inspection",
"awaiting-user-review",
]);
const NON_TERMINAL_STEP_STATUSES = new Set([
"pending",
"in-progress",
]);
const NON_TERMINAL_WORKFLOW_STATUSES = new Set<WorkflowStepResult["status"]>([
"pending",
]);
/**
* Returns a human-readable reason when a task in review is not safe to finalize.
* Undefined means the task is eligible to move from `in-review` to `done`.
*/
export function getTaskMergeBlocker(
task: Pick<Task, "column" | "paused" | "status" | "error" | "steps" | "workflowStepResults">,
): string | undefined {
if (task.column !== "in-review") {
return `task is in '${task.column}', must be in 'in-review'`;
}
if (task.paused) {
return "task is paused";
}
if (task.status && BLOCKING_TASK_STATUSES.has(task.status)) {
return task.error
? `task is marked '${task.status}': ${task.error}`
: `task is marked '${task.status}'`;
}
if (task.steps.length > 0 && task.steps.some((step) => NON_TERMINAL_STEP_STATUSES.has(step.status))) {
return "task has incomplete steps";
}
// Only pre-merge workflow step failures block merge.
// Post-merge failures run after merge and do not block it.
if (
task.workflowStepResults?.some((result) => {
const phase = result.phase || "pre-merge";
return phase === "pre-merge" && NON_TERMINAL_WORKFLOW_STATUSES.has(result.status);
})
) {
return "task has incomplete or failed pre-merge workflow steps";
}
if (
task.workflowStepResults?.some((result) => {
const phase = result.phase || "pre-merge";
return phase === "pre-merge" && result.status === "failed";
})
) {
return "task has failed pre-merge workflow steps";
}
return undefined;
}
export function isTaskReadyForMerge(
task: Pick<Task, "column" | "paused" | "status" | "error" | "steps" | "workflowStepResults">,
): boolean {
return getTaskMergeBlocker(task) === undefined;
}
export interface TaskCompletionBlockerOptions {
resolveTask?: (taskId: string) => Promise<Pick<Task, "id" | "column"> | null | undefined>;
}
/**
* Returns a human-readable reason when a task should not be treated as
* successfully complete yet. Undefined means the task can be finalized.
*
* This is intentionally conservative: if dependency state cannot be resolved,
* the helper only blocks when the task itself carries enough state to prove
* completion is unsafe (`blockedBy`).
*/
export async function getTaskCompletionBlocker(
task: Pick<Task, "blockedBy" | "dependencies">,
options: TaskCompletionBlockerOptions = {},
): Promise<string | undefined> {
if (task.blockedBy?.trim()) {
return `task is blocked by ${task.blockedBy.trim()}`;
}
const dependencies = task.dependencies ?? [];
if (dependencies.length === 0 || !options.resolveTask) {
return undefined;
}
const unresolvedDependencies: string[] = [];
for (const dependencyId of dependencies) {
const dependency = await options.resolveTask(dependencyId);
if (!dependency || (dependency.column !== "done" && dependency.column !== "archived")) {
unresolvedDependencies.push(dependencyId);
}
}
if (unresolvedDependencies.length > 0) {
return `task has unresolved dependencies: ${unresolvedDependencies.join(", ")}`;
}
return undefined;
}