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
75 changes: 73 additions & 2 deletions src/commands/integration/connect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ type ConnectableType =
| 'braintrust'
| 'incident_io'
| 'depot'
| 'circleci'
| 'mixpanel'
| 'amplitude'
| 'posthog'
Expand Down Expand Up @@ -104,6 +105,7 @@ const TYPE_OPTIONS: Array<{ value: ConnectableType; label: string; hint: string;
{ value: 'braintrust', label: 'Braintrust', hint: 'service token · LLM observability', category: 'observability' },
{ value: 'incident_io', label: 'incident.io', hint: 'API key', category: 'incident-management' },
{ value: 'depot', label: 'Depot', hint: 'organization token', category: 'ci-cd' },
{ value: 'circleci', label: 'CircleCI', hint: 'personal API token', category: 'ci-cd' },
{ value: 'mixpanel', label: 'Mixpanel', hint: 'service account + project ID', category: 'product-analytics' },
{ value: 'amplitude', label: 'Amplitude', hint: 'API key + secret key', category: 'product-analytics' },
{ value: 'posthog', label: 'PostHog', hint: "choose one project on PostHog's consent screen", category: 'product-analytics' },
Expand Down Expand Up @@ -767,6 +769,48 @@ const NEWRELIC_ACCOUNT_RETRY: ScopeRetry<{ accountId: number }> = {
parse: (v) => ({ accountId: parseNewrelicAccountId(v) }),
};

function circleciOrganizations(metadata: Record<string, unknown> | undefined): Array<{ id: string; name: string; slug: string }> {
const raw = metadata?.organizations;
if (!Array.isArray(raw)) return [];
return raw.flatMap((o: unknown) => {
if (typeof o !== 'object' || o === null) return [];
const { id, name, slug } = o as { id?: unknown; name?: unknown; slug?: unknown };
if (typeof id !== 'string') return [];
return [{ id, name: typeof name === 'string' ? name : id, slug: typeof slug === 'string' ? slug : '' }];
});
}

// A personal CircleCI token reaches every organization its user belongs to. The
// API refuses to guess (400) and lists the reachable organizations in the error
// metadata, so pick one here and retry instead of surfacing a dead end.
export async function connectCircleci(
config: Config,
api: PolylaneAPI,
body: Extract<ConnectBody, { type: 'circleci' }>
): Promise<typeof BACK | Integration> {
try {
return await api.integrationsConnect(body);
} catch (err) {
if (!isApiError(err) || err.status !== 400 || body.organizationId !== undefined) throw err;
const organizations = circleciOrganizations(err.metadata);
if (organizations.length === 0) throw err;
if (!isInteractive(config.nonInteractive)) {
throw new CLIError(
err.message,
ExitCode.USAGE,
'Pass --organization-id <id> with one of:\n' + organizations.map((o) => ` ${o.id} ${o.name} (${o.slug})`).join('\n')
);
}
const picked = await promptSelectOrBack<string>(
{ nonInteractive: config.nonInteractive },
'This token can reach more than one CircleCI organization. Select the one to connect.',
organizations.map((o) => ({ value: o.id, label: o.name, hint: o.slug }))
);
if (picked === BACK) return BACK;
return api.integrationsConnect({ ...body, organizationId: picked });
}
}

const LANGSMITH_WORKSPACE_RETRY: ScopeRetry<{ langsmithWorkspaceId: string }> = {
pattern: /more than one LangSmith workspace/i,
flag: '--langsmith-workspace',
Expand Down Expand Up @@ -1339,6 +1383,29 @@ async function connectWithCredentials(
]);
if (!ok) return BACK;
body = { type: 'depot', workspaceId, apiToken };
} else if (type === 'circleci') {
const organizationId = getArgString(args, 'organizationId')?.trim() || undefined;
let apiKey = '';
const ok = await runSteps([
secretStep(
config,
args,
'apiKey',
'--api-key',
{
message: 'CircleCI personal API token',
instructions:
'In CircleCI, open User Settings, then Personal API Tokens, and create a token. Project API tokens do not work. If the token reaches more than one organization, you choose the one to connect.',
link: 'https://app.circleci.com/settings/user/tokens',
linkLabel: 'Open CircleCI token settings',
},
(v) => {
apiKey = v;
}
),
]);
if (!ok) return BACK;
body = { type: 'circleci', workspaceId, apiKey, ...(organizationId !== undefined ? { organizationId } : {}) };
} else if (type === 'amplitude') {
let apiKey = '';
let secretKey = '';
Expand Down Expand Up @@ -1409,7 +1476,9 @@ async function connectWithCredentials(
? await connectPickingScope(config, api, body, body.accountId !== undefined, NEWRELIC_ACCOUNT_RETRY)
: body.type === 'langsmith'
? await connectPickingScope(config, api, body, body.langsmithWorkspaceId !== undefined, LANGSMITH_WORKSPACE_RETRY)
: await api.integrationsConnect(body);
: body.type === 'circleci'
? await connectCircleci(config, api, body)
: await api.integrationsConnect(body);
if (integration === BACK) return BACK;
if (type === 'honeycomb') assertHoneycombManagementKeyStored(integration);
printConnectSuccess(config, integration, TYPE_OPTIONS.find((o) => o.value === type)?.label ?? type);
Expand Down Expand Up @@ -1503,12 +1572,13 @@ export const integrationConnectCommand: Command = {
{
flag: '--api-key <key>',
description:
'API key (Datadog / Honeycomb / OpenStatus / Logfire project key / New Relic User key / LangSmith / Braintrust service token / incident.io / Amplitude / Devin / Cursor / Factory / Conductor / Capy / Amp access token / Linear)',
'API key (Datadog / Honeycomb / OpenStatus / Logfire project key / New Relic User key / LangSmith / Braintrust service token / incident.io / Amplitude / CircleCI personal API token / Devin / Cursor / Factory / Conductor / Capy / Amp access token / Linear)',
type: 'string',
},
{ flag: '--secret-key <key>', description: 'Secret key (Amplitude only)', type: 'string' },
{ flag: '--account-id <id>', description: 'New Relic account ID, when the key can read more than one account', type: 'string' },
{ flag: '--langsmith-workspace <id>', description: 'LangSmith workspace ID, when the key can read more than one workspace', type: 'string' },
{ flag: '--organization-id <id>', description: 'CircleCI organization ID, when the token can reach more than one organization', type: 'string' },
{
flag: '--escalation-severity <s>',
description: `incident.io: lowest escalation severity sent as an incident.io alert: ${INCIDENT_IO_SEVERITIES.join(' | ')} (default: high)`,
Expand Down Expand Up @@ -1567,6 +1637,7 @@ export const integrationConnectCommand: Command = {
'polylane integration connect --type braintrust --api-key bt-st-...',
'polylane integration connect --type incident_io --api-key ... --escalation-severity critical',
'polylane integration connect --type depot --api-token ...',
'polylane integration connect --type circleci --api-key ... --organization-id <id>',
'polylane integration connect --type amplitude --api-key ... --secret-key ...',
'polylane integration connect --type posthog',
'polylane integration connect --type cursor --api-key crsr_...',
Expand Down
8 changes: 4 additions & 4 deletions test/integration-connect-category.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { isCLIError } from '../src/errors/base';
describe('typeOptionsForCategory', () => {
it('returns every option when no category is given', () => {
const all = typeOptionsForCategory(undefined);
assert.equal(all.length, 26);
assert.equal(all.length, 27);
});

it('narrows to exactly the observability integrations', () => {
Expand Down Expand Up @@ -42,7 +42,7 @@ describe('typeOptionsForCategory', () => {

it('narrows to exactly the incident management and CI/CD integrations', () => {
assert.deepEqual(typeOptionsForCategory('incident-management').map((o) => o.value), ['incident_io']);
assert.deepEqual(typeOptionsForCategory('ci-cd').map((o) => o.value), ['depot']);
assert.deepEqual(typeOptionsForCategory('ci-cd').map((o) => o.value), ['depot', 'circleci']);
});

it('narrows to exactly the issue trackers', () => {
Expand Down Expand Up @@ -95,9 +95,9 @@ describe('typeOptionsForCategory', () => {

describe('resolveTypeOptions', () => {
it('lets --type win over the filter', () => {
assert.equal(resolveTypeOptions('observability', true).length, 26);
assert.equal(resolveTypeOptions('observability', true).length, 27);
assert.equal(resolveTypeOptions('observability', false).length, 11);
assert.equal(resolveTypeOptions(undefined, false).length, 26);
assert.equal(resolveTypeOptions(undefined, false).length, 27);
});

it('rejects an unknown category even when --type is present', () => {
Expand Down
80 changes: 80 additions & 0 deletions test/integration-connect-circleci.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { connectCircleci } from '../src/commands/integration/connect';
import { ApiError, mapApiError } from '../src/errors/api';
import { CLIError } from '../src/errors/base';
import { ExitCode } from '../src/errors/codes';
import type { Config } from '../src/config/schema';
import type { PolylaneAPI } from '../src/generated/client';

const config = { nonInteractive: true } as Config;
const body = { type: 'circleci', workspaceId: 'ws_1', apiKey: 'circle_token' } as const;
const MULTI_ORG = 'This CircleCI token reaches more than one organization. Select the organization to connect.';

function mockApi(connect: (body: unknown) => Promise<unknown>): PolylaneAPI {
return { integrationsConnect: connect } as unknown as PolylaneAPI;
}

function multiOrgError(): ApiError {
return mapApiError(400, {
message: 'Bad Request',
detail: MULTI_ORG,
metadata: {
organizations: [
{ id: 'org-acme', name: 'acme', slug: 'gh/acme' },
{ id: 'org-beta', name: 'beta', slug: 'circleci/beta' },
],
},
});
}

describe('connectCircleci', () => {
it('sends the body without organizationId and returns the integration', async () => {
const seen: unknown[] = [];
const integration = { id: 'int_1', type: 'circleci' };
const api = mockApi(async (b) => {
seen.push(b);
return integration;
});
assert.equal(await connectCircleci(config, api, body), integration);
assert.deepEqual(seen, [body]);
});

it('lists the reachable organizations in the --organization-id hint when not interactive', async () => {
const api = mockApi(async () => {
throw multiOrgError();
});
await assert.rejects(
() => connectCircleci(config, api, body),
(err: unknown) =>
err instanceof CLIError &&
err.exitCode === ExitCode.USAGE &&
err.message === MULTI_ORG &&
(err.hint?.includes('--organization-id') ?? false) &&
(err.hint?.includes('org-acme acme (gh/acme)') ?? false) &&
(err.hint?.includes('org-beta beta (circleci/beta)') ?? false)
);
});

it('rethrows the 400 when an organizationId was already sent', async () => {
const original = multiOrgError();
const api = mockApi(async () => {
throw original;
});
await assert.rejects(
() => connectCircleci(config, api, { ...body, organizationId: 'org-acme' }),
(err: unknown) => err === original
);
});

it('rethrows a 400 that lists no organizations', async () => {
const original = new ApiError(400, 'CircleCI did not accept this API key.', ExitCode.USAGE);
const api = mockApi(async () => {
throw original;
});
await assert.rejects(
() => connectCircleci(config, api, body),
(err: unknown) => err === original
);
});
});
Loading