diff --git a/apps/cli-docs/src/fragments/commands/agent-conversation.md b/apps/cli-docs/src/fragments/commands/agent-conversation.md index 9ae15932f..516ca01e3 100644 --- a/apps/cli-docs/src/fragments/commands/agent-conversation.md +++ b/apps/cli-docs/src/fragments/commands/agent-conversation.md @@ -9,8 +9,14 @@ # List recent agent conversations sentry agent-conversation list -# Explicit organization -sentry agent-conversation list my-org +# Explicit organization (all projects) +sentry agent-conversation list my-org/ + +# One project +sentry agent-conversation list my-org/my-project + +# Find a project across organizations +sentry agent-conversation list my-project # Show more, last 24 hours sentry agent-conversation list --limit 50 --period 24h @@ -18,8 +24,8 @@ sentry agent-conversation list --limit 50 --period 24h # Filter conversations sentry agent-conversation list -q "has:errors" -# Paginate through results -sentry agent-conversation list my-org -c next +# Paginate through project results +sentry agent-conversation list my-org/my-project -c next ``` ### View a conversation transcript diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 34b5fde3d..1f8eaa009 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -491,7 +491,7 @@ Manage code mappings for stack trace linking List and view agent conversations -- `sentry agent-conversation list ` — List recent agent conversations +- `sentry agent-conversation list ` — List recent agent conversations - `sentry agent-conversation view ` — View an agent conversation transcript → Full flags and examples: `references/agent-conversation.md` diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/agent-conversation.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/agent-conversation.md index d2cbc9ef7..39e746dcc 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/agent-conversation.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/agent-conversation.md @@ -11,7 +11,7 @@ requires: List and view agent conversations -### `sentry agent-conversation list ` +### `sentry agent-conversation list ` List recent agent conversations @@ -51,8 +51,14 @@ List recent agent conversations # List recent agent conversations sentry agent-conversation list -# Explicit organization -sentry agent-conversation list my-org +# Explicit organization (all projects) +sentry agent-conversation list my-org/ + +# One project +sentry agent-conversation list my-org/my-project + +# Find a project across organizations +sentry agent-conversation list my-project # Show more, last 24 hours sentry agent-conversation list --limit 50 --period 24h @@ -60,8 +66,8 @@ sentry agent-conversation list --limit 50 --period 24h # Filter conversations sentry agent-conversation list -q "has:errors" -# Paginate through results -sentry agent-conversation list my-org -c next +# Paginate through project results +sentry agent-conversation list my-org/my-project -c next ``` ### `sentry agent-conversation view ` diff --git a/packages/cli/src/commands/agent-conversation/list.ts b/packages/cli/src/commands/agent-conversation/list.ts index 009809093..77165aa9d 100644 --- a/packages/cli/src/commands/agent-conversation/list.ts +++ b/packages/cli/src/commands/agent-conversation/list.ts @@ -5,7 +5,7 @@ */ import type { SentryContext } from "../../context.js"; -import { listConversations } from "../../lib/api-client.js"; +import { getProject, listConversations } from "../../lib/api-client.js"; import { validateLimit } from "../../lib/arg-parsing.js"; import { advancePaginationState, @@ -13,7 +13,6 @@ import { hasPreviousPage, resolveCursor, } from "../../lib/db/pagination.js"; -import { ContextError } from "../../lib/errors.js"; import { formatConversationTable } from "../../lib/formatters/conversation.js"; import { filterFields } from "../../lib/formatters/json.js"; import { CommandOutput } from "../../lib/formatters/output.js"; @@ -25,9 +24,13 @@ import { LIST_PERIOD_FLAG, PERIOD_ALIASES, paginationHint, + targetPatternExplanation, } from "../../lib/list-command.js"; import { withProgress } from "../../lib/polling.js"; -import { resolveOrg } from "../../lib/resolve-target.js"; +import { + resolveOrgOptionalFromArg, + toNumericId, +} from "../../lib/resolve-target.js"; import { appendPeriodHint, serializeTimeRange, @@ -55,6 +58,7 @@ type ConversationListResult = { hasPrev?: boolean; nextCursor?: string; org: string; + project?: string; }; const COMMAND_NAME = "agent-conversation list"; @@ -66,13 +70,14 @@ function parseLimit(value: string): number { } function formatListHuman(result: ConversationListResult): string { - const { conversations, hasMore, org } = result; + const { conversations, hasMore, org, project } = result; if (conversations.length === 0) { return hasMore ? "No conversations on this page." : "No agent conversations found."; } - return `Agent conversations in ${org}:\n\n${formatConversationTable(conversations)}`; + const scope = project ? `${org}/${project}` : `${org} (all projects)`; + return `Agent conversations in ${scope}:\n\n${formatConversationTable(conversations)}`; } function jsonTransform( @@ -99,13 +104,18 @@ export const listCommand = buildListCommand("agent-conversation", { docs: { brief: "List recent agent conversations", fullDescription: - "List recent agent conversations from a Sentry organization.\n\n" + + "List recent agent conversations from Sentry projects.\n\n" + + "Target patterns:\n" + + " sentry agent-conversation list # Auto-detect organization\n" + + " sentry agent-conversation list / # All projects in an organization\n" + + " sentry agent-conversation list / # One project\n" + + " sentry agent-conversation list # Find project across organizations\n\n" + + `${targetPatternExplanation()}\n\n` + "Examples:\n" + - " sentry agent-conversation list # List recent conversations\n" + - " sentry agent-conversation list my-org # Explicit org\n" + - " sentry agent-conversation list --limit 50 # Show more\n" + - " sentry agent-conversation list --period 24h # Last 24 hours\n" + - ' sentry agent-conversation list -q "has:errors" # Filter\n', + " sentry agent-conversation list my-org/\n" + + " sentry agent-conversation list my-org/my-project --limit 50\n" + + " sentry agent-conversation list --period 24h\n" + + ' sentry agent-conversation list -q "has:errors"\n', }, output: { human: formatListHuman, @@ -117,8 +127,8 @@ export const listCommand = buildListCommand("agent-conversation", { kind: "tuple", parameters: [ { - placeholder: "org", - brief: "Organization slug", + placeholder: "org/project", + brief: "/, /, or (search)", parse: String, optional: true, }, @@ -148,13 +158,17 @@ export const listCommand = buildListCommand("agent-conversation", { async *func(this: SentryContext, flags: ListFlags, target?: string) { const { cwd } = this; - const resolved = await resolveOrg({ org: target, cwd }); - if (!resolved) { - throw new ContextError("Organization", `sentry ${COMMAND_NAME} `); + const resolved = await resolveOrgOptionalFromArg(target, cwd, COMMAND_NAME); + const { org, project } = resolved; + let projectId: number | undefined; + if (project) { + const projectData = + resolved.projectData ?? (await getProject(org, project)); + projectId = toNumericId(projectData.id); } - const org = resolved.org; + const scope = project ? `${org}/${project}` : `${org}/`; - const contextKey = buildPaginationContextKey("agent-conversation", org, { + const contextKey = buildPaginationContextKey("agent-conversation", scope, { q: flags.query, period: serializeTimeRange(flags.period), }); @@ -176,6 +190,7 @@ export const listCommand = buildListCommand("agent-conversation", { query: flags.query, limit: flags.limit, cursor, + project: projectId === undefined ? undefined : String(projectId), ...timeParams, }) ); @@ -190,6 +205,7 @@ export const listCommand = buildListCommand("agent-conversation", { hasPrev, nextCursor, org, + project, }); const parts: string[] = []; @@ -203,8 +219,8 @@ export const listCommand = buildListCommand("agent-conversation", { hint: paginationHint({ hasMore, hasPrev: !!hasPrev, - nextHint: `sentry agent-conversation list ${org} -c next${flagSuffix}`, - prevHint: `sentry agent-conversation list ${org} -c prev${flagSuffix}`, + nextHint: `sentry agent-conversation list ${scope} -c next${flagSuffix}`, + prevHint: `sentry agent-conversation list ${scope} -c prev${flagSuffix}`, }), }; }, diff --git a/packages/cli/test/commands/agent-conversation/list.test.ts b/packages/cli/test/commands/agent-conversation/list.test.ts index 21a26bd6c..8afb71740 100644 --- a/packages/cli/test/commands/agent-conversation/list.test.ts +++ b/packages/cli/test/commands/agent-conversation/list.test.ts @@ -2,9 +2,8 @@ * Conversation List Command Tests * * Tests for the `sentry agent-conversation list` command func() body, covering: - * - Organization resolution from positional arg - * - Organization resolution via resolveOrg fallback - * - Error when org cannot be resolved + * - Organization and project target resolution + * - Organization auto-detection * - Yielding CommandOutput with conversation data * - Query filter passthrough * - Time params passthrough @@ -44,7 +43,6 @@ vi.mock("../../../src/lib/db/auth.js", async (importOriginal) => { // biome-ignore lint/performance/noNamespaceImport: needed for spyOn mocking import * as dbAuth from "../../../src/lib/db/auth.js"; -import { ContextError } from "../../../src/lib/errors.js"; vi.mock("../../../src/lib/polling.js", async (importOriginal) => { const actual = @@ -95,6 +93,7 @@ import type { ConversationListItem } from "../../../src/types/conversation.js"; // ============================================================================ const ORG = "test-org"; +const PROJECT = { id: "42", slug: "backend", name: "Backend" }; function createMockContext() { const stdoutWrite = vi.fn(() => true); @@ -197,16 +196,20 @@ afterEach(() => { // ============================================================================ describe("listCommand.func", () => { + let getProjectSpy: ReturnType; let listConversationsSpy: ReturnType; - let resolveOrgSpy: ReturnType; + let resolveTargetSpy: ReturnType; let withProgressSpy: ReturnType; let resolveCursorSpy: ReturnType; let advancePaginationStateSpy: ReturnType; let hasPreviousPageSpy: ReturnType; beforeEach(() => { + getProjectSpy = vi.spyOn(apiClient, "getProject"); listConversationsSpy = vi.spyOn(apiClient, "listConversations"); - resolveOrgSpy = vi.spyOn(resolveTarget, "resolveOrg"); + resolveTargetSpy = vi + .spyOn(resolveTarget, "resolveOrgOptionalFromArg") + .mockResolvedValue({ org: ORG }); withProgressSpy = vi .spyOn(polling, "withProgress") .mockImplementation(mockWithProgress); @@ -223,15 +226,16 @@ describe("listCommand.func", () => { }); afterEach(() => { + getProjectSpy.mockRestore(); listConversationsSpy.mockRestore(); - resolveOrgSpy.mockRestore(); + resolveTargetSpy.mockRestore(); withProgressSpy.mockRestore(); resolveCursorSpy.mockRestore(); advancePaginationStateSpy.mockRestore(); hasPreviousPageSpy.mockRestore(); }); - test("resolves org from positional arg", async () => { + test("resolves explicit organization scope", async () => { listConversationsSpy.mockResolvedValue({ data: sampleConversations, nextCursor: undefined, @@ -239,18 +243,21 @@ describe("listCommand.func", () => { const { context } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, JSON_FLAGS, ORG); + await func.call(context, JSON_FLAGS, `${ORG}/`); - // resolveOrg receives the positional org directly - expect(resolveOrgSpy).toHaveBeenCalledWith( - expect.objectContaining({ org: ORG }) + expect(resolveTargetSpy).toHaveBeenCalledWith( + `${ORG}/`, + "/tmp", + "agent-conversation list" + ); + expect(listConversationsSpy).toHaveBeenCalledWith( + ORG, + expect.objectContaining({ project: undefined }) ); - // listConversations called with the resolved org - expect(listConversationsSpy).toHaveBeenCalledWith(ORG, expect.any(Object)); }); - test("resolves org via resolveOrg when no positional", async () => { - resolveOrgSpy.mockResolvedValue({ org: "auto-org" }); + test("auto-detects organization when target is omitted", async () => { + resolveTargetSpy.mockResolvedValue({ org: "auto-org" }); listConversationsSpy.mockResolvedValue({ data: [], nextCursor: undefined, @@ -260,8 +267,10 @@ describe("listCommand.func", () => { const func = await listCommand.loader(); await func.call(context, JSON_FLAGS, undefined); - expect(resolveOrgSpy).toHaveBeenCalledWith( - expect.objectContaining({ org: undefined }) + expect(resolveTargetSpy).toHaveBeenCalledWith( + undefined, + "/tmp", + "agent-conversation list" ); expect(listConversationsSpy).toHaveBeenCalledWith( "auto-org", @@ -269,14 +278,38 @@ describe("listCommand.func", () => { ); }); - test("throws error when org cannot be resolved", async () => { - resolveOrgSpy.mockResolvedValue(null); + test("resolves and passes explicit project scope", async () => { + resolveTargetSpy.mockResolvedValue({ org: ORG, project: PROJECT.slug }); + getProjectSpy.mockResolvedValue(PROJECT); + listConversationsSpy.mockResolvedValue({ data: [] }); const { context } = createMockContext(); const func = await listCommand.loader(); + await func.call(context, JSON_FLAGS, `${ORG}/${PROJECT.slug}`); - await expect(func.call(context, HUMAN_FLAGS, undefined)).rejects.toThrow( - ContextError + expect(getProjectSpy).toHaveBeenCalledWith(ORG, PROJECT.slug); + expect(listConversationsSpy).toHaveBeenCalledWith( + ORG, + expect.objectContaining({ project: PROJECT.id }) + ); + }); + + test("uses project data returned by bare-project search", async () => { + resolveTargetSpy.mockResolvedValue({ + org: ORG, + project: PROJECT.slug, + projectData: PROJECT, + }); + listConversationsSpy.mockResolvedValue({ data: [] }); + + const { context } = createMockContext(); + const func = await listCommand.loader(); + await func.call(context, JSON_FLAGS, PROJECT.slug); + + expect(getProjectSpy).not.toHaveBeenCalled(); + expect(listConversationsSpy).toHaveBeenCalledWith( + ORG, + expect.objectContaining({ project: PROJECT.id }) ); }); @@ -367,7 +400,12 @@ describe("listCommand.func", () => { ); }); - test("returns pagination hints with -q flag preserved", async () => { + test("preserves project and query in pagination hints", async () => { + resolveTargetSpy.mockResolvedValue({ + org: ORG, + project: PROJECT.slug, + projectData: PROJECT, + }); listConversationsSpy.mockResolvedValue({ data: sampleConversations, nextCursor: "next-cursor-abc", @@ -375,12 +413,18 @@ describe("listCommand.func", () => { const { context, stdoutWrite } = createMockContext(); const func = await listCommand.loader(); - await func.call(context, { ...HUMAN_FLAGS, query: "has:errors" }, ORG); + await func.call( + context, + { ...HUMAN_FLAGS, query: "conversation.errors:>0" }, + `${ORG}/${PROJECT.slug}` + ); const output = stdoutWrite.mock.calls.map((c) => c[0]).join(""); - // The hint should include the -q flag for navigation commands - expect(output).toContain("-c next"); - expect(output).toContain('-q "has:errors"'); + expect(output).toContain(`Agent conversations in ${ORG}/${PROJECT.slug}:`); + expect(output).toContain( + `agent-conversation list ${ORG}/${PROJECT.slug} -c next` + ); + expect(output).toContain('-q "conversation.errors:>0"'); }); test("handles empty results (human mode)", async () => {