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
2 changes: 2 additions & 0 deletions .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/usr/bin/env sh
bun run check
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@
"format": "biome format --write .",
"lint": "biome lint .",
"check": "biome check .",
"check:write": "biome check --write ."
"check:write": "biome check --write .",
"prepare": "husky"
},
"devDependencies": {
"@types/bun": "^1.3.2",
"@biomejs/biome": "1.9.4",
"husky": "^9.1.7",
"typescript": "^5.9.3"
},
"dependencies": {
Expand Down
45 changes: 44 additions & 1 deletion src/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export class LinearClient {
private resolvedStatusMap:
| ResolvedProjectConfig["linear"]["statusMap"]
| null = null;
private resolvedCanceledStateId: string | null = null;
private resolvedWorkflowLabelIds: Partial<
Record<WorkflowLabelStage, string>
> = {};
Expand Down Expand Up @@ -57,10 +58,11 @@ export class LinearClient {
this.mapSdkIssueToLinearIssue(issue, includeLabels),
),
);
const assignedStateId = this.requiredStatusMap().assigned;

return sortIssuesByPriority(
issues
.filter((issue) => issue.state.id === this.requiredStatusMap().assigned)
.filter((issue) => issue.state.id === assignedStateId)
.filter((issue) => {
if (!this.config.linear.requiredLabel) {
return true;
Expand All @@ -74,6 +76,11 @@ export class LinearClient {
);
}

async isAssignedState(stateId: string): Promise<boolean> {
await this.ensureResolvedStatusMap();
return this.requiredStatusMap().assigned === stateId;
}

async markStage(
issueId: string,
stage: keyof ResolvedProjectConfig["linear"]["statusMap"],
Expand All @@ -86,6 +93,15 @@ export class LinearClient {
await this.client.updateIssue(issueId, { stateId });
}

async markCanceled(issueId: string): Promise<void> {
if (this.config.dryRun) {
return;
}
await this.ensureResolvedStatusMap();
const stateId = await this.resolveCanceledStateId();
await this.client.updateIssue(issueId, { stateId });
}

async applyStageLabel(issueId: string, stage: WorkflowStage): Promise<void> {
if (!isWorkflowLabelStage(stage)) {
return;
Expand Down Expand Up @@ -183,6 +199,33 @@ export class LinearClient {
};
}

private async resolveCanceledStateId(): Promise<string> {
if (this.resolvedCanceledStateId) {
return this.resolvedCanceledStateId;
}

const workflowStates = await this.client.workflowStates({
first: 250,
});
const states = workflowStates.nodes.filter((state) =>
this.config.linear.teamId
? state.teamId === this.config.linear.teamId
: true,
);
const canceled = states.find(
(state) => state.name.toLowerCase() === "canceled",
);

if (canceled?.id) {
this.resolvedCanceledStateId = canceled.id;
return canceled.id;
}

// Fallback for customized workflows without a literal "Canceled" state name.
this.resolvedCanceledStateId = this.requiredStatusMap().blocked;
return this.resolvedCanceledStateId;
}

private async ensureResolvedWorkflowLabels(): Promise<void> {
if (this.workflowLabelsResolved) {
return;
Expand Down
87 changes: 82 additions & 5 deletions src/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,11 +104,28 @@ async function runProjectWorkflow(
async function processIssue(
config: ResolvedProjectConfig,
linear: LinearClient,
issue: { id: string; identifier: string; title: string; url: string },
issue: {
id: string;
identifier: string;
title: string;
url: string;
state: {
id: string;
name: 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 isAssignedState = await linear.isAssignedState(issue.state.id);
if (!existing && !isAssignedState) {
issueLogger.info(
{ issueState: issue.state.name, issueStateId: issue.state.id },
"Skipping in-progress issue without resumable local run state",
);
return;
}
const runState: RunState =
existing ??
({
Expand All @@ -131,6 +148,12 @@ async function processIssue(
startedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} satisfies RunState);
issueLogger.info(
buildIssueJobLogFields(runState, runState.stage, {
resumed: existing !== null,
}),
"Taking issue job",
);

try {
await executeIssue(config, linear, runState);
Expand All @@ -140,11 +163,11 @@ async function processIssue(
runState.lastError = message;
runState.stage = "blocked";
await saveRunState(config.workspacePath, runState);
await safeLinearStageUpdate(linear, runState.issue.id, "blocked");
await safeLinearMoveToCanceled(linear, runState.issue.id);
await safeLinearComment(
linear,
runState.issue.id,
`PIV loop failed and marked blocked.\n\nError:\n${message}`,
`PIV loop failed and moved issue to Canceled.\n\nError:\n${message}`,
);
issueLogger.error(
{
Expand Down Expand Up @@ -179,6 +202,30 @@ export async function sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}

export interface IssueJobLogFields {
projectId: string;
issueKey: string;
issueId: string;
issueTitle: string;
stage: string;
resumed?: true;
}

export function buildIssueJobLogFields(
state: RunState,
stage: string,
options?: { resumed?: boolean },
): IssueJobLogFields {
return {
projectId: state.projectId,
issueKey: state.issue.key,
issueId: state.issue.id,
issueTitle: state.issue.title,
stage,
...(options?.resumed ? { resumed: true as const } : {}),
};
}

async function executeIssue(
config: ResolvedProjectConfig,
linear: LinearClient,
Expand All @@ -196,6 +243,7 @@ async function executeIssue(
}

if (state.stage === "planning") {
logger.info(buildIssueJobLogFields(state, "planning"), "Planning issue");
const prompt = await buildPlanPrompt(config.skills.plan, state.issue);
const result = await runPlanSession(config, prompt);
state.codexSessionId = result.sessionId ?? state.codexSessionId;
Expand All @@ -207,12 +255,17 @@ async function executeIssue(
state.issue.id,
buildPlanComment(state.issue.key, state.planSummary),
);
logger.info(buildIssueJobLogFields(state, "planning"), "Plan completed");
}

if (state.stage === "implementing") {
if (!state.codexSessionId) {
throw new Error("Missing codex session id for implement step");
}
logger.info(
buildIssueJobLogFields(state, "implementing"),
"Implementing issue",
);
const prompt = await buildImplementPrompt(
config.skills.implement,
state.issue,
Expand Down Expand Up @@ -243,6 +296,10 @@ async function executeIssue(
state.issue.id,
`Implementation completed. Draft PR: ${state.pullRequest.url ?? "(created)"}`,
);
logger.info(
buildIssueJobLogFields(state, "implementing"),
"Implementation completed",
);
}

if (state.stage === "pr_created") {
Expand All @@ -253,6 +310,7 @@ async function executeIssue(
}

if (state.stage === "reviewing" || state.stage === "testing") {
logger.info(buildIssueJobLogFields(state, "testing"), "Testing issue");
await linear.markStage(state.issue.id, "testing");
await linear.applyStageLabel(state.issue.id, "testing");
Object.assign(state, transitionStage(state, "testing"));
Expand Down Expand Up @@ -301,11 +359,11 @@ async function executeIssue(
}
Object.assign(state, transitionStage(state, "blocked"));
await saveRunState(config.workspacePath, state);
await linear.markStage(state.issue.id, "blocked");
await linear.markCanceled(state.issue.id);
await linear.comment(
state.issue.id,
[
"Review/testing found bugs. Marked as blocked.",
"Review/testing found bugs. Moved issue to Canceled.",
...state.bugs.map(
(bug) =>
`- ${bug.title}${bug.issueUrl ? ` (${bug.issueUrl})` : ""}`,
Expand All @@ -319,6 +377,10 @@ async function executeIssue(
await saveRunState(config.workspacePath, state);
await linear.markStage(state.issue.id, "done");
await linear.comment(state.issue.id, "Review/testing passed. Marked done.");
logger.info(
buildIssueJobLogFields(state, "testing"),
"Review/testing completed",
);
}
}

Expand Down Expand Up @@ -447,3 +509,18 @@ async function safeLinearStageUpdate(
);
}
}

async function safeLinearMoveToCanceled(
linear: LinearClient,
issueId: string,
): Promise<void> {
const runLogger = logger.child({ issueId, stage: "canceled" });
try {
await linear.markCanceled(issueId);
} catch (error) {
runLogger.error(
{ err: normalizeError(error) },
"Failed to move Linear issue to Canceled",
);
}
}
75 changes: 75 additions & 0 deletions tests/workflow.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it } from "bun:test";
import type { ResolvedProjectConfig } from "../src/types";
import {
buildIssueJobLogFields,
buildPlanComment,
parseReviewOutcome,
resolvePollingSettings,
Expand Down Expand Up @@ -120,3 +121,77 @@ describe("resolvePollingSettings", () => {
});
});
});

describe("buildIssueJobLogFields", () => {
it("returns consistent issue job fields", () => {
const now = new Date().toISOString();
const fields = buildIssueJobLogFields(
{
projectId: "default",
projectName: "Default",
workspacePath: "/tmp/work",
repository: {
owner: "acme",
name: "repo",
baseBranch: "main",
},
issue: {
id: "lin_123",
key: "ENG-1",
title: "Improve logging",
url: "https://linear.app/acme/issue/ENG-1/improve-logging",
},
stage: "planning",
bugs: [],
startedAt: now,
updatedAt: now,
},
"planning",
);

expect(fields).toEqual({
projectId: "default",
issueKey: "ENG-1",
issueId: "lin_123",
issueTitle: "Improve logging",
stage: "planning",
});
});

it("includes resumed flag when issue run is resumed", () => {
const now = new Date().toISOString();
const fields = buildIssueJobLogFields(
{
projectId: "default",
projectName: "Default",
workspacePath: "/tmp/work",
repository: {
owner: "acme",
name: "repo",
baseBranch: "main",
},
issue: {
id: "lin_123",
key: "ENG-1",
title: "Improve logging",
url: "https://linear.app/acme/issue/ENG-1/improve-logging",
},
stage: "implementing",
bugs: [],
startedAt: now,
updatedAt: now,
},
"implementing",
{ resumed: true },
);

expect(fields).toEqual({
projectId: "default",
issueKey: "ENG-1",
issueId: "lin_123",
issueTitle: "Improve logging",
stage: "implementing",
resumed: true,
});
});
});
Loading