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 src/backends/secretBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
11 changes: 8 additions & 3 deletions src/cli/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*/
Expand Down Expand Up @@ -47,7 +51,7 @@ function wrapWithCredentialScopes(fn: () => Promise<void>): () => Promise<void>
}
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 = () =>
Expand All @@ -69,7 +73,7 @@ function wrapWithCredentialScopes(fn: () => Promise<void>): () => Promise<void>
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';
Expand All @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion src/gadgets/session/core/sidecar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
2 changes: 1 addition & 1 deletion src/jira/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions tests/unit/backends/secretBuilder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
38 changes: 37 additions & 1 deletion tests/unit/cli/credential-scoping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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();
});

Expand Down Expand Up @@ -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 <cmd>`.
Expand Down
25 changes: 24 additions & 1 deletion tests/unit/cli/pm/add-checklist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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',
);
});
});
4 changes: 3 additions & 1 deletion tests/unit/jira/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Loading