Skip to content

Commit 63d397e

Browse files
betegonclaudeBYK
authored
fix(api): use numeric project ID to avoid "not actively selected" error (#312)
## Summary `sentry issue list sentry/sentry` failed with "Project(s) sentry do not exist or are not actively selected" because `listIssuesPaginated` passed the project as `project:sentry` in the search query string. The Sentry Issues API only searches within "actively selected" projects for that syntax. When we have the numeric project ID, we now use the `project` query param (`Array<number>`) instead, which selects the project directly and bypasses the "actively selected" requirement. ## Changes Thread numeric `projectId` through `ResolvedTarget` → API calls: - **`project_cache` schema**: add `project_id` column (schema v7) so cached lookups also return the ID - **`ResolvedTarget`**: add `projectId?: number`, populated from all resolution paths (explicit targets via `getProject`, DSN detection, cache hits, directory inference) - **`listIssuesPaginated`**: when `projectId` is provided, use `project: [id]` query param instead of `project:<slug>` search syntax; fall back to slug when ID is unavailable - **`listIssuesAllPages` / `fetchIssuesForTarget`**: pass `projectId` through For explicit targets (`sentry issue list org/project`), the `getProject` call also serves as early validation — typos now fail with a clear 404 instead of a confusing "not actively selected" error from the issues endpoint. ## Test plan - New unit tests for `projectId` in `listIssuesPaginated` (3 tests) - All existing tests pass (113 tests across api-client, resolve-target, schema) - Manual: `sentry issue list sentry/sentry -n 5` now returns issues (was failing) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Burak Yigit Kaya <byk@sentry.io>
1 parent bc81573 commit 63d397e

File tree

10 files changed

+496
-98
lines changed

10 files changed

+496
-98
lines changed

AGENTS.md

Lines changed: 77 additions & 69 deletions
Large diffs are not rendered by default.

src/commands/issue/list.ts

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { buildOrgAwareAliases } from "../../lib/alias.js";
1010
import {
1111
API_MAX_PER_PAGE,
1212
findProjectsBySlug,
13+
getProject,
1314
type IssuesPage,
1415
listIssuesAllPages,
1516
listIssuesPaginated,
@@ -55,8 +56,10 @@ import {
5556
} from "../../lib/org-list.js";
5657
import { withProgress } from "../../lib/polling.js";
5758
import {
59+
fetchProjectId,
5860
type ResolvedTarget,
5961
resolveAllTargets,
62+
toNumericId,
6063
} from "../../lib/resolve-target.js";
6164
import { getApiBaseUrl } from "../../lib/sentry-client.js";
6265
import type {
@@ -282,29 +285,54 @@ async function resolveTargetsFromParsedArg(
282285
cwd: string
283286
): Promise<TargetResolutionResult> {
284287
switch (parsed.type) {
285-
case "auto-detect":
288+
case "auto-detect": {
286289
// Use existing resolution logic (DSN detection, config defaults)
287-
return resolveAllTargets({ cwd, usageHint: USAGE_HINT });
290+
const result = await resolveAllTargets({ cwd, usageHint: USAGE_HINT });
291+
// DSN-detected and directory-inferred targets already carry a projectId.
292+
// Env var / config-default paths return targets without one, so enrich
293+
// them now using the project API. Any failure silently falls back to
294+
// slug-based querying — the target was already resolved, so we never
295+
// surface a ResolutionError here (that's only for the explicit case).
296+
result.targets = await Promise.all(
297+
result.targets.map(async (t) => {
298+
if (t.projectId !== undefined) {
299+
return t;
300+
}
301+
try {
302+
const info = await getProject(t.org, t.project);
303+
const id = toNumericId(info.id);
304+
return id !== undefined ? { ...t, projectId: id } : t;
305+
} catch {
306+
return t;
307+
}
308+
})
309+
);
310+
return result;
311+
}
288312

289-
case "explicit":
290-
// Single explicit target
313+
case "explicit": {
314+
// Single explicit target — fetch project ID for API query param
315+
const projectId = await fetchProjectId(parsed.org, parsed.project);
291316
return {
292317
targets: [
293318
{
294319
org: parsed.org,
295320
project: parsed.project,
321+
projectId,
296322
orgDisplay: parsed.org,
297323
projectDisplay: parsed.project,
298324
},
299325
],
300326
};
327+
}
301328

302329
case "org-all": {
303330
// List all projects in the specified org
304331
const projects = await listProjects(parsed.org);
305332
const targets: ResolvedTarget[] = projects.map((p) => ({
306333
org: parsed.org,
307334
project: p.slug,
335+
projectId: toNumericId(p.id),
308336
orgDisplay: parsed.org,
309337
projectDisplay: p.name,
310338
}));
@@ -341,6 +369,7 @@ async function resolveTargetsFromParsedArg(
341369
const targets: ResolvedTarget[] = matches.map((m) => ({
342370
org: m.orgSlug,
343371
project: m.slug,
372+
projectId: toNumericId(m.id),
344373
orgDisplay: m.orgSlug,
345374
projectDisplay: m.name,
346375
}));
@@ -386,7 +415,7 @@ async function fetchIssuesForTarget(
386415
const { issues, nextCursor } = await listIssuesAllPages(
387416
target.org,
388417
target.project,
389-
options
418+
{ ...options, projectId: target.projectId }
390419
);
391420
return {
392421
success: true,
@@ -928,19 +957,22 @@ async function handleResolvedTargets(
928957
}
929958

930959
const validResults: IssueListResult[] = [];
931-
const failures: Error[] = [];
960+
const failures: { target: ResolvedTarget; error: Error }[] = [];
932961

933-
for (const result of results) {
962+
for (let i = 0; i < results.length; i++) {
963+
// biome-ignore lint/style/noNonNullAssertion: index within bounds
964+
const result = results[i]!;
934965
if (result.success) {
935966
validResults.push(result.data);
936967
} else {
937-
failures.push(result.error);
968+
// biome-ignore lint/style/noNonNullAssertion: index within bounds
969+
failures.push({ target: activeTargets[i]!, error: result.error });
938970
}
939971
}
940972

941973
if (validResults.length === 0 && failures.length > 0) {
942974
// biome-ignore lint/style/noNonNullAssertion: guarded by failures.length > 0
943-
const first = failures[0]!;
975+
const { error: first } = failures[0]!;
944976
const prefix = `Failed to fetch issues from ${targets.length} project(s)`;
945977

946978
// Propagate ApiError so telemetry sees the original status code
@@ -997,20 +1029,27 @@ async function handleResolvedTargets(
9971029
hasMore: hasMoreToShow,
9981030
};
9991031
if (failures.length > 0) {
1000-
output.errors = failures.map((e) =>
1032+
output.errors = failures.map(({ target: t, error: e }) =>
10011033
e instanceof ApiError
1002-
? { status: e.status, message: e.message }
1003-
: { message: e.message }
1034+
? {
1035+
project: `${t.org}/${t.project}`,
1036+
status: e.status,
1037+
message: e.message,
1038+
}
1039+
: { project: `${t.org}/${t.project}`, message: e.message }
10041040
);
10051041
}
10061042
writeJson(stdout, output);
10071043
return;
10081044
}
10091045

10101046
if (failures.length > 0) {
1047+
const failedNames = failures
1048+
.map(({ target: t }) => `${t.org}/${t.project}`)
1049+
.join(", ");
10111050
stderr.write(
10121051
muted(
1013-
`\nNote: Failed to fetch issues from ${failures.length} project(s). Showing results from ${validResults.length} project(s).\n`
1052+
`\nNote: Failed to fetch issues from ${failedNames}. Showing results from ${validResults.length} project(s).\n`
10141053
)
10151054
);
10161055
}

src/lib/api-client.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1050,12 +1050,19 @@ export async function listIssuesPaginated(
10501050
perPage?: number;
10511051
sort?: IssueSort;
10521052
statsPeriod?: string;
1053+
/** Numeric project ID. When provided, uses the `project` query param
1054+
* instead of `project:<slug>` search syntax, avoiding "not actively
1055+
* selected" errors. */
1056+
projectId?: number;
10531057
} = {}
10541058
): Promise<PaginatedResponse<SentryIssue[]>> {
1055-
// Only add project filter when projectSlug is non-empty; an empty slug would
1056-
// produce "project:" (a truthy string that .filter(Boolean) won't remove),
1057-
// sending a malformed query to the API for org-wide listing.
1058-
const projectFilter = projectSlug ? `project:${projectSlug}` : "";
1059+
// When we have a numeric project ID, use the `project` query param (Array<number>)
1060+
// instead of `project:<slug>` in the search query. The API's `project` param
1061+
// selects the project directly, bypassing the "actively selected" requirement.
1062+
let projectFilter = "";
1063+
if (!options.projectId && projectSlug) {
1064+
projectFilter = `project:${projectSlug}`;
1065+
}
10591066
const fullQuery = [projectFilter, options.query].filter(Boolean).join(" ");
10601067

10611068
const config = await getOrgSdkConfig(orgSlug);
@@ -1064,6 +1071,7 @@ export async function listIssuesPaginated(
10641071
...config,
10651072
path: { organization_id_or_slug: orgSlug },
10661073
query: {
1074+
project: options.projectId ? [options.projectId] : undefined,
10671075
// Convert empty string to undefined so the SDK omits the param entirely;
10681076
// sending `query=` causes the Sentry API to behave differently than
10691077
// omitting the parameter.
@@ -1116,6 +1124,8 @@ export async function listIssuesAllPages(
11161124
limit: number;
11171125
sort?: IssueSort;
11181126
statsPeriod?: string;
1127+
/** Numeric project ID for direct project selection via query param. */
1128+
projectId?: number;
11191129
/** Resume pagination from this cursor instead of starting from the beginning. */
11201130
startCursor?: string;
11211131
/** Called after each page is fetched. Useful for progress indicators. */
@@ -1141,6 +1151,7 @@ export async function listIssuesAllPages(
11411151
perPage,
11421152
sort: options.sort,
11431153
statsPeriod: options.statsPeriod,
1154+
projectId: options.projectId,
11441155
});
11451156

11461157
allResults.push(...response.data);

src/lib/db/project-cache.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ type ProjectCacheRow = {
1212
org_name: string;
1313
project_slug: string;
1414
project_name: string;
15+
project_id?: string;
1516
cached_at: number;
1617
last_accessed: number;
1718
};
@@ -30,6 +31,7 @@ function rowToCachedProject(row: ProjectCacheRow): CachedProject {
3031
orgName: row.org_name,
3132
projectSlug: row.project_slug,
3233
projectName: row.project_name,
34+
projectId: row.project_id,
3335
cachedAt: row.cached_at,
3436
};
3537
}
@@ -78,6 +80,7 @@ export async function setCachedProject(
7880
org_name: info.orgName,
7981
project_slug: info.projectSlug,
8082
project_name: info.projectName,
83+
project_id: info.projectId ?? null,
8184
cached_at: now,
8285
last_accessed: now,
8386
},
@@ -124,6 +127,7 @@ export async function setCachedProjectByDsnKey(
124127
org_name: info.orgName,
125128
project_slug: info.projectSlug,
126129
project_name: info.projectName,
130+
project_id: info.projectId ?? null,
127131
cached_at: now,
128132
last_accessed: now,
129133
},

src/lib/db/schema.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import type { Database } from "bun:sqlite";
1515
import { stringifyUnknown } from "../errors.js";
1616

17-
export const CURRENT_SCHEMA_VERSION = 6;
17+
export const CURRENT_SCHEMA_VERSION = 7;
1818

1919
/** Environment variable to disable auto-repair */
2020
const NO_AUTO_REPAIR_ENV = "SENTRY_CLI_NO_AUTO_REPAIR";
@@ -85,6 +85,7 @@ export const TABLE_SCHEMAS: Record<string, TableSchema> = {
8585
org_name: { type: "TEXT", notNull: true },
8686
project_slug: { type: "TEXT", notNull: true },
8787
project_name: { type: "TEXT", notNull: true },
88+
project_id: { type: "TEXT", addedInVersion: 7 },
8889
cached_at: {
8990
type: "INTEGER",
9091
notNull: true,
@@ -708,6 +709,11 @@ export function runMigrations(db: Database): void {
708709
db.exec(EXPECTED_TABLES.pagination_cursors as string);
709710
}
710711

712+
// Migration 6 -> 7: Add project_id column to project_cache for numeric project filtering
713+
if (currentVersion < 7) {
714+
addColumnIfMissing(db, "project_cache", "project_id", "TEXT");
715+
}
716+
711717
if (currentVersion < CURRENT_SCHEMA_VERSION) {
712718
db.query("UPDATE schema_version SET version = ?").run(
713719
CURRENT_SCHEMA_VERSION

0 commit comments

Comments
 (0)