From ab4b412de57cd42f06b4f862d42d16fdca9c2efb Mon Sep 17 00:00:00 2001 From: 0xRoy <1997roylee@gmail.com> Date: Wed, 6 May 2026 16:55:57 +0800 Subject: [PATCH 1/3] [piv-loop] ROY-14: create github action to format code and run test --- .github/workflows/ci.yml | 31 +++++++++++++++++++++++++++++++ src/codex.ts | 2 -- tests/codex.test.ts | 1 + 3 files changed, 32 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..6adbc246 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,31 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +jobs: + check: + name: Check, typecheck, and test + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Check formatting and lint + run: bun run check + + - name: Typecheck + run: bun run typecheck + + - name: Test + run: bun test diff --git a/src/codex.ts b/src/codex.ts index e128a2ae..8366c437 100644 --- a/src/codex.ts +++ b/src/codex.ts @@ -44,8 +44,6 @@ export function buildCodexResumeArgs( "resume", "--json", "--skip-git-repo-check", - "--cd", - config.executionPath, "--output-last-message", outputFile, ]; diff --git a/tests/codex.test.ts b/tests/codex.test.ts index f2829e7c..0f9a4219 100644 --- a/tests/codex.test.ts +++ b/tests/codex.test.ts @@ -80,6 +80,7 @@ describe("codex args", () => { ]), ); expect(args).not.toContain("--sandbox"); + expect(args).not.toContain("--cd"); }); it("extracts session id from jsonl", () => { From 3c1650d5a88b5769bce0490b0049886a2363c067 Mon Sep 17 00:00:00 2001 From: 0xRoy <1997roylee@gmail.com> Date: Wed, 6 May 2026 17:03:46 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(codex):=20=E2=9C=A8=20Add=20support=20?= =?UTF-8?q?for=20stage-specific=20model=20overrides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Introduced `CODEX_MODEL_PLAN`, `CODEX_MODEL_IMPLEMENT`, and `CODEX_MODEL_REVIEW_TEST` environment variables to allow overriding models for different stages. * Updated configuration to include models in the `codex` section. * Modified `buildCodexExecArgs` and `buildCodexResumeArgs` to accept model overrides. * Enhanced tests to validate the new model loading functionality. --- README.md | 4 + bun.lock | 7 + package.json | 1 + piv-loop.config.ts | 7 + src/codex.ts | 26 +++- src/config.ts | 5 + src/linear.ts | 354 +++++++++++++++---------------------------- src/types.ts | 5 + tests/codex.test.ts | 26 ++++ tests/config.test.ts | 13 ++ 10 files changed, 208 insertions(+), 240 deletions(-) diff --git a/README.md b/README.md index 3c4b69e2..6afca71e 100644 --- a/README.md +++ b/README.md @@ -72,6 +72,9 @@ Optional: - `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`) +- `CODEX_MODEL_PLAN` (optional; overrides planning model) +- `CODEX_MODEL_IMPLEMENT` (optional; overrides implementation model) +- `CODEX_MODEL_REVIEW_TEST` (optional; overrides review/testing model) - `CODEX_HOME` to override Codex runtime state directory - `PIV_LOG_LEVEL` (optional; default `info`) - `PIV_LOG_PRETTY` (optional; default `1` in TTY, `0` otherwise) @@ -107,3 +110,4 @@ bun test - Run with authenticated `gh` (`gh auth status`). - Codex uses the default CLI home unless you explicitly set `CODEX_HOME`. +- Linear integration uses the official `@linear/sdk` client. diff --git a/bun.lock b/bun.lock index c0acdd94..b74c0fb3 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "piv-loop", "dependencies": { + "@linear/sdk": "^83.0.0", "pino": "^10.3.1", "pino-pretty": "^13.1.3", }, @@ -34,6 +35,10 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="], + "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], + + "@linear/sdk": ["@linear/sdk@83.0.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.2.0" } }, "sha512-/M1FETZfA8U7WrNvBavO+/fkWrIV49/G0g3w/57q2h/K/1FtVmskXpsvSvQXuGJr2njJmAQbygF3yNi/AJ5Jag=="], + "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], "@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="], @@ -54,6 +59,8 @@ "fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="], + "graphql": ["graphql@16.13.2", "", {}, "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig=="], + "help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="], "joycon": ["joycon@3.1.1", "", {}, "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw=="], diff --git a/package.json b/package.json index 11c6be47..4cd2cbf7 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "typescript": "^5.9.3" }, "dependencies": { + "@linear/sdk": "^83.0.0", "pino": "^10.3.1", "pino-pretty": "^13.1.3" } diff --git a/piv-loop.config.ts b/piv-loop.config.ts index 986091cf..d11b54a7 100644 --- a/piv-loop.config.ts +++ b/piv-loop.config.ts @@ -36,6 +36,13 @@ const config: DeepPartial = { }, autoCreateLabels: process.env.LINEAR_AUTO_CREATE_LABELS !== "0", }, + codex: { + models: { + plan: "gpt-5.5", + implement: "gpt-5.3-codex", + reviewTest: "gpt-5.3-codex", + }, + }, skills: { plan: path.join(cwd, "skills", "piv-plan", "SKILL.md"), implement: path.join(cwd, "skills", "piv-implement", "SKILL.md"), diff --git a/src/codex.ts b/src/codex.ts index 8366c437..6f4fd666 100644 --- a/src/codex.ts +++ b/src/codex.ts @@ -13,6 +13,7 @@ export function buildCodexExecArgs( config: ResolvedProjectConfig, prompt: string, outputFile: string, + modelOverride?: string, ): string[] { const args = [ "exec", @@ -23,8 +24,9 @@ export function buildCodexExecArgs( "--output-last-message", outputFile, ]; - if (config.codex.model) { - args.push("--model", config.codex.model); + const model = modelOverride ?? config.codex.model; + if (model) { + args.push("--model", model); } if (config.codex.sandbox) { args.push("--sandbox", config.codex.sandbox); @@ -38,6 +40,7 @@ export function buildCodexResumeArgs( sessionId: string, prompt: string, outputFile: string, + modelOverride?: string, ): string[] { const args = [ "exec", @@ -47,8 +50,9 @@ export function buildCodexResumeArgs( "--output-last-message", outputFile, ]; - if (config.codex.model) { - args.push("--model", config.codex.model); + const model = modelOverride ?? config.codex.model; + if (model) { + args.push("--model", model); } args.push(sessionId, prompt); return args; @@ -58,9 +62,10 @@ export async function runPlanSession( config: ResolvedProjectConfig, prompt: string, ): Promise { + const model = config.codex.models?.plan ?? config.codex.model; return runCodex( config, - buildCodexExecArgs(config, prompt, await nextOutputFile(config)), + buildCodexExecArgs(config, prompt, await nextOutputFile(config), model), ); } @@ -69,6 +74,7 @@ export async function runResumeSession( sessionId: string, prompt: string, ): Promise { + const model = config.codex.models?.implement ?? config.codex.model; return runCodex( config, buildCodexResumeArgs( @@ -76,6 +82,7 @@ export async function runResumeSession( sessionId, prompt, await nextOutputFile(config), + model, ), ); } @@ -84,7 +91,14 @@ export async function runReviewSession( config: ResolvedProjectConfig, prompt: string, ): Promise { - return runPlanSession(config, prompt); + const model = + config.codex.models?.reviewTest ?? + config.codex.models?.implement ?? + config.codex.model; + return runCodex( + config, + buildCodexExecArgs(config, prompt, await nextOutputFile(config), model), + ); } async function runCodex( diff --git a/src/config.ts b/src/config.ts index d67ed4bd..9f0304b6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -78,6 +78,11 @@ function buildEnvBase(cwd: string): ProjectRuntimeConfig { codex: { binary: env.CODEX_BINARY ?? "codex", model: env.CODEX_MODEL, + models: { + plan: env.CODEX_MODEL_PLAN, + implement: env.CODEX_MODEL_IMPLEMENT, + reviewTest: env.CODEX_MODEL_REVIEW_TEST, + }, sandbox, codexHome, }, diff --git a/src/linear.ts b/src/linear.ts index 8650e4f9..35451d9d 100644 --- a/src/linear.ts +++ b/src/linear.ts @@ -1,3 +1,9 @@ +import { + LinearClient as LinearSdkClient, + type Issue as LinearSdkIssue, + type IssueLabel as LinearSdkIssueLabel, + type WorkflowState as LinearSdkWorkflowState, +} from "@linear/sdk"; import { normalizeIssueKey } from "./state"; import type { LinearIssue, @@ -5,48 +11,16 @@ import type { WorkflowStage, } from "./types"; -interface GraphQLResult { - data?: T; - errors?: Array<{ message: string }>; -} - -interface RawWorkflowState { - id: string; - name: string; - team?: { - id: string; - }; -} +type WorkflowLabelStage = keyof ResolvedProjectConfig["linear"]["labelMap"]; -interface RawIssueLabel { +interface LinearLabelRecord { id: string; name: string; - team?: { - id: string; - }; + teamId?: string; } -interface RawLinearIssue { - id: string; - identifier: string; - title: string; - url: string; - state: { - id: string; - name: string; - }; - labels?: { - nodes: Array<{ - id: string; - name: string; - }>; - }; -} - -type WorkflowLabelStage = keyof ResolvedProjectConfig["linear"]["labelMap"]; - export class LinearClient { - constructor(private readonly config: ResolvedProjectConfig) {} + private readonly client: LinearSdkClient; private resolvedStatusMap: | ResolvedProjectConfig["linear"]["statusMap"] | null = null; @@ -56,6 +30,13 @@ export class LinearClient { private workflowLabelIds: string[] = []; private workflowLabelsResolved = false; + constructor(private readonly config: ResolvedProjectConfig) { + this.client = new LinearSdkClient({ + apiKey: config.linear.apiKey, + apiUrl: config.linear.apiUrl, + }); + } + async fetchWork(issueArg?: string): Promise { await this.ensureResolvedStatusMap(); @@ -66,35 +47,18 @@ export class LinearClient { return issue ? [issue] : []; } - const data = await this.graphql<{ - viewer: { - assignedIssues: { - nodes: RawLinearIssue[]; - }; - }; - }>( - ` - query AssignedIssues($first: Int!) { - viewer { - assignedIssues(first: $first) { - nodes { - id - identifier - title - url - state { id name } - labels { nodes { id name } } - } - } - } - } - `, - { first: this.config.linear.pollLimit }, + const viewer = await this.client.viewer; + const assignedIssues = await viewer.assignedIssues({ + first: this.config.linear.pollLimit, + }); + const includeLabels = Boolean(this.config.linear.requiredLabel); + const issues = await Promise.all( + assignedIssues.nodes.map((issue) => + this.mapSdkIssueToLinearIssue(issue, includeLabels), + ), ); - const raw = data.viewer.assignedIssues.nodes; - return raw - .map(mapRawIssue) + return issues .filter((issue) => issue.state.id === this.requiredStatusMap().assigned) .filter((issue) => { if (!this.config.linear.requiredLabel) { @@ -117,16 +81,7 @@ export class LinearClient { return; } const stateId = this.requiredStatusMap()[stage]; - await this.graphql( - ` - mutation UpdateIssueState($id: String!, $stateId: String!) { - issueUpdate(id: $id, input: { stateId: $stateId }) { - success - } - } - `, - { id: issueId, stateId }, - ); + await this.client.updateIssue(issueId, { stateId }); } async applyStageLabel(issueId: string, stage: WorkflowStage): Promise { @@ -142,98 +97,44 @@ export class LinearClient { const removedLabelIds = this.workflowLabelIds.filter( (labelId) => labelId !== nextLabelId, ); - await this.graphql( - ` - mutation UpdateIssueLabels($id: String!, $addedLabelIds: [String!], $removedLabelIds: [String!]) { - issueUpdate(id: $id, input: { addedLabelIds: $addedLabelIds, removedLabelIds: $removedLabelIds }) { - success - } - } - `, - { - id: issueId, - addedLabelIds: [nextLabelId], - removedLabelIds, - }, - ); + await this.client.updateIssue(issueId, { + addedLabelIds: [nextLabelId], + removedLabelIds, + }); } async comment(issueId: string, body: string): Promise { if (this.config.dryRun) { return; } - await this.graphql( - ` - mutation AddComment($issueId: String!, $body: String!) { - commentCreate(input: { issueId: $issueId, body: $body }) { - success - } - } - `, - { issueId, body }, - ); + await this.client.createComment({ + issueId, + body, + }); } private async findIssueByIdentifier( identifier: string, ): Promise { - const data = await this.graphql<{ - issues: { - nodes: RawLinearIssue[]; - }; - }>( - ` - query IssueByIdentifier($identifier: String!) { - issues(first: 1, filter: { identifier: { eq: $identifier } }) { - nodes { - id - identifier - title - url - state { id name } - labels { nodes { id name } } - } - } - } - `, - { identifier }, - ); - const issue = data.issues.nodes[0]; + let issue: LinearSdkIssue | undefined; + try { + issue = await this.client.issue(identifier); + } catch (error) { + const message = + error instanceof Error ? error.message.toLowerCase() : String(error); + if ( + message.includes("not found") || + message.includes("invalid") || + message.includes("does not exist") + ) { + return null; + } + throw error; + } if (!issue) { return null; } - return mapRawIssue(issue); - } - - private async graphql( - query: string, - variables: Record, - ): Promise { - const response = await fetch(this.config.linear.apiUrl, { - method: "POST", - headers: { - "content-type": "application/json", - authorization: this.config.linear.apiKey, - }, - body: JSON.stringify({ query, variables }), - }); - - if (!response.ok) { - throw new Error( - `Linear API request failed: ${response.status} ${response.statusText}`, - ); - } - - const payload = (await response.json()) as GraphQLResult; - if (payload.errors?.length) { - throw new Error( - `Linear GraphQL error: ${payload.errors.map((e) => e.message).join("; ")}`, - ); - } - if (!payload.data) { - throw new Error("Linear GraphQL response did not include data"); - } - return payload.data; + return this.mapSdkIssueToLinearIssue(issue, true); } private async ensureResolvedStatusMap(): Promise { @@ -241,28 +142,12 @@ export class LinearClient { return; } - const statesData = await this.graphql<{ - workflowStates: { - nodes: RawWorkflowState[]; - }; - }>( - ` - query WorkflowStates($first: Int!) { - workflowStates(first: $first) { - nodes { - id - name - team { id } - } - } - } - `, - { first: 250 }, - ); - - const states = statesData.workflowStates.nodes.filter((state) => + const workflowStates = await this.client.workflowStates({ + first: 250, + }); + const states = workflowStates.nodes.filter((state) => this.config.linear.teamId - ? state.team?.id === this.config.linear.teamId + ? state.teamId === this.config.linear.teamId : true, ); @@ -309,26 +194,12 @@ export class LinearClient { return; } - const labelsData = await this.graphql<{ - issueLabels: { - nodes: RawIssueLabel[]; - }; - }>( - ` - query IssueLabels($first: Int!) { - issueLabels(first: $first) { - nodes { - id - name - team { id } - } - } - } - `, - { first: 250 }, + const labelsConnection = await this.client.issueLabels({ + first: 250, + }); + const availableLabels = labelsConnection.nodes.map((label) => + this.mapSdkLabelToRecord(label), ); - - const availableLabels = [...labelsData.issueLabels.nodes]; const resolved: Partial> = {}; for (const [stage, labelNameRaw] of configuredEntries) { @@ -360,7 +231,7 @@ export class LinearClient { private findLabelIdByName( labelName: string, - labels: RawIssueLabel[], + labels: LinearLabelRecord[], ): string | undefined { const matches = labels.filter( (label) => label.name.toLowerCase() === labelName.toLowerCase(), @@ -370,12 +241,12 @@ export class LinearClient { } if (this.config.linear.teamId) { const teamMatch = matches.find( - (label) => label.team?.id === this.config.linear.teamId, + (label) => label.teamId === this.config.linear.teamId, ); if (teamMatch) { return teamMatch.id; } - const workspaceLabel = matches.find((label) => !label.team?.id); + const workspaceLabel = matches.find((label) => !label.teamId); if (workspaceLabel) { return workspaceLabel.id; } @@ -383,43 +254,32 @@ export class LinearClient { return matches[0]?.id; } - private async createIssueLabel(labelName: string): Promise { - const data = await this.graphql<{ - issueLabelCreate: { - success: boolean; - issueLabel: RawIssueLabel; - }; - }>( - ` - mutation CreateIssueLabel($input: IssueLabelCreateInput!) { - issueLabelCreate(input: $input) { - success - issueLabel { - id - name - team { id } - } - } - } - `, - { - input: { - name: labelName, - teamId: this.config.linear.teamId, - }, - }, - ); - - if (!data.issueLabelCreate.success) { + private async createIssueLabel( + labelName: string, + ): Promise { + const payload = await this.client.createIssueLabel({ + name: labelName, + teamId: this.config.linear.teamId, + }); + if (!payload.success) { throw new Error(`Failed to create Linear label '${labelName}'.`); } - return data.issueLabelCreate.issueLabel; + + const issueLabel = + (await payload.issueLabel) ?? + (payload.issueLabelId + ? await this.client.issueLabel(payload.issueLabelId) + : undefined); + if (!issueLabel?.id) { + throw new Error(`Linear label '${labelName}' was created without an id.`); + } + return this.mapSdkLabelToRecord(issueLabel); } private resolveStatusValue( key: keyof ResolvedProjectConfig["linear"]["statusMap"], value: string, - states: RawWorkflowState[], + states: LinearSdkWorkflowState[], ): string { const trimmed = value.trim(); if (isLikelyUuid(trimmed)) { @@ -442,17 +302,43 @@ export class LinearClient { } return this.resolvedStatusMap; } -} -function mapRawIssue(issue: RawLinearIssue): LinearIssue { - return { - id: issue.id, - identifier: issue.identifier, - title: issue.title, - url: issue.url, - state: issue.state, - labels: issue.labels?.nodes ?? [], - }; + private async mapSdkIssueToLinearIssue( + issue: LinearSdkIssue, + includeLabels: boolean, + ): Promise { + const state = await issue.state; + if (!state?.id) { + throw new Error( + `Issue ${issue.identifier} is missing workflow state data.`, + ); + } + const labels = includeLabels + ? (await issue.labels()).nodes.map((label) => ({ + id: label.id, + name: label.name, + })) + : []; + return { + id: issue.id, + identifier: issue.identifier, + title: issue.title, + url: issue.url, + state: { + id: state.id, + name: state.name, + }, + labels, + }; + } + + private mapSdkLabelToRecord(label: LinearSdkIssueLabel): LinearLabelRecord { + return { + id: label.id, + name: label.name, + teamId: label.teamId ?? undefined, + }; + } } function isLikelyUuid(value: string): boolean { diff --git a/src/types.ts b/src/types.ts index bb63764e..490e6a9a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -65,6 +65,11 @@ export interface ProjectRuntimeConfig { codex: { binary: string; model?: string; + models?: { + plan?: string; + implement?: string; + reviewTest?: string; + }; sandbox?: "read-only" | "workspace-write" | "danger-full-access"; codexHome?: string; }; diff --git a/tests/codex.test.ts b/tests/codex.test.ts index 0f9a4219..811afbbc 100644 --- a/tests/codex.test.ts +++ b/tests/codex.test.ts @@ -37,6 +37,11 @@ const config: ResolvedProjectConfig = { codex: { binary: "codex", model: "gpt-5.4", + models: { + plan: "gpt-5.5", + implement: "gpt-5.3-codex", + reviewTest: "gpt-5.3-codex", + }, sandbox: "workspace-write", codexHome: "/tmp/codex", }, @@ -54,6 +59,27 @@ describe("codex args", () => { expect(args).toContain("--sandbox"); }); + it("supports stage model overrides", () => { + const planArgs = buildCodexExecArgs( + config, + "plan", + "/tmp/out.txt", + config.codex.models?.plan, + ); + expect(planArgs).toEqual(expect.arrayContaining(["--model", "gpt-5.5"])); + + const implementArgs = buildCodexResumeArgs( + config, + "session-123", + "implement", + "/tmp/out.txt", + config.codex.models?.implement, + ); + expect(implementArgs).toEqual( + expect.arrayContaining(["--model", "gpt-5.3-codex"]), + ); + }); + it("omits sandbox when not configured", () => { const args = buildCodexExecArgs( { ...config, codex: { ...config.codex, sandbox: undefined } }, diff --git a/tests/config.test.ts b/tests/config.test.ts index fb7e8ac6..8589e046 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -15,6 +15,9 @@ const envKeys = [ "PIV_EXECUTION_PATH", "CODEX_SANDBOX", "CODEX_HOME", + "CODEX_MODEL_PLAN", + "CODEX_MODEL_IMPLEMENT", + "CODEX_MODEL_REVIEW_TEST", ] as const; const previousEnv: Record = {}; @@ -70,4 +73,14 @@ describe("loadConfig", () => { "/tmp/custom-codex-home", ); }); + + it("loads stage-specific codex models from env", async () => { + process.env.CODEX_MODEL_PLAN = "gpt-5.5"; + process.env.CODEX_MODEL_IMPLEMENT = "gpt-5.3-codex"; + process.env.CODEX_MODEL_REVIEW_TEST = "gpt-5.3-codex"; + const config = await loadConfig(process.cwd()); + expect(config.projects[0]?.codex.models?.plan).toBe("gpt-5.5"); + expect(config.projects[0]?.codex.models?.implement).toBe("gpt-5.3-codex"); + expect(config.projects[0]?.codex.models?.reviewTest).toBe("gpt-5.3-codex"); + }); }); From 3a56dcd08f551b69cb25eb2015e67b139501f198 Mon Sep 17 00:00:00 2001 From: 0xRoy <1997roylee@gmail.com> Date: Wed, 6 May 2026 17:06:15 +0800 Subject: [PATCH 3/3] [piv-loop] ROY-15: we should sort the issue/tasks by priorty --- src/linear.ts | 57 ++++++++++++++++++++++++++++++++--------- src/types.ts | 4 +++ tests/linear.test.ts | 61 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 12 deletions(-) create mode 100644 tests/linear.test.ts diff --git a/src/linear.ts b/src/linear.ts index 35451d9d..5fcd8c53 100644 --- a/src/linear.ts +++ b/src/linear.ts @@ -58,18 +58,20 @@ export class LinearClient { ), ); - return issues - .filter((issue) => issue.state.id === this.requiredStatusMap().assigned) - .filter((issue) => { - if (!this.config.linear.requiredLabel) { - return true; - } - return issue.labels.some( - (label) => - label.name.toLowerCase() === - this.config.linear.requiredLabel?.toLowerCase(), - ); - }); + return sortIssuesByPriority( + issues + .filter((issue) => issue.state.id === this.requiredStatusMap().assigned) + .filter((issue) => { + if (!this.config.linear.requiredLabel) { + return true; + } + return issue.labels.some( + (label) => + label.name.toLowerCase() === + this.config.linear.requiredLabel?.toLowerCase(), + ); + }), + ); } async markStage( @@ -324,6 +326,10 @@ export class LinearClient { identifier: issue.identifier, title: issue.title, url: issue.url, + priority: { + value: issue.priority ?? 0, + name: issue.priorityLabel ?? "No priority", + }, state: { id: state.id, name: state.name, @@ -352,3 +358,30 @@ function isWorkflowLabelStage( ): stage is WorkflowLabelStage { return stage === "pr_created" || stage === "reviewing" || stage === "testing"; } + +const PRIORITY_SORT_ORDER: Record = { + 1: 0, + 2: 1, + 3: 2, + 4: 3, + 0: 4, +}; + +function getPriorityRank(priority: number): number { + return PRIORITY_SORT_ORDER[priority] ?? PRIORITY_SORT_ORDER[0]; +} + +export function sortIssuesByPriority(issues: LinearIssue[]): LinearIssue[] { + return issues + .map((issue, index) => ({ issue, index })) + .sort((left, right) => { + const rankDiff = + getPriorityRank(left.issue.priority.value) - + getPriorityRank(right.issue.priority.value); + if (rankDiff !== 0) { + return rankDiff; + } + return left.index - right.index; + }) + .map((entry) => entry.issue); +} diff --git a/src/types.ts b/src/types.ts index 490e6a9a..6d484995 100644 --- a/src/types.ts +++ b/src/types.ts @@ -100,6 +100,10 @@ export interface LinearIssue { identifier: string; title: string; url: string; + priority: { + value: number; + name: string; + }; state: { id: string; name: string; diff --git a/tests/linear.test.ts b/tests/linear.test.ts new file mode 100644 index 00000000..2051464d --- /dev/null +++ b/tests/linear.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "bun:test"; +import { sortIssuesByPriority } from "../src/linear"; +import type { LinearIssue } from "../src/types"; + +function createIssue( + identifier: string, + priorityValue: number, + priorityName: string, +): LinearIssue { + return { + id: identifier, + identifier, + title: identifier, + url: `https://linear.app/roy/issue/${identifier}`, + priority: { + value: priorityValue, + name: priorityName, + }, + state: { + id: "state", + name: "Todo", + }, + labels: [], + }; +} + +describe("sortIssuesByPriority", () => { + it("sorts issues from urgent to no priority", () => { + const issues = [ + createIssue("ROY-4", 4, "Low"), + createIssue("ROY-0", 0, "No priority"), + createIssue("ROY-2", 2, "High"), + createIssue("ROY-1", 1, "Urgent"), + createIssue("ROY-3", 3, "Medium"), + ]; + + const sorted = sortIssuesByPriority(issues); + expect(sorted.map((issue) => issue.identifier)).toEqual([ + "ROY-1", + "ROY-2", + "ROY-3", + "ROY-4", + "ROY-0", + ]); + }); + + it("keeps input order for issues with equal priority", () => { + const issues = [ + createIssue("ROY-10", 2, "High"), + createIssue("ROY-11", 2, "High"), + createIssue("ROY-12", 2, "High"), + ]; + + const sorted = sortIssuesByPriority(issues); + expect(sorted.map((issue) => issue.identifier)).toEqual([ + "ROY-10", + "ROY-11", + "ROY-12", + ]); + }); +});