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
2 changes: 1 addition & 1 deletion src/cli/dashboard/email/integration-set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export default class EmailIntegrationSet extends DashboardCommand {
this.log('Note: Run "cascade email oauth" to authenticate Gmail.');
} else {
this.log(
'Note: Link IMAP credentials using "cascade projects integration-credential-set".',
'Note: Link IMAP credentials using "cascade projects integration-credential-set --category email".',
);
}
} catch (err) {
Expand Down
6 changes: 3 additions & 3 deletions src/cli/dashboard/projects/override-rm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ export default class ProjectsIntegrationCredentialRm extends DashboardCommand {
static override flags = {
...DashboardCommand.baseFlags,
category: Flags.string({
description: 'Integration category (pm or scm)',
description: 'Integration category (pm, scm, or email)',
required: true,
options: ['pm', 'scm'],
options: ['pm', 'scm', 'email'],
}),
role: Flags.string({
description: 'Credential role to unlink (e.g. api_key, token, implementer_token)',
Expand All @@ -29,7 +29,7 @@ export default class ProjectsIntegrationCredentialRm extends DashboardCommand {
try {
await this.client.projects.integrationCredentials.remove.mutate({
projectId: args.id,
category: flags.category as 'pm' | 'scm',
category: flags.category as 'pm' | 'scm' | 'email',
role: flags.role,
});

Expand Down
6 changes: 3 additions & 3 deletions src/cli/dashboard/projects/override-set.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ export default class ProjectsIntegrationCredentialSet extends DashboardCommand {
static override flags = {
...DashboardCommand.baseFlags,
category: Flags.string({
description: 'Integration category (pm or scm)',
description: 'Integration category (pm, scm, or email)',
required: true,
options: ['pm', 'scm'],
options: ['pm', 'scm', 'email'],
}),
role: Flags.string({
description: 'Credential role (e.g. api_key, token, implementer_token)',
Expand All @@ -30,7 +30,7 @@ export default class ProjectsIntegrationCredentialSet extends DashboardCommand {
try {
await this.client.projects.integrationCredentials.set.mutate({
projectId: args.id,
category: flags.category as 'pm' | 'scm',
category: flags.category as 'pm' | 'scm' | 'email',
role: flags.role,
credentialId: flags['credential-id'],
});
Expand Down
8 changes: 4 additions & 4 deletions src/cli/dashboard/projects/overrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@ export default class ProjectsIntegrationCredentials extends DashboardCommand {
static override flags = {
...DashboardCommand.baseFlags,
category: Flags.string({
description: 'Filter by integration category (pm or scm)',
options: ['pm', 'scm'],
description: 'Filter by integration category (pm, scm, or email)',
options: ['pm', 'scm', 'email'],
}),
};

Expand All @@ -23,8 +23,8 @@ export default class ProjectsIntegrationCredentials extends DashboardCommand {

try {
const categories = flags.category
? [flags.category as 'pm' | 'scm']
: (['pm', 'scm'] as const);
? [flags.category as 'pm' | 'scm' | 'email']
: (['pm', 'scm', 'email'] as const);

const allCreds: Array<Record<string, unknown>> = [];

Expand Down
59 changes: 42 additions & 17 deletions src/triggers/shared/agent-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import { handleAgentResultArtifacts } from './agent-result-handler.js';
import { checkBudgetExceeded } from './budget.js';
import { triggerDebugAnalysis } from './debug-runner.js';
import { shouldTriggerDebug } from './debug-trigger.js';
import { formatValidationErrors, validateIntegrations } from './integration-validation.js';
import {
type ValidationResult,
formatValidationErrors,
validateIntegrations,
} from './integration-validation.js';

/**
* Configuration for source-specific behavior in the agent execution pipeline.
Expand Down Expand Up @@ -124,6 +128,36 @@ async function runPostAgentLifecycle(
}
}

/**
* Notify PM and GitHub when integration validation fails before the agent runs.
*/
async function notifyValidationFailure(
result: TriggerResult,
validation: ValidationResult,
lifecycle: PMLifecycleManager,
executionConfig: AgentExecutionConfig,
agentType: string,
projectId: string,
): Promise<void> {
const errorMessage = formatValidationErrors(validation);
logger.error('Integration validation failed', {
agentType,
projectId,
errors: validation.errors,
});

// Only notify via PM if PM validation passed (otherwise PM isn't configured)
const pmFailed = validation.errors.some((e) => e.category === 'pm');
if (result.workItemId && !pmFailed) {
await lifecycle.handleFailure(result.workItemId, errorMessage);
}

// Call onFailure callback (for GitHub PR updates)
if (executionConfig.onFailure) {
await executionConfig.onFailure(result, { success: false, output: '', error: errorMessage });
}
}

/**
* Shared agent execution pipeline.
*
Expand Down Expand Up @@ -164,23 +198,14 @@ export async function runAgentExecutionPipeline(
// Pre-flight integration validation
const validation = await validateIntegrations(project.id, agentType);
if (!validation.valid) {
const errorMessage = formatValidationErrors(validation);
logger.error('Integration validation failed', {
await notifyValidationFailure(
result,
validation,
lifecycle,
executionConfig,
agentType,
projectId: project.id,
errors: validation.errors,
});

// Only notify via PM if PM validation passed (otherwise PM isn't configured)
const pmFailed = validation.errors.some((e) => e.category === 'pm');
if (result.workItemId && !pmFailed) {
await lifecycle.handleFailure(result.workItemId, errorMessage);
}

// Call onFailure callback (for GitHub PR updates)
if (executionConfig.onFailure) {
await executionConfig.onFailure(result, { success: false, output: '', error: errorMessage });
}
project.id,
);
return;
}

Expand Down
50 changes: 50 additions & 0 deletions tests/unit/api/routers/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,22 @@ describe('projectsRouter', () => {

expect(result).toEqual([]);
});

it('lists email credentials', async () => {
mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]);
mockGetIntegrationByProjectAndCategory.mockResolvedValue({ id: 20 });
const creds = [{ role: 'gmail_refresh_token', credentialId: 7, credentialName: 'Gmail' }];
mockListIntegrationCredentials.mockResolvedValue(creds);
const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });

const result = await caller.integrationCredentials.list({
projectId: 'p1',
category: 'email',
});

expect(mockGetIntegrationByProjectAndCategory).toHaveBeenCalledWith('p1', 'email');
expect(result).toEqual(creds);
});
});

describe('set', () => {
Expand All @@ -359,6 +375,24 @@ describe('projectsRouter', () => {
expect(mockSetIntegrationCredential).toHaveBeenCalledWith(10, 'api_key', 42);
});

it('sets email credential role', async () => {
mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1' }]); // project
mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1' }]); // credential
mockGetIntegrationByProjectAndCategory.mockResolvedValue({ id: 20 });
mockSetIntegrationCredential.mockResolvedValue(undefined);
const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });

await caller.integrationCredentials.set({
projectId: 'p1',
category: 'email',
role: 'imap_password',
credentialId: 7,
});

expect(mockGetIntegrationByProjectAndCategory).toHaveBeenCalledWith('p1', 'email');
expect(mockSetIntegrationCredential).toHaveBeenCalledWith(20, 'imap_password', 7);
});

it('throws NOT_FOUND when credential belongs to different org', async () => {
mockDbWhere.mockResolvedValueOnce([{ orgId: 'org-1' }]); // project OK
mockDbWhere.mockResolvedValueOnce([{ orgId: 'different-org' }]); // credential not owned
Expand Down Expand Up @@ -390,6 +424,22 @@ describe('projectsRouter', () => {

expect(mockRemoveIntegrationCredential).toHaveBeenCalledWith(10, 'api_key');
});

it('removes email credential role', async () => {
mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]);
mockGetIntegrationByProjectAndCategory.mockResolvedValue({ id: 20 });
mockRemoveIntegrationCredential.mockResolvedValue(undefined);
const caller = createCaller({ user: mockUser, effectiveOrgId: mockUser.orgId });

await caller.integrationCredentials.remove({
projectId: 'p1',
category: 'email',
role: 'imap_password',
});

expect(mockGetIntegrationByProjectAndCategory).toHaveBeenCalledWith('p1', 'email');
expect(mockRemoveIntegrationCredential).toHaveBeenCalledWith(20, 'imap_password');
});
});
});
});
128 changes: 128 additions & 0 deletions tests/unit/cli/dashboard/email/integration-set.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

const mockLoadConfig = vi.fn();
const mockCreateDashboardClient = vi.fn();

vi.mock('../../../../../src/cli/dashboard/_shared/config.js', () => ({
loadConfig: (...args: unknown[]) => mockLoadConfig(...args),
}));

vi.mock('../../../../../src/cli/dashboard/_shared/client.js', () => ({
createDashboardClient: (...args: unknown[]) => mockCreateDashboardClient(...args),
}));

vi.mock('chalk', () => ({
default: {
bold: (s: string) => s,
blue: (s: string) => s,
green: (s: string) => s,
red: (s: string) => s,
yellow: (s: string) => s,
dim: (s: string) => s,
},
}));

import EmailIntegrationSet from '../../../../../src/cli/dashboard/email/integration-set.js';

// oclif's Command.parse() calls this.config.runHook internally
const oclifConfig = {
runHook: vi.fn().mockResolvedValue({ successes: [], failures: [] }),
};

function makeClient() {
return {
projects: {
integrations: {
upsert: { mutate: vi.fn().mockResolvedValue(undefined) },
},
},
};
}

const baseConfig = { serverUrl: 'http://localhost:3000', sessionToken: 'tok' };

describe('EmailIntegrationSet', () => {
beforeEach(() => {
mockLoadConfig.mockReturnValue(baseConfig);
});

it('upserts a gmail integration and prints oauth guidance', async () => {
const client = makeClient();
mockCreateDashboardClient.mockReturnValue(client);

const logMessages: string[] = [];
const cmd = new EmailIntegrationSet(
['my-project', '--provider', 'gmail'],
oclifConfig as never,
);
vi.spyOn(cmd, 'log').mockImplementation((msg?: string) => {
if (msg) logMessages.push(msg);
});

await cmd.run();

expect(client.projects.integrations.upsert.mutate).toHaveBeenCalledWith(
expect.objectContaining({
projectId: 'my-project',
category: 'email',
provider: 'gmail',
}),
);

const oauthGuidance = logMessages.find((m) => m.includes('cascade email oauth'));
expect(oauthGuidance).toBeDefined();
});

it('upserts an imap integration and prints credential-set guidance with --category email', async () => {
const client = makeClient();
mockCreateDashboardClient.mockReturnValue(client);

const logMessages: string[] = [];
const cmd = new EmailIntegrationSet(['my-project', '--provider', 'imap'], oclifConfig as never);
vi.spyOn(cmd, 'log').mockImplementation((msg?: string) => {
if (msg) logMessages.push(msg);
});

await cmd.run();

expect(client.projects.integrations.upsert.mutate).toHaveBeenCalledWith(
expect.objectContaining({
projectId: 'my-project',
category: 'email',
provider: 'imap',
}),
);

const credGuidance = logMessages.find((m) => m.includes('integration-credential-set'));
expect(credGuidance).toBeDefined();
expect(credGuidance).toContain('--category email');
});

it('upserts with custom config JSON', async () => {
const client = makeClient();
mockCreateDashboardClient.mockReturnValue(client);

const cmd = new EmailIntegrationSet(
['my-project', '--provider', 'imap', '--config', '{"host":"mail.example.com"}'],
oclifConfig as never,
);
vi.spyOn(cmd, 'log').mockImplementation(() => {});
await cmd.run();

expect(client.projects.integrations.upsert.mutate).toHaveBeenCalledWith(
expect.objectContaining({
config: { host: 'mail.example.com' },
}),
);
});

it('errors on invalid JSON config', async () => {
mockCreateDashboardClient.mockReturnValue(makeClient());

const cmd = new EmailIntegrationSet(
['my-project', '--provider', 'imap', '--config', 'not-json'],
oclifConfig as never,
);
await expect(cmd.run()).rejects.toThrow();
});
});
Loading