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 5dcc7ec2c..4a98046b5 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -493,7 +493,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 3fc4cc1eb..7cd9c0fb6 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 a91abb263..42e32ca47 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,23 +13,25 @@ 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"; -import { validateResourceId } from "../../lib/input-validation.js"; -import { getPositionalString } from "../../lib/introspect.js"; import { buildListCommand, LIST_DEFAULT_LIMIT, LIST_MAX_LIMIT, LIST_MIN_LIMIT, LIST_PERIOD_FLAG, + LIST_TARGET_POSITIONAL, 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, @@ -57,36 +59,26 @@ type ConversationListResult = { hasPrev?: boolean; nextCursor?: string; org: string; + project?: string; }; const COMMAND_NAME = "agent-conversation list"; const PAGINATION_KEY = "agent-conversation-list"; const DEFAULT_PERIOD = "7d"; -const POSITIONAL = { - kind: "tuple", - parameters: [ - { - placeholder: "org", - brief: "Organization slug", - parse: String, - optional: true, - }, - ], -} as const; -const USAGE_HINT = `sentry ${COMMAND_NAME} ${getPositionalString(POSITIONAL)}`; function parseLimit(value: string): number { return validateLimit(value, LIST_MIN_LIMIT, LIST_MAX_LIMIT); } 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( @@ -113,16 +105,29 @@ export const listCommand = buildListCommand("agent-conversation", { docs: { brief: "List recent agent conversations", fullDescription: - "List recent agent conversations from a Sentry organization.\n\n" + - "The organization is auto-detected when omitted.", + "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(), examples: [ { description: "List recent agent conversations", command: "sentry agent-conversation list", }, { - description: "Explicit organization", - command: "sentry agent-conversation list my-org", + description: "Explicit organization (all projects)", + command: "sentry agent-conversation list my-org/", + }, + { + description: "One project", + command: "sentry agent-conversation list my-org/my-project", + }, + { + description: "Find a project across organizations", + command: "sentry agent-conversation list my-project", }, { description: "Show more, last 24 hours", @@ -133,8 +138,8 @@ export const listCommand = buildListCommand("agent-conversation", { command: 'sentry agent-conversation list -q "has:errors"', }, { - description: "Paginate through results", - command: "sentry agent-conversation list my-org -c next", + description: "Paginate through project results", + command: "sentry agent-conversation list my-org/my-project -c next", }, ], }, @@ -144,7 +149,7 @@ export const listCommand = buildListCommand("agent-conversation", { schema: ConversationListItemSchema, }, parameters: { - positional: POSITIONAL, + positional: LIST_TARGET_POSITIONAL, flags: { limit: { kind: "parsed", @@ -168,17 +173,18 @@ export const listCommand = buildListCommand("agent-conversation", { }, async *func(this: SentryContext, flags: ListFlags, target?: string) { const { cwd } = this; - if (target !== undefined) { - validateResourceId(target, "organization slug"); - } - const resolved = await resolveOrg({ org: target, cwd }); - if (!resolved) { - throw new ContextError("Organization", USAGE_HINT); + 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), }); @@ -200,6 +206,7 @@ export const listCommand = buildListCommand("agent-conversation", { query: flags.query, limit: flags.limit, cursor, + project: projectId === undefined ? undefined : String(projectId), ...timeParams, }), ); @@ -214,6 +221,7 @@ export const listCommand = buildListCommand("agent-conversation", { hasPrev, nextCursor, org, + project, }); const parts: string[] = []; @@ -227,8 +235,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 476dd14e3..a796e2661 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) => { // oxlint-disable-next-line sentry-cli/no-namespace-import -- needed for spyOn mocking import * as dbAuth from "../../../src/lib/db/auth.js"; -import { ContextError, ValidationError } 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,46 +278,40 @@ 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 the optional-org syntax in resolution errors", async () => { - resolveOrgSpy.mockResolvedValue(null); + 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 expect( - func.call(context, HUMAN_FLAGS, undefined), - ).rejects.toMatchObject({ - command: "sentry agent-conversation list []", - }); - }); + await func.call(context, JSON_FLAGS, PROJECT.slug); - test.each([ - "acme?x=1", - "acme#fragment", - "acme%20bad", - "acme bad", - "acme\tbad", - ])( - "rejects unsafe explicit organization %s before resolution", - async (target) => { - const { context } = createMockContext(); - const func = await listCommand.loader(); - await expect(func.call(context, HUMAN_FLAGS, target)).rejects.toThrow( - ValidationError, - ); - expect(resolveOrgSpy).not.toHaveBeenCalled(); - expect(listConversationsSpy).not.toHaveBeenCalled(); - }, - ); + expect(getProjectSpy).not.toHaveBeenCalled(); + expect(listConversationsSpy).toHaveBeenCalledWith( + ORG, + expect.objectContaining({ project: PROJECT.id }), + ); + }); test("yields CommandOutput with conversation data (JSON)", async () => { listConversationsSpy.mockResolvedValue({ @@ -397,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", @@ -405,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 () => { diff --git a/packages/cli/test/script/generate-skill-markdown.test.ts b/packages/cli/test/script/generate-skill-markdown.test.ts index 78265a027..bc2f7659a 100644 --- a/packages/cli/test/script/generate-skill-markdown.test.ts +++ b/packages/cli/test/script/generate-skill-markdown.test.ts @@ -43,9 +43,9 @@ describe("matchExampleToCommand", () => { viewCommand as never, "sentry agent-conversation view", ); - expect(list.positional).toBe("[]"); + expect(list.positional).toBe("[]"); expect(list.examples).toContain( - "# Explicit organization\nsentry agent-conversation list my-org", + "# Explicit organization (all projects)\nsentry agent-conversation list my-org/", ); expect(view.positional).toBe("[/]"); expect(view.examples).toContain( @@ -63,7 +63,9 @@ describe("matchExampleToCommand", () => { formatCommandArguments( info(listCommand, "sentry agent-conversation list"), ), - ).toContain("| `[]` | Organization slug |"); + ).toContain( + "| `[]` | <org>/ (all projects), <org>/<project>, or <project> (search) |", + ); expect( formatCommandArguments( info(viewCommand, "sentry agent-conversation view"), @@ -99,7 +101,9 @@ describe("matchExampleToCommand", () => { expect(content).toContain( "sentry agent-conversation view [/]", ); - expect(content).toContain("sentry agent-conversation list []"); + expect(content).toContain( + "sentry agent-conversation list []", + ); expect(content).toContain( "sentry agent-conversation view my-org/conv-123", );