Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 3 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {
UnexpectedPositionalError,
UnsatisfiedPositionalError,
} from "@stricli/core";
import { aiConversationsRoute } from "./commands/ai-conversations/index.js";
import { listCommand as aiConversationsListCommand } from "./commands/ai-conversations/list.js";
import { apiCommand } from "./commands/api.js";
import { authRoute } from "./commands/auth/index.js";
import { whoamiCommand } from "./commands/auth/whoami.js";
Expand Down Expand Up @@ -83,6 +85,7 @@ const PLURAL_TO_SINGULAR: Record<string, string> = {
/** Top-level route map containing all CLI commands */
export const routes = buildRouteMap({
routes: {
"ai-conversations": aiConversationsRoute,
help: helpCommand,
auth: authRoute,
cli: cliRoute,
Expand Down
26 changes: 26 additions & 0 deletions src/commands/ai-conversations/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* sentry ai-conversations
*
* List and view AI conversations from Sentry Explore.
*/

import { buildRouteMap } from "../../lib/route-map.js";
import { listCommand } from "./list.js";
import { viewCommand } from "./view.js";

export const aiConversationsRoute = buildRouteMap({
routes: {
list: listCommand,
view: viewCommand,
},
defaultCommand: "list",
docs: {
brief: "List and view AI conversations",
fullDescription:
"List and view AI conversations from Sentry Explore.\n\n" +
"Commands:\n" +
" list List recent AI conversations\n" +
" view View a conversation transcript\n",
hideRoute: {},
},
});
208 changes: 208 additions & 0 deletions src/commands/ai-conversations/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
/**
* sentry ai-conversations list
*
* List recent AI conversations from Sentry projects.
*/

import type { SentryContext } from "../../context.js";
import { listConversations } from "../../lib/api/ai-conversations.js";
import { validateLimit } from "../../lib/arg-parsing.js";
import {
advancePaginationState,
buildPaginationContextKey,
hasPreviousPage,
resolveCursor,
} from "../../lib/db/pagination.js";
import { formatConversationTable } from "../../lib/formatters/ai-conversations.js";
import { filterFields } from "../../lib/formatters/json.js";
import { CommandOutput } from "../../lib/formatters/output.js";
import {
buildListCommand,
LIST_DEFAULT_LIMIT,
LIST_MAX_LIMIT,
LIST_MIN_LIMIT,
LIST_PERIOD_FLAG,
PERIOD_ALIASES,
paginationHint,
} from "../../lib/list-command.js";
import { withProgress } from "../../lib/polling.js";
import { resolveOrg } from "../../lib/resolve-target.js";
import {
appendPeriodHint,
serializeTimeRange,
type TimeRange,
timeRangeToApiParams,
} from "../../lib/time-range.js";
import {
type ConversationListItem,
ConversationListItemSchema,
} from "../../types/ai-conversations.js";

type ListFlags = {
readonly limit: number;
readonly query?: string;
readonly period: TimeRange;
readonly json: boolean;
readonly cursor?: string;
readonly fresh: boolean;
readonly fields?: string[];
};

type ConversationListResult = {
conversations: ConversationListItem[];
hasMore: boolean;
hasPrev?: boolean;
nextCursor?: string;
org: string;
};

const COMMAND_NAME = "ai-conversations list";
const PAGINATION_KEY = "ai-conversations-list";
const DEFAULT_PERIOD = "14d";

function parseLimit(value: string): number {
return validateLimit(value, LIST_MIN_LIMIT, LIST_MAX_LIMIT);
}

function formatListHuman(result: ConversationListResult): string {
const { conversations, hasMore, org } = result;
if (conversations.length === 0) {
return hasMore
? "No conversations on this page."
: "No AI conversations found.";
}
return `AI conversations in ${org}:\n\n${formatConversationTable(conversations)}`;
}

function jsonTransform(
result: ConversationListResult,
fields?: string[],
): unknown {
const items =
fields && fields.length > 0
? result.conversations.map((c) => filterFields(c, fields))
: result.conversations;

const envelope: Record<string, unknown> = {
data: items,
hasMore: result.hasMore,
hasPrev: !!result.hasPrev,
};
if (result.nextCursor) envelope.nextCursor = result.nextCursor;
return envelope;
}

export const listCommand = buildListCommand("ai-conversations", {
docs: {
brief: "List recent AI conversations",
fullDescription:
"List recent AI conversations from a Sentry organization.\n\n" +
"Examples:\n" +
" sentry ai-conversations list # List last 10 conversations\n" +
" sentry ai-conversations list my-org # Explicit org\n" +
" sentry ai-conversations list --limit 50 # Show more\n" +
" sentry ai-conversations list --period 24h # Last 24 hours\n" +
' sentry ai-conversations list -q "has:errors" # Filter\n',
},
output: {
human: formatListHuman,
jsonTransform: jsonTransform,
schema: ConversationListItemSchema,
},
parameters: {
positional: {
kind: "tuple",
parameters: [
{
placeholder: "org",
brief: "Organization slug",
parse: String,
optional: true,
},
],
},
flags: {
limit: {
kind: "parsed",
parse: parseLimit,
brief: `Number of conversations (${LIST_MIN_LIMIT}-${LIST_MAX_LIMIT})`,
default: String(LIST_DEFAULT_LIMIT),
},
query: {
kind: "parsed",
parse: String,
brief: "Search query",
optional: true,
},
period: LIST_PERIOD_FLAG,
},
aliases: {
...PERIOD_ALIASES,
n: "limit",
q: "query",
},
},
async *func(this: SentryContext, flags: ListFlags, target?: string) {
const { cwd } = this;

const resolved = await resolveOrg({ org: target, cwd });
if (!resolved) {
throw new Error(
`Could not determine organization. Pass it explicitly: sentry ${COMMAND_NAME} <org>`,
);
}
const org = resolved.org;

const contextKey = buildPaginationContextKey(
"ai-conversations",
org,
{ q: flags.query, period: serializeTimeRange(flags.period) },
);
const { cursor, direction } = resolveCursor(
flags.cursor,
PAGINATION_KEY,
contextKey,
);

const timeParams = timeRangeToApiParams(flags.period);

const { data: conversations, nextCursor } = await withProgress(
{
message: `Fetching conversations (up to ${flags.limit})...`,
json: flags.json,
},
() =>
listConversations(org, {
query: flags.query,
limit: flags.limit,
cursor,
statsPeriod: timeParams.statsPeriod,
}),
);

Check warning on line 182 in src/commands/ai-conversations/list.ts

View check run for this annotation

@sentry/warden / warden: find-bugs

Absolute date ranges from `--period` are silently ignored when listing conversations

Only `statsPeriod` is passed to `listConversations`, so absolute ranges like `--period 2024-01-01..2024-02-01` produce no time filtering at all — the API uses its default window instead.
advancePaginationState(PAGINATION_KEY, contextKey, direction, nextCursor);
const hasPrev = hasPreviousPage(PAGINATION_KEY, contextKey);
const hasMore = !!nextCursor;

yield new CommandOutput<ConversationListResult>({
conversations,
hasMore,
hasPrev,
nextCursor,
org,
});

const parts: string[] = [];
appendPeriodHint(parts, flags.period, DEFAULT_PERIOD);
const flagSuffix = parts.length > 0 ? ` ${parts.join(" ")}` : "";

return {
hint: paginationHint({
hasMore,
hasPrev: !!hasPrev,
nextHint: `sentry ai-conversations list ${org} -c next${flagSuffix}`,
prevHint: `sentry ai-conversations list ${org} -c prev${flagSuffix}`,
}),
};
},
});
Loading
Loading