Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ Legacy fallback for default project:
bun run src/index.ts run --project default
bun run src/index.ts run --all-projects
bun run src/index.ts run --project default --issue ENG-123
bun run src/index.ts run --project default --poll
bun run src/index.ts run --project default --poll --poll-interval-ms 15000 --max-poll-cycles 20
bun run src/index.ts status --project default --issue ENG-123
bun run src/index.ts projects
```
Expand Down Expand Up @@ -69,6 +71,9 @@ Optional:
- `GITHUB_BASE_BRANCH` (default `main`)
- `PIV_WORKSPACE_PATH` (default current directory; state root)
- `PIV_EXECUTION_PATH` (default `PIV_WORKSPACE_PATH`; command execution path)
- `PIV_POLL_INTERVAL_MS` (default `30000`; polling sleep between cycles)
- `PIV_MAX_POLL_CYCLES` (optional; stop polling after this many cycles)
- `PIV_EXIT_WHEN_IDLE` (optional; default `1`, set `0` to keep polling when no issues are found)
- `PIV_DRY_RUN=1` to avoid Linear/GitHub mutations
- `PIV_DEV_MODE=1` to stream Codex stdout/stderr logs during runs
- `CODEX_SANDBOX` (optional; leave empty to disable sandbox, or set `read-only`, `workspace-write`, `danger-full-access`)
Expand Down
30 changes: 29 additions & 1 deletion src/args.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,23 @@ export function parseArgs(argv: string[]): CliCommand {
const issueArg = readFlagValue(args, "--issue");
const projectId = readFlagValue(args, "--project");
const allProjects = args.includes("--all-projects");
const poll = args.includes("--poll");
const pollIntervalMs = readOptionalPositiveInt(args, "--poll-interval-ms");
const maxPollCycles = readOptionalPositiveInt(args, "--max-poll-cycles");
if (projectId && allProjects) {
throw new Error("run command cannot use --project with --all-projects");
}
return { kind: "run", options: { issueArg, projectId, allProjects } };
return {
kind: "run",
options: {
issueArg,
projectId,
allProjects,
poll,
pollIntervalMs,
maxPollCycles,
},
};
}

if (command === "status") {
Expand Down Expand Up @@ -56,3 +69,18 @@ function readFlagValue(args: string[], flag: string): string | undefined {
}
return args[index + 1];
}

function readOptionalPositiveInt(
args: string[],
flag: string,
): number | undefined {
const raw = readFlagValue(args, flag);
if (raw === undefined) {
return undefined;
}
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`${flag} must be a positive integer`);
}
return value;
}
40 changes: 40 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ function buildEnvBase(cwd: string): ProjectRuntimeConfig {
},
autoCreateLabels: env.LINEAR_AUTO_CREATE_LABELS !== "0",
},
polling: {
intervalMs: Number(env.PIV_POLL_INTERVAL_MS ?? "30000"),
maxCycles: parseOptionalPositiveInt(env.PIV_MAX_POLL_CYCLES),
exitWhenIdle: env.PIV_EXIT_WHEN_IDLE !== "0",
},
github: {
useGhCli: true,
defaultBugLabel: env.GITHUB_BUG_LABEL ?? "bug",
Expand Down Expand Up @@ -202,6 +207,11 @@ function mergeRuntime(
...(rootDefaults.github ?? {}),
...(project.github ?? {}),
},
polling: {
...base.polling,
...(rootDefaults.polling ?? {}),
...(project.polling ?? {}),
},
codex: {
...base.codex,
...(rootDefaults.codex ?? {}),
Expand All @@ -216,6 +226,19 @@ function mergeRuntime(
};
}

function parseOptionalPositiveInt(
value: string | undefined,
): number | undefined {
if (!value) {
return undefined;
}
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
return undefined;
}
return parsed;
}

function validateProjects(projects: ResolvedProjectConfig[]): void {
if (projects.length === 0) {
throw new Error("At least one project configuration is required");
Expand Down Expand Up @@ -287,4 +310,21 @@ function validateProject(project: ResolvedProjectConfig): void {
.join(", ")}`,
);
}
if (
!Number.isInteger(project.polling.intervalMs) ||
project.polling.intervalMs <= 0
) {
throw new Error(
`Polling interval must be a positive integer for project '${project.id}'`,
);
}
if (
project.polling.maxCycles !== undefined &&
(!Number.isInteger(project.polling.maxCycles) ||
project.polling.maxCycles <= 0)
) {
throw new Error(
`Polling max cycles must be a positive integer for project '${project.id}'`,
);
}
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ function printHelp(): void {
"piv-loop - Codex CLI orchestration workflow",
"",
"Commands:",
" piv-loop run [--project <PROJECT_ID>] [--issue <LINEAR_KEY_OR_URL>]",
" piv-loop run [--project <PROJECT_ID>] [--issue <LINEAR_KEY_OR_URL>] [--poll] [--poll-interval-ms <MS>] [--max-poll-cycles <N>]",
" piv-loop run --all-projects [--issue <LINEAR_KEY_OR_URL>]",
" piv-loop status --project <PROJECT_ID> --issue <LINEAR_KEY>",
" piv-loop projects",
Expand Down
9 changes: 9 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ export interface ProjectRuntimeConfig {
labelMap: LinearLabelMap;
autoCreateLabels: boolean;
};
polling: {
intervalMs: number;
maxCycles?: number;
exitWhenIdle: boolean;
};
github: {
useGhCli: boolean;
defaultBugLabel: string;
Expand Down Expand Up @@ -162,4 +167,8 @@ export interface RunOptions {
issueArg?: string;
projectId?: string;
allProjects?: boolean;
poll?: boolean;
pollIntervalMs?: number;
maxPollCycles?: number;
exitWhenIdle?: boolean;
}
162 changes: 110 additions & 52 deletions src/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,64 +63,122 @@ async function runProjectWorkflow(
): Promise<void> {
const linear = new LinearClient(config);
const projectLogger = logger.child({ projectId: config.id });
const issues = await linear.fetchWork(options.issueArg);
if (issues.length === 0) {
projectLogger.info("No eligible Linear issues found.");
return;
}
const polling = resolvePollingSettings(config, options);
let cycle = 0;

for (const issue of issues) {
const key = normalizeIssueKey(issue.identifier);
const issueLogger = projectLogger.child({ issueKey: key });
const existing = await loadRunState(config.workspacePath, config.id, key);
const runState: RunState =
existing ??
({
projectId: config.id,
projectName: config.name,
workspacePath: config.executionPath,
repository: {
owner: config.repo.owner,
name: config.repo.name,
baseBranch: config.repo.baseBranch,
},
issue: {
id: issue.id,
key,
title: issue.title,
url: issue.url,
},
stage: "received",
bugs: [],
startedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} satisfies RunState);

try {
await executeIssue(config, linear, runState);
issueLogger.info({ stage: runState.stage }, "Issue workflow finished");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
runState.lastError = message;
runState.stage = "blocked";
await saveRunState(config.workspacePath, runState);
await safeLinearStageUpdate(linear, runState.issue.id, "blocked");
await safeLinearComment(
linear,
runState.issue.id,
`PIV loop failed and marked blocked.\n\nError:\n${message}`,
);
issueLogger.error(
{
err: normalizeError(error),
stage: runState.stage,
},
"Issue workflow failed",
while (true) {
cycle += 1;
const issues = await linear.fetchWork(options.issueArg);
projectLogger.info(
{ cycle, issueCount: issues.length, pollingEnabled: polling.enabled },
"Fetched eligible Linear issues",
);

if (issues.length === 0) {
projectLogger.info({ cycle }, "No eligible Linear issues found.");
}

for (const issue of issues) {
await processIssue(config, linear, issue);
}

if (!polling.enabled || options.issueArg) {
return;
}
if (polling.maxCycles !== undefined && cycle >= polling.maxCycles) {
projectLogger.info(
{ cycle, maxCycles: polling.maxCycles },
"Polling stopped after reaching configured max cycles",
);
return;
}
if (issues.length === 0 && polling.exitWhenIdle) {
projectLogger.info({ cycle }, "Polling exited after idle cycle.");
return;
}

await sleep(polling.intervalMs);
}
}

async function processIssue(
config: ResolvedProjectConfig,
linear: LinearClient,
issue: { id: string; identifier: string; title: string; url: string },
): Promise<void> {
const key = normalizeIssueKey(issue.identifier);
const issueLogger = logger.child({ projectId: config.id, issueKey: key });
const existing = await loadRunState(config.workspacePath, config.id, key);
const runState: RunState =
existing ??
({
projectId: config.id,
projectName: config.name,
workspacePath: config.executionPath,
repository: {
owner: config.repo.owner,
name: config.repo.name,
baseBranch: config.repo.baseBranch,
},
issue: {
id: issue.id,
key,
title: issue.title,
url: issue.url,
},
stage: "received",
bugs: [],
startedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} satisfies RunState);

try {
await executeIssue(config, linear, runState);
issueLogger.info({ stage: runState.stage }, "Issue workflow finished");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
runState.lastError = message;
runState.stage = "blocked";
await saveRunState(config.workspacePath, runState);
await safeLinearStageUpdate(linear, runState.issue.id, "blocked");
await safeLinearComment(
linear,
runState.issue.id,
`PIV loop failed and marked blocked.\n\nError:\n${message}`,
);
issueLogger.error(
{
err: normalizeError(error),
stage: runState.stage,
},
"Issue workflow failed",
);
}
}

export interface PollingSettings {
enabled: boolean;
intervalMs: number;
maxCycles?: number;
exitWhenIdle: boolean;
}

export function resolvePollingSettings(
config: ResolvedProjectConfig,
options: RunOptions,
): PollingSettings {
return {
enabled: options.poll === true,
intervalMs: options.pollIntervalMs ?? config.polling.intervalMs,
maxCycles: options.maxPollCycles ?? config.polling.maxCycles,
exitWhenIdle: options.exitWhenIdle ?? config.polling.exitWhenIdle,
};
}

export async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}

async function executeIssue(
config: ResolvedProjectConfig,
linear: LinearClient,
Expand Down
Loading
Loading