From ad8e71e1a227fde65d4e96ac954f1f626a2dc3ee Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 29 Aug 2026 20:22:10 -0700 Subject: [PATCH] Split GitHub project view read path (#17266) * Split speech session lifecycle * Split terminal output scheduler pipeline * Split mobile browser pane modules * Prune resolved max-lines suppressions * Split pane tree equalization logic * Extract mobile troubleshoot screen styles * Split external automation manager * Split main window service attachments * Split hosted review creation checks * Split automation dispatch event handling * Split settings navigation metadata * Split daemon initialization lifecycle * Split GitLab item dialog * Split relay dispatcher layers * Split mobile host screen * Retarget mobile view settings source test * Split runtime file client layers * Split ports panel layers * Split runtime environments pane layers * Split local PTY provider responsibilities * Split CDP bridge responsibilities * Split relay Git handler responsibilities * Track moved relay Git fetch audit * Split Linear item drawer responsibilities * Split telemetry event schema responsibilities * Split resource usage status responsibilities * Split remote terminal multiplexer responsibilities * Split Git worktree responsibilities * Split Codex hook service responsibilities * Keep mirrored hook trust type private * Split web runtime session responsibilities * Split GitHub project view read path * Fix F3-speech for #17123 * Fix F1-cycle for #17131 * Fix F4-navtest for #17157 * Fix F2-allowlist for #17161 --- config/max-lines-baseline.txt | 1 - src/main/github/project-view.ts | 1800 +---------------- .../github/project-view/project-view-cache.ts | 149 ++ .../project-view/project-view-config.ts | 242 +++ .../project-view/project-view-discovery.ts | 204 ++ .../project-view-field-normalization.ts | 176 ++ .../project-view-item-normalization.ts | 153 ++ .../project-view/project-view-item-page.ts | 171 ++ .../github/project-view/project-view-items.ts | 233 +++ .../project-view/project-view-listing.ts | 59 + .../project-view-query-fragments.ts | 73 + .../project-view/project-view-reference.ts | 230 +++ .../github/project-view/project-view-table.ts | 163 ++ 13 files changed, 1873 insertions(+), 1781 deletions(-) create mode 100644 src/main/github/project-view/project-view-cache.ts create mode 100644 src/main/github/project-view/project-view-config.ts create mode 100644 src/main/github/project-view/project-view-discovery.ts create mode 100644 src/main/github/project-view/project-view-field-normalization.ts create mode 100644 src/main/github/project-view/project-view-item-normalization.ts create mode 100644 src/main/github/project-view/project-view-item-page.ts create mode 100644 src/main/github/project-view/project-view-items.ts create mode 100644 src/main/github/project-view/project-view-listing.ts create mode 100644 src/main/github/project-view/project-view-query-fragments.ts create mode 100644 src/main/github/project-view/project-view-reference.ts create mode 100644 src/main/github/project-view/project-view-table.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 0ec7d3f48db..3c39c7c9718 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -9,7 +9,6 @@ inline src/main/browser/browser-manager.ts inline src/main/claude-accounts/runtime-auth-service.ts inline src/main/codex-accounts/runtime-home-service.ts inline src/main/codex-accounts/service.ts -inline src/main/github/project-view.ts inline src/main/index.ts inline src/main/ipc/filesystem-watcher.ts inline src/main/ipc/filesystem.ts diff --git a/src/main/github/project-view.ts b/src/main/github/project-view.ts index 9a0d9e97f78..71566110b36 100644 --- a/src/main/github/project-view.ts +++ b/src/main/github/project-view.ts @@ -1,62 +1,3 @@ -/* eslint-disable max-lines -- Why: co-locate the ProjectV2 read path (normalize/retry/paste-parser/discovery) as one reviewable surface; plumbing lives in ./project-view/internals and mutations. */ -import { - acquire, - release, - extractExecError, - ghExecFileAsync, - repositoryRateLimitGuard, - noteRepositoryRateLimitSpend, - runGraphql, - isValidOwnerSlug, - assertSlug, - assertPositiveInt, - projectHostAuthenticationError, - projectGhExecOptions, - type GraphqlVars -} from './project-view/internals' -import { - classifyProjectError, - driftError, - errorsIndicateParentField, - rateLimitedError, - type GhGraphqlErrorShape -} from './project-view/project-error-classification' -import type { - GitHubProjectField, - GitHubProjectFieldValue, - GitHubProjectIteration, - GitHubProjectLabel, - GitHubProjectOwnerType, - GitHubProjectRow, - GitHubProjectRowItemType, - GitHubProjectSingleSelectOption, - GitHubProjectSort, - GitHubProjectSummary, - GitHubProjectTable, - GitHubProjectUser, - GitHubProjectView, - GitHubProjectViewLayout, - GitHubProjectViewSummary -} from '../../shared/github/project-types' -import type { - GetProjectViewTableResult, - GitHubProjectViewError, - ListAccessibleProjectsResult, - ListProjectViewsResult, - ResolveProjectRefResult -} from '../../shared/github/project-result-types' -import type { - GetProjectViewTableArgs, - ListAccessibleProjectsArgs, - ListProjectViewsArgs, - ResolveProjectRefArgs -} from '../../shared/github/project-request-types' -import { - GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR, - isGitHubProjectRefInputTooLarge -} from '../../shared/github/project-ref-input' -import { githubProjectHost } from '../../shared/github/project-identity' - // Re-export the public API so existing `./project-view` call sites keep working; the split is internal-only. export { isValidOwnerSlug, isValidRepoSlug } from './project-view/internals' export { classifyProjectError } from './project-view/project-error-classification' @@ -78,1724 +19,23 @@ export { } from './project-view/repository-field-options' export { updateIssueTypeBySlug } from './project-view/issue-type-mutation' export { getWorkItemDetailsBySlug } from './project-view/work-item-details' - -// ─── Constants ───────────────────────────────────────────────────────── - -// Why: defaults deliberately shrunk to cut quota spend in discovery — the org loop dominates and produced the HTTP 504; overflow owners can paste a URL. -const ITEM_PAGE_SIZE = 100 -const MAX_ITEMS = 500 -const VIEWS_PAGE_SIZE = 20 -const FIELDS_PAGE_SIZE = 50 -const DISCOVERY_PROJECTS_PER_OWNER = 40 -const DISCOVERY_MAX_ORGS = 20 -const DISCOVERY_ORG_PAGE_SIZE = 20 -const DISCOVERY_PROJECTS_PER_ORG = 20 -const FIELD_VALUES_PAGE_SIZE = 100 -export const PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES = 512 - -// ─── Module-scope caches (reset on HMR — intentional) ────────────────── - -// Why: owners are user-controlled over a long session; bound cache entries to avoid unbounded retention while keeping the hot-owner fast path. -function rememberProjectViewCacheEntry( - cache: Map, - key: K, - value: V, - maxEntries = PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES -): void { - if (cache.has(key)) { - cache.delete(key) - } - cache.set(key, value) - while (cache.size > maxEntries) { - const oldest = cache.keys().next() - if (oldest.done) { - break - } - cache.delete(oldest.value) - } -} - -function getProjectViewCacheEntry(cache: Map, key: K): V | undefined { - if (!cache.has(key)) { - return undefined - } - const value = cache.get(key) as V - rememberProjectViewCacheEntry(cache, key, value) - return value -} - -// Why: plain module locals so HMR code swaps re-run capability probes instead of carrying a stale "unsupported" flag. -const ownerTypeCache = new Map() -// Why: keyed per owner (not a process-global flag) so one owner's capability gap doesn't poison others that DO support Issue.parent (bug-scan finding 2). -const parentFieldRetriedByOwner = new Map() -const parentFieldWarningLoggedByOwner = new Map() -// Why: in-flight promise per owner so concurrent fetchAllItems callers share one probe instead of each racing a duplicate first-page probe. -const parentFieldProbeInFlight = new Map>() - -// Why: GHES owners are a separate namespace and capability surface from -// github.com owners with the same login — scope cache keys by host so one -// host's probe result can't leak into another. Normalize github.com so -// host-less callers share the same probe state as explicitly pinned calls. -function ownerScopeKey(owner: string, ownerType: GitHubProjectOwnerType, host?: string): string { - const base = `${owner}\u0000${ownerType}` - return `${base}\u0000${githubProjectHost(host)}` -} - -function ownerTypeCacheKey(owner: string, host?: string): string { - return `${owner}\u0000${githubProjectHost(host)}` -} - -function rememberOwnerType( - owner: string, - ownerType: GitHubProjectOwnerType | null, - host?: string -): void { - rememberProjectViewCacheEntry(ownerTypeCache, ownerTypeCacheKey(owner, host), ownerType) -} - -function getCachedOwnerType( - owner: string, - host?: string -): GitHubProjectOwnerType | null | undefined { - return getProjectViewCacheEntry(ownerTypeCache, ownerTypeCacheKey(owner, host)) -} - -function markParentFieldRetried(scopeKey: string): void { - rememberProjectViewCacheEntry(parentFieldRetriedByOwner, scopeKey, true) -} - -function hasParentFieldRetried(scopeKey: string): boolean { - return getProjectViewCacheEntry(parentFieldRetriedByOwner, scopeKey) === true -} - -function markParentFieldWarningLogged(scopeKey: string): void { - rememberProjectViewCacheEntry(parentFieldWarningLoggedByOwner, scopeKey, true) -} - -function hasParentFieldWarningLogged(scopeKey: string): boolean { - return getProjectViewCacheEntry(parentFieldWarningLoggedByOwner, scopeKey) === true -} - -export function _resetProjectViewCachesForTests(): void { - ownerTypeCache.clear() - parentFieldRetriedByOwner.clear() - parentFieldWarningLoggedByOwner.clear() - parentFieldProbeInFlight.clear() -} - -export function _getProjectViewCacheSizesForTests(): { - ownerTypes: number - parentFieldRetries: number - parentFieldWarnings: number - parentFieldProbes: number -} { - return { - ownerTypes: ownerTypeCache.size, - parentFieldRetries: parentFieldRetriedByOwner.size, - parentFieldWarnings: parentFieldWarningLoggedByOwner.size, - parentFieldProbes: parentFieldProbeInFlight.size - } -} - -/** @internal - exposed for cache-bound tests only. */ -export function _rememberProjectViewOwnerTypeForTests( - owner: string, - ownerType: GitHubProjectOwnerType | null, - host?: string -): void { - rememberOwnerType(owner, ownerType, host) -} - -/** @internal - exposed for cache-bound tests only. */ -export function _getProjectViewOwnerTypeForTests( - owner: string, - host?: string -): GitHubProjectOwnerType | null | undefined { - return getCachedOwnerType(owner, host) -} - -/** @internal - exposed for cache-bound tests only. */ -export function _markProjectViewParentFieldRetriedForTests(scopeKey: string): void { - markParentFieldRetried(scopeKey) -} - -/** @internal - exposed for cache-bound tests only. */ -export function _hasProjectViewParentFieldRetriedForTests(scopeKey: string): boolean { - return hasParentFieldRetried(scopeKey) -} - -/** @internal - exposed for cache-bound tests only. */ -export function _markProjectViewParentFieldWarningLoggedForTests(scopeKey: string): void { - markParentFieldWarningLogged(scopeKey) -} - -/** @internal - exposed for cache-bound tests only. */ -export function _hasProjectViewParentFieldWarningLoggedForTests(scopeKey: string): boolean { - return hasParentFieldWarningLogged(scopeKey) -} - -// ─── Normalizers ─────────────────────────────────────────────────────── - -type RawProjectV2Field = { - __typename?: string - id?: string - name?: string - dataType?: string - options?: { id?: string; name?: string; color?: string }[] - configuration?: { - iterations?: { id?: string; title?: string; startDate?: string; duration?: number }[] - completedIterations?: { - id?: string - title?: string - startDate?: string - duration?: number - }[] - } -} - -export function normalizeField( - raw: RawProjectV2Field | null | undefined -): GitHubProjectField | null { - if (!raw || typeof raw.id !== 'string' || typeof raw.name !== 'string') { - return null - } - const dataType = raw.dataType ?? raw.__typename ?? '' - if (raw.__typename === 'ProjectV2SingleSelectField' || dataType === 'SINGLE_SELECT') { - const options: GitHubProjectSingleSelectOption[] = (raw.options ?? []) - .map((o) => - typeof o.id === 'string' && typeof o.name === 'string' - ? { id: o.id, name: o.name, color: o.color ?? '' } - : null - ) - .filter((o): o is GitHubProjectSingleSelectOption => o !== null) - return { kind: 'single-select', id: raw.id, name: raw.name, dataType: 'SINGLE_SELECT', options } - } - if (raw.__typename === 'ProjectV2IterationField' || dataType === 'ITERATION') { - const cfg = raw.configuration ?? {} - const iterations: GitHubProjectIteration[] = [] - for (const it of cfg.completedIterations ?? []) { - if (typeof it.id === 'string' && typeof it.title === 'string') { - iterations.push({ - id: it.id, - title: it.title, - startDate: it.startDate ?? '', - duration: typeof it.duration === 'number' ? it.duration : 0, - completed: true - }) - } - } - for (const it of cfg.iterations ?? []) { - if (typeof it.id === 'string' && typeof it.title === 'string') { - iterations.push({ - id: it.id, - title: it.title, - startDate: it.startDate ?? '', - duration: typeof it.duration === 'number' ? it.duration : 0, - completed: false - }) - } - } - return { kind: 'iteration', id: raw.id, name: raw.name, dataType: 'ITERATION', iterations } - } - return { kind: 'field', id: raw.id, name: raw.name, dataType } -} - -type RawUser = { - login?: string - name?: string | null - avatarUrl?: string | null -} - -function normalizeUser(raw: RawUser | null | undefined): GitHubProjectUser | null { - if (!raw || typeof raw.login !== 'string') { - return null - } - return { - login: raw.login, - name: raw.name ?? null, - avatarUrl: raw.avatarUrl ?? null - } -} - -type RawLabel = { name?: string; color?: string } - -function normalizeLabel(raw: RawLabel | null | undefined): GitHubProjectLabel | null { - if (!raw || typeof raw.name !== 'string') { - return null - } - return { name: raw.name, color: raw.color ?? '' } -} - -type RawFieldValue = { - __typename?: string - field?: RawProjectV2Field - name?: string - color?: string - optionId?: string - title?: string - startDate?: string - duration?: number - iterationId?: string - text?: string - number?: number - date?: string - labels?: { nodes?: RawLabel[] } - users?: { nodes?: RawUser[] } -} - -export function normalizeFieldValue( - raw: RawFieldValue | null | undefined -): GitHubProjectFieldValue | null { - if (!raw || !raw.field || typeof raw.field.id !== 'string') { - return null - } - const fieldId = raw.field.id - switch (raw.__typename) { - case 'ProjectV2ItemFieldSingleSelectValue': - if (typeof raw.optionId !== 'string') { - return null - } - return { - kind: 'single-select', - fieldId, - optionId: raw.optionId, - name: raw.name ?? '', - color: raw.color ?? '' - } - case 'ProjectV2ItemFieldIterationValue': - if (typeof raw.iterationId !== 'string') { - return null - } - return { - kind: 'iteration', - fieldId, - iterationId: raw.iterationId, - title: raw.title ?? '', - startDate: raw.startDate ?? '', - duration: typeof raw.duration === 'number' ? raw.duration : 0 - } - case 'ProjectV2ItemFieldTextValue': - return { kind: 'text', fieldId, text: raw.text ?? '' } - case 'ProjectV2ItemFieldNumberValue': - if (typeof raw.number !== 'number') { - return null - } - return { kind: 'number', fieldId, number: raw.number } - case 'ProjectV2ItemFieldDateValue': - return { kind: 'date', fieldId, date: raw.date ?? '' } - case 'ProjectV2ItemFieldLabelValue': { - const labels = (raw.labels?.nodes ?? []) - .map(normalizeLabel) - .filter((l): l is GitHubProjectLabel => l !== null) - return { kind: 'labels', fieldId, labels } - } - case 'ProjectV2ItemFieldUserValue': { - const users = (raw.users?.nodes ?? []) - .map(normalizeUser) - .filter((u): u is GitHubProjectUser => u !== null) - return { kind: 'users', fieldId, users } - } - case undefined: - default: - // Unknown __typename → forward-compat: drop silently, don't classify as drift (see design §Error Handling). - return null - } -} - -type RawContent = { - __typename?: string - id?: string - number?: number - title?: string - body?: string - url?: string - state?: string - stateReason?: string | null - isDraft?: boolean - repository?: { nameWithOwner?: string } - assignees?: { nodes?: RawUser[] } - labels?: { nodes?: RawLabel[] } - parent?: { number?: number; title?: string; url?: string } | null - issueType?: { - id?: string - name?: string - color?: string | null - description?: string | null - } | null -} - -type RawItem = { - id?: string - type?: string - updatedAt?: string - content?: RawContent | null - fieldValues?: { - nodes?: RawFieldValue[] - pageInfo?: { hasNextPage?: boolean } - } -} - -type NormalizedItemOutcome = - | { ok: true; row: GitHubProjectRow } - | { ok: false; drift: GitHubProjectViewError } - -function mapItemType(raw: string | undefined, hasContent: boolean): GitHubProjectRowItemType { - if (raw === 'ISSUE') { - return 'ISSUE' - } - if (raw === 'PULL_REQUEST') { - return 'PULL_REQUEST' - } - if (raw === 'DRAFT_ISSUE') { - return 'DRAFT_ISSUE' - } - if (raw === 'REDACTED' || !hasContent) { - return 'REDACTED' - } - // Unknown item type with content — treat as redacted rather than dropping. - return 'REDACTED' -} - -export function normalizeItem(raw: RawItem, position: number): NormalizedItemOutcome { - if (!raw || typeof raw.id !== 'string') { - return { - ok: false, - drift: driftError('item missing id', { path: ['items', 'nodes', position, 'id'] }) - } - } - if (raw.fieldValues?.pageInfo?.hasNextPage === true) { - return { - ok: false, - drift: driftError('item field values exceeded single page', { - path: ['items', 'nodes', position, 'fieldValues', 'pageInfo', 'hasNextPage'] - }) - } - } - const itemType = mapItemType(raw.type, raw.content !== null && raw.content !== undefined) - const content = raw.content ?? null - const assignees = (content?.assignees?.nodes ?? []) - .map(normalizeUser) - .filter((u): u is GitHubProjectUser => u !== null) - const labels = (content?.labels?.nodes ?? []) - .map(normalizeLabel) - .filter((l): l is GitHubProjectLabel => l !== null) - const parentIssue = - content?.parent && - typeof content.parent.number === 'number' && - typeof content.parent.title === 'string' && - typeof content.parent.url === 'string' - ? { number: content.parent.number, title: content.parent.title, url: content.parent.url } - : null - const issueType = - content?.issueType && - typeof content.issueType.id === 'string' && - typeof content.issueType.name === 'string' - ? { - id: content.issueType.id, - name: content.issueType.name, - color: typeof content.issueType.color === 'string' ? content.issueType.color : null, - description: - typeof content.issueType.description === 'string' ? content.issueType.description : null - } - : null - const fieldValuesByFieldId: Record = {} - for (const fv of raw.fieldValues?.nodes ?? []) { - const normalized = normalizeFieldValue(fv) - if (normalized) { - fieldValuesByFieldId[normalized.fieldId] = normalized - } - } - const title = - itemType === 'REDACTED' - ? 'Restricted item' - : typeof content?.title === 'string' - ? content.title - : '' - const row: GitHubProjectRow = { - id: raw.id, - itemType, - content: { - number: typeof content?.number === 'number' ? content.number : null, - title, - body: typeof content?.body === 'string' ? content.body : null, - url: typeof content?.url === 'string' ? content.url : null, - state: typeof content?.state === 'string' ? content.state : null, - stateReason: typeof content?.stateReason === 'string' ? content.stateReason : null, - isDraft: typeof content?.isDraft === 'boolean' ? content.isDraft : null, - repository: - typeof content?.repository?.nameWithOwner === 'string' - ? content.repository.nameWithOwner - : null, - assignees, - labels, - parentIssue, - issueType - }, - fieldValuesByFieldId, - updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : '', - position - } - return { ok: true, row } -} - -// ─── GraphQL query fragments ─────────────────────────────────────────── - -const FIELD_CONFIG_FRAGMENT = ` -fragment FieldConfig on ProjectV2FieldConfiguration { - __typename - ... on ProjectV2Field { id name dataType } - ... on ProjectV2SingleSelectField { - id - name - dataType - options { id name color } - } - ... on ProjectV2IterationField { - id - name - dataType - configuration { - iterations { id title startDate duration } - completedIterations { id title startDate duration } - } - } -} -` - -function itemContentSelection(includeParent: boolean): string { - const parentFrag = includeParent ? 'parent { number title url }' : '' - return ` - __typename - ... on Issue { - id - number - title - url - state - stateReason - repository { nameWithOwner } - assignees(first:5) { nodes { login name avatarUrl } } - labels(first:10) { nodes { name color } } - issueType { id name color description } - ${parentFrag} - } - ... on PullRequest { - id - number - title - url - state - isDraft - repository { nameWithOwner } - assignees(first:5) { nodes { login name avatarUrl } } - labels(first:10) { nodes { name color } } - } - ... on DraftIssue { id title body } - ` -} - -const FIELD_VALUES_SELECTION = ` - fieldValues(first:${FIELD_VALUES_PAGE_SIZE}) { - pageInfo { hasNextPage } - nodes { - __typename - ... on ProjectV2ItemFieldSingleSelectValue { field { ...FieldConfig } name color optionId } - ... on ProjectV2ItemFieldIterationValue { field { ...FieldConfig } title startDate duration iterationId } - ... on ProjectV2ItemFieldTextValue { field { ...FieldConfig } text } - ... on ProjectV2ItemFieldNumberValue { field { ...FieldConfig } number } - ... on ProjectV2ItemFieldDateValue { field { ...FieldConfig } date } - ... on ProjectV2ItemFieldLabelValue { field { ...FieldConfig } labels(first:10) { nodes { name color } } } - ... on ProjectV2ItemFieldUserValue { field { ...FieldConfig } users(first:5) { nodes { login name avatarUrl } } } - } - } -` - -// ─── Project config fetch (views + fields, paginated) ────────────────── - -type RawProjectConfig = { - id?: string - title?: string - url?: string - views?: { - pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } - nodes?: (RawProjectView | null)[] - } -} - -type RawProjectView = { - id?: string - number?: number - name?: string - layout?: string - filter?: string | null - fields?: { - pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } - nodes?: (RawProjectV2Field | null)[] - } - groupByFields?: { nodes?: (RawProjectV2Field | null)[] } - sortByFields?: { - nodes?: ({ direction?: string; field?: RawProjectV2Field | null } | null)[] - } -} - -function ownerQueryRoot(ownerType: GitHubProjectOwnerType): string { - return ownerType === 'organization' ? 'organization' : 'user' -} - -async function fetchProjectViewsPage(args: { - owner: string - ownerType: GitHubProjectOwnerType - projectNumber: number - host?: string - after: string | null -}): Promise< - | { - ok: true - project: { id: string; title: string; url: string } - views: RawProjectView[] - hasNextPage: boolean - endCursor: string | null - } - | { ok: false; error: GitHubProjectViewError } -> { - const root = ownerQueryRoot(args.ownerType) - const afterArg = args.after ? `, after: $after` : '' - const afterVar = args.after ? `$after:String!, ` : '' - const query = ` - query(${afterVar}$owner:String!, $num:Int!) { - ${root}(login:$owner) { - projectV2(number:$num) { - id title url - views(first:${VIEWS_PAGE_SIZE}${afterArg}) { - pageInfo { hasNextPage endCursor } - nodes { - id number name layout filter - fields(first:${FIELDS_PAGE_SIZE}) { - pageInfo { hasNextPage endCursor } - nodes { ...FieldConfig } - } - groupByFields(first:10) { nodes { ...FieldConfig } } - sortByFields(first:10) { - nodes { direction field { ...FieldConfig } } - } - } - } - } - } - } - ${FIELD_CONFIG_FRAGMENT} - ` - const vars: GraphqlVars = { owner: args.owner, num: args.projectNumber } - if (args.after) { - vars.after = args.after - } - const res = await runGraphql>( - query, - vars, - projectGhExecOptions(args.host) - ) - if (!res.ok) { - return res - } - const top = res.data[root] - const project = top?.projectV2 ?? null - if (!project || typeof project.id !== 'string') { - return { ok: false, error: { type: 'not_found', message: 'Project not found.' } } - } - const pageInfo = project.views?.pageInfo - const views = (project.views?.nodes ?? []).filter((v): v is RawProjectView => v !== null) - return { - ok: true, - project: { id: project.id, title: project.title ?? '', url: project.url ?? '' }, - views, - hasNextPage: pageInfo?.hasNextPage === true, - endCursor: pageInfo?.endCursor ?? null - } -} - -async function fetchViewFieldsContinuation( - viewId: string, - after: string, - host?: string -): Promise< - { ok: true; fields: RawProjectV2Field[] } | { ok: false; error: GitHubProjectViewError } -> { - // Why: address the view directly via node(id:) instead of re-walking all views each page — one round-trip per field page. - const query = ` - query($after:String!, $viewId:ID!) { - node(id:$viewId) { - ... on ProjectV2View { - id - fields(first:${FIELDS_PAGE_SIZE}, after:$after) { - pageInfo { hasNextPage endCursor } - nodes { ...FieldConfig } - } - } - } - } - ${FIELD_CONFIG_FRAGMENT} - ` - const collected: RawProjectV2Field[] = [] - let cursor: string | null = after - while (cursor !== null) { - const res = await runGraphql<{ - node?: { - id?: string - fields?: { - pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } - nodes?: (RawProjectV2Field | null)[] - } - } | null - }>(query, { viewId, after: cursor }, projectGhExecOptions(host)) - if (!res.ok) { - return res - } - const view = res.data.node ?? null - if (!view) { - return { ok: false, error: driftError('view disappeared during field pagination') } - } - const nodes = (view.fields?.nodes ?? []).filter((f): f is RawProjectV2Field => f !== null) - collected.push(...nodes) - const pi = view.fields?.pageInfo - cursor = pi?.hasNextPage === true && typeof pi.endCursor === 'string' ? pi.endCursor : null - } - return { ok: true, fields: collected } -} - -function finalizeView( - raw: RawProjectView, - extraFields: RawProjectV2Field[] -): { ok: true; view: GitHubProjectView } | { ok: false; drift: GitHubProjectViewError } { - if (typeof raw.id !== 'string' || typeof raw.layout !== 'string') { - return { ok: false, drift: driftError('view missing id or layout') } - } - const layout = raw.layout as GitHubProjectViewLayout - const fields: GitHubProjectField[] = [] - const all = [...(raw.fields?.nodes ?? []), ...extraFields.map((f) => f as RawProjectV2Field)] - for (const f of all) { - const n = normalizeField(f) - if (n) { - fields.push(n) - } - } - const groupByFields: GitHubProjectField[] = [] - for (const f of raw.groupByFields?.nodes ?? []) { - const n = normalizeField(f) - if (n) { - groupByFields.push(n) - } - } - const sortByFields: GitHubProjectSort[] = [] - for (const s of raw.sortByFields?.nodes ?? []) { - if (!s || (s.direction !== 'ASC' && s.direction !== 'DESC')) { - continue - } - const n = normalizeField(s.field) - if (n) { - sortByFields.push({ direction: s.direction, field: n }) - } - } - return { - ok: true, - view: { - id: raw.id, - number: typeof raw.number === 'number' ? raw.number : 0, - name: typeof raw.name === 'string' ? raw.name : '', - layout, - // Why: `ProjectV2View.filter` is nullable — normalize to ''. - filter: typeof raw.filter === 'string' ? raw.filter : '', - fields, - groupByFields, - sortByFields - } - } -} - -// ─── View selection ─────────────────────────────────────────────────── - -function matchesSelector( - raw: RawProjectView, - sel: { viewId?: string; viewNumber?: number; viewName?: string } -): 'none' | 'id' | 'number' | 'name' | 'default' { - if (sel.viewId && raw.id === sel.viewId) { - return 'id' - } - if (sel.viewNumber !== undefined && raw.number === sel.viewNumber) { - return 'number' - } - if (sel.viewName && raw.name === sel.viewName) { - return 'name' - } - if ( - sel.viewId === undefined && - sel.viewNumber === undefined && - sel.viewName === undefined && - raw.layout === 'TABLE_LAYOUT' - ) { - return 'default' - } - return 'none' -} - -// ─── Items fetch (paginated) ────────────────────────────────────────── - -type RawItemsPage = { - totalCount?: number - pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } - nodes?: (RawItem | null)[] -} - -// Why: unlike runGraphql, return the raw GraphQL error envelope so the parent-field retry decision can re-inspect it. -async function fetchItemsPageWithRaw(args: { - owner: string - ownerType: GitHubProjectOwnerType - projectNumber: number - query: string - first: number - after: string | null - includeParent: boolean - host?: string -}): Promise< - | { ok: true; page: RawItemsPage } - | { - ok: false - error: GitHubProjectViewError - rawErrors: GhGraphqlErrorShape[] - stderr: string - } -> { - const authError = await projectHostAuthenticationError(args.host) - if (authError) { - return { ok: false, error: authError, rawErrors: [], stderr: '' } - } - const root = ownerQueryRoot(args.ownerType) - const afterArg = args.after ? `, after: $after` : '' - const afterVar = args.after ? `$after:String!, ` : '' - const query = ` - query(${afterVar}$owner:String!, $num:Int!, $q:String!, $first:Int!) { - ${root}(login:$owner) { - projectV2(number:$num) { - items(first:$first${afterArg}, query:$q, orderBy:{ field: POSITION, direction: ASC }) { - totalCount - pageInfo { hasNextPage endCursor } - nodes { - id - type - updatedAt - content { ${itemContentSelection(args.includeParent)} } - ${FIELD_VALUES_SELECTION} - } - } - } - } - } - ${FIELD_CONFIG_FRAGMENT} - ` - const argsArr: string[] = ['api', 'graphql', '-f', `query=${query}`] - argsArr.push('-f', `owner=${args.owner}`) - argsArr.push('-F', `num=${args.projectNumber}`) - argsArr.push('-f', `q=${args.query}`) - argsArr.push('-F', `first=${args.first}`) - if (args.after) { - argsArr.push('-f', `after=${args.after}`) - } - - // Why: GHES traffic runs against its own quota — only github.com requests - // consult/debit the shared snapshot. - const guard = repositoryRateLimitGuard(args, 'graphql') - if (guard.blocked) { - return { - ok: false, - error: rateLimitedError(guard), - rawErrors: [], - stderr: '' - } - } - await acquire() - noteRepositoryRateLimitSpend(args, 'graphql') - try { - let stdout = '' - let stderr = '' - let execFailed = false - try { - const r = await ghExecFileAsync(argsArr, { - encoding: 'utf-8', - ...projectGhExecOptions(args.host) - }) - stdout = r.stdout - stderr = r.stderr - } catch (err) { - const extracted = extractExecError(err) - stderr = extracted.stderr - stdout = extracted.stdout - execFailed = true - } - let parsed: { data?: Record; errors?: GhGraphqlErrorShape[] } = {} - try { - parsed = JSON.parse(stdout) - } catch { - // Why: gh exited non-zero with unparseable stdout; classify against stderr so callers see the real cause, not a synthesized drift/not-found. - if (execFailed) { - return { - ok: false, - error: classifyProjectError(stderr, stdout, args.host), - rawErrors: [], - stderr - } - } - return { - ok: false, - error: driftError('failed to parse items response'), - rawErrors: [], - stderr - } - } - // Why: gh rejected but stdout parsed; fall through to parsed.errors below, else surface the stderr classification rather than not_found. - if (execFailed && (!parsed.errors || parsed.errors.length === 0) && !parsed.data) { - return { - ok: false, - error: classifyProjectError(stderr, stdout, args.host), - rawErrors: [], - stderr - } - } - if (parsed.errors && parsed.errors.length > 0) { - return { - ok: false, - error: classifyProjectError(stderr, stdout, args.host), - rawErrors: parsed.errors, - stderr - } - } - const top = parsed.data?.[root] as { projectV2?: { items?: RawItemsPage } | null } | undefined - const page = top?.projectV2?.items - if (!page) { - return { - ok: false, - error: { type: 'not_found', message: 'Project or view not found.' }, - rawErrors: [], - stderr - } - } - return { ok: true, page } - } finally { - release() - } -} - -async function fetchAllItems(args: { - owner: string - ownerType: GitHubProjectOwnerType - projectNumber: number - query: string - host?: string -}): Promise< - | { ok: true; rows: GitHubProjectRow[]; totalCount: number; parentFieldDropped: boolean } - | { ok: false; error: GitHubProjectViewError; totalCount?: number } -> { - // Why: isolate missing Issue.parent capability by owner, type, and host. - const scopeKey = ownerScopeKey(args.owner, args.ownerType, args.host) - // Why: await the same-scope probe, then re-read state because it may have changed. - const inFlight = parentFieldProbeInFlight.get(scopeKey) - if (inFlight) { - await inFlight.catch(() => {}) - } - let includeParent = !hasParentFieldRetried(scopeKey) - let parentFieldDropped = !includeParent - // Single-flight the with-parent probe per owner; assign the in-flight promise synchronously (no await between get() and set()) so callers share one probe. - let first: Awaited> - let probePromise: Promise>> | null = null - if (includeParent && !parentFieldProbeInFlight.has(scopeKey)) { - let resolveProbe: () => void = () => {} - const probe = new Promise((resolve) => { - resolveProbe = resolve - }) - parentFieldProbeInFlight.set(scopeKey, probe) - probePromise = (async () => { - try { - const result = await fetchItemsPageWithRaw({ - owner: args.owner, - ownerType: args.ownerType, - projectNumber: args.projectNumber, - query: args.query, - first: ITEM_PAGE_SIZE, - after: null, - includeParent: true, - host: args.host - }) - // Why: set the retried flag BEFORE resolving/clearing the probe so siblings awoken on inFlight.catch() see it and don't fire duplicate with-parent probes. - if (!result.ok && errorsIndicateParentField(result.rawErrors, result.stderr)) { - markParentFieldRetried(scopeKey) - } - return result - } finally { - resolveProbe() - parentFieldProbeInFlight.delete(scopeKey) - } - })() - first = await probePromise - } else { - first = await fetchItemsPageWithRaw({ - owner: args.owner, - ownerType: args.ownerType, - projectNumber: args.projectNumber, - query: args.query, - first: ITEM_PAGE_SIZE, - after: null, - includeParent, - host: args.host - }) - } - if (!first.ok && includeParent && errorsIndicateParentField(first.rawErrors, first.stderr)) { - // Retry the whole table without parent; mark this owner retried so later fetches skip the probe (other owners unaffected). - markParentFieldRetried(scopeKey) - includeParent = false - parentFieldDropped = true - if (!hasParentFieldWarningLogged(scopeKey)) { - console.warn( - `[project-view] Issue.parent is not available for ${args.owner} on this token — retrying without the parent selection.` - ) - markParentFieldWarningLogged(scopeKey) - } - first = await fetchItemsPageWithRaw({ - owner: args.owner, - ownerType: args.ownerType, - projectNumber: args.projectNumber, - query: args.query, - first: ITEM_PAGE_SIZE, - after: null, - includeParent: false, - host: args.host - }) - } - if (!first.ok) { - return { ok: false, error: first.error } - } - - // Drift guards - if (first.page.totalCount === undefined || first.page.totalCount === null) { - return { ok: false, error: driftError('items.totalCount missing') } - } - const totalCount = first.page.totalCount - if (first.page.pageInfo?.hasNextPage === undefined) { - return { ok: false, error: driftError('items.pageInfo.hasNextPage missing'), totalCount } - } - if (!Array.isArray(first.page.nodes)) { - return { ok: false, error: driftError('items.nodes missing'), totalCount } - } - - // Size cap - if (totalCount > MAX_ITEMS) { - return { - ok: false, - error: { type: 'too_large', message: `View has ${totalCount} items.` }, - totalCount - } - } - - const rows: GitHubProjectRow[] = [] - let position = 0 - const appendNodes = (nodes: (RawItem | null)[]): GitHubProjectViewError | null => { - for (const n of nodes) { - if (!n) { - continue - } - const norm = normalizeItem(n, position) - if (!norm.ok) { - return norm.drift - } - rows.push(norm.row) - position++ - } - return null - } - const e1 = appendNodes(first.page.nodes) - if (e1) { - return { ok: false, error: e1, totalCount } - } - - // Paginate - let hasNext = first.page.pageInfo.hasNextPage === true - let cursor: string | null | undefined = first.page.pageInfo.endCursor - if (hasNext && typeof cursor !== 'string') { - return { - ok: false, - error: driftError('items.pageInfo.endCursor missing with hasNextPage=true'), - totalCount - } - } - while (hasNext) { - const next = await fetchItemsPageWithRaw({ - owner: args.owner, - ownerType: args.ownerType, - projectNumber: args.projectNumber, - query: args.query, - first: ITEM_PAGE_SIZE, - after: cursor as string, - includeParent, - host: args.host - }) - if (!next.ok) { - return { ok: false, error: next.error, totalCount } - } - if (!Array.isArray(next.page.nodes)) { - return { ok: false, error: driftError('items.nodes missing on follow page'), totalCount } - } - if (next.page.pageInfo?.hasNextPage === undefined) { - return { - ok: false, - error: driftError('items.pageInfo.hasNextPage missing on follow page'), - totalCount - } - } - const e2 = appendNodes(next.page.nodes) - if (e2) { - return { ok: false, error: e2, totalCount } - } - hasNext = next.page.pageInfo.hasNextPage === true - cursor = next.page.pageInfo.endCursor - if (hasNext && typeof cursor !== 'string') { - return { - ok: false, - error: driftError('items.pageInfo.endCursor missing with hasNextPage=true'), - totalCount - } - } - } - return { ok: true, rows, totalCount, parentFieldDropped } -} - -// ─── Cheap count-only query (for unsupported_layout) ────────────────── - -async function fetchItemsCountOnly(args: { - owner: string - ownerType: GitHubProjectOwnerType - projectNumber: number - query: string - host?: string -}): Promise { - const root = ownerQueryRoot(args.ownerType) - const query = ` - query($owner:String!, $num:Int!, $q:String!) { - ${root}(login:$owner) { - projectV2(number:$num) { - items(first:1, query:$q) { totalCount } - } - } - } - ` - const res = await runGraphql< - Record - >( - query, - { owner: args.owner, num: args.projectNumber, q: args.query }, - projectGhExecOptions(args.host) - ) - if (!res.ok) { - return null - } - const count = res.data[root]?.projectV2?.items?.totalCount - return typeof count === 'number' ? count : null -} - -// ─── Public: getProjectViewTable ────────────────────────────────────── - -export async function getProjectViewTable( - args: GetProjectViewTableArgs -): Promise { - const ownerCheck = assertSlug(args.owner, 'owner') - if (!ownerCheck.ok) { - return { ok: false, error: ownerCheck.error } - } - const numCheck = assertPositiveInt(args.projectNumber, 'projectNumber') - if (!numCheck.ok) { - return { ok: false, error: numCheck.error } - } - if (args.ownerType !== 'organization' && args.ownerType !== 'user') { - return { - ok: false, - error: { type: 'validation_error', message: 'Invalid ownerType.' } - } - } - - // Paginate views until a match is found. - let cursor: string | null = null - let project: { id: string; title: string; url: string } | null = null - let selectedRaw: RawProjectView | null = null - let matchStrength: 'id' | 'number' | 'name' | 'default' | null = null - const viewsSeen: RawProjectView[] = [] - while (true) { - const page = await fetchProjectViewsPage({ - owner: args.owner, - ownerType: args.ownerType, - projectNumber: args.projectNumber, - host: args.host, - after: cursor - }) - if (!page.ok) { - return { ok: false, error: page.error } - } - project = page.project - for (const v of page.views) { - viewsSeen.push(v) - const m = matchesSelector(v, { - viewId: args.viewId, - viewNumber: args.viewNumber, - viewName: args.viewName - }) - if (m === 'none') { - continue - } - // Precedence: id > number > name > default. - const rank: Record = { id: 4, number: 3, name: 2, default: 1 } - const currentRank = matchStrength ? rank[matchStrength] : 0 - if (!selectedRaw || rank[m] > currentRank) { - selectedRaw = v - matchStrength = m - } - } - // Why: stop on ANY match (incl. 'default' = first table view); walking further pages costs a GraphQL call per page with no re-ranking upside. - if (selectedRaw) { - break - } - if (!page.hasNextPage) { - break - } - cursor = page.endCursor - if (typeof cursor !== 'string') { - break - } - } - if (!project) { - return { ok: false, error: { type: 'not_found', message: 'Project not found.' } } - } - if (!selectedRaw) { - return { ok: false, error: { type: 'not_found', message: 'Could not find the selected view.' } } - } - - // Paginate view fields if necessary. - let extraFields: RawProjectV2Field[] = [] - const fieldsPi = selectedRaw.fields?.pageInfo - if (fieldsPi?.hasNextPage === true && typeof fieldsPi.endCursor === 'string' && selectedRaw.id) { - const cont = await fetchViewFieldsContinuation(selectedRaw.id, fieldsPi.endCursor, args.host) - if (!cont.ok) { - return { ok: false, error: cont.error } - } - extraFields = cont.fields - } - - const finalized = finalizeView(selectedRaw, extraFields) - if (!finalized.ok) { - return { ok: false, error: finalized.drift } - } - const selectedView = finalized.view - - // Why: empty-string override means "no filter"; undefined means "use the view's stored filter". The override is ephemeral, never persisted. - const effectiveQuery = - typeof args.queryOverride === 'string' ? args.queryOverride : selectedView.filter - - // Unsupported layout: skip item pagination; best-effort count-only query. - if (selectedView.layout !== 'TABLE_LAYOUT') { - const count = await fetchItemsCountOnly({ - owner: args.owner, - ownerType: args.ownerType, - projectNumber: args.projectNumber, - query: effectiveQuery, - host: args.host - }) - return { - ok: false, - error: { - type: 'unsupported_layout', - message: `Orca only renders table views. This is a ${selectedView.layout.replace('_LAYOUT', '').toLowerCase()} view.` - }, - ...(typeof count === 'number' ? { totalCount: count } : {}) - } - } - - // Fetch items. - const items = await fetchAllItems({ - owner: args.owner, - ownerType: args.ownerType, - projectNumber: args.projectNumber, - query: effectiveQuery, - host: args.host - }) - if (!items.ok) { - return { - ok: false, - error: items.error, - ...(typeof items.totalCount === 'number' ? { totalCount: items.totalCount } : {}) - } - } - - const table: GitHubProjectTable = { - project: { - id: project.id, - host: githubProjectHost(args.host), - owner: args.owner, - ownerType: args.ownerType, - number: args.projectNumber, - title: project.title, - url: project.url - }, - selectedView, - rows: items.rows, - totalCount: items.totalCount, - parentFieldDropped: items.parentFieldDropped - } - return { ok: true, data: table } -} - -// ─── listAccessibleProjects ──────────────────────────────────────────── - -type RawViewerDiscovery = { - viewer?: { - login?: string - projectsV2?: { - pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } - nodes?: ({ - id?: string - number?: number - title?: string - url?: string - owner?: { __typename?: string; login?: string } - } | null)[] - } - organizations?: { - pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } - nodes?: ({ - login?: string - projectsV2?: { - pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } - nodes?: ({ id?: string; number?: number; title?: string; url?: string } | null)[] - } - } | null)[] - } - } -} - -export async function listAccessibleProjects( - args?: ListAccessibleProjectsArgs -): Promise { - const host = githubProjectHost(args?.host) - const viewerProjects: GitHubProjectSummary[] = [] - const orgProjects: GitHubProjectSummary[] = [] - // Why: collect per-org failures so the picker shows a "some orgs didn't load" banner instead of aborting discovery on the first 504. - const partialFailures: { owner: string; message: string }[] = [] - let viewerLogin: string | null = null - - // 1) Viewer projects (paginated, single owner so cap at DISCOVERY_PROJECTS_PER_OWNER total). - let viewerCursor: string | null = null - let viewerMore = true - let viewerFetched = 0 - while (viewerMore && viewerFetched < DISCOVERY_PROJECTS_PER_OWNER) { - const afterArg = viewerCursor ? ', after: $after' : '' - const afterVar = viewerCursor ? '$after:String!' : '' - const query = ` - query${afterVar ? `(${afterVar})` : ''} { - viewer { - login - projectsV2(first:${DISCOVERY_PROJECTS_PER_ORG}${afterArg}) { - pageInfo { hasNextPage endCursor } - nodes { - id number title url - owner { __typename ... on Organization { login } ... on User { login } } - } - } - } - } - ` - const vars: GraphqlVars = {} - if (viewerCursor) { - vars.after = viewerCursor - } - const res = await runGraphql(query, vars, projectGhExecOptions(host)) - if (!res.ok) { - // Why: viewer-level failure is structural (no projects to build on), so propagate hard; org-level errors below are non-fatal. - return { ok: false, error: res.error } - } - if (!res.data.viewer) { - return { ok: false, error: driftError('viewer missing') } - } - if (viewerLogin === null) { - viewerLogin = res.data.viewer.login ?? null - } - const nodes = res.data.viewer.projectsV2?.nodes ?? [] - for (const n of nodes) { - if (!n || typeof n.id !== 'string' || typeof n.number !== 'number') { - continue - } - const ownerLogin = n.owner?.login ?? viewerLogin ?? '' - const ownerType: GitHubProjectOwnerType = - n.owner?.__typename === 'Organization' ? 'organization' : 'user' - viewerProjects.push({ - id: n.id, - host, - owner: ownerLogin, - ownerType, - number: n.number, - title: n.title ?? '', - url: n.url ?? '', - source: 'viewer' - }) - viewerFetched++ - if (viewerFetched >= DISCOVERY_PROJECTS_PER_OWNER) { - break - } - } - const pi = res.data.viewer.projectsV2?.pageInfo - viewerMore = pi?.hasNextPage === true && typeof pi.endCursor === 'string' - viewerCursor = viewerMore ? (pi?.endCursor ?? null) : null - } - - // 2) Organizations the viewer belongs to, each with its projectsV2. - // Why: no per-org projectsV2 continuation loop — it was the dominant 504 cost; users past the cap can paste a URL instead. - let orgCursor: string | null = null - let orgMore = true - let orgsSeen = 0 - while (orgMore && orgsSeen < DISCOVERY_MAX_ORGS) { - const afterArg = orgCursor ? ', after: $orgAfter' : '' - const afterVar = orgCursor ? '$orgAfter:String!' : '' - const query = ` - query${afterVar ? `(${afterVar})` : ''} { - viewer { - organizations(first:${DISCOVERY_ORG_PAGE_SIZE}${afterArg}) { - pageInfo { hasNextPage endCursor } - nodes { - login - projectsV2(first:${DISCOVERY_PROJECTS_PER_ORG}) { - pageInfo { hasNextPage endCursor } - nodes { id number title url } - } - } - } - } - } - ` - const vars: GraphqlVars = {} - if (orgCursor) { - vars.orgAfter = orgCursor - } - const res = await runGraphql(query, vars, projectGhExecOptions(host)) - if (!res.ok) { - // Why: org-listing failed; record a synthetic '*' partial failure so the banner explains it, but keep collected viewer projects (the reported 504 path). - partialFailures.push({ owner: '*', message: res.error.message }) - break - } - const orgs = res.data.viewer?.organizations?.nodes ?? [] - for (const org of orgs) { - if (!org || typeof org.login !== 'string') { - continue - } - if (orgsSeen >= DISCOVERY_MAX_ORGS) { - break - } - orgsSeen++ - const login = org.login - // Cache for paste/resolve even when the nested projects query was empty or partially failed. - rememberOwnerType(login, 'organization', host) - const nodes = org.projectsV2?.nodes ?? [] - let ownerCount = 0 - for (const n of nodes) { - if (!n || typeof n.id !== 'string' || typeof n.number !== 'number') { - continue - } - if (ownerCount >= DISCOVERY_PROJECTS_PER_OWNER) { - break - } - orgProjects.push({ - id: n.id, - host, - owner: login, - ownerType: 'organization', - number: n.number, - title: n.title ?? '', - url: n.url ?? '', - source: `org:${login}` - }) - ownerCount++ - } - } - const pi = res.data.viewer?.organizations?.pageInfo - orgMore = pi?.hasNextPage === true && typeof pi.endCursor === 'string' - orgCursor = orgMore ? (pi?.endCursor ?? null) : null - } - - if (viewerLogin) { - rememberOwnerType(viewerLogin, 'user', host) - } - - return { - ok: true, - projects: [...viewerProjects, ...orgProjects], - ...(partialFailures.length > 0 ? { partialFailures } : {}) - } -} - -// ─── resolveProjectRef ───────────────────────────────────────────────── - -type ParsedPaste = - | { kind: 'org'; owner: string; number: number; host: string; viewNumber?: number } - | { kind: 'user'; owner: string; number: number; host: string; viewNumber?: number } - | { kind: 'bare'; owner: string; number: number } - -export function parseProjectPaste(input: string, host?: string): ParsedPaste | null { - const trimmed = input.trim() - if (!trimmed) { - return null - } - if (isGitHubProjectRefInputTooLarge(trimmed)) { - return null - } - // Why: URL parsing enforces an exact Project path and rejects credentials; - // a prefix regex could silently turn `/projects/1evil` into Project 1. - try { - const url = new URL(trimmed) - const allowedHosts = new Set(['github.com', ...(host ? [host.trim().toLowerCase()] : [])]) - const parts = url.pathname.split('/').filter(Boolean) - const hasView = parts.length === 6 && parts[4] === 'views' - if ( - (url.protocol !== 'https:' && url.protocol !== 'http:') || - url.username || - url.password || - !allowedHosts.has(url.host.toLowerCase()) || - (parts[0] !== 'orgs' && parts[0] !== 'users') || - !isValidOwnerSlug(parts[1]) || - parts[2] !== 'projects' || - (parts.length !== 4 && !hasView) - ) { - return null - } - const number = Number(parts[3]) - const viewNumber = hasView ? Number(parts[5]) : undefined - if ( - !Number.isSafeInteger(number) || - number < 1 || - (hasView && (!Number.isSafeInteger(viewNumber) || (viewNumber ?? 0) < 1)) - ) { - return null - } - return { - kind: parts[0] === 'orgs' ? 'org' : 'user', - owner: parts[1], - number, - host: url.host.toLowerCase(), - ...(viewNumber !== undefined ? { viewNumber } : {}) - } - } catch { - // Shorthand parsing below remains available for non-URL input. - } - // owner/number shorthand — owner alphabet matches OWNER_SLUG_RE. - const shortRe = /^([A-Za-z0-9][A-Za-z0-9-]*)\/(\d+)$/ - const sm = trimmed.match(shortRe) - if (sm) { - const number = Number.parseInt(sm[2], 10) - if (!Number.isInteger(number) || number < 1) { - return null - } - return { kind: 'bare', owner: sm[1], number } - } - return null -} - -async function resolveOwnerType( - owner: string, - preferred: GitHubProjectOwnerType | null, - host?: string -): Promise< - | { ok: true; ownerType: GitHubProjectOwnerType; title: string } - | { ok: false; error: GitHubProjectViewError } -> { - const tryOne = async ( - ot: GitHubProjectOwnerType, - num: number | null - ): Promise<{ ok: true; title: string } | { ok: false; error: GitHubProjectViewError }> => { - const root = ownerQueryRoot(ot) - // If number is provided, fetch the project title; else just confirm owner exists. - const query = num - ? ` - query($owner:String!, $num:Int!) { - ${root}(login:$owner) { projectV2(number:$num) { id title } } - } - ` - : ` - query($owner:String!) { - ${root}(login:$owner) { login } - } - ` - const vars: GraphqlVars = { owner } - if (num) { - vars.num = num - } - const res = await runGraphql< - Record - >(query, vars, projectGhExecOptions(host)) - if (!res.ok) { - return { ok: false, error: res.error } - } - const top = res.data[root] - if (!top) { - return { ok: false, error: { type: 'not_found', message: 'Owner not found.' } } - } - if (num) { - const p = top.projectV2 - if (!p || typeof p.id !== 'string') { - return { ok: false, error: { type: 'not_found', message: 'Project not found.' } } - } - return { ok: true, title: p.title ?? '' } - } - return { ok: true, title: '' } - } - - const cached = getCachedOwnerType(owner, host) - const candidates: GitHubProjectOwnerType[] = preferred - ? [preferred] - : cached - ? [cached] - : ['organization', 'user'] - const fallback: GitHubProjectOwnerType[] = preferred - ? [] - : cached - ? cached === 'organization' - ? ['user'] - : ['organization'] - : [] - const ordered = [...candidates, ...fallback] - let lastError: GitHubProjectViewError | null = null - for (const ot of ordered) { - const r = await tryOne(ot, null) - if (r.ok) { - rememberOwnerType(owner, ot, host) - return { ok: true, ownerType: ot, title: r.title } - } - lastError = r.error - if (r.error.type !== 'not_found') { - // Non-NOT_FOUND errors (auth, network, rate) should not trigger fallback. - return { ok: false, error: r.error } - } - } - rememberOwnerType(owner, null, host) - return { - ok: false, - error: lastError ?? { type: 'not_found', message: 'Owner not found.' } - } -} - -export async function resolveProjectRef( - args: ResolveProjectRefArgs -): Promise { - const input = typeof args.input === 'string' ? args.input.trim() : '' - if (!input) { - return { - ok: false, - error: { type: 'validation_error', message: 'Input required.' } - } - } - if (isGitHubProjectRefInputTooLarge(input)) { - return { - ok: false, - error: { type: 'validation_error', message: GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR } - } - } - const parsed = parseProjectPaste(input, args.host) - if (!parsed) { - return { - ok: false, - error: { - type: 'validation_error', - message: 'Could not parse input. Expected a GitHub project URL or `owner/number`.' - } - } - } - const preferred: GitHubProjectOwnerType | null = - parsed.kind === 'org' ? 'organization' : parsed.kind === 'user' ? 'user' : null - // Why: a pasted URL is authoritative. The ambient host only applies to - // owner/number shorthand; otherwise same-number Projects can cross hosts. - const executionHost = parsed.kind === 'bare' ? githubProjectHost(args.host) : parsed.host - // Verify by fetching project title. - const ownerRes = await resolveOwnerType(parsed.owner, preferred, executionHost) - if (!ownerRes.ok) { - return { ok: false, error: ownerRes.error } - } - const ownerType = ownerRes.ownerType - const root = ownerQueryRoot(ownerType) - const query = ` - query($owner:String!, $num:Int!) { - ${root}(login:$owner) { projectV2(number:$num) { id title } } - } - ` - const res = await runGraphql< - Record - >(query, { owner: parsed.owner, num: parsed.number }, projectGhExecOptions(executionHost)) - if (!res.ok) { - return { ok: false, error: res.error } - } - const p = res.data[root]?.projectV2 - if (!p || typeof p.id !== 'string') { - return { ok: false, error: { type: 'not_found', message: 'Project not found.' } } - } - return { - ok: true, - owner: parsed.owner, - ownerType, - number: parsed.number, - title: p.title ?? '', - host: executionHost, - // Why: forward URL view numbers so the renderer can skip view selection; bare shorthand has none. - ...(parsed.kind !== 'bare' && parsed.viewNumber !== undefined - ? { viewNumber: parsed.viewNumber } - : {}) - } -} - -// ─── listProjectViews ────────────────────────────────────────────────── - -export async function listProjectViews( - args: ListProjectViewsArgs -): Promise { - const ownerCheck = assertSlug(args.owner, 'owner') - if (!ownerCheck.ok) { - return { ok: false, error: ownerCheck.error } - } - const numCheck = assertPositiveInt(args.projectNumber, 'projectNumber') - if (!numCheck.ok) { - return { ok: false, error: numCheck.error } - } - if (args.ownerType !== 'organization' && args.ownerType !== 'user') { - return { ok: false, error: { type: 'validation_error', message: 'Invalid ownerType.' } } - } - const summaries: GitHubProjectViewSummary[] = [] - let cursor: string | null = null - while (true) { - const page = await fetchProjectViewsPage({ - owner: args.owner, - ownerType: args.ownerType, - projectNumber: args.projectNumber, - host: args.host, - after: cursor - }) - if (!page.ok) { - return { ok: false, error: page.error } - } - for (const v of page.views) { - if (typeof v.id !== 'string' || typeof v.layout !== 'string') { - continue - } - summaries.push({ - id: v.id, - number: typeof v.number === 'number' ? v.number : 0, - name: typeof v.name === 'string' ? v.name : '', - layout: v.layout as GitHubProjectViewLayout - }) - } - if (!page.hasNextPage) { - break - } - cursor = page.endCursor - if (typeof cursor !== 'string') { - break - } - } - return { ok: true, views: summaries } -} +export { + PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES, + _resetProjectViewCachesForTests, + _getProjectViewCacheSizesForTests, + _rememberProjectViewOwnerTypeForTests, + _getProjectViewOwnerTypeForTests, + _markProjectViewParentFieldRetriedForTests, + _hasProjectViewParentFieldRetriedForTests, + _markProjectViewParentFieldWarningLoggedForTests, + _hasProjectViewParentFieldWarningLoggedForTests +} from './project-view/project-view-cache' +export { + normalizeField, + normalizeFieldValue +} from './project-view/project-view-field-normalization' +export { normalizeItem } from './project-view/project-view-item-normalization' +export { getProjectViewTable } from './project-view/project-view-table' +export { listAccessibleProjects } from './project-view/project-view-discovery' +export { parseProjectPaste, resolveProjectRef } from './project-view/project-view-reference' +export { listProjectViews } from './project-view/project-view-listing' diff --git a/src/main/github/project-view/project-view-cache.ts b/src/main/github/project-view/project-view-cache.ts new file mode 100644 index 00000000000..571e5318dc8 --- /dev/null +++ b/src/main/github/project-view/project-view-cache.ts @@ -0,0 +1,149 @@ +import type { GitHubProjectOwnerType } from '../../../shared/github/project-types' +import { githubProjectHost } from '../../../shared/github/project-identity' + +export const PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES = 512 + +// ─── Module-scope caches (reset on HMR — intentional) ────────────────── + +// Why: owners are user-controlled over a long session; bound cache entries to avoid unbounded retention while keeping the hot-owner fast path. +function rememberProjectViewCacheEntry( + cache: Map, + key: K, + value: V, + maxEntries = PROJECT_VIEW_OWNER_CACHE_MAX_ENTRIES +): void { + if (cache.has(key)) { + cache.delete(key) + } + cache.set(key, value) + while (cache.size > maxEntries) { + const oldest = cache.keys().next() + if (oldest.done) { + break + } + cache.delete(oldest.value) + } +} + +function getProjectViewCacheEntry(cache: Map, key: K): V | undefined { + if (!cache.has(key)) { + return undefined + } + const value = cache.get(key) as V + rememberProjectViewCacheEntry(cache, key, value) + return value +} + +// Why: plain module locals so HMR code swaps re-run capability probes instead of carrying a stale "unsupported" flag. +const ownerTypeCache = new Map() +// Why: keyed per owner (not a process-global flag) so one owner's capability gap doesn't poison others that DO support Issue.parent (bug-scan finding 2). +const parentFieldRetriedByOwner = new Map() +const parentFieldWarningLoggedByOwner = new Map() +// Why: in-flight promise per owner so concurrent fetchAllItems callers share one probe instead of each racing a duplicate first-page probe. +export const parentFieldProbeInFlight = new Map>() + +// Why: GHES owners are a separate namespace and capability surface from +// github.com owners with the same login — scope cache keys by host so one +// host's probe result can't leak into another. Normalize github.com so +// host-less callers share the same probe state as explicitly pinned calls. +export function ownerScopeKey( + owner: string, + ownerType: GitHubProjectOwnerType, + host?: string +): string { + const base = `${owner}\u0000${ownerType}` + return `${base}\u0000${githubProjectHost(host)}` +} + +function ownerTypeCacheKey(owner: string, host?: string): string { + return `${owner}\u0000${githubProjectHost(host)}` +} + +export function rememberOwnerType( + owner: string, + ownerType: GitHubProjectOwnerType | null, + host?: string +): void { + rememberProjectViewCacheEntry(ownerTypeCache, ownerTypeCacheKey(owner, host), ownerType) +} + +export function getCachedOwnerType( + owner: string, + host?: string +): GitHubProjectOwnerType | null | undefined { + return getProjectViewCacheEntry(ownerTypeCache, ownerTypeCacheKey(owner, host)) +} + +export function markParentFieldRetried(scopeKey: string): void { + rememberProjectViewCacheEntry(parentFieldRetriedByOwner, scopeKey, true) +} + +export function hasParentFieldRetried(scopeKey: string): boolean { + return getProjectViewCacheEntry(parentFieldRetriedByOwner, scopeKey) === true +} + +export function markParentFieldWarningLogged(scopeKey: string): void { + rememberProjectViewCacheEntry(parentFieldWarningLoggedByOwner, scopeKey, true) +} + +export function hasParentFieldWarningLogged(scopeKey: string): boolean { + return getProjectViewCacheEntry(parentFieldWarningLoggedByOwner, scopeKey) === true +} + +export function _resetProjectViewCachesForTests(): void { + ownerTypeCache.clear() + parentFieldRetriedByOwner.clear() + parentFieldWarningLoggedByOwner.clear() + parentFieldProbeInFlight.clear() +} + +export function _getProjectViewCacheSizesForTests(): { + ownerTypes: number + parentFieldRetries: number + parentFieldWarnings: number + parentFieldProbes: number +} { + return { + ownerTypes: ownerTypeCache.size, + parentFieldRetries: parentFieldRetriedByOwner.size, + parentFieldWarnings: parentFieldWarningLoggedByOwner.size, + parentFieldProbes: parentFieldProbeInFlight.size + } +} + +/** @internal - exposed for cache-bound tests only. */ +export function _rememberProjectViewOwnerTypeForTests( + owner: string, + ownerType: GitHubProjectOwnerType | null, + host?: string +): void { + rememberOwnerType(owner, ownerType, host) +} + +/** @internal - exposed for cache-bound tests only. */ +export function _getProjectViewOwnerTypeForTests( + owner: string, + host?: string +): GitHubProjectOwnerType | null | undefined { + return getCachedOwnerType(owner, host) +} + +/** @internal - exposed for cache-bound tests only. */ +export function _markProjectViewParentFieldRetriedForTests(scopeKey: string): void { + markParentFieldRetried(scopeKey) +} + +/** @internal - exposed for cache-bound tests only. */ +export function _hasProjectViewParentFieldRetriedForTests(scopeKey: string): boolean { + return hasParentFieldRetried(scopeKey) +} + +/** @internal - exposed for cache-bound tests only. */ +export function _markProjectViewParentFieldWarningLoggedForTests(scopeKey: string): void { + markParentFieldWarningLogged(scopeKey) +} + +/** @internal - exposed for cache-bound tests only. */ +export function _hasProjectViewParentFieldWarningLoggedForTests(scopeKey: string): boolean { + return hasParentFieldWarningLogged(scopeKey) +} diff --git a/src/main/github/project-view/project-view-config.ts b/src/main/github/project-view/project-view-config.ts new file mode 100644 index 00000000000..794e7861bf9 --- /dev/null +++ b/src/main/github/project-view/project-view-config.ts @@ -0,0 +1,242 @@ +import type { + GitHubProjectField, + GitHubProjectOwnerType, + GitHubProjectSort, + GitHubProjectView, + GitHubProjectViewLayout +} from '../../../shared/github/project-types' +import type { GitHubProjectViewError } from '../../../shared/github/project-result-types' +import { driftError } from './project-error-classification' +import { projectGhExecOptions, runGraphql, type GraphqlVars } from './internals' +import { normalizeField, type RawProjectV2Field } from './project-view-field-normalization' +import { FIELD_CONFIG_FRAGMENT } from './project-view-query-fragments' + +const VIEWS_PAGE_SIZE = 20 +const FIELDS_PAGE_SIZE = 50 + +// ─── Project config fetch (views + fields, paginated) ────────────────── + +type RawProjectConfig = { + id?: string + title?: string + url?: string + views?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } + nodes?: (RawProjectView | null)[] + } +} + +export type RawProjectView = { + id?: string + number?: number + name?: string + layout?: string + filter?: string | null + fields?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } + nodes?: (RawProjectV2Field | null)[] + } + groupByFields?: { nodes?: (RawProjectV2Field | null)[] } + sortByFields?: { + nodes?: ({ direction?: string; field?: RawProjectV2Field | null } | null)[] + } +} + +export function ownerQueryRoot(ownerType: GitHubProjectOwnerType): string { + return ownerType === 'organization' ? 'organization' : 'user' +} + +export async function fetchProjectViewsPage(args: { + owner: string + ownerType: GitHubProjectOwnerType + projectNumber: number + host?: string + after: string | null +}): Promise< + | { + ok: true + project: { id: string; title: string; url: string } + views: RawProjectView[] + hasNextPage: boolean + endCursor: string | null + } + | { ok: false; error: GitHubProjectViewError } +> { + const root = ownerQueryRoot(args.ownerType) + const afterArg = args.after ? `, after: $after` : '' + const afterVar = args.after ? `$after:String!, ` : '' + const query = ` + query(${afterVar}$owner:String!, $num:Int!) { + ${root}(login:$owner) { + projectV2(number:$num) { + id title url + views(first:${VIEWS_PAGE_SIZE}${afterArg}) { + pageInfo { hasNextPage endCursor } + nodes { + id number name layout filter + fields(first:${FIELDS_PAGE_SIZE}) { + pageInfo { hasNextPage endCursor } + nodes { ...FieldConfig } + } + groupByFields(first:10) { nodes { ...FieldConfig } } + sortByFields(first:10) { + nodes { direction field { ...FieldConfig } } + } + } + } + } + } + } + ${FIELD_CONFIG_FRAGMENT} + ` + const vars: GraphqlVars = { owner: args.owner, num: args.projectNumber } + if (args.after) { + vars.after = args.after + } + const res = await runGraphql>( + query, + vars, + projectGhExecOptions(args.host) + ) + if (!res.ok) { + return res + } + const top = res.data[root] + const project = top?.projectV2 ?? null + if (!project || typeof project.id !== 'string') { + return { ok: false, error: { type: 'not_found', message: 'Project not found.' } } + } + const pageInfo = project.views?.pageInfo + const views = (project.views?.nodes ?? []).filter((v): v is RawProjectView => v !== null) + return { + ok: true, + project: { id: project.id, title: project.title ?? '', url: project.url ?? '' }, + views, + hasNextPage: pageInfo?.hasNextPage === true, + endCursor: pageInfo?.endCursor ?? null + } +} + +export async function fetchViewFieldsContinuation( + viewId: string, + after: string, + host?: string +): Promise< + { ok: true; fields: RawProjectV2Field[] } | { ok: false; error: GitHubProjectViewError } +> { + // Why: address the view directly via node(id:) instead of re-walking all views each page — one round-trip per field page. + const query = ` + query($after:String!, $viewId:ID!) { + node(id:$viewId) { + ... on ProjectV2View { + id + fields(first:${FIELDS_PAGE_SIZE}, after:$after) { + pageInfo { hasNextPage endCursor } + nodes { ...FieldConfig } + } + } + } + } + ${FIELD_CONFIG_FRAGMENT} + ` + const collected: RawProjectV2Field[] = [] + let cursor: string | null = after + while (cursor !== null) { + const res = await runGraphql<{ + node?: { + id?: string + fields?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } + nodes?: (RawProjectV2Field | null)[] + } + } | null + }>(query, { viewId, after: cursor }, projectGhExecOptions(host)) + if (!res.ok) { + return res + } + const view = res.data.node ?? null + if (!view) { + return { ok: false, error: driftError('view disappeared during field pagination') } + } + const nodes = (view.fields?.nodes ?? []).filter((f): f is RawProjectV2Field => f !== null) + collected.push(...nodes) + const pi = view.fields?.pageInfo + cursor = pi?.hasNextPage === true && typeof pi.endCursor === 'string' ? pi.endCursor : null + } + return { ok: true, fields: collected } +} + +export function finalizeView( + raw: RawProjectView, + extraFields: RawProjectV2Field[] +): { ok: true; view: GitHubProjectView } | { ok: false; drift: GitHubProjectViewError } { + if (typeof raw.id !== 'string' || typeof raw.layout !== 'string') { + return { ok: false, drift: driftError('view missing id or layout') } + } + const layout = raw.layout as GitHubProjectViewLayout + const fields: GitHubProjectField[] = [] + const all = [...(raw.fields?.nodes ?? []), ...extraFields.map((f) => f as RawProjectV2Field)] + for (const f of all) { + const n = normalizeField(f) + if (n) { + fields.push(n) + } + } + const groupByFields: GitHubProjectField[] = [] + for (const f of raw.groupByFields?.nodes ?? []) { + const n = normalizeField(f) + if (n) { + groupByFields.push(n) + } + } + const sortByFields: GitHubProjectSort[] = [] + for (const s of raw.sortByFields?.nodes ?? []) { + if (!s || (s.direction !== 'ASC' && s.direction !== 'DESC')) { + continue + } + const n = normalizeField(s.field) + if (n) { + sortByFields.push({ direction: s.direction, field: n }) + } + } + return { + ok: true, + view: { + id: raw.id, + number: typeof raw.number === 'number' ? raw.number : 0, + name: typeof raw.name === 'string' ? raw.name : '', + layout, + // Why: `ProjectV2View.filter` is nullable — normalize to ''. + filter: typeof raw.filter === 'string' ? raw.filter : '', + fields, + groupByFields, + sortByFields + } + } +} + +// ─── View selection ─────────────────────────────────────────────────── + +export function matchesSelector( + raw: RawProjectView, + sel: { viewId?: string; viewNumber?: number; viewName?: string } +): 'none' | 'id' | 'number' | 'name' | 'default' { + if (sel.viewId && raw.id === sel.viewId) { + return 'id' + } + if (sel.viewNumber !== undefined && raw.number === sel.viewNumber) { + return 'number' + } + if (sel.viewName && raw.name === sel.viewName) { + return 'name' + } + if ( + sel.viewId === undefined && + sel.viewNumber === undefined && + sel.viewName === undefined && + raw.layout === 'TABLE_LAYOUT' + ) { + return 'default' + } + return 'none' +} diff --git a/src/main/github/project-view/project-view-discovery.ts b/src/main/github/project-view/project-view-discovery.ts new file mode 100644 index 00000000000..8fdd47c97ac --- /dev/null +++ b/src/main/github/project-view/project-view-discovery.ts @@ -0,0 +1,204 @@ +import type { ListAccessibleProjectsArgs } from '../../../shared/github/project-request-types' +import type { ListAccessibleProjectsResult } from '../../../shared/github/project-result-types' +import type { + GitHubProjectOwnerType, + GitHubProjectSummary +} from '../../../shared/github/project-types' +import { githubProjectHost } from '../../../shared/github/project-identity' +import { projectGhExecOptions, runGraphql, type GraphqlVars } from './internals' +import { driftError } from './project-error-classification' +import { rememberOwnerType } from './project-view-cache' + +// ─── Constants ───────────────────────────────────────────────────────── + +// Why: defaults deliberately shrunk to cut quota spend in discovery — the org loop dominates and produced the HTTP 504; overflow owners can paste a URL. +const DISCOVERY_PROJECTS_PER_OWNER = 40 +const DISCOVERY_MAX_ORGS = 20 +const DISCOVERY_ORG_PAGE_SIZE = 20 +const DISCOVERY_PROJECTS_PER_ORG = 20 + +type RawViewerDiscovery = { + viewer?: { + login?: string + projectsV2?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } + nodes?: ({ + id?: string + number?: number + title?: string + url?: string + owner?: { __typename?: string; login?: string } + } | null)[] + } + organizations?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } + nodes?: ({ + login?: string + projectsV2?: { + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } + nodes?: ({ id?: string; number?: number; title?: string; url?: string } | null)[] + } + } | null)[] + } + } +} + +// ─── listAccessibleProjects ──────────────────────────────────────────── + +export async function listAccessibleProjects( + args?: ListAccessibleProjectsArgs +): Promise { + const host = githubProjectHost(args?.host) + const viewerProjects: GitHubProjectSummary[] = [] + const orgProjects: GitHubProjectSummary[] = [] + // Why: collect per-org failures so the picker shows a "some orgs didn't load" banner instead of aborting discovery on the first 504. + const partialFailures: { owner: string; message: string }[] = [] + let viewerLogin: string | null = null + + // 1) Viewer projects (paginated, single owner so cap at DISCOVERY_PROJECTS_PER_OWNER total). + let viewerCursor: string | null = null + let viewerMore = true + let viewerFetched = 0 + while (viewerMore && viewerFetched < DISCOVERY_PROJECTS_PER_OWNER) { + const afterArg = viewerCursor ? ', after: $after' : '' + const afterVar = viewerCursor ? '$after:String!' : '' + const query = ` + query${afterVar ? `(${afterVar})` : ''} { + viewer { + login + projectsV2(first:${DISCOVERY_PROJECTS_PER_ORG}${afterArg}) { + pageInfo { hasNextPage endCursor } + nodes { + id number title url + owner { __typename ... on Organization { login } ... on User { login } } + } + } + } + } + ` + const vars: GraphqlVars = {} + if (viewerCursor) { + vars.after = viewerCursor + } + const res = await runGraphql(query, vars, projectGhExecOptions(host)) + if (!res.ok) { + // Why: viewer-level failure is structural (no projects to build on), so propagate hard; org-level errors below are non-fatal. + return { ok: false, error: res.error } + } + if (!res.data.viewer) { + return { ok: false, error: driftError('viewer missing') } + } + if (viewerLogin === null) { + viewerLogin = res.data.viewer.login ?? null + } + const nodes = res.data.viewer.projectsV2?.nodes ?? [] + for (const n of nodes) { + if (!n || typeof n.id !== 'string' || typeof n.number !== 'number') { + continue + } + const ownerLogin = n.owner?.login ?? viewerLogin ?? '' + const ownerType: GitHubProjectOwnerType = + n.owner?.__typename === 'Organization' ? 'organization' : 'user' + viewerProjects.push({ + id: n.id, + host, + owner: ownerLogin, + ownerType, + number: n.number, + title: n.title ?? '', + url: n.url ?? '', + source: 'viewer' + }) + viewerFetched++ + if (viewerFetched >= DISCOVERY_PROJECTS_PER_OWNER) { + break + } + } + const pi = res.data.viewer.projectsV2?.pageInfo + viewerMore = pi?.hasNextPage === true && typeof pi.endCursor === 'string' + viewerCursor = viewerMore ? (pi?.endCursor ?? null) : null + } + + // 2) Organizations the viewer belongs to, each with its projectsV2. + // Why: no per-org projectsV2 continuation loop — it was the dominant 504 cost; users past the cap can paste a URL instead. + let orgCursor: string | null = null + let orgMore = true + let orgsSeen = 0 + while (orgMore && orgsSeen < DISCOVERY_MAX_ORGS) { + const afterArg = orgCursor ? ', after: $orgAfter' : '' + const afterVar = orgCursor ? '$orgAfter:String!' : '' + const query = ` + query${afterVar ? `(${afterVar})` : ''} { + viewer { + organizations(first:${DISCOVERY_ORG_PAGE_SIZE}${afterArg}) { + pageInfo { hasNextPage endCursor } + nodes { + login + projectsV2(first:${DISCOVERY_PROJECTS_PER_ORG}) { + pageInfo { hasNextPage endCursor } + nodes { id number title url } + } + } + } + } + } + ` + const vars: GraphqlVars = {} + if (orgCursor) { + vars.orgAfter = orgCursor + } + const res = await runGraphql(query, vars, projectGhExecOptions(host)) + if (!res.ok) { + // Why: org-listing failed; record a synthetic '*' partial failure so the banner explains it, but keep collected viewer projects (the reported 504 path). + partialFailures.push({ owner: '*', message: res.error.message }) + break + } + const orgs = res.data.viewer?.organizations?.nodes ?? [] + for (const org of orgs) { + if (!org || typeof org.login !== 'string') { + continue + } + if (orgsSeen >= DISCOVERY_MAX_ORGS) { + break + } + orgsSeen++ + const login = org.login + // Cache for paste/resolve even when the nested projects query was empty or partially failed. + rememberOwnerType(login, 'organization', host) + const nodes = org.projectsV2?.nodes ?? [] + let ownerCount = 0 + for (const n of nodes) { + if (!n || typeof n.id !== 'string' || typeof n.number !== 'number') { + continue + } + if (ownerCount >= DISCOVERY_PROJECTS_PER_OWNER) { + break + } + orgProjects.push({ + id: n.id, + host, + owner: login, + ownerType: 'organization', + number: n.number, + title: n.title ?? '', + url: n.url ?? '', + source: `org:${login}` + }) + ownerCount++ + } + } + const pi = res.data.viewer?.organizations?.pageInfo + orgMore = pi?.hasNextPage === true && typeof pi.endCursor === 'string' + orgCursor = orgMore ? (pi?.endCursor ?? null) : null + } + + if (viewerLogin) { + rememberOwnerType(viewerLogin, 'user', host) + } + + return { + ok: true, + projects: [...viewerProjects, ...orgProjects], + ...(partialFailures.length > 0 ? { partialFailures } : {}) + } +} diff --git a/src/main/github/project-view/project-view-field-normalization.ts b/src/main/github/project-view/project-view-field-normalization.ts new file mode 100644 index 00000000000..1441a999809 --- /dev/null +++ b/src/main/github/project-view/project-view-field-normalization.ts @@ -0,0 +1,176 @@ +import type { + GitHubProjectField, + GitHubProjectFieldValue, + GitHubProjectIteration, + GitHubProjectLabel, + GitHubProjectSingleSelectOption, + GitHubProjectUser +} from '../../../shared/github/project-types' + +// ─── Normalizers ─────────────────────────────────────────────────────── + +export type RawProjectV2Field = { + __typename?: string + id?: string + name?: string + dataType?: string + options?: { id?: string; name?: string; color?: string }[] + configuration?: { + iterations?: { id?: string; title?: string; startDate?: string; duration?: number }[] + completedIterations?: { + id?: string + title?: string + startDate?: string + duration?: number + }[] + } +} + +export function normalizeField( + raw: RawProjectV2Field | null | undefined +): GitHubProjectField | null { + if (!raw || typeof raw.id !== 'string' || typeof raw.name !== 'string') { + return null + } + const dataType = raw.dataType ?? raw.__typename ?? '' + if (raw.__typename === 'ProjectV2SingleSelectField' || dataType === 'SINGLE_SELECT') { + const options: GitHubProjectSingleSelectOption[] = (raw.options ?? []) + .map((o) => + typeof o.id === 'string' && typeof o.name === 'string' + ? { id: o.id, name: o.name, color: o.color ?? '' } + : null + ) + .filter((o): o is GitHubProjectSingleSelectOption => o !== null) + return { kind: 'single-select', id: raw.id, name: raw.name, dataType: 'SINGLE_SELECT', options } + } + if (raw.__typename === 'ProjectV2IterationField' || dataType === 'ITERATION') { + const cfg = raw.configuration ?? {} + const iterations: GitHubProjectIteration[] = [] + for (const it of cfg.completedIterations ?? []) { + if (typeof it.id === 'string' && typeof it.title === 'string') { + iterations.push({ + id: it.id, + title: it.title, + startDate: it.startDate ?? '', + duration: typeof it.duration === 'number' ? it.duration : 0, + completed: true + }) + } + } + for (const it of cfg.iterations ?? []) { + if (typeof it.id === 'string' && typeof it.title === 'string') { + iterations.push({ + id: it.id, + title: it.title, + startDate: it.startDate ?? '', + duration: typeof it.duration === 'number' ? it.duration : 0, + completed: false + }) + } + } + return { kind: 'iteration', id: raw.id, name: raw.name, dataType: 'ITERATION', iterations } + } + return { kind: 'field', id: raw.id, name: raw.name, dataType } +} + +export type RawUser = { + login?: string + name?: string | null + avatarUrl?: string | null +} + +export function normalizeUser(raw: RawUser | null | undefined): GitHubProjectUser | null { + if (!raw || typeof raw.login !== 'string') { + return null + } + return { + login: raw.login, + name: raw.name ?? null, + avatarUrl: raw.avatarUrl ?? null + } +} + +export type RawLabel = { name?: string; color?: string } + +export function normalizeLabel(raw: RawLabel | null | undefined): GitHubProjectLabel | null { + if (!raw || typeof raw.name !== 'string') { + return null + } + return { name: raw.name, color: raw.color ?? '' } +} + +export type RawFieldValue = { + __typename?: string + field?: RawProjectV2Field + name?: string + color?: string + optionId?: string + title?: string + startDate?: string + duration?: number + iterationId?: string + text?: string + number?: number + date?: string + labels?: { nodes?: RawLabel[] } + users?: { nodes?: RawUser[] } +} + +export function normalizeFieldValue( + raw: RawFieldValue | null | undefined +): GitHubProjectFieldValue | null { + if (!raw || !raw.field || typeof raw.field.id !== 'string') { + return null + } + const fieldId = raw.field.id + switch (raw.__typename) { + case 'ProjectV2ItemFieldSingleSelectValue': + if (typeof raw.optionId !== 'string') { + return null + } + return { + kind: 'single-select', + fieldId, + optionId: raw.optionId, + name: raw.name ?? '', + color: raw.color ?? '' + } + case 'ProjectV2ItemFieldIterationValue': + if (typeof raw.iterationId !== 'string') { + return null + } + return { + kind: 'iteration', + fieldId, + iterationId: raw.iterationId, + title: raw.title ?? '', + startDate: raw.startDate ?? '', + duration: typeof raw.duration === 'number' ? raw.duration : 0 + } + case 'ProjectV2ItemFieldTextValue': + return { kind: 'text', fieldId, text: raw.text ?? '' } + case 'ProjectV2ItemFieldNumberValue': + if (typeof raw.number !== 'number') { + return null + } + return { kind: 'number', fieldId, number: raw.number } + case 'ProjectV2ItemFieldDateValue': + return { kind: 'date', fieldId, date: raw.date ?? '' } + case 'ProjectV2ItemFieldLabelValue': { + const labels = (raw.labels?.nodes ?? []) + .map(normalizeLabel) + .filter((l): l is GitHubProjectLabel => l !== null) + return { kind: 'labels', fieldId, labels } + } + case 'ProjectV2ItemFieldUserValue': { + const users = (raw.users?.nodes ?? []) + .map(normalizeUser) + .filter((u): u is GitHubProjectUser => u !== null) + return { kind: 'users', fieldId, users } + } + case undefined: + default: + // Unknown __typename → forward-compat: drop silently, don't classify as drift (see design §Error Handling). + return null + } +} diff --git a/src/main/github/project-view/project-view-item-normalization.ts b/src/main/github/project-view/project-view-item-normalization.ts new file mode 100644 index 00000000000..3bcfda9d6ba --- /dev/null +++ b/src/main/github/project-view/project-view-item-normalization.ts @@ -0,0 +1,153 @@ +import type { + GitHubProjectFieldValue, + GitHubProjectLabel, + GitHubProjectRow, + GitHubProjectRowItemType, + GitHubProjectUser +} from '../../../shared/github/project-types' +import type { GitHubProjectViewError } from '../../../shared/github/project-result-types' +import { driftError } from './project-error-classification' +import { + normalizeFieldValue, + normalizeLabel, + normalizeUser, + type RawFieldValue, + type RawLabel, + type RawUser +} from './project-view-field-normalization' + +type RawContent = { + __typename?: string + id?: string + number?: number + title?: string + body?: string + url?: string + state?: string + stateReason?: string | null + isDraft?: boolean + repository?: { nameWithOwner?: string } + assignees?: { nodes?: RawUser[] } + labels?: { nodes?: RawLabel[] } + parent?: { number?: number; title?: string; url?: string } | null + issueType?: { + id?: string + name?: string + color?: string | null + description?: string | null + } | null +} + +export type RawItem = { + id?: string + type?: string + updatedAt?: string + content?: RawContent | null + fieldValues?: { + nodes?: RawFieldValue[] + pageInfo?: { hasNextPage?: boolean } + } +} + +type NormalizedItemOutcome = + | { ok: true; row: GitHubProjectRow } + | { ok: false; drift: GitHubProjectViewError } + +function mapItemType(raw: string | undefined, hasContent: boolean): GitHubProjectRowItemType { + if (raw === 'ISSUE') { + return 'ISSUE' + } + if (raw === 'PULL_REQUEST') { + return 'PULL_REQUEST' + } + if (raw === 'DRAFT_ISSUE') { + return 'DRAFT_ISSUE' + } + if (raw === 'REDACTED' || !hasContent) { + return 'REDACTED' + } + // Unknown item type with content — treat as redacted rather than dropping. + return 'REDACTED' +} + +export function normalizeItem(raw: RawItem, position: number): NormalizedItemOutcome { + if (!raw || typeof raw.id !== 'string') { + return { + ok: false, + drift: driftError('item missing id', { path: ['items', 'nodes', position, 'id'] }) + } + } + if (raw.fieldValues?.pageInfo?.hasNextPage === true) { + return { + ok: false, + drift: driftError('item field values exceeded single page', { + path: ['items', 'nodes', position, 'fieldValues', 'pageInfo', 'hasNextPage'] + }) + } + } + const itemType = mapItemType(raw.type, raw.content !== null && raw.content !== undefined) + const content = raw.content ?? null + const assignees = (content?.assignees?.nodes ?? []) + .map(normalizeUser) + .filter((u): u is GitHubProjectUser => u !== null) + const labels = (content?.labels?.nodes ?? []) + .map(normalizeLabel) + .filter((l): l is GitHubProjectLabel => l !== null) + const parentIssue = + content?.parent && + typeof content.parent.number === 'number' && + typeof content.parent.title === 'string' && + typeof content.parent.url === 'string' + ? { number: content.parent.number, title: content.parent.title, url: content.parent.url } + : null + const issueType = + content?.issueType && + typeof content.issueType.id === 'string' && + typeof content.issueType.name === 'string' + ? { + id: content.issueType.id, + name: content.issueType.name, + color: typeof content.issueType.color === 'string' ? content.issueType.color : null, + description: + typeof content.issueType.description === 'string' ? content.issueType.description : null + } + : null + const fieldValuesByFieldId: Record = {} + for (const fv of raw.fieldValues?.nodes ?? []) { + const normalized = normalizeFieldValue(fv) + if (normalized) { + fieldValuesByFieldId[normalized.fieldId] = normalized + } + } + const title = + itemType === 'REDACTED' + ? 'Restricted item' + : typeof content?.title === 'string' + ? content.title + : '' + const row: GitHubProjectRow = { + id: raw.id, + itemType, + content: { + number: typeof content?.number === 'number' ? content.number : null, + title, + body: typeof content?.body === 'string' ? content.body : null, + url: typeof content?.url === 'string' ? content.url : null, + state: typeof content?.state === 'string' ? content.state : null, + stateReason: typeof content?.stateReason === 'string' ? content.stateReason : null, + isDraft: typeof content?.isDraft === 'boolean' ? content.isDraft : null, + repository: + typeof content?.repository?.nameWithOwner === 'string' + ? content.repository.nameWithOwner + : null, + assignees, + labels, + parentIssue, + issueType + }, + fieldValuesByFieldId, + updatedAt: typeof raw.updatedAt === 'string' ? raw.updatedAt : '', + position + } + return { ok: true, row } +} diff --git a/src/main/github/project-view/project-view-item-page.ts b/src/main/github/project-view/project-view-item-page.ts new file mode 100644 index 00000000000..ca135fded51 --- /dev/null +++ b/src/main/github/project-view/project-view-item-page.ts @@ -0,0 +1,171 @@ +import type { GitHubProjectOwnerType } from '../../../shared/github/project-types' +import type { GitHubProjectViewError } from '../../../shared/github/project-result-types' +import { + acquire, + extractExecError, + ghExecFileAsync, + noteRepositoryRateLimitSpend, + projectGhExecOptions, + projectHostAuthenticationError, + release, + repositoryRateLimitGuard +} from './internals' +import { + classifyProjectError, + driftError, + rateLimitedError, + type GhGraphqlErrorShape +} from './project-error-classification' +import { ownerQueryRoot } from './project-view-config' +import type { RawItem } from './project-view-item-normalization' +import { + FIELD_CONFIG_FRAGMENT, + FIELD_VALUES_SELECTION, + itemContentSelection +} from './project-view-query-fragments' + +// ─── Items fetch (paginated) ────────────────────────────────────────── + +export type RawItemsPage = { + totalCount?: number + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null } + nodes?: (RawItem | null)[] +} + +// Why: unlike runGraphql, return the raw GraphQL error envelope so the parent-field retry decision can re-inspect it. +export async function fetchItemsPageWithRaw(args: { + owner: string + ownerType: GitHubProjectOwnerType + projectNumber: number + query: string + first: number + after: string | null + includeParent: boolean + host?: string +}): Promise< + | { ok: true; page: RawItemsPage } + | { + ok: false + error: GitHubProjectViewError + rawErrors: GhGraphqlErrorShape[] + stderr: string + } +> { + const authError = await projectHostAuthenticationError(args.host) + if (authError) { + return { ok: false, error: authError, rawErrors: [], stderr: '' } + } + const root = ownerQueryRoot(args.ownerType) + const afterArg = args.after ? `, after: $after` : '' + const afterVar = args.after ? `$after:String!, ` : '' + const query = ` + query(${afterVar}$owner:String!, $num:Int!, $q:String!, $first:Int!) { + ${root}(login:$owner) { + projectV2(number:$num) { + items(first:$first${afterArg}, query:$q, orderBy:{ field: POSITION, direction: ASC }) { + totalCount + pageInfo { hasNextPage endCursor } + nodes { + id + type + updatedAt + content { ${itemContentSelection(args.includeParent)} } + ${FIELD_VALUES_SELECTION} + } + } + } + } + } + ${FIELD_CONFIG_FRAGMENT} + ` + const argsArr: string[] = ['api', 'graphql', '-f', `query=${query}`] + argsArr.push('-f', `owner=${args.owner}`) + argsArr.push('-F', `num=${args.projectNumber}`) + argsArr.push('-f', `q=${args.query}`) + argsArr.push('-F', `first=${args.first}`) + if (args.after) { + argsArr.push('-f', `after=${args.after}`) + } + + // Why: GHES traffic runs against its own quota — only github.com requests + // consult/debit the shared snapshot. + const guard = repositoryRateLimitGuard(args, 'graphql') + if (guard.blocked) { + return { + ok: false, + error: rateLimitedError(guard), + rawErrors: [], + stderr: '' + } + } + await acquire() + noteRepositoryRateLimitSpend(args, 'graphql') + try { + let stdout = '' + let stderr = '' + let execFailed = false + try { + const r = await ghExecFileAsync(argsArr, { + encoding: 'utf-8', + ...projectGhExecOptions(args.host) + }) + stdout = r.stdout + stderr = r.stderr + } catch (err) { + const extracted = extractExecError(err) + stderr = extracted.stderr + stdout = extracted.stdout + execFailed = true + } + let parsed: { data?: Record; errors?: GhGraphqlErrorShape[] } = {} + try { + parsed = JSON.parse(stdout) + } catch { + // Why: gh exited non-zero with unparseable stdout; classify against stderr so callers see the real cause, not a synthesized drift/not-found. + if (execFailed) { + return { + ok: false, + error: classifyProjectError(stderr, stdout, args.host), + rawErrors: [], + stderr + } + } + return { + ok: false, + error: driftError('failed to parse items response'), + rawErrors: [], + stderr + } + } + // Why: gh rejected but stdout parsed; fall through to parsed.errors below, else surface the stderr classification rather than not_found. + if (execFailed && (!parsed.errors || parsed.errors.length === 0) && !parsed.data) { + return { + ok: false, + error: classifyProjectError(stderr, stdout, args.host), + rawErrors: [], + stderr + } + } + if (parsed.errors && parsed.errors.length > 0) { + return { + ok: false, + error: classifyProjectError(stderr, stdout, args.host), + rawErrors: parsed.errors, + stderr + } + } + const top = parsed.data?.[root] as { projectV2?: { items?: RawItemsPage } | null } | undefined + const page = top?.projectV2?.items + if (!page) { + return { + ok: false, + error: { type: 'not_found', message: 'Project or view not found.' }, + rawErrors: [], + stderr + } + } + return { ok: true, page } + } finally { + release() + } +} diff --git a/src/main/github/project-view/project-view-items.ts b/src/main/github/project-view/project-view-items.ts new file mode 100644 index 00000000000..3ecdb55bd9a --- /dev/null +++ b/src/main/github/project-view/project-view-items.ts @@ -0,0 +1,233 @@ +import type { GitHubProjectOwnerType, GitHubProjectRow } from '../../../shared/github/project-types' +import type { GitHubProjectViewError } from '../../../shared/github/project-result-types' +import { projectGhExecOptions, runGraphql } from './internals' +import { driftError, errorsIndicateParentField } from './project-error-classification' +import { + hasParentFieldRetried, + hasParentFieldWarningLogged, + markParentFieldRetried, + markParentFieldWarningLogged, + ownerScopeKey, + parentFieldProbeInFlight +} from './project-view-cache' +import { ownerQueryRoot } from './project-view-config' +import { fetchItemsPageWithRaw } from './project-view-item-page' +import { normalizeItem, type RawItem } from './project-view-item-normalization' + +const ITEM_PAGE_SIZE = 100 +const MAX_ITEMS = 500 + +export async function fetchAllItems(args: { + owner: string + ownerType: GitHubProjectOwnerType + projectNumber: number + query: string + host?: string +}): Promise< + | { ok: true; rows: GitHubProjectRow[]; totalCount: number; parentFieldDropped: boolean } + | { ok: false; error: GitHubProjectViewError; totalCount?: number } +> { + // Why: isolate missing Issue.parent capability by owner, type, and host. + const scopeKey = ownerScopeKey(args.owner, args.ownerType, args.host) + // Why: await the same-scope probe, then re-read state because it may have changed. + const inFlight = parentFieldProbeInFlight.get(scopeKey) + if (inFlight) { + await inFlight.catch(() => {}) + } + let includeParent = !hasParentFieldRetried(scopeKey) + let parentFieldDropped = !includeParent + // Single-flight the with-parent probe per owner; assign the in-flight promise synchronously (no await between get() and set()) so callers share one probe. + let first: Awaited> + let probePromise: Promise>> | null = null + if (includeParent && !parentFieldProbeInFlight.has(scopeKey)) { + let resolveProbe: () => void = () => {} + const probe = new Promise((resolve) => { + resolveProbe = resolve + }) + parentFieldProbeInFlight.set(scopeKey, probe) + probePromise = (async () => { + try { + const result = await fetchItemsPageWithRaw({ + owner: args.owner, + ownerType: args.ownerType, + projectNumber: args.projectNumber, + query: args.query, + first: ITEM_PAGE_SIZE, + after: null, + includeParent: true, + host: args.host + }) + // Why: set the retried flag BEFORE resolving/clearing the probe so siblings awoken on inFlight.catch() see it and don't fire duplicate with-parent probes. + if (!result.ok && errorsIndicateParentField(result.rawErrors, result.stderr)) { + markParentFieldRetried(scopeKey) + } + return result + } finally { + resolveProbe() + parentFieldProbeInFlight.delete(scopeKey) + } + })() + first = await probePromise + } else { + first = await fetchItemsPageWithRaw({ + owner: args.owner, + ownerType: args.ownerType, + projectNumber: args.projectNumber, + query: args.query, + first: ITEM_PAGE_SIZE, + after: null, + includeParent, + host: args.host + }) + } + if (!first.ok && includeParent && errorsIndicateParentField(first.rawErrors, first.stderr)) { + // Retry the whole table without parent; mark this owner retried so later fetches skip the probe (other owners unaffected). + markParentFieldRetried(scopeKey) + includeParent = false + parentFieldDropped = true + if (!hasParentFieldWarningLogged(scopeKey)) { + console.warn( + `[project-view] Issue.parent is not available for ${args.owner} on this token — retrying without the parent selection.` + ) + markParentFieldWarningLogged(scopeKey) + } + first = await fetchItemsPageWithRaw({ + owner: args.owner, + ownerType: args.ownerType, + projectNumber: args.projectNumber, + query: args.query, + first: ITEM_PAGE_SIZE, + after: null, + includeParent: false, + host: args.host + }) + } + if (!first.ok) { + return { ok: false, error: first.error } + } + + // Drift guards + if (first.page.totalCount === undefined || first.page.totalCount === null) { + return { ok: false, error: driftError('items.totalCount missing') } + } + const totalCount = first.page.totalCount + if (first.page.pageInfo?.hasNextPage === undefined) { + return { ok: false, error: driftError('items.pageInfo.hasNextPage missing'), totalCount } + } + if (!Array.isArray(first.page.nodes)) { + return { ok: false, error: driftError('items.nodes missing'), totalCount } + } + + // Size cap + if (totalCount > MAX_ITEMS) { + return { + ok: false, + error: { type: 'too_large', message: `View has ${totalCount} items.` }, + totalCount + } + } + + const rows: GitHubProjectRow[] = [] + let position = 0 + const appendNodes = (nodes: (RawItem | null)[]): GitHubProjectViewError | null => { + for (const n of nodes) { + if (!n) { + continue + } + const norm = normalizeItem(n, position) + if (!norm.ok) { + return norm.drift + } + rows.push(norm.row) + position++ + } + return null + } + const e1 = appendNodes(first.page.nodes) + if (e1) { + return { ok: false, error: e1, totalCount } + } + + // Paginate + let hasNext = first.page.pageInfo.hasNextPage === true + let cursor: string | null | undefined = first.page.pageInfo.endCursor + if (hasNext && typeof cursor !== 'string') { + return { + ok: false, + error: driftError('items.pageInfo.endCursor missing with hasNextPage=true'), + totalCount + } + } + while (hasNext) { + const next = await fetchItemsPageWithRaw({ + owner: args.owner, + ownerType: args.ownerType, + projectNumber: args.projectNumber, + query: args.query, + first: ITEM_PAGE_SIZE, + after: cursor as string, + includeParent, + host: args.host + }) + if (!next.ok) { + return { ok: false, error: next.error, totalCount } + } + if (!Array.isArray(next.page.nodes)) { + return { ok: false, error: driftError('items.nodes missing on follow page'), totalCount } + } + if (next.page.pageInfo?.hasNextPage === undefined) { + return { + ok: false, + error: driftError('items.pageInfo.hasNextPage missing on follow page'), + totalCount + } + } + const e2 = appendNodes(next.page.nodes) + if (e2) { + return { ok: false, error: e2, totalCount } + } + hasNext = next.page.pageInfo.hasNextPage === true + cursor = next.page.pageInfo.endCursor + if (hasNext && typeof cursor !== 'string') { + return { + ok: false, + error: driftError('items.pageInfo.endCursor missing with hasNextPage=true'), + totalCount + } + } + } + return { ok: true, rows, totalCount, parentFieldDropped } +} + +// ─── Cheap count-only query (for unsupported_layout) ────────────────── + +export async function fetchItemsCountOnly(args: { + owner: string + ownerType: GitHubProjectOwnerType + projectNumber: number + query: string + host?: string +}): Promise { + const root = ownerQueryRoot(args.ownerType) + const query = ` + query($owner:String!, $num:Int!, $q:String!) { + ${root}(login:$owner) { + projectV2(number:$num) { + items(first:1, query:$q) { totalCount } + } + } + } + ` + const res = await runGraphql< + Record + >( + query, + { owner: args.owner, num: args.projectNumber, q: args.query }, + projectGhExecOptions(args.host) + ) + if (!res.ok) { + return null + } + const count = res.data[root]?.projectV2?.items?.totalCount + return typeof count === 'number' ? count : null +} diff --git a/src/main/github/project-view/project-view-listing.ts b/src/main/github/project-view/project-view-listing.ts new file mode 100644 index 00000000000..f6784110428 --- /dev/null +++ b/src/main/github/project-view/project-view-listing.ts @@ -0,0 +1,59 @@ +import type { ListProjectViewsArgs } from '../../../shared/github/project-request-types' +import type { ListProjectViewsResult } from '../../../shared/github/project-result-types' +import type { + GitHubProjectViewLayout, + GitHubProjectViewSummary +} from '../../../shared/github/project-types' +import { assertPositiveInt, assertSlug } from './internals' +import { fetchProjectViewsPage } from './project-view-config' + +// ─── listProjectViews ────────────────────────────────────────────────── + +export async function listProjectViews( + args: ListProjectViewsArgs +): Promise { + const ownerCheck = assertSlug(args.owner, 'owner') + if (!ownerCheck.ok) { + return { ok: false, error: ownerCheck.error } + } + const numCheck = assertPositiveInt(args.projectNumber, 'projectNumber') + if (!numCheck.ok) { + return { ok: false, error: numCheck.error } + } + if (args.ownerType !== 'organization' && args.ownerType !== 'user') { + return { ok: false, error: { type: 'validation_error', message: 'Invalid ownerType.' } } + } + const summaries: GitHubProjectViewSummary[] = [] + let cursor: string | null = null + while (true) { + const page = await fetchProjectViewsPage({ + owner: args.owner, + ownerType: args.ownerType, + projectNumber: args.projectNumber, + host: args.host, + after: cursor + }) + if (!page.ok) { + return { ok: false, error: page.error } + } + for (const v of page.views) { + if (typeof v.id !== 'string' || typeof v.layout !== 'string') { + continue + } + summaries.push({ + id: v.id, + number: typeof v.number === 'number' ? v.number : 0, + name: typeof v.name === 'string' ? v.name : '', + layout: v.layout as GitHubProjectViewLayout + }) + } + if (!page.hasNextPage) { + break + } + cursor = page.endCursor + if (typeof cursor !== 'string') { + break + } + } + return { ok: true, views: summaries } +} diff --git a/src/main/github/project-view/project-view-query-fragments.ts b/src/main/github/project-view/project-view-query-fragments.ts new file mode 100644 index 00000000000..aca32a0b2ae --- /dev/null +++ b/src/main/github/project-view/project-view-query-fragments.ts @@ -0,0 +1,73 @@ +const FIELD_VALUES_PAGE_SIZE = 100 + +// ─── GraphQL query fragments ─────────────────────────────────────────── + +export const FIELD_CONFIG_FRAGMENT = ` +fragment FieldConfig on ProjectV2FieldConfiguration { + __typename + ... on ProjectV2Field { id name dataType } + ... on ProjectV2SingleSelectField { + id + name + dataType + options { id name color } + } + ... on ProjectV2IterationField { + id + name + dataType + configuration { + iterations { id title startDate duration } + completedIterations { id title startDate duration } + } + } +} +` + +export function itemContentSelection(includeParent: boolean): string { + const parentFrag = includeParent ? 'parent { number title url }' : '' + return ` + __typename + ... on Issue { + id + number + title + url + state + stateReason + repository { nameWithOwner } + assignees(first:5) { nodes { login name avatarUrl } } + labels(first:10) { nodes { name color } } + issueType { id name color description } + ${parentFrag} + } + ... on PullRequest { + id + number + title + url + state + isDraft + repository { nameWithOwner } + assignees(first:5) { nodes { login name avatarUrl } } + labels(first:10) { nodes { name color } } + } + ... on DraftIssue { id title body } + ` +} + +export const FIELD_VALUES_SELECTION = ` + fieldValues(first:${FIELD_VALUES_PAGE_SIZE}) { + pageInfo { hasNextPage } + nodes { + __typename + ... on ProjectV2ItemFieldSingleSelectValue { field { ...FieldConfig } name color optionId } + ... on ProjectV2ItemFieldIterationValue { field { ...FieldConfig } title startDate duration iterationId } + ... on ProjectV2ItemFieldTextValue { field { ...FieldConfig } text } + ... on ProjectV2ItemFieldNumberValue { field { ...FieldConfig } number } + ... on ProjectV2ItemFieldDateValue { field { ...FieldConfig } date } + ... on ProjectV2ItemFieldLabelValue { field { ...FieldConfig } labels(first:10) { nodes { name color } } } + ... on ProjectV2ItemFieldUserValue { field { ...FieldConfig } users(first:5) { nodes { login name avatarUrl } } } + } + } +` diff --git a/src/main/github/project-view/project-view-reference.ts b/src/main/github/project-view/project-view-reference.ts new file mode 100644 index 00000000000..54542a3e720 --- /dev/null +++ b/src/main/github/project-view/project-view-reference.ts @@ -0,0 +1,230 @@ +import type { ResolveProjectRefArgs } from '../../../shared/github/project-request-types' +import type { + GitHubProjectViewError, + ResolveProjectRefResult +} from '../../../shared/github/project-result-types' +import type { GitHubProjectOwnerType } from '../../../shared/github/project-types' +import { + GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR, + isGitHubProjectRefInputTooLarge +} from '../../../shared/github/project-ref-input' +import { githubProjectHost } from '../../../shared/github/project-identity' +import { isValidOwnerSlug, projectGhExecOptions, runGraphql, type GraphqlVars } from './internals' +import { getCachedOwnerType, rememberOwnerType } from './project-view-cache' +import { ownerQueryRoot } from './project-view-config' + +// ─── resolveProjectRef ───────────────────────────────────────────────── + +type ParsedPaste = + | { kind: 'org'; owner: string; number: number; host: string; viewNumber?: number } + | { kind: 'user'; owner: string; number: number; host: string; viewNumber?: number } + | { kind: 'bare'; owner: string; number: number } + +export function parseProjectPaste(input: string, host?: string): ParsedPaste | null { + const trimmed = input.trim() + if (!trimmed) { + return null + } + if (isGitHubProjectRefInputTooLarge(trimmed)) { + return null + } + // Why: URL parsing enforces an exact Project path and rejects credentials; + // a prefix regex could silently turn `/projects/1evil` into Project 1. + try { + const url = new URL(trimmed) + const allowedHosts = new Set(['github.com', ...(host ? [host.trim().toLowerCase()] : [])]) + const parts = url.pathname.split('/').filter(Boolean) + const hasView = parts.length === 6 && parts[4] === 'views' + if ( + (url.protocol !== 'https:' && url.protocol !== 'http:') || + url.username || + url.password || + !allowedHosts.has(url.host.toLowerCase()) || + (parts[0] !== 'orgs' && parts[0] !== 'users') || + !isValidOwnerSlug(parts[1]) || + parts[2] !== 'projects' || + (parts.length !== 4 && !hasView) + ) { + return null + } + const number = Number(parts[3]) + const viewNumber = hasView ? Number(parts[5]) : undefined + if ( + !Number.isSafeInteger(number) || + number < 1 || + (hasView && (!Number.isSafeInteger(viewNumber) || (viewNumber ?? 0) < 1)) + ) { + return null + } + return { + kind: parts[0] === 'orgs' ? 'org' : 'user', + owner: parts[1], + number, + host: url.host.toLowerCase(), + ...(viewNumber !== undefined ? { viewNumber } : {}) + } + } catch { + // Shorthand parsing below remains available for non-URL input. + } + // owner/number shorthand — owner alphabet matches OWNER_SLUG_RE. + const shortRe = /^([A-Za-z0-9][A-Za-z0-9-]*)\/(\d+)$/ + const sm = trimmed.match(shortRe) + if (sm) { + const number = Number.parseInt(sm[2], 10) + if (!Number.isInteger(number) || number < 1) { + return null + } + return { kind: 'bare', owner: sm[1], number } + } + return null +} + +async function resolveOwnerType( + owner: string, + preferred: GitHubProjectOwnerType | null, + host?: string +): Promise< + | { ok: true; ownerType: GitHubProjectOwnerType; title: string } + | { ok: false; error: GitHubProjectViewError } +> { + const tryOne = async ( + ot: GitHubProjectOwnerType, + num: number | null + ): Promise<{ ok: true; title: string } | { ok: false; error: GitHubProjectViewError }> => { + const root = ownerQueryRoot(ot) + // If number is provided, fetch the project title; else just confirm owner exists. + const query = num + ? ` + query($owner:String!, $num:Int!) { + ${root}(login:$owner) { projectV2(number:$num) { id title } } + } + ` + : ` + query($owner:String!) { + ${root}(login:$owner) { login } + } + ` + const vars: GraphqlVars = { owner } + if (num) { + vars.num = num + } + const res = await runGraphql< + Record + >(query, vars, projectGhExecOptions(host)) + if (!res.ok) { + return { ok: false, error: res.error } + } + const top = res.data[root] + if (!top) { + return { ok: false, error: { type: 'not_found', message: 'Owner not found.' } } + } + if (num) { + const p = top.projectV2 + if (!p || typeof p.id !== 'string') { + return { ok: false, error: { type: 'not_found', message: 'Project not found.' } } + } + return { ok: true, title: p.title ?? '' } + } + return { ok: true, title: '' } + } + + const cached = getCachedOwnerType(owner, host) + const candidates: GitHubProjectOwnerType[] = preferred + ? [preferred] + : cached + ? [cached] + : ['organization', 'user'] + const fallback: GitHubProjectOwnerType[] = preferred + ? [] + : cached + ? cached === 'organization' + ? ['user'] + : ['organization'] + : [] + const ordered = [...candidates, ...fallback] + let lastError: GitHubProjectViewError | null = null + for (const ot of ordered) { + const r = await tryOne(ot, null) + if (r.ok) { + rememberOwnerType(owner, ot, host) + return { ok: true, ownerType: ot, title: r.title } + } + lastError = r.error + if (r.error.type !== 'not_found') { + // Non-NOT_FOUND errors (auth, network, rate) should not trigger fallback. + return { ok: false, error: r.error } + } + } + rememberOwnerType(owner, null, host) + return { + ok: false, + error: lastError ?? { type: 'not_found', message: 'Owner not found.' } + } +} + +export async function resolveProjectRef( + args: ResolveProjectRefArgs +): Promise { + const input = typeof args.input === 'string' ? args.input.trim() : '' + if (!input) { + return { + ok: false, + error: { type: 'validation_error', message: 'Input required.' } + } + } + if (isGitHubProjectRefInputTooLarge(input)) { + return { + ok: false, + error: { type: 'validation_error', message: GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR } + } + } + const parsed = parseProjectPaste(input, args.host) + if (!parsed) { + return { + ok: false, + error: { + type: 'validation_error', + message: 'Could not parse input. Expected a GitHub project URL or `owner/number`.' + } + } + } + const preferred: GitHubProjectOwnerType | null = + parsed.kind === 'org' ? 'organization' : parsed.kind === 'user' ? 'user' : null + // Why: a pasted URL is authoritative. The ambient host only applies to + // owner/number shorthand; otherwise same-number Projects can cross hosts. + const executionHost = parsed.kind === 'bare' ? githubProjectHost(args.host) : parsed.host + // Verify by fetching project title. + const ownerRes = await resolveOwnerType(parsed.owner, preferred, executionHost) + if (!ownerRes.ok) { + return { ok: false, error: ownerRes.error } + } + const ownerType = ownerRes.ownerType + const root = ownerQueryRoot(ownerType) + const query = ` + query($owner:String!, $num:Int!) { + ${root}(login:$owner) { projectV2(number:$num) { id title } } + } + ` + const res = await runGraphql< + Record + >(query, { owner: parsed.owner, num: parsed.number }, projectGhExecOptions(executionHost)) + if (!res.ok) { + return { ok: false, error: res.error } + } + const p = res.data[root]?.projectV2 + if (!p || typeof p.id !== 'string') { + return { ok: false, error: { type: 'not_found', message: 'Project not found.' } } + } + return { + ok: true, + owner: parsed.owner, + ownerType, + number: parsed.number, + title: p.title ?? '', + host: executionHost, + // Why: forward URL view numbers so the renderer can skip view selection; bare shorthand has none. + ...(parsed.kind !== 'bare' && parsed.viewNumber !== undefined + ? { viewNumber: parsed.viewNumber } + : {}) + } +} diff --git a/src/main/github/project-view/project-view-table.ts b/src/main/github/project-view/project-view-table.ts new file mode 100644 index 00000000000..042aeb333cf --- /dev/null +++ b/src/main/github/project-view/project-view-table.ts @@ -0,0 +1,163 @@ +import type { GetProjectViewTableArgs } from '../../../shared/github/project-request-types' +import type { GetProjectViewTableResult } from '../../../shared/github/project-result-types' +import type { GitHubProjectTable } from '../../../shared/github/project-types' +import { githubProjectHost } from '../../../shared/github/project-identity' +import { assertPositiveInt, assertSlug } from './internals' +import { + fetchProjectViewsPage, + fetchViewFieldsContinuation, + finalizeView, + matchesSelector, + type RawProjectView +} from './project-view-config' +import type { RawProjectV2Field } from './project-view-field-normalization' +import { fetchAllItems, fetchItemsCountOnly } from './project-view-items' + +// ─── Public: getProjectViewTable ────────────────────────────────────── + +export async function getProjectViewTable( + args: GetProjectViewTableArgs +): Promise { + const ownerCheck = assertSlug(args.owner, 'owner') + if (!ownerCheck.ok) { + return { ok: false, error: ownerCheck.error } + } + const numCheck = assertPositiveInt(args.projectNumber, 'projectNumber') + if (!numCheck.ok) { + return { ok: false, error: numCheck.error } + } + if (args.ownerType !== 'organization' && args.ownerType !== 'user') { + return { + ok: false, + error: { type: 'validation_error', message: 'Invalid ownerType.' } + } + } + + // Paginate views until a match is found. + let cursor: string | null = null + let project: { id: string; title: string; url: string } | null = null + let selectedRaw: RawProjectView | null = null + let matchStrength: 'id' | 'number' | 'name' | 'default' | null = null + const viewsSeen: RawProjectView[] = [] + while (true) { + const page = await fetchProjectViewsPage({ + owner: args.owner, + ownerType: args.ownerType, + projectNumber: args.projectNumber, + host: args.host, + after: cursor + }) + if (!page.ok) { + return { ok: false, error: page.error } + } + project = page.project + for (const v of page.views) { + viewsSeen.push(v) + const m = matchesSelector(v, { + viewId: args.viewId, + viewNumber: args.viewNumber, + viewName: args.viewName + }) + if (m === 'none') { + continue + } + // Precedence: id > number > name > default. + const rank: Record = { id: 4, number: 3, name: 2, default: 1 } + const currentRank = matchStrength ? rank[matchStrength] : 0 + if (!selectedRaw || rank[m] > currentRank) { + selectedRaw = v + matchStrength = m + } + } + // Why: stop on ANY match (incl. 'default' = first table view); walking further pages costs a GraphQL call per page with no re-ranking upside. + if (selectedRaw) { + break + } + if (!page.hasNextPage) { + break + } + cursor = page.endCursor + if (typeof cursor !== 'string') { + break + } + } + if (!project) { + return { ok: false, error: { type: 'not_found', message: 'Project not found.' } } + } + if (!selectedRaw) { + return { ok: false, error: { type: 'not_found', message: 'Could not find the selected view.' } } + } + + // Paginate view fields if necessary. + let extraFields: RawProjectV2Field[] = [] + const fieldsPi = selectedRaw.fields?.pageInfo + if (fieldsPi?.hasNextPage === true && typeof fieldsPi.endCursor === 'string' && selectedRaw.id) { + const cont = await fetchViewFieldsContinuation(selectedRaw.id, fieldsPi.endCursor, args.host) + if (!cont.ok) { + return { ok: false, error: cont.error } + } + extraFields = cont.fields + } + + const finalized = finalizeView(selectedRaw, extraFields) + if (!finalized.ok) { + return { ok: false, error: finalized.drift } + } + const selectedView = finalized.view + + // Why: empty-string override means "no filter"; undefined means "use the view's stored filter". The override is ephemeral, never persisted. + const effectiveQuery = + typeof args.queryOverride === 'string' ? args.queryOverride : selectedView.filter + + // Unsupported layout: skip item pagination; best-effort count-only query. + if (selectedView.layout !== 'TABLE_LAYOUT') { + const count = await fetchItemsCountOnly({ + owner: args.owner, + ownerType: args.ownerType, + projectNumber: args.projectNumber, + query: effectiveQuery, + host: args.host + }) + return { + ok: false, + error: { + type: 'unsupported_layout', + message: `Orca only renders table views. This is a ${selectedView.layout.replace('_LAYOUT', '').toLowerCase()} view.` + }, + ...(typeof count === 'number' ? { totalCount: count } : {}) + } + } + + // Fetch items. + const items = await fetchAllItems({ + owner: args.owner, + ownerType: args.ownerType, + projectNumber: args.projectNumber, + query: effectiveQuery, + host: args.host + }) + if (!items.ok) { + return { + ok: false, + error: items.error, + ...(typeof items.totalCount === 'number' ? { totalCount: items.totalCount } : {}) + } + } + + const table: GitHubProjectTable = { + project: { + id: project.id, + host: githubProjectHost(args.host), + owner: args.owner, + ownerType: args.ownerType, + number: args.projectNumber, + title: project.title, + url: project.url + }, + selectedView, + rows: items.rows, + totalCount: items.totalCount, + parentFieldDropped: items.parentFieldDropped + } + return { ok: true, data: table } +}