Skip to content

Commit 5401713

Browse files
committed
refactor(list): extract compound cursor and target resolution to shared lib
Moves duplicated code from issue/list.ts and alert/issues/list.ts into shared modules so both commands use the same implementation: - lib/db/pagination.ts: exports CURSOR_SEP, encodeCompoundCursor, decodeCompoundCursor, buildMultiTargetContextKey - lib/resolve-target.ts: exports resolveTargetsFromParsedArg — handles all four target modes (auto-detect, explicit, org-all, project-search) with options for enrichProjectIds and checkIssueShortId (issue-list-specific) issue/list.ts drops ~260 lines of local duplicates; alert/issues/list.ts never needs to define them in the first place.
1 parent 19028f0 commit 5401713

File tree

3 files changed

+296
-262
lines changed

3 files changed

+296
-262
lines changed

src/commands/issue/list.ts

Lines changed: 17 additions & 261 deletions
Original file line numberDiff line numberDiff line change
@@ -10,24 +10,20 @@ import { buildOrgAwareAliases } from "../../lib/alias.js";
1010
import {
1111
API_MAX_PER_PAGE,
1212
buildIssueListCollapse,
13-
findProjectsByPattern,
14-
findProjectsBySlug,
15-
getProject,
1613
type IssueCollapseField,
1714
type IssuesPage,
1815
listIssuesAllPages,
1916
listIssuesPaginated,
20-
listProjects,
2117
} from "../../lib/api-client.js";
22-
import {
23-
looksLikeIssueShortId,
24-
parseOrgProjectArg,
25-
} from "../../lib/arg-parsing.js";
18+
import { parseOrgProjectArg } from "../../lib/arg-parsing.js";
2619
import { getActiveEnvVarName, isEnvTokenActive } from "../../lib/db/auth.js";
2720
import {
2821
advancePaginationState,
22+
buildMultiTargetContextKey,
2923
buildPaginationContextKey,
30-
escapeContextKeyValue,
24+
CURSOR_SEP,
25+
decodeCompoundCursor,
26+
encodeCompoundCursor,
3127
hasPreviousPage,
3228
resolveCursor,
3329
} from "../../lib/db/pagination.js";
@@ -39,7 +35,6 @@ import { createDsnFingerprint } from "../../lib/dsn/index.js";
3935
import {
4036
ApiError,
4137
ContextError,
42-
ResolutionError,
4338
ValidationError,
4439
withAuthGuard,
4540
} from "../../lib/errors.js";
@@ -72,13 +67,9 @@ import {
7267
} from "../../lib/org-list.js";
7368
import { withProgress } from "../../lib/polling.js";
7469
import {
75-
fetchProjectId,
7670
type ResolvedTarget,
77-
resolveAllTargets,
78-
toNumericId,
71+
resolveTargetsFromParsedArg,
7972
} from "../../lib/resolve-target.js";
80-
import { getApiBaseUrl } from "../../lib/sentry-client.js";
81-
import { setOrgProjectContext } from "../../lib/telemetry.js";
8273
import type {
8374
ProjectAliasEntry,
8475
SentryIssue,
@@ -346,191 +337,6 @@ type FetchResult =
346337
| { success: true; data: IssueListFetchResult }
347338
| { success: false; error: Error };
348339

349-
/** Result of resolving targets from parsed argument */
350-
type TargetResolutionResult = {
351-
targets: ResolvedTarget[];
352-
footer?: string;
353-
skippedSelfHosted?: number;
354-
detectedDsns?: import("../../lib/dsn/index.js").DetectedDsn[];
355-
};
356-
357-
/**
358-
* Resolve targets based on parsed org/project argument.
359-
*
360-
* Handles all four cases:
361-
* - auto-detect: Use DSN detection / config defaults
362-
* - explicit: Single org/project target
363-
* - org-all: All projects in specified org
364-
* - project-search: Find project across all orgs
365-
*/
366-
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: inherent multi-mode target resolution with per-mode error handling
367-
async function resolveTargetsFromParsedArg(
368-
parsed: ReturnType<typeof parseOrgProjectArg>,
369-
cwd: string
370-
): Promise<TargetResolutionResult> {
371-
switch (parsed.type) {
372-
case "auto-detect": {
373-
// Use existing resolution logic (DSN detection, config defaults)
374-
const result = await resolveAllTargets({ cwd, usageHint: USAGE_HINT });
375-
// DSN-detected and directory-inferred targets already carry a projectId.
376-
// Env var / config-default paths return targets without one, so enrich
377-
// them now using the project API. Any failure silently falls back to
378-
// slug-based querying — the target was already resolved, so we never
379-
// surface a ResolutionError here (that's only for the explicit case).
380-
result.targets = await Promise.all(
381-
result.targets.map(async (t) => {
382-
if (t.projectId !== undefined) {
383-
return t;
384-
}
385-
try {
386-
const info = await getProject(t.org, t.project);
387-
const id = toNumericId(info.id);
388-
return id !== undefined ? { ...t, projectId: id } : t;
389-
} catch {
390-
return t;
391-
}
392-
})
393-
);
394-
return result;
395-
}
396-
397-
case "explicit": {
398-
// Single explicit target — fetch project ID for API query param
399-
// Telemetry context is set by dispatchOrgScopedList before this handler runs.
400-
const projectId = await fetchProjectId(parsed.org, parsed.project);
401-
return {
402-
targets: [
403-
{
404-
org: parsed.org,
405-
project: parsed.project,
406-
projectId,
407-
orgDisplay: parsed.org,
408-
projectDisplay: parsed.project,
409-
},
410-
],
411-
};
412-
}
413-
414-
case "org-all": {
415-
// List all projects in the specified org
416-
// Telemetry context is set by dispatchOrgScopedList before this handler runs.
417-
const projects = await listProjects(parsed.org);
418-
const targets: ResolvedTarget[] = projects.map((p) => ({
419-
org: parsed.org,
420-
project: p.slug,
421-
projectId: toNumericId(p.id),
422-
orgDisplay: parsed.org,
423-
projectDisplay: p.name,
424-
}));
425-
426-
if (targets.length === 0) {
427-
throw new ResolutionError(
428-
`Organization '${parsed.org}'`,
429-
"has no accessible projects",
430-
`sentry project list ${parsed.org}/`,
431-
["Check that you have access to projects in this organization"]
432-
);
433-
}
434-
435-
return {
436-
targets,
437-
footer:
438-
targets.length > 1
439-
? `Showing issues from ${targets.length} projects in ${parsed.org}`
440-
: undefined,
441-
};
442-
}
443-
444-
case "project-search": {
445-
// Detect when user passes an issue short ID instead of a project slug.
446-
// Short IDs like "CONVERSATION-SVC-F" or "CLI-BM" are all-uppercase
447-
// with a dash-separated suffix — a pattern that never occurs in project
448-
// slugs (which are always lowercase).
449-
if (looksLikeIssueShortId(parsed.projectSlug)) {
450-
throw new ResolutionError(
451-
`'${parsed.projectSlug}'`,
452-
"looks like an issue short ID, not a project slug",
453-
`sentry issue view ${parsed.projectSlug}`,
454-
["To list issues in a project: sentry issue list <org>/<project>"]
455-
);
456-
}
457-
458-
// Find project across all orgs
459-
const { projects: matches, orgs } = await findProjectsBySlug(
460-
parsed.projectSlug
461-
);
462-
463-
if (matches.length === 0) {
464-
// Check if the slug matches an organization — common mistake.
465-
// The orgSlugMatchBehavior: "redirect" pre-check handles this for
466-
// cached orgs (hot path). This is the cold-cache fallback: org
467-
// isn't cached yet, so the pre-check couldn't fire. We throw a
468-
// ResolutionError with a hint — after this command, the org will
469-
// be cached and future runs will auto-redirect.
470-
const isOrg = orgs.some((o) => o.slug === parsed.projectSlug);
471-
if (isOrg) {
472-
throw new ResolutionError(
473-
`'${parsed.projectSlug}'`,
474-
"is an organization, not a project",
475-
`sentry issue list ${parsed.projectSlug}/`,
476-
[
477-
`List projects: sentry project list ${parsed.projectSlug}/`,
478-
`Specify a project: sentry issue list ${parsed.projectSlug}/<project>`,
479-
]
480-
);
481-
}
482-
483-
// Try word-boundary matching to suggest similar projects (CLI-A4, 16 users).
484-
// Uses the same findProjectsByPattern used by directory name inference.
485-
// Only runs on the error path, so the extra API cost is acceptable.
486-
const similar = await findProjectsByPattern(parsed.projectSlug);
487-
const suggestions: string[] = [];
488-
if (similar.length > 0) {
489-
const names = similar
490-
.slice(0, 3)
491-
.map((p) => `'${p.orgSlug}/${p.slug}'`);
492-
suggestions.push(`Similar projects: ${names.join(", ")}`);
493-
}
494-
suggestions.push(
495-
"No project with this slug found in any accessible organization"
496-
);
497-
throw new ResolutionError(
498-
`Project '${parsed.projectSlug}'`,
499-
"not found",
500-
"sentry project list",
501-
suggestions
502-
);
503-
}
504-
505-
const targets: ResolvedTarget[] = matches.map((m) => ({
506-
org: m.orgSlug,
507-
project: m.slug,
508-
projectId: toNumericId(m.id),
509-
orgDisplay: m.orgSlug,
510-
projectDisplay: m.name,
511-
}));
512-
513-
const uniqueOrgs = [...new Set(targets.map((t) => t.org))];
514-
const uniqueProjects = [...new Set(targets.map((t) => t.project))];
515-
setOrgProjectContext(uniqueOrgs, uniqueProjects);
516-
517-
return {
518-
targets,
519-
footer:
520-
matches.length > 1
521-
? `Found '${parsed.projectSlug}' in ${matches.length} organizations`
522-
: undefined,
523-
};
524-
}
525-
526-
default: {
527-
// TypeScript exhaustiveness check - this should never be reached
528-
const _exhaustiveCheck: never = parsed;
529-
throw new Error(`Unexpected parsed type: ${_exhaustiveCheck}`);
530-
}
531-
}
532-
}
533-
534340
/**
535341
* Fetch issues for a single target project.
536342
*
@@ -768,65 +574,6 @@ function trimWithProjectGuarantee(
768574
return issues.filter((_, i) => selected.has(i));
769575
}
770576

771-
/** Separator for compound cursor entries (pipe — not present in Sentry cursors). */
772-
const CURSOR_SEP = "|";
773-
774-
/**
775-
* Encode per-target cursors as a pipe-separated string for storage.
776-
*
777-
* The position of each entry matches the **sorted** target order encoded in
778-
* the context key fingerprint, so we only need to store the cursor values —
779-
* no org/project metadata is needed in the cursor string itself.
780-
*
781-
* Empty string = project exhausted (no more pages).
782-
*
783-
* @example "1735689600:0:0||1735689601:0:0" — 3 targets, middle one exhausted
784-
*/
785-
function encodeCompoundCursor(cursors: (string | null)[]): string {
786-
return cursors.map((c) => c ?? "").join(CURSOR_SEP);
787-
}
788-
789-
/**
790-
* Decode a compound cursor string back to an array of per-target cursors.
791-
*
792-
* Returns `null` for exhausted entries (empty segments) and `string` for active
793-
* cursors. Returns an empty array if `raw` is empty or looks like a legacy
794-
* JSON cursor (starts with `[`), causing a fresh start.
795-
*/
796-
function decodeCompoundCursor(raw: string): (string | null)[] {
797-
// Guard against legacy JSON compound cursors or corrupted data
798-
if (!raw || raw.startsWith("[")) {
799-
return [];
800-
}
801-
return raw.split(CURSOR_SEP).map((s) => (s === "" ? null : s));
802-
}
803-
804-
/**
805-
* Build a compound cursor context key that encodes the full target set, sort,
806-
* query, and period so that a cursor from one search is never reused for a
807-
* different search.
808-
*/
809-
function buildMultiTargetContextKey(
810-
targets: ResolvedTarget[],
811-
flags: Pick<ListFlags, "sort" | "query" | "period">
812-
): string {
813-
const host = getApiBaseUrl();
814-
const targetFingerprint = targets
815-
.map((t) => `${t.org}/${t.project}`)
816-
.sort()
817-
.join(",");
818-
const escapedQuery = flags.query
819-
? escapeContextKeyValue(flags.query)
820-
: undefined;
821-
const escapedPeriod = escapeContextKeyValue(flags.period ?? "90d");
822-
const escapedSort = escapeContextKeyValue(flags.sort);
823-
return (
824-
`host:${host}|type:multi:${targetFingerprint}` +
825-
`|sort:${escapedSort}|period:${escapedPeriod}` +
826-
(escapedQuery ? `|q:${escapedQuery}` : "")
827-
);
828-
}
829-
830577
/** Build the CLI hint for fetching the next page, preserving active flags. */
831578
/** Append active non-default issue list flags to a base command string. */
832579
function appendIssueFlags(base: string, flags: ListFlags): string {
@@ -1140,7 +887,12 @@ async function handleResolvedTargets(
1140887
const { parsed, flags, cwd } = options;
1141888

1142889
const { targets, footer, skippedSelfHosted, detectedDsns } =
1143-
await resolveTargetsFromParsedArg(parsed, cwd);
890+
await resolveTargetsFromParsedArg(parsed, {
891+
cwd,
892+
usageHint: USAGE_HINT,
893+
enrichProjectIds: true,
894+
checkIssueShortId: true,
895+
});
1144896

1145897
if (targets.length === 0) {
1146898
if (skippedSelfHosted) {
@@ -1154,7 +906,11 @@ async function handleResolvedTargets(
1154906

1155907
// Build a compound cursor context key that encodes the full target set +
1156908
// search parameters so a cursor from one search is never reused for another.
1157-
const contextKey = buildMultiTargetContextKey(targets, flags);
909+
const contextKey = buildMultiTargetContextKey(targets, {
910+
sort: flags.sort,
911+
query: flags.query,
912+
period: flags.period,
913+
});
1158914

1159915
// Resolve per-target start cursors from the stored compound cursor (--cursor resume).
1160916
// Sorted target keys must match the order used in buildMultiTargetContextKey.

0 commit comments

Comments
 (0)