diff --git a/src/backends/secretBuilder.ts b/src/backends/secretBuilder.ts index 5a6f8533..9bcb83fd 100644 --- a/src/backends/secretBuilder.ts +++ b/src/backends/secretBuilder.ts @@ -47,6 +47,7 @@ export async function augmentProjectSecrets( if (jiraConfig) { projectSecrets.CASCADE_JIRA_PROJECT_KEY = jiraConfig.projectKey; projectSecrets.CASCADE_JIRA_BASE_URL = jiraConfig.baseUrl; + projectSecrets.JIRA_BASE_URL = jiraConfig.baseUrl; if (jiraConfig.statuses) { projectSecrets.CASCADE_JIRA_STATUSES = JSON.stringify(jiraConfig.statuses); } diff --git a/src/cli/base.ts b/src/cli/base.ts index bc9d6514..5765024f 100644 --- a/src/cli/base.ts +++ b/src/cli/base.ts @@ -9,6 +9,10 @@ import type { PMType } from '../pm/types.js'; import { withTrelloCredentials } from '../trello/client.js'; import type { ProjectConfig } from '../types/index.js'; +export function resolveJiraBaseUrl(): string | undefined { + return process.env.JIRA_BASE_URL || process.env.CASCADE_JIRA_BASE_URL; +} + /** * Resolve repository owner/repo from flags, env vars, or git remote (in that order). */ @@ -47,7 +51,7 @@ function wrapWithCredentialScopes(fn: () => Promise): () => Promise } const jiraEmail = process.env.JIRA_EMAIL; const jiraApiToken = process.env.JIRA_API_TOKEN; - const jiraBaseUrl = process.env.JIRA_BASE_URL; + const jiraBaseUrl = resolveJiraBaseUrl(); if (jiraEmail && jiraApiToken && jiraBaseUrl) { const prev = fn; fn = () => @@ -69,7 +73,7 @@ function wrapWithCredentialScopes(fn: () => Promise): () => Promise function resolvePmType(): PMType { const explicit = process.env.CASCADE_PM_TYPE as PMType | undefined; if (explicit) return explicit; - if (process.env.JIRA_EMAIL && process.env.JIRA_API_TOKEN && process.env.JIRA_BASE_URL) { + if (process.env.JIRA_EMAIL && process.env.JIRA_API_TOKEN && resolveJiraBaseUrl()) { return 'jira'; } if (process.env.LINEAR_API_KEY) return 'linear'; @@ -84,11 +88,12 @@ function resolvePmType(): PMType { function synthesizeProjectFromEnv(pmType: PMType): ProjectConfig { if (pmType === 'jira') { const jiraStatuses = process.env.CASCADE_JIRA_STATUSES; + const jiraBaseUrl = resolveJiraBaseUrl(); return { pm: { type: 'jira' }, jira: { projectKey: process.env.CASCADE_JIRA_PROJECT_KEY ?? '', - baseUrl: process.env.JIRA_BASE_URL as string, + baseUrl: jiraBaseUrl ?? '', statuses: jiraStatuses ? JSON.parse(jiraStatuses) : {}, }, } as ProjectConfig; diff --git a/src/gadgets/session/core/sidecar.ts b/src/gadgets/session/core/sidecar.ts index 4f79fb53..f1a1f5bf 100644 --- a/src/gadgets/session/core/sidecar.ts +++ b/src/gadgets/session/core/sidecar.ts @@ -26,7 +26,9 @@ export function writePMWriteSidecar(sidecarPath: string | undefined, workItemId: export function writePushedChangesSidecar(sidecarPath: string | undefined): boolean { if (!sidecarPath || sidecarPath === 'undefined') { - logger.warn('CASCADE_SIDECAR_PATH not set — pushed-changes sidecar will not be written'); + logger.warn( + 'CASCADE_PUSHED_CHANGES_SIDECAR_PATH not set — pushed-changes sidecar will not be written', + ); return false; } diff --git a/src/jira/client.ts b/src/jira/client.ts index 88df2b78..a2a24629 100644 --- a/src/jira/client.ts +++ b/src/jira/client.ts @@ -20,7 +20,7 @@ export function getJiraCredentials(): JiraCredentials { const scoped = jiraCredentialStore.getStore(); if (!scoped) { throw new Error( - 'No JIRA credentials in scope. Wrap the call with withJiraCredentials() or ensure per-project JIRA_EMAIL/JIRA_API_TOKEN/JIRA_BASE_URL are set in the database.', + 'No JIRA credentials in scope. Wrap the call with withJiraCredentials() or ensure JIRA_EMAIL and JIRA_API_TOKEN are configured, plus a JIRA base URL via JIRA_BASE_URL or CASCADE_JIRA_BASE_URL. The base URL normally comes from injected project config, not a separate database credential.', ); } return scoped; diff --git a/tests/unit/backends/secretBuilder.test.ts b/tests/unit/backends/secretBuilder.test.ts index b0723512..95eb355a 100644 --- a/tests/unit/backends/secretBuilder.test.ts +++ b/tests/unit/backends/secretBuilder.test.ts @@ -111,6 +111,7 @@ describe('augmentProjectSecrets', () => { const secrets = await augmentProjectSecrets(project, 'implementation', {} as AgentInput); expect(secrets.CASCADE_JIRA_PROJECT_KEY).toBe('PROJ'); expect(secrets.CASCADE_JIRA_BASE_URL).toBe('https://acme.atlassian.net'); + expect(secrets.JIRA_BASE_URL).toBe('https://acme.atlassian.net'); }); it('injects CASCADE_JIRA_STATUSES as JSON when jira.statuses is set', async () => { diff --git a/tests/unit/cli/credential-scoping.test.ts b/tests/unit/cli/credential-scoping.test.ts index daa1535e..b40383ac 100644 --- a/tests/unit/cli/credential-scoping.test.ts +++ b/tests/unit/cli/credential-scoping.test.ts @@ -56,8 +56,9 @@ import '../../../src/integrations/pm/index.js'; import '../../../src/github/register.js'; import '../../../src/sentry/register.js'; -import { CredentialScopedCommand } from '../../../src/cli/base.js'; +import { CredentialScopedCommand, resolveJiraBaseUrl } from '../../../src/cli/base.js'; import { withGitHubToken } from '../../../src/github/client.js'; +import { withJiraCredentials } from '../../../src/jira/client.js'; import { withLinearCredentials } from '../../../src/linear/client.js'; import { withTrelloCredentials } from '../../../src/trello/client.js'; @@ -89,6 +90,8 @@ describe('CredentialScopedCommand', () => { delete process.env.JIRA_EMAIL; delete process.env.JIRA_API_TOKEN; delete process.env.JIRA_BASE_URL; + delete process.env.CASCADE_JIRA_BASE_URL; + vi.mocked(withJiraCredentials).mockClear(); vi.mocked(withLinearCredentials).mockClear(); }); @@ -157,6 +160,39 @@ describe('CredentialScopedCommand', () => { ); }); + it('wraps execute() with withJiraCredentials when only CASCADE_JIRA_BASE_URL is set', async () => { + process.env.JIRA_EMAIL = 'bot@example.com'; + process.env.JIRA_API_TOKEN = 'jira-token'; + process.env.CASCADE_JIRA_BASE_URL = 'https://cascade.atlassian.net'; + process.env.CASCADE_JIRA_PROJECT_KEY = 'CASCADE'; + + const cmd = new TestCommand([], {} as never); + await cmd.run(); + + expect(cmd.executeCalled).toBe(true); + expect(withJiraCredentials).toHaveBeenCalledWith( + { + email: 'bot@example.com', + apiToken: 'jira-token', + baseUrl: 'https://cascade.atlassian.net', + }, + expect.any(Function), + ); + }); + + it('prefers JIRA_BASE_URL over CASCADE_JIRA_BASE_URL when both are set', async () => { + process.env.JIRA_BASE_URL = 'https://legacy.atlassian.net'; + process.env.CASCADE_JIRA_BASE_URL = 'https://injected.atlassian.net'; + + expect(resolveJiraBaseUrl()).toBe('https://legacy.atlassian.net'); + }); + + it('falls back to CASCADE_JIRA_BASE_URL when JIRA_BASE_URL is not set', async () => { + process.env.CASCADE_JIRA_BASE_URL = 'https://injected.atlassian.net'; + + expect(resolveJiraBaseUrl()).toBe('https://injected.atlassian.net'); + }); + // Linear scope — mirrors the GitHub/Trello/JIRA pattern. Without these the CLI // throws `Linear integration requires teamId in config` whenever a Linear-backed // agent run invokes any `cascade-tools pm `. diff --git a/tests/unit/cli/pm/add-checklist.test.ts b/tests/unit/cli/pm/add-checklist.test.ts index 7366c242..49c64df3 100644 --- a/tests/unit/cli/pm/add-checklist.test.ts +++ b/tests/unit/cli/pm/add-checklist.test.ts @@ -11,10 +11,23 @@ vi.mock('../../../../src/pm/index.js', () => ({ getPMProvider: vi.fn(() => mockProvider), })); +const { mockWarn } = vi.hoisted(() => ({ + mockWarn: vi.fn(), +})); + +vi.mock('../../../../src/utils/logging.js', () => ({ + logger: { + warn: mockWarn, + }, +})); + // Import after mocks so the module picks up the mocked getPMProvider import { parseItem } from '../../../../src/cli/pm/add-checklist.js'; import { addChecklist } from '../../../../src/gadgets/pm/core/addChecklist.js'; -import { writePMWriteSidecar } from '../../../../src/gadgets/session/core/sidecar.js'; +import { + writePMWriteSidecar, + writePushedChangesSidecar, +} from '../../../../src/gadgets/session/core/sidecar.js'; // --------------------------------------------------------------------------- // Unit tests for parseItem() — the JSON-parsing helper @@ -256,6 +269,7 @@ describe('writePMWriteSidecar', () => { afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); Reflect.deleteProperty(process.env, 'CASCADE_PM_WRITE_SIDECAR_PATH'); + mockWarn.mockReset(); }); it('writes sidecar file with correct JSON when path is set', () => { @@ -292,4 +306,13 @@ describe('writePMWriteSidecar', () => { expect(result).toBe(false); expect(existsSync(badPath)).toBe(false); }); + + it('logs the correct pushed-changes env var name when pushed-changes sidecar path is missing', () => { + const result = writePushedChangesSidecar(undefined); + + expect(result).toBe(false); + expect(mockWarn).toHaveBeenCalledWith( + 'CASCADE_PUSHED_CHANGES_SIDECAR_PATH not set — pushed-changes sidecar will not be written', + ); + }); }); diff --git a/tests/unit/jira/client.test.ts b/tests/unit/jira/client.test.ts index e845811f..d09be0b0 100644 --- a/tests/unit/jira/client.test.ts +++ b/tests/unit/jira/client.test.ts @@ -793,7 +793,9 @@ describe('jiraClient', () => { describe('getJiraCredentials', () => { it('throws when called outside scope', () => { - expect(() => getJiraCredentials()).toThrow('No JIRA credentials in scope'); + expect(() => getJiraCredentials()).toThrow( + 'No JIRA credentials in scope. Wrap the call with withJiraCredentials() or ensure JIRA_EMAIL and JIRA_API_TOKEN are configured, plus a JIRA base URL via JIRA_BASE_URL or CASCADE_JIRA_BASE_URL. The base URL normally comes from injected project config, not a separate database credential.', + ); }); it('returns credentials when inside withJiraCredentials scope', async () => {