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
This commit is contained in:
Neil
2026-08-29 20:22:10 -07:00
committed by GitHub
parent 07652e252f
commit ad8e71e1a2
13 changed files with 1873 additions and 1781 deletions
-1
View File
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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<K, V>(
cache: Map<K, V>,
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<K, V>(cache: Map<K, V>, 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<string, GitHubProjectOwnerType | null>()
// 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<string, true>()
const parentFieldWarningLoggedByOwner = new Map<string, true>()
// 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<string, Promise<void>>()
// 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)
}
@@ -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<Record<string, { projectV2?: RawProjectConfig | null } | null>>(
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'
}
@@ -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<ListAccessibleProjectsResult> {
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<RawViewerDiscovery>(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<RawViewerDiscovery>(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 } : {})
}
}
@@ -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
}
}
@@ -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<string, GitHubProjectFieldValue> = {}
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 }
}
@@ -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<string, unknown>; 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()
}
}
@@ -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<ReturnType<typeof fetchItemsPageWithRaw>>
let probePromise: Promise<Awaited<ReturnType<typeof fetchItemsPageWithRaw>>> | null = null
if (includeParent && !parentFieldProbeInFlight.has(scopeKey)) {
let resolveProbe: () => void = () => {}
const probe = new Promise<void>((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<number | null> {
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<string, { projectV2?: { items?: { totalCount?: number } | null } | null } | null>
>(
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
}
@@ -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<ListProjectViewsResult> {
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 }
}
@@ -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 } } }
}
}
`
@@ -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<string, { projectV2?: { id?: string; title?: string } | null; login?: string } | null>
>(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<ResolveProjectRefResult> {
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<string, { projectV2?: { id?: string; title?: string } | null } | null>
>(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 }
: {})
}
}
@@ -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<GetProjectViewTableResult> {
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<typeof m, number> = { 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 }
}