Skip to content
This repository was archived by the owner on Jul 8, 2026. It is now read-only.
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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,11 @@ abs experiments list --state running --items 50 --sort created_at --desc
abs experiments list --app my-app --search "checkout" --page 2
abs experiments list --created-after 2025-01-01 --tags v1,mobile

# Find experiments that use a specific metric (by ID or name), in any role
abs experiments list --metric "Revenue"
abs experiments list --metric 42 --metric-role guardrail,primary
abs experiments list --metric 42 --type test,feature --state running -o ids

# Customize columns
abs experiments list --show experiment_report archived # add extra columns
abs experiments list --exclude primary_metric owner # hide columns
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@absmartly/cli",
"version": "1.11.0",
"version": "1.12.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
34 changes: 27 additions & 7 deletions src/commands/experiments/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
shouldOutputIdsOnly,
withErrorHandling,
} from '../../lib/utils/api-helper.js';
import { printPaginationFooter } from '../../lib/utils/pagination.js';
import { printPaginationFooter, printMetricFooter } from '../../lib/utils/pagination.js';
import { getDefaultType } from './default-type.js';
import { listExperiments } from '../../core/experiments/list.js';

Expand All @@ -33,6 +33,14 @@ export const listCommand = new Command('list')
)
.option('--iterations <n>', 'filter by iteration count', (v) => parseInt(v, 10))
.option('--iterations-of <id>', 'show all iterations of experiment ID', (v) => parseInt(v, 10))
.option(
'--metric <id|name>',
'find experiments using this metric (by ID or name) in any role; scans all pages'
)
.option(
'--metric-role <roles>',
'with --metric, limit to roles: primary,secondary,guardrail,exploratory (comma-separated; default: all)'
)
.option('--created-after <timestamp>', 'filter experiments created after timestamp')
.option('--created-before <timestamp>', 'filter experiments created before timestamp')
.option('--started-after <timestamp>', 'filter experiments started after timestamp')
Expand Down Expand Up @@ -82,11 +90,23 @@ export const listCommand = new Command('list')
printFormatted(result.rows, globalOptions);
}

printPaginationFooter(
result.data.length,
options.items,
options.page,
globalOptions.output as string
);
const metricPagination = result.pagination?.metric;
if (metricPagination) {
printMetricFooter(
result.pagination!.total ?? result.data.length,
metricPagination.name,
metricPagination.id,
options.page,
options.items,
globalOptions.output as string
);
} else {
printPaginationFooter(
result.data.length,
options.items,
options.page,
globalOptions.output as string
);
}
})
);
75 changes: 75 additions & 0 deletions src/core/experiments/list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,3 +120,78 @@ describe('list', () => {
);
});
});

describe('list --metric', () => {
let mockClient: Record<string, ReturnType<typeof vi.fn>>;

beforeEach(() => {
vi.clearAllMocks();
mockClient = {
getMetric: vi.fn().mockResolvedValue({ id: 42, name: 'Revenue' }),
listMetrics: vi.fn(),
listExperiments: vi.fn().mockResolvedValue([
{ id: 1, name: 'Exp A', primary_metric_id: 42, secondary_metrics: [] },
{ id: 2, name: 'Exp B', primary_metric_id: 7, secondary_metrics: [] },
{
id: 3,
name: 'Exp C',
primary_metric_id: 7,
secondary_metrics: [{ metric_id: 42, type: 'guardrail' }],
},
]),
};
});

it('returns only experiments using the metric, tagged with metric_role', async () => {
const result = await listExperiments(mockClient as any, { items: 25, page: 1, metric: '42' });
expect(result.data.map((e: any) => e.id)).toEqual([1, 3]);
expect((result.data as any[]).map((e) => e.metric_role)).toEqual(['primary', 'guardrail']);
});

it('scans all pages with a large page size rather than the display items', async () => {
await listExperiments(mockClient as any, { items: 25, page: 1, metric: '42' });
expect(mockClient.listExperiments).toHaveBeenCalledWith(
expect.objectContaining({ page: 1, items: 200 })
);
});

it('reports the true match total and resolved metric in pagination', async () => {
const result = await listExperiments(mockClient as any, { items: 25, page: 1, metric: '42' });
expect(result.pagination).toMatchObject({
total: 2,
metric: { id: 42, name: 'Revenue' },
});
});

it('narrows by role via metricRole', async () => {
const result = await listExperiments(mockClient as any, {
items: 25,
page: 1,
metric: '42',
metricRole: 'guardrail',
});
expect(result.data.map((e: any) => e.id)).toEqual([3]);
});

it('appends metric_role to the shown fields', async () => {
const { summarizeExperimentRow } = await import('../../api-client/experiment-summary.js');
await listExperiments(mockClient as any, {
items: 25,
page: 1,
metric: '42',
show: ['tags'],
});
expect(summarizeExperimentRow).toHaveBeenCalledWith(
expect.objectContaining({ id: 1 }),
['tags', 'metric_role'],
[],
undefined
);
});

it('paginates the matched set client-side', async () => {
const result = await listExperiments(mockClient as any, { items: 1, page: 2, metric: '42' });
expect(result.data.map((e: any) => e.id)).toEqual([3]);
expect(result.pagination).toMatchObject({ page: 2, items: 1, total: 2, hasMore: false });
});
});
44 changes: 43 additions & 1 deletion src/core/experiments/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ import {
resolveApplicationIds,
resolveUnitTypeIds,
} from '../resolve.js';
import {
resolveMetricId,
parseMetricRoles,
fetchAllExperiments,
filterExperimentsByMetric,
} from './metric-filter.js';

export interface ListExperimentsParams {
state?: string;
Expand All @@ -27,6 +33,8 @@ export interface ListExperimentsParams {
significance?: string;
iterations?: number;
iterationsOf?: number;
metric?: string;
metricRole?: string;
createdAfter?: string;
createdBefore?: string;
startedAfter?: string;
Expand Down Expand Up @@ -135,10 +143,44 @@ export async function listExperiments(
...(params.significance && { significance: params.significance }),
} as ListOptions;

const excludeFields = params.exclude ?? [];

if (params.metric) {
const { id: metricId, name: metricName } = await resolveMetricId(client, params.metric);
const roles = parseMetricRoles(params.metricRole);

// Scan all pages (ignoring the display page/items) then filter client-side,
// since the API has no server-side metric filter.
const { page: _page, items: _items, ...scanOptions } = listOptions;
const allExperiments = await fetchAllExperiments(client, scanOptions as ListOptions);
const matched = filterExperimentsByMetric(allExperiments, metricId, roles);

const start = (params.page - 1) * params.items;
const pageItems = matched.slice(start, start + params.items);

const metricExtraFields = [...(params.show ?? []), 'metric_role'];
const rows = params.raw
? (pageItems as unknown[])
: pageItems.map((e) =>
summarizeExperimentRow(e, metricExtraFields, excludeFields, params.showOnly)
);

return {
data: pageItems as unknown[],
rows: rows as Record<string, unknown>[],
pagination: {
page: params.page,
items: params.items,
hasMore: start + params.items < matched.length,
total: matched.length,
metric: { id: Number(metricId), name: metricName },
},
};
}

const experiments = await client.listExperiments(listOptions);

const extraFields = params.show ?? [];
const excludeFields = params.exclude ?? [];

const rows = params.raw
? (experiments as unknown[])
Expand Down
Loading
Loading