diff --git a/packages/code/CHANGELOG.md b/packages/code/CHANGELOG.md index 4e59d15..fb1b52b 100644 --- a/packages/code/CHANGELOG.md +++ b/packages/code/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- **CLI argument tests no longer hang on a live tracker host**: parse-only CLI tests point Jira, Linear, and Trello traffic at a closed local port and disable fetch retries (`DEVINTERN_FETCH_MAX_RETRIES=0`), so a slow remote lookup cannot burn the 30s bun timeout and fail pre-push - **Default branch detection**: repository automations now ask the remote for its authoritative default branch before checkout or fetch operations, avoiding failed `master` attempts for repositories whose default is `main` (and supporting custom default branch names). Cached `origin/HEAD` remains an offline fallback - **Structured agent responses**: feasibility checks now accept the valid bare JSON commonly returned by Codex instead of requiring a Markdown code fence. Feasibility, estimation, and auto-review share brace-aware extraction that also tolerates narration and ignores unrelated braces in prose diff --git a/packages/code/tests/cli.test.ts b/packages/code/tests/cli.test.ts index 4b49e02..98b278d 100644 --- a/packages/code/tests/cli.test.ts +++ b/packages/code/tests/cli.test.ts @@ -10,12 +10,37 @@ setDefaultTimeout(30_000); const CLI_PATH = join(__dirname, "..", "src", "index.ts"); const CLI_SPAWN_TIMEOUT_MS = 30_000; +/** Closed local port: argument-parse tests must not hang on a live tracker host. */ +const CLI_UNREACHABLE_TRACKER_URL = "http://127.0.0.1:1"; + +function cliTrackerTestEnv(extra: Record = {}): NodeJS.ProcessEnv { + return { + ...process.env, + JIRA_BASE_URL: CLI_UNREACHABLE_TRACKER_URL, + JIRA_EMAIL: "test@example.com", + JIRA_API_TOKEN: "test-token", + LINEAR_API_URL: `${CLI_UNREACHABLE_TRACKER_URL}/graphql`, + TRELLO_API_BASE_URL: `${CLI_UNREACHABLE_TRACKER_URL}/1`, + DEVINTERN_FETCH_MAX_RETRIES: "0", + DEVINTERN_SKIP_LICENSE_CHECK: "1", + DEVINTERN_NO_UPDATE: "1", + ...extra, + }; +} + +function spawnTimedOut(result: ReturnType): boolean { + return ( + (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT" || + Boolean(result.signal) + ); +} // Helper to run the CLI in an isolated directory to avoid lock conflicts function runCLI(args: string[]): { stdout: string; stderr: string; exitCode: number; + timedOut: boolean; } { // Create unique temp directory for this test run const testDir = join( @@ -25,24 +50,20 @@ function runCLI(args: string[]): { mkdirSync(testDir, { recursive: true }); try { - const result = spawnSync("bun", [CLI_PATH, ...args], { + // Skip git for argument-handling runs (init has its own gitignore checks). + const extraArgs = args[0] === "init" || args.includes("--no-git") ? [] : ["--no-git"]; + const result = spawnSync("bun", [CLI_PATH, ...args, ...extraArgs], { encoding: "utf8", timeout: CLI_SPAWN_TIMEOUT_MS, cwd: testDir, // Run in isolated directory - env: { - ...process.env, - JIRA_BASE_URL: "https://test.atlassian.net", - JIRA_EMAIL: "test@example.com", - JIRA_API_TOKEN: "test-token", - DEVINTERN_SKIP_LICENSE_CHECK: "1", - DEVINTERN_NO_UPDATE: "1", - }, + env: cliTrackerTestEnv(), }); return { stdout: result.stdout || "", stderr: result.stderr || "", exitCode: result.status || 0, + timedOut: spawnTimedOut(result), }; } finally { // Clean up temp directory @@ -113,15 +134,13 @@ describe("CLI Argument Handling", () => { encoding: "utf8", timeout: CLI_SPAWN_TIMEOUT_MS, cwd: testDir, - env: { - ...process.env, + env: cliTrackerTestEnv({ TASK_TRACKER: "linear", LINEAR_API_KEY: "lin_api_test", - DEVINTERN_SKIP_LICENSE_CHECK: "1", - DEVINTERN_NO_UPDATE: "1", - }, + }), }); const output = (result.stdout || "") + (result.stderr || ""); + expect(spawnTimedOut(result)).toBe(false); expect(output).not.toContain("Unsupported task tracker"); expect(output).toContain("Processing 3 task(s): DAN-6, DAN-7, DAN-8"); expect(output).toContain("[1/3] 🔍 Fetching task: DAN-6"); @@ -227,20 +246,18 @@ describe("CLI Argument Handling", () => { const testDir = require("os").tmpdir() + `/cli-trello-env-test-${Date.now()}`; require("fs").mkdirSync(testDir, { recursive: true }); try { - const result = spawnSync("bun", [CLI_PATH, "4uWKPOTv"], { + const result = spawnSync("bun", [CLI_PATH, "4uWKPOTv", "--no-git"], { encoding: "utf8", timeout: CLI_SPAWN_TIMEOUT_MS, cwd: testDir, - env: { - ...process.env, + env: cliTrackerTestEnv({ TASK_TRACKER: "trello", TRELLO_API_KEY: "test-api-key", TRELLO_API_TOKEN: "test-api-token", - DEVINTERN_SKIP_LICENSE_CHECK: "1", - DEVINTERN_NO_UPDATE: "1", - }, + }), }); const output = (result.stdout || "") + (result.stderr || ""); + expect(spawnTimedOut(result)).toBe(false); expect(output).not.toContain("Unsupported task tracker"); expect(output).toContain("Fetching task"); } finally { @@ -256,20 +273,18 @@ describe("CLI Argument Handling", () => { const testDir = require("os").tmpdir() + `/cli-trello-test-${Date.now()}`; require("fs").mkdirSync(testDir, { recursive: true }); try { - const result = spawnSync("bun", [CLI_PATH, "--query", 'list:"To Do" is:open'], { + const result = spawnSync("bun", [CLI_PATH, "--query", 'list:"To Do" is:open', "--no-git"], { encoding: "utf8", timeout: CLI_SPAWN_TIMEOUT_MS, cwd: testDir, - env: { - ...process.env, + env: cliTrackerTestEnv({ TASK_TRACKER: "trello", TRELLO_API_KEY: "test-api-key", TRELLO_API_TOKEN: "test-api-token", - DEVINTERN_SKIP_LICENSE_CHECK: "1", - DEVINTERN_NO_UPDATE: "1", - }, + }), }); const output = (result.stdout || "") + (result.stderr || ""); + expect(spawnTimedOut(result)).toBe(false); expect(output).not.toContain("--query is not supported"); expect(output).toContain("Searching task tracker with query"); } finally { @@ -282,7 +297,14 @@ describe("CLI Argument Handling", () => { }); test("should handle task keys that look like options", () => { + // Hyphenated tracker keys must stay positional args, not Commander flags. + // Tracker I/O is aimed at a closed local port with retries disabled so + // this cannot hang on test.atlassian.net until bun's 30s timeout. const result = runCLI(["TEST-123"]); + const output = result.stdout + result.stderr; + expect(result.timedOut).toBe(false); + expect(output).not.toMatch(/unknown option/i); + expect(result.stdout).toContain("Processing"); expect(result.stdout).toContain("TEST-123"); }); }); diff --git a/packages/task-trackers/linear-client.test.ts b/packages/task-trackers/linear-client.test.ts index eb5a92d..58919cb 100644 --- a/packages/task-trackers/linear-client.test.ts +++ b/packages/task-trackers/linear-client.test.ts @@ -85,6 +85,42 @@ describe("LinearClient.getIssueByIdentifier", () => { }); }); +describe("LinearClient endpoint override", () => { + test("constructor baseUrl is used for GraphQL requests", async () => { + let requested: string | undefined; + globalThis.fetch = (async (url: unknown) => { + requested = String(url); + return new Response(JSON.stringify({ data: { issue: null } }), { status: 200 }); + }) as typeof fetch; + + const client = new LinearClient({ apiKey: "key", baseUrl: "http://127.0.0.1:1/graphql" }); + await client.getIssueByIdentifier("ENG-1"); + expect(requested).toBe("http://127.0.0.1:1/graphql"); + }); + + test("LINEAR_API_URL overrides the default GraphQL endpoint", async () => { + const previous = process.env.LINEAR_API_URL; + process.env.LINEAR_API_URL = "http://127.0.0.1:1/graphql"; + try { + let requested: string | undefined; + globalThis.fetch = (async (url: unknown) => { + requested = String(url); + return new Response(JSON.stringify({ data: { issue: null } }), { status: 200 }); + }) as typeof fetch; + + const client = new LinearClient({ apiKey: "key" }); + await client.getIssueByIdentifier("ENG-1"); + expect(requested).toBe("http://127.0.0.1:1/graphql"); + } finally { + if (previous === undefined) { + delete process.env.LINEAR_API_URL; + } else { + process.env.LINEAR_API_URL = previous; + } + } + }); +}); + describe("LinearClient.getIssueIdByIdentifier", () => { test("resolves a UUID via issue(id:) and caches the identifier", async () => { const calls = mockGraphQL(() => ({ diff --git a/packages/task-trackers/src/clients/linear.ts b/packages/task-trackers/src/clients/linear.ts index ebfbf88..a7aa1f2 100644 --- a/packages/task-trackers/src/clients/linear.ts +++ b/packages/task-trackers/src/clients/linear.ts @@ -104,10 +104,12 @@ export class LinearClient { /** * Create a Linear GraphQL API client. * - * @param config - Personal API key from Linear settings. + * @param config - Personal API key from Linear settings. `baseUrl` and + * `LINEAR_API_URL` are for tests/proxies; production uses Linear's API. */ - constructor(config: { apiKey: string }) { + constructor(config: { apiKey: string; baseUrl?: string }) { this.apiKey = config.apiKey; + this.baseUrl = config.baseUrl ?? process.env.LINEAR_API_URL ?? "https://api.linear.app/graphql"; } /** diff --git a/packages/task-trackers/src/clients/trello.ts b/packages/task-trackers/src/clients/trello.ts index af4bdbf..c0dfa85 100644 --- a/packages/task-trackers/src/clients/trello.ts +++ b/packages/task-trackers/src/clients/trello.ts @@ -109,11 +109,13 @@ export class TrelloClient { /** * Create a Trello REST API client. * - * @param config - Power-Up API key and user token. + * @param config - Power-Up API key and user token. `baseUrl` and + * `TRELLO_API_BASE_URL` are for tests/proxies; production uses Trello's API. */ - constructor(config: { apiKey: string; apiToken: string }) { + constructor(config: { apiKey: string; apiToken: string; baseUrl?: string }) { this.apiKey = config.apiKey; this.apiToken = config.apiToken; + this.baseUrl = config.baseUrl ?? process.env.TRELLO_API_BASE_URL ?? "https://api.trello.com/1"; } /** diff --git a/packages/task-trackers/trello-search.test.ts b/packages/task-trackers/trello-search.test.ts index 92784cf..1eca66f 100644 --- a/packages/task-trackers/trello-search.test.ts +++ b/packages/task-trackers/trello-search.test.ts @@ -16,6 +16,37 @@ function mockFetch(json: unknown): string[] { return urls; } +describe("TrelloClient endpoint override", () => { + test("constructor baseUrl is used for REST requests", async () => { + const urls = mockFetch([]); + const client = new TrelloClient({ + apiKey: "k", + apiToken: "t", + baseUrl: "http://127.0.0.1:1/1", + }); + await client.getBoardLabels("board-1"); + expect(new URL(urls[0]).origin).toBe("http://127.0.0.1:1"); + expect(new URL(urls[0]).pathname).toBe("/1/boards/board-1/labels"); + }); + + test("TRELLO_API_BASE_URL overrides the default REST endpoint", async () => { + const previous = process.env.TRELLO_API_BASE_URL; + process.env.TRELLO_API_BASE_URL = "http://127.0.0.1:1/1"; + try { + const urls = mockFetch([]); + const client = new TrelloClient({ apiKey: "k", apiToken: "t" }); + await client.getBoardLabels("board-1"); + expect(new URL(urls[0]).origin).toBe("http://127.0.0.1:1"); + } finally { + if (previous === undefined) { + delete process.env.TRELLO_API_BASE_URL; + } else { + process.env.TRELLO_API_BASE_URL = previous; + } + } + }); +}); + describe("TrelloClient labels", () => { test("getBoardLabels hits the board labels endpoint", async () => { const urls = mockFetch([ diff --git a/packages/utils/fetch-retry.test.ts b/packages/utils/fetch-retry.test.ts index 63d0d34..201d90f 100644 --- a/packages/utils/fetch-retry.test.ts +++ b/packages/utils/fetch-retry.test.ts @@ -2,9 +2,15 @@ import { afterEach, describe, expect, test } from "bun:test"; import { fetchWithRetry } from "./src/fetch-retry.ts"; const originalFetch = globalThis.fetch; +const originalMaxRetries = process.env.DEVINTERN_FETCH_MAX_RETRIES; afterEach(() => { globalThis.fetch = originalFetch; + if (originalMaxRetries === undefined) { + delete process.env.DEVINTERN_FETCH_MAX_RETRIES; + } else { + process.env.DEVINTERN_FETCH_MAX_RETRIES = originalMaxRetries; + } }); describe("fetchWithRetry", () => { @@ -76,6 +82,18 @@ describe("fetchWithRetry", () => { await expect(fetchWithRetry("https://example.com/fail")).rejects.toThrow("invalid url format"); }); + test("DEVINTERN_FETCH_MAX_RETRIES=0 skips backoff retries", async () => { + process.env.DEVINTERN_FETCH_MAX_RETRIES = "0"; + let attempts = 0; + globalThis.fetch = async () => { + attempts++; + throw new Error("ECONNREFUSED"); + }; + + await expect(fetchWithRetry("http://127.0.0.1:1")).rejects.toThrow("ECONNREFUSED"); + expect(attempts).toBe(1); + }); + test("does not log retry messages when verbose is false", async () => { let attempts = 0; globalThis.fetch = async () => { diff --git a/packages/utils/src/fetch-retry.ts b/packages/utils/src/fetch-retry.ts index 50be412..2672b59 100644 --- a/packages/utils/src/fetch-retry.ts +++ b/packages/utils/src/fetch-retry.ts @@ -18,6 +18,23 @@ function isRetryableNetworkError(error: Error): boolean { ); } +/** + * Default retry count. `DEVINTERN_FETCH_MAX_RETRIES=0` disables retries so + * tests can fail immediately instead of sleeping through backoff against a + * fake tracker host. + */ +function defaultMaxRetries(): number { + const raw = process.env.DEVINTERN_FETCH_MAX_RETRIES; + if (raw === undefined || raw === "") { + return 3; + } + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed < 0) { + return 3; + } + return parsed; +} + /** * Fetch with exponential backoff retry for transient failures. * Automatically retries on network errors and retryable HTTP status codes. @@ -38,7 +55,7 @@ export async function fetchWithRetry( verbose?: boolean; }, ): Promise { - const maxRetries = retryOptions?.maxRetries ?? 3; + const maxRetries = retryOptions?.maxRetries ?? defaultMaxRetries(); const baseDelay = retryOptions?.baseDelay ?? 1000; const maxDelay = retryOptions?.maxDelay ?? 30000; const jitter = retryOptions?.jitter ?? true;