feat: enhance workflow and testing infrastructure

- Introduced ExecutionMode type for project execution modes (parallel, sequential).
- Updated SetupOpts to use ExecutionMode instead of string literals.
- Enhanced workflow states to include a new "In Review" state with appropriate transitions.
- Implemented TestHarness for end-to-end testing, including command interception and workspace setup.
- Created TestProvider for in-memory issue tracking during tests.
- Refactored project registration and setup tools to utilize ExecutionMode.
- Updated various tools to ensure compatibility with new workflow and execution modes.
- Added new dependencies: cockatiel for resilience and zod for schema validation.
This commit is contained in:
Lauren ten Hoor
2026-02-16 13:27:14 +08:00
parent a359ffed34
commit 371e760d94
37 changed files with 2444 additions and 263 deletions

View File

@@ -0,0 +1,224 @@
/**
* TestProvider — In-memory IssueProvider for integration tests.
*
* Tracks all method calls for assertion. Issues are stored in a simple map.
* No external dependencies — pure TypeScript.
*/
import type {
IssueProvider,
Issue,
StateLabel,
IssueComment,
PrStatus,
} from "../providers/provider.js";
import { getStateLabels } from "../workflow.js";
import { DEFAULT_WORKFLOW, type WorkflowConfig } from "../workflow.js";
// ---------------------------------------------------------------------------
// Call tracking
// ---------------------------------------------------------------------------
export type ProviderCall =
| { method: "ensureLabel"; args: { name: string; color: string } }
| { method: "ensureAllStateLabels"; args: {} }
| { method: "createIssue"; args: { title: string; description: string; label: StateLabel; assignees?: string[] } }
| { method: "listIssuesByLabel"; args: { label: StateLabel } }
| { method: "getIssue"; args: { issueId: number } }
| { method: "listComments"; args: { issueId: number } }
| { method: "transitionLabel"; args: { issueId: number; from: StateLabel; to: StateLabel } }
| { method: "closeIssue"; args: { issueId: number } }
| { method: "reopenIssue"; args: { issueId: number } }
| { method: "hasMergedMR"; args: { issueId: number } }
| { method: "getMergedMRUrl"; args: { issueId: number } }
| { method: "getPrStatus"; args: { issueId: number } }
| { method: "addComment"; args: { issueId: number; body: string } }
| { method: "healthCheck"; args: {} };
// ---------------------------------------------------------------------------
// TestProvider
// ---------------------------------------------------------------------------
export class TestProvider implements IssueProvider {
/** All issues keyed by iid. */
issues = new Map<number, Issue>();
/** Comments per issue. */
comments = new Map<number, IssueComment[]>();
/** Labels that have been ensured. */
labels = new Map<string, string>();
/** PR status overrides per issue. Default: { state: "closed", url: null }. */
prStatuses = new Map<number, PrStatus>();
/** Merged MR URLs per issue. */
mergedMrUrls = new Map<number, string>();
/** All calls, in order. */
calls: ProviderCall[] = [];
private nextIssueId = 1;
private workflow: WorkflowConfig;
constructor(opts?: { workflow?: WorkflowConfig }) {
this.workflow = opts?.workflow ?? DEFAULT_WORKFLOW;
}
// -------------------------------------------------------------------------
// Test helpers
// -------------------------------------------------------------------------
/** Create an issue directly in the store (bypasses createIssue tracking). */
seedIssue(overrides: Partial<Issue> & { iid: number }): Issue {
const issue: Issue = {
iid: overrides.iid,
title: overrides.title ?? `Issue #${overrides.iid}`,
description: overrides.description ?? "",
labels: overrides.labels ?? [],
state: overrides.state ?? "opened",
web_url: overrides.web_url ?? `https://example.com/issues/${overrides.iid}`,
};
this.issues.set(issue.iid, issue);
if (issue.iid >= this.nextIssueId) this.nextIssueId = issue.iid + 1;
return issue;
}
/** Set PR status for an issue (used by review pass tests). */
setPrStatus(issueId: number, status: PrStatus): void {
this.prStatuses.set(issueId, status);
}
/** Get calls filtered by method name. */
callsTo<M extends ProviderCall["method"]>(
method: M,
): Extract<ProviderCall, { method: M }>[] {
return this.calls.filter((c) => c.method === method) as any;
}
/** Reset call tracking (keeps issue state). */
resetCalls(): void {
this.calls = [];
}
/** Full reset — clear everything. */
reset(): void {
this.issues.clear();
this.comments.clear();
this.labels.clear();
this.prStatuses.clear();
this.mergedMrUrls.clear();
this.calls = [];
this.nextIssueId = 1;
}
// -------------------------------------------------------------------------
// IssueProvider implementation
// -------------------------------------------------------------------------
async ensureLabel(name: string, color: string): Promise<void> {
this.calls.push({ method: "ensureLabel", args: { name, color } });
this.labels.set(name, color);
}
async ensureAllStateLabels(): Promise<void> {
this.calls.push({ method: "ensureAllStateLabels", args: {} });
const stateLabels = getStateLabels(this.workflow);
for (const label of stateLabels) {
this.labels.set(label, "#000000");
}
}
async createIssue(
title: string,
description: string,
label: StateLabel,
assignees?: string[],
): Promise<Issue> {
this.calls.push({ method: "createIssue", args: { title, description, label, assignees } });
const iid = this.nextIssueId++;
const issue: Issue = {
iid,
title,
description,
labels: [label],
state: "opened",
web_url: `https://example.com/issues/${iid}`,
};
this.issues.set(iid, issue);
return issue;
}
async listIssuesByLabel(label: StateLabel): Promise<Issue[]> {
this.calls.push({ method: "listIssuesByLabel", args: { label } });
return [...this.issues.values()].filter((i) => i.labels.includes(label));
}
async getIssue(issueId: number): Promise<Issue> {
this.calls.push({ method: "getIssue", args: { issueId } });
const issue = this.issues.get(issueId);
if (!issue) throw new Error(`Issue #${issueId} not found in TestProvider`);
return issue;
}
async listComments(issueId: number): Promise<IssueComment[]> {
this.calls.push({ method: "listComments", args: { issueId } });
return this.comments.get(issueId) ?? [];
}
async transitionLabel(
issueId: number,
from: StateLabel,
to: StateLabel,
): Promise<void> {
this.calls.push({ method: "transitionLabel", args: { issueId, from, to } });
const issue = this.issues.get(issueId);
if (!issue) throw new Error(`Issue #${issueId} not found in TestProvider`);
// Remove all state labels, add the new one
const stateLabels = getStateLabels(this.workflow);
issue.labels = issue.labels.filter((l) => !stateLabels.includes(l));
issue.labels.push(to);
}
async closeIssue(issueId: number): Promise<void> {
this.calls.push({ method: "closeIssue", args: { issueId } });
const issue = this.issues.get(issueId);
if (issue) issue.state = "closed";
}
async reopenIssue(issueId: number): Promise<void> {
this.calls.push({ method: "reopenIssue", args: { issueId } });
const issue = this.issues.get(issueId);
if (issue) issue.state = "opened";
}
hasStateLabel(issue: Issue, expected: StateLabel): boolean {
return issue.labels.includes(expected);
}
getCurrentStateLabel(issue: Issue): StateLabel | null {
const stateLabels = getStateLabels(this.workflow);
return stateLabels.find((l) => issue.labels.includes(l)) ?? null;
}
async hasMergedMR(issueId: number): Promise<boolean> {
this.calls.push({ method: "hasMergedMR", args: { issueId } });
return this.mergedMrUrls.has(issueId);
}
async getMergedMRUrl(issueId: number): Promise<string | null> {
this.calls.push({ method: "getMergedMRUrl", args: { issueId } });
return this.mergedMrUrls.get(issueId) ?? null;
}
async getPrStatus(issueId: number): Promise<PrStatus> {
this.calls.push({ method: "getPrStatus", args: { issueId } });
return this.prStatuses.get(issueId) ?? { state: "closed", url: null };
}
async addComment(issueId: number, body: string): Promise<void> {
this.calls.push({ method: "addComment", args: { issueId, body } });
const existing = this.comments.get(issueId) ?? [];
existing.push({ author: "test", body, created_at: new Date().toISOString() });
this.comments.set(issueId, existing);
}
async healthCheck(): Promise<boolean> {
this.calls.push({ method: "healthCheck", args: {} });
return true;
}
}