From 416e25a60d0730d112221cf3d263b5e274944241 Mon Sep 17 00:00:00 2001 From: "devintern-internal[bot]" <4622575+devintern-internal[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:19:06 +0700 Subject: [PATCH 1/2] fix(code): stop CLI arg tests hanging on a live Jira host Argument-handling tests spawned the real CLI against test.atlassian.net with no fetch timeout. Under pre-push load that lookup could sit until bun's 30s limit, failing CLI Argument Handling > should handle task keys that look like options and aborting PR creation. Point tracker traffic at a closed local port, skip git, and honor DEVINTERN_FETCH_MAX_RETRIES=0 so the first connection refused is fatal. --- packages/code/CHANGELOG.md | 1 + packages/code/tests/cli.test.ts | 20 ++++++++++++++++++-- packages/utils/fetch-retry.test.ts | 18 ++++++++++++++++++ packages/utils/src/fetch-retry.ts | 19 ++++++++++++++++++- 4 files changed, 55 insertions(+), 3 deletions(-) diff --git a/packages/code/CHANGELOG.md b/packages/code/CHANGELOG.md index 4e59d15..082cdd0 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 Jira host**: parse-only CLI tests point tracker traffic at a closed local port and disable fetch retries (`DEVINTERN_FETCH_MAX_RETRIES=0`), so a slow `test.atlassian.net` 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..0e09fb4 100644 --- a/packages/code/tests/cli.test.ts +++ b/packages/code/tests/cli.test.ts @@ -10,12 +10,15 @@ 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 Jira host. */ +const CLI_UNREACHABLE_TRACKER_URL = "http://127.0.0.1:1"; // 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,15 +28,18 @@ 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_BASE_URL: CLI_UNREACHABLE_TRACKER_URL, JIRA_EMAIL: "test@example.com", JIRA_API_TOKEN: "test-token", + DEVINTERN_FETCH_MAX_RETRIES: "0", DEVINTERN_SKIP_LICENSE_CHECK: "1", DEVINTERN_NO_UPDATE: "1", }, @@ -43,6 +49,9 @@ function runCLI(args: string[]): { stdout: result.stdout || "", stderr: result.stderr || "", exitCode: result.status || 0, + timedOut: + (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT" || + Boolean(result.signal), }; } finally { // Clean up temp directory @@ -282,7 +291,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/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; From b19b99fc9353560fb1a0202efe75da07a08ac96b Mon Sep 17 00:00:00 2001 From: "devintern-internal[bot]" <4622575+devintern-internal[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:24:54 +0700 Subject: [PATCH 2/2] test(code): isolate Linear and Trello CLI arg tests from live APIs Those spawnSync cases still called api.linear.app and api.trello.com (raw fetch, so DEVINTERN_FETCH_MAX_RETRIES did not apply). Honor LINEAR_API_URL and TRELLO_API_BASE_URL and point parse-only CLI tests at a closed local port so they fail immediately instead of hanging. --- packages/code/CHANGELOG.md | 2 +- packages/code/tests/cli.test.ts | 66 +++++++++++--------- packages/task-trackers/linear-client.test.ts | 36 +++++++++++ packages/task-trackers/src/clients/linear.ts | 6 +- packages/task-trackers/src/clients/trello.ts | 6 +- packages/task-trackers/trello-search.test.ts | 31 +++++++++ 6 files changed, 112 insertions(+), 35 deletions(-) diff --git a/packages/code/CHANGELOG.md b/packages/code/CHANGELOG.md index 082cdd0..fb1b52b 100644 --- a/packages/code/CHANGELOG.md +++ b/packages/code/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- **CLI argument tests no longer hang on a live Jira host**: parse-only CLI tests point tracker traffic at a closed local port and disable fetch retries (`DEVINTERN_FETCH_MAX_RETRIES=0`), so a slow `test.atlassian.net` lookup cannot burn the 30s bun timeout and fail pre-push +- **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 0e09fb4..98b278d 100644 --- a/packages/code/tests/cli.test.ts +++ b/packages/code/tests/cli.test.ts @@ -10,9 +10,31 @@ 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 Jira host. */ +/** 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; @@ -34,24 +56,14 @@ function runCLI(args: string[]): { encoding: "utf8", timeout: CLI_SPAWN_TIMEOUT_MS, cwd: testDir, // Run in isolated directory - env: { - ...process.env, - JIRA_BASE_URL: CLI_UNREACHABLE_TRACKER_URL, - JIRA_EMAIL: "test@example.com", - JIRA_API_TOKEN: "test-token", - DEVINTERN_FETCH_MAX_RETRIES: "0", - DEVINTERN_SKIP_LICENSE_CHECK: "1", - DEVINTERN_NO_UPDATE: "1", - }, + env: cliTrackerTestEnv(), }); return { stdout: result.stdout || "", stderr: result.stderr || "", exitCode: result.status || 0, - timedOut: - (result.error as NodeJS.ErrnoException | undefined)?.code === "ETIMEDOUT" || - Boolean(result.signal), + timedOut: spawnTimedOut(result), }; } finally { // Clean up temp directory @@ -122,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"); @@ -236,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 { @@ -265,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 { 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([