From 9df2ac2a4eaf03bcaec3305691ee080319276165 Mon Sep 17 00:00:00 2001 From: 0xRoy <1997roylee@gmail.com> Date: Wed, 6 May 2026 18:01:26 +0800 Subject: [PATCH 1/2] [piv-loop] ROY-18: default please log each job --- src/workflow.ts | 45 +++++++++++++++++++++++++ tests/workflow.test.ts | 75 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/src/workflow.ts b/src/workflow.ts index 13ea9b02..0fd0f34e 100644 --- a/src/workflow.ts +++ b/src/workflow.ts @@ -131,6 +131,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); @@ -179,6 +185,30 @@ export async function sleep(ms: number): Promise { 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, @@ -196,6 +226,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; @@ -207,12 +238,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, @@ -243,6 +279,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") { @@ -253,6 +293,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")); @@ -319,6 +360,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", + ); } } diff --git a/tests/workflow.test.ts b/tests/workflow.test.ts index 34697c33..74332805 100644 --- a/tests/workflow.test.ts +++ b/tests/workflow.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "bun:test"; import type { ResolvedProjectConfig } from "../src/types"; import { + buildIssueJobLogFields, buildPlanComment, parseReviewOutcome, resolvePollingSettings, @@ -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, + }); + }); +}); From 48c2dfa0b7f43f71336df3222edfb0e9146d81a4 Mon Sep 17 00:00:00 2001 From: 0xRoy <1997roylee@gmail.com> Date: Wed, 6 May 2026 18:15:43 +0800 Subject: [PATCH 2/2] [piv-loop] ROY-20: install husky to check biome format and lint. --- .husky/pre-commit | 2 ++ package.json | 4 +++- src/linear.ts | 45 ++++++++++++++++++++++++++++++++++++++++++++- src/workflow.ts | 42 +++++++++++++++++++++++++++++++++++++----- 4 files changed, 86 insertions(+), 7 deletions(-) create mode 100755 .husky/pre-commit diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000..5ee2c1e7 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +bun run check diff --git a/package.json b/package.json index 4cd2cbf7..527570e6 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/linear.ts b/src/linear.ts index 800f4bd6..2ceb0490 100644 --- a/src/linear.ts +++ b/src/linear.ts @@ -24,6 +24,7 @@ export class LinearClient { private resolvedStatusMap: | ResolvedProjectConfig["linear"]["statusMap"] | null = null; + private resolvedCanceledStateId: string | null = null; private resolvedWorkflowLabelIds: Partial< Record > = {}; @@ -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; @@ -74,6 +76,11 @@ export class LinearClient { ); } + async isAssignedState(stateId: string): Promise { + await this.ensureResolvedStatusMap(); + return this.requiredStatusMap().assigned === stateId; + } + async markStage( issueId: string, stage: keyof ResolvedProjectConfig["linear"]["statusMap"], @@ -86,6 +93,15 @@ export class LinearClient { await this.client.updateIssue(issueId, { stateId }); } + async markCanceled(issueId: string): Promise { + 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 { if (!isWorkflowLabelStage(stage)) { return; @@ -183,6 +199,33 @@ export class LinearClient { }; } + private async resolveCanceledStateId(): Promise { + 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 { if (this.workflowLabelsResolved) { return; diff --git a/src/workflow.ts b/src/workflow.ts index 0fd0f34e..2be62f6f 100644 --- a/src/workflow.ts +++ b/src/workflow.ts @@ -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 { 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 ?? ({ @@ -146,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( { @@ -342,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})` : ""}`, @@ -492,3 +509,18 @@ async function safeLinearStageUpdate( ); } } + +async function safeLinearMoveToCanceled( + linear: LinearClient, + issueId: string, +): Promise { + 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", + ); + } +}