Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
2fbec72
fix(cli): summarize metrics list/get responses by default
joalves Sep 24, 2026
f5caef8
fix(cli): summarize goals list/get responses by default
joalves Sep 24, 2026
23d3b87
fix(cli): summarize users list/get responses by default
joalves Sep 24, 2026
bf52297
fix(cli): summarize tags list response by default
joalves Sep 24, 2026
55aefb3
fix(cli): summarize webhooks list/get responses by default
joalves Sep 24, 2026
9182c99
fix(cli): add bounded pagination to permissions list commands
joalves Sep 24, 2026
9796321
test(cli): cover permissions CLI pagination flags
joalves Sep 24, 2026
c8610b6
fix(cli): add bounded pagination to listAssetRoles
joalves Sep 24, 2026
4336342
fix(cli): add bounded pagination to listCorsOrigins
joalves Sep 24, 2026
5bbb56a
fix(cli): add bounded pagination to listDatasources
joalves Sep 24, 2026
c585234
fix(cli): add bounded pagination to listExportConfigs
joalves Sep 24, 2026
6be8dc1
fix(cli): add bounded pagination to listUpdateSchedules
joalves Sep 24, 2026
93d907a
fix(cli): add bounded pagination to listCustomSections
joalves Sep 24, 2026
3009bf2
fix(cli): add bounded pagination to listStorageConfigs
joalves Sep 24, 2026
424dfe7
fix(cli): add bounded pagination to listActionDialogFields
joalves Sep 24, 2026
e8c5ad5
fix(cli): wire pagination flags for asset-roles list command
joalves Sep 24, 2026
216f2a6
fix(cli): wire pagination flags for cors list command
joalves Sep 24, 2026
cf3912c
fix(cli): wire pagination flags for datasources list command
joalves Sep 24, 2026
2085dcf
fix(cli): wire pagination flags for export-configs list command
joalves Sep 24, 2026
08e8b05
fix(cli): wire pagination flags for update-schedules list command
joalves Sep 24, 2026
7458e7d
fix(cli): wire pagination flags for custom-sections list command
joalves Sep 24, 2026
88e476e
fix(cli): wire pagination flags for storage-configs list command
joalves Sep 24, 2026
da62710
fix(cli): wire pagination flags for action-dialog-fields list command
joalves Sep 24, 2026
c084a78
fix(cli): cap notifications list results with a default limit
joalves Sep 24, 2026
c42e994
fix(cli): default a bounded take for events list
joalves Sep 24, 2026
d97de6a
fix(cli): skip notifications truncation warning for json/yaml output
joalves Sep 24, 2026
4aa95fd
fix(cli): restore backward compatibility for listCustomSections publi…
joalves Sep 24, 2026
afa17a7
chore(release): bump cli to 1.15.0
joalves Sep 24, 2026
e005ad9
style: run prettier on notifications/api-client fixes
joalves Sep 24, 2026
181b506
fix(cli): render webhook event names instead of [object Object]
joalves Sep 25, 2026
18bd85a
fix(cli): correct 3 stale endpoint paths/response shapes broken again…
joalves Sep 25, 2026
9cca960
fix(cli): revert 3 wrong endpoint fixes, fix access_control_policies …
joalves Sep 25, 2026
aed8590
fix(cli): correct velocity insights paths — no /velocity/ segment on …
joalves Sep 26, 2026
bd63c1e
fix(cli): validate notifications --limit, stop suggesting a page past…
joalves Sep 26, 2026
cd617e6
chore(deps): bump absmartly-api-mocks to ^1.0.9
joalves Sep 26, 2026
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
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@absmartly/cli",
"version": "1.14.0",
"version": "1.15.0",
"description": "ABSmartly CLI - A/B Testing and Feature Flags command-line tool for AI agents and humans",
"type": "module",
"main": "./dist/index.js",
Expand Down Expand Up @@ -112,7 +112,7 @@
"@typescript-eslint/parser": "^8.21.0",
"@vitest/coverage-v8": "^4.1.4",
"@vitest/ui": "^4.0.18",
"absmartly-api-mocks": "npm:@absmartly/api-mocks@^1.0.6",
"absmartly-api-mocks": "npm:@absmartly/api-mocks@^1.0.9",
"dotenv": "^16.4.7",
"eslint": "^9.19.0",
"jsdom": "^28.0.0",
Expand Down
34 changes: 27 additions & 7 deletions src/api-client/api-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1016,9 +1016,7 @@ describe.skipIf(isLiveMode)('APIClient core', () => {
});

it('should check for new notifications', async () => {
server.use(
http.get(`${BASE_URL}/notifications/has-new`, () => HttpResponse.json({ has_new: true }))
);
server.use(http.get(`${BASE_URL}/notifications/has-new`, () => HttpResponse.json(true)));
expect(await client.hasNewNotifications()).toBe(true);
});
});
Expand Down Expand Up @@ -1193,6 +1191,30 @@ describe.skipIf(isLiveMode)('APIClient core', () => {
expect(await client.listCustomSections()).toEqual([]);
});

it('should list custom sections with the legacy positional type string', async () => {
server.use(
http.get(`${BASE_URL}/experiment_custom_sections`, ({ request }) => {
const url = new URL(request.url);
expect(url.searchParams.get('type')).toBe('active');
return HttpResponse.json({ experiment_custom_sections: [{ id: 1, type: 'active' }] });
})
);
expect(await client.listCustomSections('active')).toEqual([{ id: 1, type: 'active' }]);
});

it('should list custom sections with the options-object form', async () => {
server.use(
http.get(`${BASE_URL}/experiment_custom_sections`, ({ request }) => {
const url = new URL(request.url);
expect(url.searchParams.get('type')).toBe('active');
expect(url.searchParams.get('items')).toBe('5');
expect(url.searchParams.get('page')).toBe('2');
return HttpResponse.json({ experiment_custom_sections: [] });
})
);
await client.listCustomSections({ type: 'active', items: 5, page: 2 });
});

it('should reorder custom sections', async () => {
server.use(
http.put(`${BASE_URL}/experiment_custom_sections/order`, () =>
Expand All @@ -1205,9 +1227,7 @@ describe.skipIf(isLiveMode)('APIClient core', () => {

describe('insights', () => {
it('should get velocity insights', async () => {
server.use(
http.get(`${BASE_URL}/insights/velocity/summary`, () => HttpResponse.json({ data: [] }))
);
server.use(http.get(`${BASE_URL}/insights/summary`, () => HttpResponse.json({ data: [] })));
const result = await client.getVelocityInsights({
from: 1000,
to: 2000,
Expand Down Expand Up @@ -1399,7 +1419,7 @@ describe.skipIf(isLiveMode)('APIClient core', () => {
it('should list access control policies', async () => {
server.use(
http.get(`${BASE_URL}/access_control_policies`, () =>
HttpResponse.json({ access_control_policies: [] })
HttpResponse.json({ access_control_policy: [] })
)
);
expect(await client.listAccessControlPolicies()).toEqual([]);
Expand Down
119 changes: 87 additions & 32 deletions src/api-client/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1025,10 +1025,23 @@ export class APIClient {
});
}

async listCustomSections(type?: string): Promise<unknown[]> {
const params: Record<string, string> = {};
if (type) params.type = type;
const response = await this.request('GET', '/experiment_custom_sections', { params });
async listCustomSections(
typeOrParams?:
| string
| {
type?: string | undefined;
items?: number | undefined;
page?: number | undefined;
}
): Promise<unknown[]> {
const params = typeof typeOrParams === 'string' ? { type: typeOrParams } : (typeOrParams ?? {});
const queryParams: Record<string, string> = {};
if (params.type) queryParams.type = params.type;
if (params.items !== undefined) queryParams.items = String(params.items);
if (params.page !== undefined) queryParams.page = String(params.page);
const response = await this.request('GET', '/experiment_custom_sections', {
params: queryParams,
});
return this.validateListResponse<unknown>(
response,
'experiment_custom_sections',
Expand Down Expand Up @@ -1402,13 +1415,21 @@ export class APIClient {
await this.request('DELETE', `/roles/${id}`);
}

async listPermissions(): Promise<Permission[]> {
const response = await this.request('GET', '/permissions');
async listPermissions(options: { items?: number; page?: number } = {}): Promise<Permission[]> {
const params: Record<string, string> = {};
if (options.items !== undefined) params.items = String(options.items);
if (options.page !== undefined) params.page = String(options.page);
const response = await this.request('GET', '/permissions', { params });
return this.validateListResponse<Permission>(response, 'permissions', 'listPermissions');
}

async listPermissionCategories(): Promise<PermissionCategory[]> {
const response = await this.request('GET', '/permission_categories');
async listPermissionCategories(
options: { items?: number; page?: number } = {}
): Promise<PermissionCategory[]> {
const params: Record<string, string> = {};
if (options.items !== undefined) params.items = String(options.items);
if (options.page !== undefined) params.page = String(options.page);
const response = await this.request('GET', '/permission_categories', { params });
return this.validateListResponse<PermissionCategory>(
response,
'permission_categories',
Expand Down Expand Up @@ -1600,14 +1621,15 @@ export class APIClient {
async hasNewNotifications(lastNotificationId?: number): Promise<boolean> {
const params: Record<string, string | number> = {};
if (lastNotificationId !== undefined) params.last_notification_id = lastNotificationId;
const response = await this.request<Record<string, unknown>>('GET', '/notifications/has-new', {
const response = await this.request<boolean>('GET', '/notifications/has-new', {
params,
});
const data = response.data;
if (!data || typeof data !== 'object') {
throw new Error('Invalid API response for hasNewNotifications: Expected object');
if (typeof response.data !== 'boolean') {
throw new Error(
`Invalid API response for hasNewNotifications: Expected boolean, got ${typeof response.data}`
);
}
return Boolean((data as Record<string, unknown>).has_new);
return response.data;
}

async listExperimentAccessUsers(id: ExperimentId): Promise<unknown[]> {
Expand Down Expand Up @@ -1756,8 +1778,11 @@ export class APIClient {
await this.request('DELETE', `/goals/${id}/asset_role_teams/${teamId}/${assetRoleId}`);
}

async listAssetRoles(): Promise<AssetRole[]> {
const response = await this.request('GET', '/asset_roles');
async listAssetRoles(options: { items?: number; page?: number } = {}): Promise<AssetRole[]> {
const params: Record<string, string> = {};
if (options.items !== undefined) params.items = String(options.items);
if (options.page !== undefined) params.page = String(options.page);
const response = await this.request('GET', '/asset_roles', { params });
return this.validateListResponse<AssetRole>(response, 'asset_roles', 'listAssetRoles');
}

Expand Down Expand Up @@ -1934,7 +1959,7 @@ export class APIClient {
team_ids?: number[];
owner_ids?: number[];
}): Promise<unknown> {
const response = await this.request('GET', '/insights/velocity/summary', {
const response = await this.request('GET', '/insights/summary', {
params: this.buildInsightParams(params),
});
return response.data;
Expand All @@ -1959,11 +1984,16 @@ export class APIClient {
return this.validateListResponse<unknown>(response, 'webhook_events', 'listWebhookEvents');
}

async listAccessControlPolicies(): Promise<unknown[]> {
const response = await this.request('GET', '/access_control_policies');
async listAccessControlPolicies(
options: { items?: number; page?: number } = {}
): Promise<unknown[]> {
const params: Record<string, string> = {};
if (options.items !== undefined) params.items = String(options.items);
if (options.page !== undefined) params.page = String(options.page);
const response = await this.request('GET', '/access_control_policies', { params });
return this.validateListResponse<unknown>(
response,
'access_control_policies',
'access_control_policy',
'listAccessControlPolicies'
);
}
Expand All @@ -1983,8 +2013,11 @@ export class APIClient {
return this.validateEntityResponse<unknown>(response, 'config', 'updatePlatformConfig');
}

async listCorsOrigins(): Promise<unknown[]> {
const response = await this.request('GET', '/cors');
async listCorsOrigins(options: { items?: number; page?: number } = {}): Promise<unknown[]> {
const params: Record<string, string> = {};
if (options.items !== undefined) params.items = String(options.items);
if (options.page !== undefined) params.page = String(options.page);
const response = await this.request('GET', '/cors', { params });
return this.validateListResponse<unknown>(response, 'cors_allowed_origins', 'listCorsOrigins');
}

Expand Down Expand Up @@ -2016,8 +2049,11 @@ export class APIClient {
this.validateOkResponse(response, 'deleteCorsOrigin');
}

async listDatasources(): Promise<unknown[]> {
const response = await this.request('GET', '/datasources');
async listDatasources(options: { items?: number; page?: number } = {}): Promise<unknown[]> {
const params: Record<string, string> = {};
if (options.items !== undefined) params.items = String(options.items);
if (options.page !== undefined) params.page = String(options.page);
const response = await this.request('GET', '/datasources', { params });
return this.validateListResponse<unknown>(
response,
'event_datasource_configs',
Expand Down Expand Up @@ -2071,9 +2107,17 @@ export class APIClient {
this.validateOkResponse(response, 'validateDatasourceQuery');
}

async listExportConfigs(params?: { statuses?: string }): Promise<ExportConfigShape[]> {
async listExportConfigs(params?: {
statuses?: string | undefined;
items?: number | undefined;
page?: number | undefined;
}): Promise<ExportConfigShape[]> {
const queryParams: Record<string, string> = {};
if (params?.statuses !== undefined) queryParams.statuses = params.statuses;
if (params?.items !== undefined) queryParams.items = String(params.items);
if (params?.page !== undefined) queryParams.page = String(params.page);
const response = await this.request('GET', '/export_configs', {
params: params as Record<string, string>,
params: queryParams,
});
return this.validateListResponse<ExportConfigShape>(
response,
Expand Down Expand Up @@ -2119,8 +2163,11 @@ export class APIClient {
);
}

async listUpdateSchedules(): Promise<unknown[]> {
const response = await this.request('GET', '/experiment_update_schedules');
async listUpdateSchedules(options: { items?: number; page?: number } = {}): Promise<unknown[]> {
const params: Record<string, string> = {};
if (options.items !== undefined) params.items = String(options.items);
if (options.page !== undefined) params.page = String(options.page);
const response = await this.request('GET', '/experiment_update_schedules', { params });
return this.validateListResponse<unknown>(
response,
'experiment_update_schedules',
Expand Down Expand Up @@ -2372,7 +2419,7 @@ export class APIClient {
teams?: string;
applications?: string;
}): Promise<unknown> {
const response = await this.request('GET', '/insights/velocity/summary/detail', {
const response = await this.request('GET', '/insights/summary/detail', {
params: this.buildInsightParams(params),
});
return response.data;
Expand Down Expand Up @@ -2418,8 +2465,11 @@ export class APIClient {
return response.data as { matrix: number[][] };
}

async listStorageConfigs(): Promise<unknown[]> {
const response = await this.request('GET', '/storage_configs');
async listStorageConfigs(options: { items?: number; page?: number } = {}): Promise<unknown[]> {
const params: Record<string, string> = {};
if (options.items !== undefined) params.items = String(options.items);
if (options.page !== undefined) params.page = String(options.page);
const response = await this.request('GET', '/storage_configs', { params });
return this.validateListResponse<unknown>(response, 'storage_configs', 'listStorageConfigs');
}

Expand Down Expand Up @@ -2531,8 +2581,13 @@ export class APIClient {
return this.validateEntityResponse<User>(response, 'user', 'updateCurrentUser');
}

async listExperimentActionDialogFields(): Promise<unknown[]> {
const response = await this.request('GET', '/experiment_action_dialog_fields');
async listExperimentActionDialogFields(
options: { items?: number; page?: number } = {}
): Promise<unknown[]> {
const params: Record<string, string> = {};
if (options.items !== undefined) params.items = String(options.items);
if (options.page !== undefined) params.page = String(options.page);
const response = await this.request('GET', '/experiment_action_dialog_fields', { params });
return this.validateListResponse<unknown>(
response,
'experiment_action_dialog_fields',
Expand Down
59 changes: 59 additions & 0 deletions src/api-client/entity-summary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
summarizeMetricCategoryRow,
summarizeNamedEntityRow,
summarizeWebhookRow,
summarizeWebhook,
} from './entity-summary.js';

describe('applyShowExclude', () => {
Expand Down Expand Up @@ -370,6 +371,64 @@ describe('summarizeNamedEntityRow', () => {
});
});

describe('summarizeWebhook', () => {
it('should include detail fields and join event subscription names', () => {
const result = summarizeWebhook({
id: 3,
name: 'Slack notify',
url: 'https://hooks.example.com/abc',
enabled: true,
ordered: false,
max_retries: 2,
archived: false,
description: 'notifies slack',
events: [
{
webhook_id: 3,
webhook_event_id: 1,
enabled: true,
event: { id: 1, name: 'ExperimentCreated', description: 'Experiment created' },
},
{
webhook_id: 3,
webhook_event_id: 2,
enabled: true,
event: { id: 2, name: 'ExperimentDevelopment', description: 'Experiment started' },
},
],
created_at: '2024-01-01T00:00:00Z',
updated_at: '2024-06-01T00:00:00Z',
secret: 'super-secret-should-be-dropped',
});
expect(result).toMatchObject({
id: 3,
name: 'Slack notify',
url: 'https://hooks.example.com/abc',
enabled: true,
description: 'notifies slack',
events: 'ExperimentCreated, ExperimentDevelopment',
});
expect(result).not.toHaveProperty('secret');
});

it('should not throw on missing/empty events and should fall back to empty string', () => {
const result = summarizeWebhook({ id: 4, name: 'No events' });
expect(result.events).toBe('');

const emptyResult = summarizeWebhook({ id: 5, name: 'Empty events', events: [] });
expect(emptyResult.events).toBe('');
});

it('should still join plain string events for backward compatibility', () => {
const result = summarizeWebhook({
id: 6,
name: 'Legacy shape',
events: ['experiment.created'],
});
expect(result.events).toBe('experiment.created');
});
});

describe('summarizeWebhookRow', () => {
it('curates webhook-specific columns and summarizes user fields', () => {
expect(
Expand Down
Loading
Loading