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:
@@ -6,8 +6,11 @@ import {
|
||||
type Issue,
|
||||
type StateLabel,
|
||||
type IssueComment,
|
||||
type PrStatus,
|
||||
PrState,
|
||||
} from "./provider.js";
|
||||
import { runCommand } from "../run-command.js";
|
||||
import { withResilience } from "./resilience.js";
|
||||
import {
|
||||
DEFAULT_WORKFLOW,
|
||||
getStateLabels,
|
||||
@@ -41,8 +44,10 @@ export class GitHubProvider implements IssueProvider {
|
||||
}
|
||||
|
||||
private async gh(args: string[]): Promise<string> {
|
||||
const result = await runCommand(["gh", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
|
||||
return result.stdout.trim();
|
||||
return withResilience(async () => {
|
||||
const result = await runCommand(["gh", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
|
||||
return result.stdout.trim();
|
||||
});
|
||||
}
|
||||
|
||||
async ensureLabel(name: string, color: string): Promise<void> {
|
||||
@@ -125,6 +130,28 @@ export class GitHubProvider implements IssueProvider {
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async getPrStatus(issueId: number): Promise<PrStatus> {
|
||||
const pat = `#${issueId}`;
|
||||
// Check open PRs first
|
||||
try {
|
||||
const raw = await this.gh(["pr", "list", "--state", "open", "--json", "title,body,url,reviewDecision", "--limit", "20"]);
|
||||
const prs = JSON.parse(raw) as Array<{ title: string; body: string; url: string; reviewDecision: string }>;
|
||||
const pr = prs.find((p) => p.title.includes(pat) || (p.body ?? "").includes(pat));
|
||||
if (pr) {
|
||||
const state = pr.reviewDecision === "APPROVED" ? PrState.APPROVED : PrState.OPEN;
|
||||
return { state, url: pr.url };
|
||||
}
|
||||
} catch { /* continue to merged check */ }
|
||||
// Check merged PRs
|
||||
try {
|
||||
const raw = await this.gh(["pr", "list", "--state", "merged", "--json", "title,body,url", "--limit", "20"]);
|
||||
const prs = JSON.parse(raw) as Array<{ title: string; body: string; url: string }>;
|
||||
const pr = prs.find((p) => p.title.includes(pat) || (p.body ?? "").includes(pat));
|
||||
if (pr) return { state: PrState.MERGED, url: pr.url };
|
||||
} catch { /* ignore */ }
|
||||
return { state: PrState.CLOSED, url: null };
|
||||
}
|
||||
|
||||
async addComment(issueId: number, body: string): Promise<void> {
|
||||
await this.gh(["issue", "comment", String(issueId), "--body", body]);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,11 @@ import {
|
||||
type Issue,
|
||||
type StateLabel,
|
||||
type IssueComment,
|
||||
type PrStatus,
|
||||
PrState,
|
||||
} from "./provider.js";
|
||||
import { runCommand } from "../run-command.js";
|
||||
import { withResilience } from "./resilience.js";
|
||||
import {
|
||||
DEFAULT_WORKFLOW,
|
||||
getStateLabels,
|
||||
@@ -25,8 +28,10 @@ export class GitLabProvider implements IssueProvider {
|
||||
}
|
||||
|
||||
private async glab(args: string[]): Promise<string> {
|
||||
const result = await runCommand(["glab", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
|
||||
return result.stdout.trim();
|
||||
return withResilience(async () => {
|
||||
const result = await runCommand(["glab", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
|
||||
return result.stdout.trim();
|
||||
});
|
||||
}
|
||||
|
||||
async ensureLabel(name: string, color: string): Promise<void> {
|
||||
@@ -122,6 +127,28 @@ export class GitLabProvider implements IssueProvider {
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
async getPrStatus(issueId: number): Promise<PrStatus> {
|
||||
const pat = `#${issueId}`;
|
||||
// Check open MRs first
|
||||
try {
|
||||
const raw = await this.glab(["mr", "list", "--output", "json", "--state", "opened"]);
|
||||
const mrs = JSON.parse(raw) as Array<{ title: string; description: string; web_url: string; approved_by?: Array<unknown> }>;
|
||||
const mr = mrs.find((m) => m.title.includes(pat) || (m.description ?? "").includes(pat));
|
||||
if (mr) {
|
||||
const state = mr.approved_by && mr.approved_by.length > 0 ? PrState.APPROVED : PrState.OPEN;
|
||||
return { state, url: mr.web_url };
|
||||
}
|
||||
} catch { /* continue to merged check */ }
|
||||
// Check merged MRs
|
||||
try {
|
||||
const raw = await this.glab(["mr", "list", "--output", "json", "--state", "merged"]);
|
||||
const mrs = JSON.parse(raw) as Array<{ title: string; description: string; web_url: string }>;
|
||||
const mr = mrs.find((m) => m.title.includes(pat) || (m.description ?? "").includes(pat));
|
||||
if (mr) return { state: PrState.MERGED, url: mr.web_url };
|
||||
} catch { /* ignore */ }
|
||||
return { state: PrState.CLOSED, url: null };
|
||||
}
|
||||
|
||||
async addComment(issueId: number, body: string): Promise<void> {
|
||||
// Pass message directly as argv — no shell escaping needed with spawn
|
||||
await this.glab(["issue", "note", String(issueId), "--message", body]);
|
||||
|
||||
@@ -28,6 +28,20 @@ export type IssueComment = {
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
/** Built-in PR states. */
|
||||
export const PrState = {
|
||||
OPEN: "open",
|
||||
APPROVED: "approved",
|
||||
MERGED: "merged",
|
||||
CLOSED: "closed",
|
||||
} as const;
|
||||
export type PrState = (typeof PrState)[keyof typeof PrState];
|
||||
|
||||
export type PrStatus = {
|
||||
state: PrState;
|
||||
url: string | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider interface
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -46,6 +60,7 @@ export interface IssueProvider {
|
||||
getCurrentStateLabel(issue: Issue): StateLabel | null;
|
||||
hasMergedMR(issueId: number): Promise<boolean>;
|
||||
getMergedMRUrl(issueId: number): Promise<string | null>;
|
||||
getPrStatus(issueId: number): Promise<PrStatus>;
|
||||
addComment(issueId: number, body: string): Promise<void>;
|
||||
healthCheck(): Promise<boolean>;
|
||||
}
|
||||
|
||||
49
lib/providers/resilience.ts
Normal file
49
lib/providers/resilience.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* providers/resilience.ts — Retry and circuit breaker policies for provider calls.
|
||||
*
|
||||
* Uses cockatiel for lightweight resilience without heavyweight orchestration.
|
||||
* Applied to GitHub/GitLab CLI calls that can fail due to network, rate limits, or timeouts.
|
||||
*/
|
||||
import {
|
||||
ExponentialBackoff,
|
||||
retry,
|
||||
circuitBreaker,
|
||||
ConsecutiveBreaker,
|
||||
handleAll,
|
||||
wrap,
|
||||
type IPolicy,
|
||||
} from "cockatiel";
|
||||
|
||||
/**
|
||||
* Default retry policy: 3 attempts with exponential backoff.
|
||||
* Handles all errors (network, timeout, CLI failure).
|
||||
*/
|
||||
const retryPolicy = retry(handleAll, {
|
||||
maxAttempts: 3,
|
||||
backoff: new ExponentialBackoff({
|
||||
initialDelay: 500,
|
||||
maxDelay: 5_000,
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Circuit breaker: opens after 5 consecutive failures, half-opens after 30s.
|
||||
* Prevents hammering a provider that's down.
|
||||
*/
|
||||
const breakerPolicy = circuitBreaker(handleAll, {
|
||||
halfOpenAfter: 30_000,
|
||||
breaker: new ConsecutiveBreaker(5),
|
||||
});
|
||||
|
||||
/**
|
||||
* Combined policy: circuit breaker wrapping retry.
|
||||
* If circuit is open, calls fail fast without retrying.
|
||||
*/
|
||||
export const providerPolicy: IPolicy = wrap(breakerPolicy, retryPolicy);
|
||||
|
||||
/**
|
||||
* Execute a provider call with retry + circuit breaker.
|
||||
*/
|
||||
export function withResilience<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return providerPolicy.execute(() => fn());
|
||||
}
|
||||
Reference in New Issue
Block a user