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
1 change: 1 addition & 0 deletions packages/code/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
74 changes: 48 additions & 26 deletions packages/code/tests/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {}): 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<typeof spawnSync>): 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(
Expand All @@ -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
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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");
});
});
Expand Down
36 changes: 36 additions & 0 deletions packages/task-trackers/linear-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => ({
Expand Down
6 changes: 4 additions & 2 deletions packages/task-trackers/src/clients/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}

/**
Expand Down
6 changes: 4 additions & 2 deletions packages/task-trackers/src/clients/trello.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}

/**
Expand Down
31 changes: 31 additions & 0 deletions packages/task-trackers/trello-search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
18 changes: 18 additions & 0 deletions packages/utils/fetch-retry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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 () => {
Expand Down
19 changes: 18 additions & 1 deletion packages/utils/src/fetch-retry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -38,7 +55,7 @@ export async function fetchWithRetry(
verbose?: boolean;
},
): Promise<Response> {
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;
Expand Down
Loading