diff --git a/src/main/git/runner.test.ts b/src/main/git/runner.test.ts new file mode 100644 index 000000000..637de12f4 --- /dev/null +++ b/src/main/git/runner.test.ts @@ -0,0 +1,93 @@ +// Why: covers two recent classifier fixes — Retry-After honoring on 429 +// (transient detection must propagate, not silently retry on 250ms cadence) +// and stderr extraction from execFile rejections (err.message is unreliable). +import { describe, expect, it } from 'vitest' +import { extractExecError, isTransientGhError, parseRetryAfterMs } from './runner' + +describe('parseRetryAfterMs', () => { + it('returns null when no Retry-After is present', () => { + expect(parseRetryAfterMs('HTTP 429 Too Many Requests')).toBeNull() + }) + + it('parses integer seconds', () => { + expect(parseRetryAfterMs('HTTP 429\nRetry-After: 30\n')).toBe(30_000) + }) + + it('handles case-insensitive header name and surrounding whitespace', () => { + expect(parseRetryAfterMs(' retry-after: 12 \n')).toBe(12_000) + }) + + it('returns null for malformed values', () => { + expect(parseRetryAfterMs('Retry-After: not-a-date')).toBeNull() + }) +}) + +describe('isTransientGhError', () => { + it('retries 5xx errors', () => { + expect(isTransientGhError('HTTP 502 Bad Gateway')).toBe(true) + expect(isTransientGhError('http 503')).toBe(true) + }) + + it('retries network resets', () => { + expect(isTransientGhError('connect ECONNRESET 10.0.0.1:443')).toBe(true) + expect(isTransientGhError('socket hang up')).toBe(true) + }) + + it('retries 429 without Retry-After', () => { + expect(isTransientGhError('HTTP 429 Too Many Requests')).toBe(true) + }) + + it('does NOT retry 429 with Retry-After', () => { + // Why: when GitHub returns Retry-After, the server is telling us how long + // to wait. Retrying on our 250ms cadence just earns another 429 and burns + // the retry budget. + expect( + isTransientGhError('HTTP 429 Too Many Requests\nRetry-After: 60\n') + ).toBe(false) + }) + + it('does NOT retry 4xx that aren\'t 429', () => { + expect(isTransientGhError('HTTP 401 Unauthorized')).toBe(false) + expect(isTransientGhError('HTTP 404 Not Found')).toBe(false) + expect(isTransientGhError('HTTP 422 Unprocessable Entity')).toBe(false) + }) +}) + +describe('extractExecError', () => { + it('reads stderr and stdout from explicit fields', () => { + const err = Object.assign(new Error('Command failed'), { + stderr: 'real stderr content', + stdout: '{"data": null}' + }) + expect(extractExecError(err)).toEqual({ + stderr: 'real stderr content', + stdout: '{"data": null}' + }) + }) + + it('decodes Buffer stderr/stdout', () => { + const err = Object.assign(new Error('Command failed'), { + stderr: Buffer.from('buf-stderr', 'utf-8'), + stdout: Buffer.from('buf-stdout', 'utf-8') + }) + expect(extractExecError(err)).toEqual({ + stderr: 'buf-stderr', + stdout: 'buf-stdout' + }) + }) + + it('falls back to err.message when stderr/stdout are absent', () => { + const err = new Error('Some message') + expect(extractExecError(err)).toEqual({ + stderr: 'Some message', + stdout: '' + }) + }) + + it('handles non-Error rejections', () => { + expect(extractExecError('plain string error')).toEqual({ + stderr: 'plain string error', + stdout: '' + }) + }) +}) diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index 9ed285df9..1be5acc94 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -70,13 +70,26 @@ function translateArgsForWsl(args: string[]): string[] { function resolveCommand( command: string, args: string[], - cwd: string | undefined + cwd: string | undefined, + wslDistroOverride?: string ): ResolvedCommand { - if (!cwd || process.platform !== 'win32') { + if (process.platform !== 'win32') { return { binary: command, args, cwd, wsl: null } } - const wsl = parseWslPath(cwd) + // Why: global gh callers (rate_limit, listAccessibleProjects) have no + // meaningful cwd to derive a WSL distro from. On WSL-only Windows setups, + // gh.exe isn't on the host PATH and the spawn fails with ENOENT. Allow + // callers to pass a distro hint so we can route through wsl.exe regardless. + // TODO(wsl-default-distro): the codebase currently has no persistent + // "default WSL distro" setting — distros are derived from individual repo + // paths. Until such a setting exists, global gh callers without an explicit + // override silently fall back to host gh.exe, which on WSL-only Windows + // installs will ENOENT. The wslDistroOverride parameter is the hook for + // wiring a future setting in without re-plumbing the runner. + const cwdWsl = cwd ? parseWslPath(cwd) : null + const wsl: WslPathInfo | null = + cwdWsl ?? (wslDistroOverride ? { distro: wslDistroOverride, linuxPath: '' } : null) if (!wsl) { return { binary: command, args, cwd, wsl: null } } @@ -89,8 +102,13 @@ function resolveCommand( const escapedArgs = translatedArgs.map( (a) => `'${a.replace(/'/g, "'\\''")}'` ) - const escapedCwd = wsl.linuxPath.replace(/'/g, "'\\''") - const shellCmd = `cd '${escapedCwd}' && ${command} ${escapedArgs.join(' ')}` + // Why: when cwd is supplied as a WSL UNC path, prepend `cd &&` + // so the command runs in the expected directory. When the caller only + // supplied a distro override (no cwd), skip the cd entirely — the gh CLI + // doesn't need a particular cwd for global calls like `api rate_limit`. + const shellCmd = cwdWsl + ? `cd '${cwdWsl.linuxPath.replace(/'/g, "'\\''")}' && ${command} ${escapedArgs.join(' ')}` + : `${command} ${escapedArgs.join(' ')}` return { binary: 'wsl.exe', @@ -188,23 +206,177 @@ export function gitSpawn( // ─── gh CLI runners ───────────────────────────────────────────────── +// Why: non-repo-scoped gh calls (listAccessibleProjects, rate_limit, etc.) +// have no meaningful cwd. Allow it to be omitted so the one WSL-aware wrapper +// serves both repo-scoped and global callers and we stop having two spawn +// sites (the other one — a plain execFileAsync in project-view.ts — bypasses +// retry/backoff and any future quota tracker). +// Why: `wslDistro` is an explicit hint for global (cwd-less) gh callers on +// WSL-only Windows installs where gh.exe isn't on the host PATH. When set, +// resolveCommand routes the spawn through `wsl.exe -d -- gh ...` +// even without a UNC cwd to parse a distro from. Repo-scoped callers should +// keep using cwd — the distro derives from the path automatically there. +type GhExecOptions = Omit & { cwd?: string; wslDistro?: string } + +/** + * Extract stderr from an execFile rejection. + * + * Why: Node's execFile rejects with an Error that has `.stdout` and `.stderr` + * fields populated separately from `.message`. Reading `err.message` alone is + * unreliable — it can truncate stderr or omit it entirely depending on Node + * version and maxBuffer behavior. We prefer the explicit fields and fall + * back to `.message` only when neither is present. + */ +export function extractExecError(err: unknown): { stderr: string; stdout: string } { + if (err && typeof err === 'object') { + const e = err as { stderr?: unknown; stdout?: unknown; message?: unknown } + const stderr = + typeof e.stderr === 'string' + ? e.stderr + : Buffer.isBuffer(e.stderr) + ? e.stderr.toString('utf-8') + : '' + const stdout = + typeof e.stdout === 'string' + ? e.stdout + : Buffer.isBuffer(e.stdout) + ? e.stdout.toString('utf-8') + : '' + if (stderr || stdout) { + return { stderr, stdout } + } + if (typeof e.message === 'string') { + return { stderr: e.message, stdout: '' } + } + } + return { stderr: String(err), stdout: '' } +} + +/** + * Detect a Retry-After hint in gh stderr and return the suggested delay in ms, + * or null when the response includes no Retry-After. + * + * Why: gh forwards response headers when verbose, and prints "Retry-After: + * " in error output for primary rate-limit 429s. When present, the + * caller is better served by propagating the error so the UI can surface the + * real wait time — retrying on our own 250ms cadence just earns another 429 + * and burns the retry budget. Also supports HTTP-date Retry-After values. + */ +export function parseRetryAfterMs(stderr: string): number | null { + const m = stderr.match(/retry-after:\s*([^\r\n]+)/i) + if (!m) { + return null + } + const raw = m[1].trim() + if (/^\d+$/.test(raw)) { + const seconds = Number(raw) + return Number.isFinite(seconds) ? seconds * 1000 : null + } + const ts = Date.parse(raw) + if (Number.isNaN(ts)) { + return null + } + return Math.max(0, ts - Date.now()) +} + +/** + * Classify whether a gh execFile rejection is worth retrying. + * + * Why: gh surfaces HTTP status in stderr as "HTTP 504", "HTTP 502", etc. + * Network resets and DNS hiccups also show up as stderr substrings. We retry + * those and 429 (rate-limited) — but only 429s without an explicit + * Retry-After (the caller is better off propagating so the UI can show the + * actual wait time). The primary-rate-limit 403 branch is NOT retried: those + * require the user to back off for minutes, which is not transient. + */ +export function isTransientGhError(stderr: string): boolean { + const s = stderr.toLowerCase() + if ( + s.includes('http 500') || + s.includes('http 502') || + s.includes('http 503') || + s.includes('http 504') || + s.includes('econnreset') || + s.includes('etimedout') || + s.includes('socket hang up') + ) { + return true + } + // 429 without Retry-After: retry. With Retry-After: propagate. + if (s.includes('http 429')) { + return parseRetryAfterMs(stderr) === null + } + return false +} + +// Why: total of 3 attempts (original + 2 retries) with 250ms → 1s backoff. +// These are standard "transient 5xx" values. Longer waits push past user +// patience for an interactive action; shorter waits would hammer the same +// unhealthy upstream that just failed. The array length defines retry count; +// total attempts = length + 1. +const GH_RETRY_DELAYS_MS = [250, 1000] as const + +// Why: the upstream Retry-After header is server-suggested but unbounded — +// GitHub has been observed to send tens-of-seconds values on rare incidents, +// and a malicious or misconfigured proxy could send anything. Cap the wait +// at 30s so a single transient gh call can never block the IPC main thread +// for longer than the user's patience budget for an interactive action. +const GH_RETRY_AFTER_MAX_MS = 30_000 + +async function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + /** * Async gh CLI execution. Drop-in replacement for * `execFileAsync('gh', args, { cwd, encoding, ... })`. + * + * Retries transient 5xx / 429 (without Retry-After) / network-reset failures + * with exponential backoff. Non-transient errors (auth, 404, rate-limit 403, + * validation, 429-with-Retry-After) fail fast on the first attempt. */ export async function ghExecFileAsync( args: string[], - options: GitExecOptions + options: GhExecOptions = {} ): Promise<{ stdout: string; stderr: string }> { - const resolved = resolveCommand('gh', args, options.cwd) - const { stdout, stderr } = await execFileAsync(resolved.binary, resolved.args, { - cwd: resolved.cwd, - encoding: (options.encoding ?? 'utf-8') as BufferEncoding, - maxBuffer: options.maxBuffer, - timeout: options.timeout, - env: options.env - }) - return { stdout: stdout as string, stderr: stderr as string } + const resolved = resolveCommand('gh', args, options.cwd, options.wslDistro) + let lastError: unknown + for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) { + try { + const { stdout, stderr } = await execFileAsync(resolved.binary, resolved.args, { + cwd: resolved.cwd, + encoding: (options.encoding ?? 'utf-8') as BufferEncoding, + maxBuffer: options.maxBuffer, + timeout: options.timeout, + env: options.env + }) + return { stdout: stdout as string, stderr: stderr as string } + } catch (err) { + lastError = err + const { stderr } = extractExecError(err) + const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length + if (!isLastAttempt && isTransientGhError(stderr)) { + // Why: when the upstream surfaced a Retry-After (e.g. on a transient + // 5xx that GitHub explicitly recommends backing off for), honor it + // instead of using our default backoff — sleeping less than the + // server suggests just earns another failure and burns our retry + // budget. Cap at GH_RETRY_AFTER_MAX_MS so a pathologically large + // hint can't block IPC for minutes; if the real wait is longer, the + // attempt will fail again and the error will propagate to the UI + // where the user can see it. + const retryAfterMs = parseRetryAfterMs(stderr) + const delayMs = + retryAfterMs !== null + ? Math.min(retryAfterMs, GH_RETRY_AFTER_MAX_MS) + : GH_RETRY_DELAYS_MS[attempt] + await sleep(delayMs) + continue + } + throw err + } + } + // Unreachable: the loop either returns or throws. Here for TS exhaustiveness. + throw lastError } // ─── Generic command runner (for rg, etc.) ────────────────────────── diff --git a/src/main/github/project-view.test.ts b/src/main/github/project-view.test.ts new file mode 100644 index 000000000..5630b7615 --- /dev/null +++ b/src/main/github/project-view.test.ts @@ -0,0 +1,141 @@ +// Why: covers the recent fixes — +// (a) network errors must NOT be misclassified as not_found ("could not +// resolve host" partially overlaps "could not resolve to a"), +// (b) repo slug validation must accept names with leading underscore +// (GitHub allows them, e.g. `_internal`), +// (c) owner slug validation must reject `.`/`_` (GitHub disallows them in +// usernames/orgs), +// (d) parseProjectPaste shorthand owner-only alphabet matches the renderer. +import { describe, expect, it } from 'vitest' +import { + classifyProjectError, + isValidOwnerSlug, + isValidRepoSlug, + parseProjectPaste +} from './project-view' + +describe('classifyProjectError', () => { + it('classifies HTTP 404 as not_found', () => { + expect(classifyProjectError('HTTP 404 Not Found', '').type).toBe('not_found') + }) + + it('classifies "Could not resolve to a User" as not_found', () => { + expect( + classifyProjectError('Could not resolve to a User with the login of foo', '').type + ).toBe('not_found') + }) + + it('classifies "could not resolve host" as network_error, NOT not_found', () => { + // Why: this was the bug — substring "could not resolve" overlaps. The + // network branch must run before not_found, and the not_found check + // must require "to a " to disambiguate. + expect(classifyProjectError('could not resolve host: api.github.com', '').type).toBe( + 'network_error' + ) + }) + + it('classifies "dial tcp" timeouts as network_error', () => { + expect(classifyProjectError('dial tcp 140.82.112.3:443: i/o timeout', '').type).toBe( + 'network_error' + ) + }) + + it('classifies rate-limit text as rate_limited', () => { + expect(classifyProjectError('API rate limit exceeded for user', '').type).toBe('rate_limited') + }) + + it('classifies missing-scope as scope_missing', () => { + expect( + classifyProjectError('your token has not been granted the required scopes', '').type + ).toBe('scope_missing') + }) + + it('classifies auth-required when gh is not signed in', () => { + expect(classifyProjectError('gh auth login required', '').type).toBe('auth_required') + }) +}) + +describe('isValidOwnerSlug', () => { + it('accepts plain alphanumerics and hyphens', () => { + expect(isValidOwnerSlug('acme')).toBe(true) + expect(isValidOwnerSlug('acme-co')).toBe(true) + expect(isValidOwnerSlug('user1')).toBe(true) + }) + + it('rejects underscore (GitHub disallows it in usernames/orgs)', () => { + expect(isValidOwnerSlug('_acme')).toBe(false) + expect(isValidOwnerSlug('acme_co')).toBe(false) + }) + + it('rejects leading hyphen and dot', () => { + expect(isValidOwnerSlug('-acme')).toBe(false) + expect(isValidOwnerSlug('.acme')).toBe(false) + }) + + it('rejects empty and slash-containing values', () => { + expect(isValidOwnerSlug('')).toBe(false) + expect(isValidOwnerSlug('a/b')).toBe(false) + expect(isValidOwnerSlug(123)).toBe(false) + }) +}) + +describe('isValidRepoSlug', () => { + it('accepts leading underscore (GitHub allows it for repo names)', () => { + expect(isValidRepoSlug('_internal')).toBe(true) + }) + + it('accepts leading dot', () => { + expect(isValidRepoSlug('.github')).toBe(true) + }) + + it('accepts dots, dashes, underscores anywhere', () => { + expect(isValidRepoSlug('repo-name')).toBe(true) + expect(isValidRepoSlug('repo.name')).toBe(true) + expect(isValidRepoSlug('repo_name')).toBe(true) + }) + + it('rejects reserved single/double dot', () => { + expect(isValidRepoSlug('.')).toBe(false) + expect(isValidRepoSlug('..')).toBe(false) + }) + + it('rejects path separators and empty', () => { + expect(isValidRepoSlug('a/b')).toBe(false) + expect(isValidRepoSlug('')).toBe(false) + }) +}) + +describe('parseProjectPaste', () => { + it('parses owner/number shorthand', () => { + expect(parseProjectPaste('acme/42')).toEqual({ kind: 'bare', owner: 'acme', number: 42 }) + }) + + it('rejects shorthand with underscore in owner (renderer parity)', () => { + // Why: the renderer's parser uses `[A-Za-z0-9][A-Za-z0-9-]*` for owner + // (matches OWNER_SLUG_RE). Both sides must reject the same inputs. + expect(parseProjectPaste('co_op/45')).toBeNull() + }) + + it('parses org URL with view number', () => { + expect( + parseProjectPaste('https://github.com/orgs/acme/projects/42/views/3') + ).toEqual({ kind: 'org', owner: 'acme', number: 42, viewNumber: 3 }) + }) + + it('parses user URL', () => { + expect(parseProjectPaste('https://github.com/users/octocat/projects/1')).toEqual({ + kind: 'user', + owner: 'octocat', + number: 1 + }) + }) + + it('rejects URLs whose owner has invalid characters', () => { + expect(parseProjectPaste('https://github.com/orgs/co_op/projects/1')).toBeNull() + }) + + it('returns null for empty input', () => { + expect(parseProjectPaste('')).toBeNull() + expect(parseProjectPaste(' ')).toBeNull() + }) +}) diff --git a/src/main/github/project-view.ts b/src/main/github/project-view.ts new file mode 100644 index 000000000..549bc9897 --- /dev/null +++ b/src/main/github/project-view.ts @@ -0,0 +1,1523 @@ +/* eslint-disable max-lines -- Why: ProjectV2 GraphQL has its own normalization +layer, retry policy (parent-field dance), paste-to-add parser, and discovery +pagination. Co-locating the read path keeps the retry/classify/normalize +contract reviewable as one surface. Lower-level plumbing (slug validation, +error classifier, runGraphql/runRest) lives in ./project-view/internals; the +slug-addressed write path lives in ./project-view/mutations. */ +import { + acquire, + release, + extractExecError, + ghExecFileAsync, + rateLimitGuard, + noteRateLimitSpend, + classifyProjectError, + driftError, + errorsIndicateParentField, + rateLimitedError, + runGraphql, + isValidOwnerSlug, + assertSlug, + assertPositiveInt, + type GhGraphqlErrorShape, + type GraphqlVars +} from './project-view/internals' +import type { + GetProjectViewTableArgs, + GetProjectViewTableResult, + GitHubProjectField, + GitHubProjectFieldValue, + GitHubProjectIteration, + GitHubProjectLabel, + GitHubProjectOwnerType, + GitHubProjectRow, + GitHubProjectRowItemType, + GitHubProjectSingleSelectOption, + GitHubProjectSort, + GitHubProjectSummary, + GitHubProjectTable, + GitHubProjectUser, + GitHubProjectView, + GitHubProjectViewError, + GitHubProjectViewLayout, + GitHubProjectViewSummary, + ListAccessibleProjectsResult, + ListProjectViewsArgs, + ListProjectViewsResult, + ResolveProjectRefArgs, + ResolveProjectRefResult +} from '../../shared/github-project-types' + +// Re-export the public API so existing call sites (`./project-view`) keep +// working unchanged. The split is internal-only. +export { + isValidOwnerSlug, + isValidRepoSlug, + isValidSlug, + classifyProjectError +} from './project-view/internals' +export { + updateProjectItemFieldValue, + clearProjectItemFieldValue, + updateIssueBySlug, + updatePullRequestBySlug, + addIssueCommentBySlug, + updateIssueCommentBySlug, + deleteIssueCommentBySlug, + listLabelsBySlug, + listAssignableUsersBySlug, + listIssueTypesBySlug, + updateIssueTypeBySlug, + getWorkItemDetailsBySlug +} from './project-view/mutations' + +// ─── Constants ───────────────────────────────────────────────────────── + +// Why: these defaults were deliberately shrunk from 50/50/100 to cut quota +// spend in the most expensive gh call reachable from TaskPage. Discovery +// walks viewer projects, then up to `DISCOVERY_MAX_ORGS` orgs × a nested +// `projectsV2(first:N)` query. The org loop dominates the cost and is the +// path that produced the user-visible HTTP 504 when one org was slow. Users +// with projects outside this window can still paste a URL to reach them — +// no functional loss. Prior values: MAX_ORGS=50, ORG_PAGE_SIZE=30, +// PROJECTS_PER_OWNER=100, nested projectsV2 first=50. +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 + +// ─── Module-scope caches (reset on HMR — intentional) ────────────────── + +// Why: HMR reloading should re-probe capability. Both caches live as plain +// module locals so a dev-time code swap naturally re-runs capability probes +// instead of carrying a stale "unsupported" flag into fresh code. +const ownerTypeCache = new Map() +let parentFieldRetried = false +let parentFieldWarningLogged = false +// Why: concurrent fetchAllItems calls all observe parentFieldRetried=false, +// each issuing a duplicate first-page probe and racing to set the flag. Use +// an in-flight promise so only one caller drives the probe; siblings await +// the same result. +let parentFieldProbeInFlight: Promise | null = null + +/** @internal — test-only */ +export function _resetProjectViewModuleState(): void { + ownerTypeCache.clear() + parentFieldRetried = false + parentFieldWarningLogged = false + parentFieldProbeInFlight = null +} + +// ─── 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 } + } + default: + // Unknown __typename → forward-compat: drop silently, do not throw, + // do not 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 + 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 + ) + 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 +): Promise<{ ok: true; fields: RawProjectV2Field[] } | { ok: false; error: GitHubProjectViewError }> { + // Why: address the view directly via `node(id:)` instead of re-fetching the + // whole project + walking views every page. Previous shape paid an + // unnecessary `${VIEWS_PAGE_SIZE}` views fan-out per field-continuation + // page; the new shape is one round-trip per field page, which is the + // minimum possible cost. Field-paged views are rare (>50 fields), so this + // only matters for the few projects that hit it — but when they do, the + // savings compound across pagination loops. + 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 }) + 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: runGraphql returns the classified error but not the raw GraphQL +// errors; for the parent-field retry decision we need those. This variant +// returns the raw envelope so callers can re-inspect. +async function fetchItemsPageWithRaw(args: { + owner: string + ownerType: GitHubProjectOwnerType + projectNumber: number + query: string + first: number + after: string | null + includeParent: boolean +}): Promise< + | { ok: true; page: RawItemsPage } + | { + ok: false + error: GitHubProjectViewError + rawErrors: GhGraphqlErrorShape[] + stderr: string + } +> { + 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}`)} + + const guard = rateLimitGuard('graphql') + if (guard.blocked) { + return { + ok: false, + error: rateLimitedError(guard), + rawErrors: [], + stderr: '' + } + } + await acquire() + noteRateLimitSpend('graphql') + try { + let stdout = '' + let stderr = '' + let execFailed = false + try { + const r = await ghExecFileAsync(argsArr, { encoding: 'utf-8' }) + 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: when gh exits non-zero with no parseable JSON on stdout (network, + // auth, rate-limit, missing scope), classify against stderr so callers + // see the real cause instead of a synthesized drift/not-found. + if (execFailed) { + return { + ok: false, + error: classifyProjectError(stderr, stdout), + rawErrors: [], + stderr + } + } + return { + ok: false, + error: driftError('failed to parse items response'), + rawErrors: [], + stderr + } + } + // Why: gh exec rejected but stdout still had a parseable error envelope — + // fall through to the parsed.errors branch below. If parsed has neither + // data nor errors, 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), + rawErrors: [], + stderr + } + } + if (parsed.errors && parsed.errors.length > 0) { + return { + ok: false, + error: classifyProjectError(stderr, stdout), + 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 +}): Promise< + | { ok: true; rows: GitHubProjectRow[]; totalCount: number; parentFieldDropped: boolean } + | { ok: false; error: GitHubProjectViewError; totalCount?: number } +> { + // Why: if another caller is currently probing whether Issue.parent is + // supported, await its decision so we don't fire a duplicate with-parent + // probe and don't capture a stale includeParent. We must re-read + // parentFieldRetried AFTER awaiting because the probe may have flipped it. + if (parentFieldProbeInFlight) { + await parentFieldProbeInFlight.catch(() => {}) + } + let includeParent = !parentFieldRetried + let parentFieldDropped = parentFieldRetried + // First page — single-flight the with-parent attempt so concurrent callers + // observe one probe result instead of each issuing their own. We must + // assign parentFieldProbeInFlight synchronously (no await between the + // null check and the assignment) so concurrent callers race on the same + // promise rather than each creating a duplicate probe. + let first: Awaited> + let probePromise: Promise>> | null = null + if (includeParent && !parentFieldProbeInFlight) { + let resolveProbe: () => void = () => {} + parentFieldProbeInFlight = new Promise((resolve) => { + resolveProbe = resolve + }) + probePromise = (async () => { + try { + return await fetchItemsPageWithRaw({ + owner: args.owner, + ownerType: args.ownerType, + projectNumber: args.projectNumber, + query: args.query, + first: ITEM_PAGE_SIZE, + after: null, + includeParent: true + }) + } finally { + resolveProbe() + parentFieldProbeInFlight = null + } + })() + 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 + }) + } + if (!first.ok && includeParent && errorsIndicateParentField(first.rawErrors, first.stderr)) { + // Retry the whole table without parent. Set module flag so the rest of + // the process never re-probes until HMR or restart. + parentFieldRetried = true + includeParent = false + parentFieldDropped = true + if (!parentFieldWarningLogged) { + console.warn( + '[project-view] Issue.parent is not available on this token — retrying without the parent selection.' + ) + parentFieldWarningLogged = true + } + first = await fetchItemsPageWithRaw({ + owner: args.owner, + ownerType: args.ownerType, + projectNumber: args.projectNumber, + query: args.query, + first: ITEM_PAGE_SIZE, + after: null, + includeParent: false + }) + } + 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 + }) + 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 +}): 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 }) + 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, + 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 as soon as we have ANY match — including 'default' (first + // table view). Continuing to walk views pages for a default selector + // costs one extra GraphQL call per page with no upside: the default + // contract is "first table view we see", and view layouts don't change + // ordering between pages such that a later view would outrank the + // first table layout. Previously we kept walking on default match + // because the precedence comment hinted at re-ranking, but no real + // selector promotes a 'default' to a stronger match within the same + // selector input — those ranks only matter when the caller supplied + // a selector. Bail early on any non-null selectedRaw. + 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) + 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: an explicit empty-string override means "no filter"; treat undefined + // as "use the view's filter as-is". The override is ephemeral — never + // persisted to GitHub — so users can clear the search without mutating the + // view's stored filter. + const effectiveQuery = + typeof args.queryOverride === 'string' ? args.queryOverride : selectedView.filter + + // Unsupported layout: return without paginating items; attempt a cheap + // count-only query best-effort. + if (selectedView.layout !== 'TABLE_LAYOUT') { + const count = await fetchItemsCountOnly({ + owner: args.owner, + ownerType: args.ownerType, + projectNumber: args.projectNumber, + query: effectiveQuery + }) + 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 + }) + if (!items.ok) { + return { + ok: false, + error: items.error, + ...(typeof items.totalCount === 'number' ? { totalCount: items.totalCount } : {}) + } + } + + const table: GitHubProjectTable = { + project: { + id: project.id, + 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(): Promise { + const viewerProjects: GitHubProjectSummary[] = [] + const orgProjects: GitHubProjectSummary[] = [] + // Why: per-org failures are collected so the picker can render a "some orgs + // didn't load" banner with the affected logins, instead of aborting the + // whole discovery on the first 504. Users with flaky org fetches still get + // viewer + other orgs in the list. + 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) + if (!res.ok) { + // Why: a viewer-level failure is structural — if we can't list the + // user's own projects, we have nothing to build on. Propagate as a + // hard error instead of returning partial. Org-level errors below + // are non-fatal because the viewer slice is still useful on its own. + 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, + 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: we intentionally drop the per-org continuation loop that previously + // ran `organization(login).projectsV2(first:50, after:$after)` when the + // first nested page had more. That inner loop was the dominant cost + // multiplier and the most common 504 source — a single slow org would + // serially block the picker for tens of seconds. Users with more than + // DISCOVERY_PROJECTS_PER_ORG projects in a given org can still paste a + // URL to reach them; the picker is discovery, not an exhaustive index. + 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) + if (!res.ok) { + // Why: the org-listing query itself failed (not a nested projectsV2). + // Record it as a partial failure against a synthetic `*` owner so the + // UI banner explains why additional orgs aren't listed, but keep any + // viewer projects we already collected. This is the critical 504 path + // the user reported in the ProjectPicker. + 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 owner → ownerType for downstream paste/resolve even when the + // nested projects query was empty or partially failed — paste-to-add + // uses this to disambiguate /orgs/ vs /users/ URLs. + ownerTypeCache.set(login, 'organization') + 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, + 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) {ownerTypeCache.set(viewerLogin, 'user')} + + return { + ok: true, + projects: [...viewerProjects, ...orgProjects], + ...(partialFailures.length > 0 ? { partialFailures } : {}) + } +} + +// ─── resolveProjectRef ───────────────────────────────────────────────── + +type ParsedPaste = + | { kind: 'org'; owner: string; number: number; viewNumber?: number } + | { kind: 'user'; owner: string; number: number; viewNumber?: number } + | { kind: 'bare'; owner: string; number: number } + +export function parseProjectPaste(input: string): ParsedPaste | null { + const trimmed = input.trim() + if (!trimmed) {return null} + // URL forms + const urlRe = /^https?:\/\/github\.com\/(orgs|users)\/([^/]+)\/projects\/(\d+)(?:\/views\/(\d+))?/i + const m = trimmed.match(urlRe) + if (m) { + const [, kindSeg, owner, nStr, vStr] = m + const number = parseInt(nStr, 10) + if (!Number.isInteger(number) || number < 1) {return null} + if (!isValidOwnerSlug(owner)) {return null} + const viewNumber = vStr ? parseInt(vStr, 10) : undefined + return { + kind: kindSeg === 'orgs' ? 'org' : 'user', + owner, + number, + ...(viewNumber !== undefined && Number.isInteger(viewNumber) && viewNumber >= 1 + ? { viewNumber } + : {}) + } + } + // 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 = 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 +): 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) + 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 = ownerTypeCache.get(owner) + 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) { + ownerTypeCache.set(owner, ot) + 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 } + } + } + ownerTypeCache.set(owner, null) + return { + ok: false, + error: lastError ?? { type: 'not_found', message: 'Owner not found.' } + } +} + +export async function resolveProjectRef( + args: ResolveProjectRefArgs +): Promise { + if (typeof args.input !== 'string' || !args.input.trim()) { + return { + ok: false, + error: { type: 'validation_error', message: 'Input required.' } + } + } + const parsed = parseProjectPaste(args.input) + 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 + // Verify by fetching project title. + const ownerRes = await resolveOwnerType(parsed.owner, preferred) + 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 }) + 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 ?? '', + // Why: forward the parsed view number from /views/{n} URLs so the + // renderer can skip the view-pick step. parsed.kind === 'bare' has no + // viewNumber (owner/number shorthand carries no view). + ...(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, + 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/internals.ts b/src/main/github/project-view/internals.ts new file mode 100644 index 000000000..779741aa5 --- /dev/null +++ b/src/main/github/project-view/internals.ts @@ -0,0 +1,355 @@ +/* eslint-disable max-lines -- Why: shared infrastructure for project-view — +slug validation, error classification, runGraphql/runRest, and rate-limit +synthesis. Co-located so the read and write paths observe identical +classification semantics. */ +// Why: `ghExecFileAsync` (WSL-aware, retry-enabled) is the single spawn site +// for gh calls. The legacy plain `execFileAsync` is NOT used here — routing +// every gh call through the runner gives us transient-5xx retry, WSL path +// translation, and a single hook point for future quota tracking. +import { acquire, release } from '../gh-utils' +import { extractExecError, ghExecFileAsync } from '../../git/runner' +import { rateLimitGuard, noteRateLimitSpend, type RateLimitBucketKind } from '../rate-limit' +import type { GitHubProjectViewError } from '../../../shared/github-project-types' + +export { acquire, release, extractExecError, ghExecFileAsync, rateLimitGuard, noteRateLimitSpend } +export type { RateLimitBucketKind } + +// ─── Slug validation ────────────────────────────────────────────────── + +// Why: GitHub usernames/org logins disallow `_`, `.`, leading `-`. Repo names +// are looser — they allow leading `_`, `.`, `-` (`.` and `..` reserved). We +// validate each separately so untrusted Project row data (`nameWithOwner`) +// can't become an arbitrary REST path while still accepting realistic repo +// names like `_internal` or `.github`. +const OWNER_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/ +const REPO_SLUG_RE = /^[A-Za-z0-9._-]+$/ +const REPO_SLUG_RESERVED = new Set(['.', '..']) + +export function isValidOwnerSlug(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && OWNER_SLUG_RE.test(value) +} + +export function isValidRepoSlug(value: unknown): value is string { + return ( + typeof value === 'string' && + value.length > 0 && + REPO_SLUG_RE.test(value) && + !REPO_SLUG_RESERVED.has(value) + ) +} + +// Backwards-compatible alias for callers that don't distinguish owner vs repo. +// Prefer `isValidOwnerSlug` / `isValidRepoSlug` at new call sites. +export function isValidSlug(value: unknown): value is string { + return isValidOwnerSlug(value) || isValidRepoSlug(value) +} + +export function assertSlug( + value: unknown, + field: 'owner' | 'repo' +): { ok: true; slug: string } | { ok: false; error: GitHubProjectViewError } { + const valid = field === 'owner' ? isValidOwnerSlug(value) : isValidRepoSlug(value) + if (!valid) { + return { + ok: false, + error: { + type: 'validation_error', + message: `Invalid ${field}: "${String(value)}" is not a valid GitHub slug.` + } + } + } + return { ok: true, slug: value as string } +} + +export function assertPositiveInt( + value: unknown, + field: string +): { ok: true; n: number } | { ok: false; error: GitHubProjectViewError } { + if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) { + return { + ok: false, + error: { + type: 'validation_error', + message: `Invalid ${field}: must be a positive integer.` + } + } + } + return { ok: true, n: value } +} + +export function validateSlugArgs( + owner: unknown, + repo: unknown +): { ok: true } | { ok: false; error: GitHubProjectViewError } { + const o = assertSlug(owner, 'owner') + if (!o.ok) {return { ok: false, error: o.error }} + const r = assertSlug(repo, 'repo') + if (!r.ok) {return { ok: false, error: r.error }} + return { ok: true } +} + +// ─── Error classification ────────────────────────────────────────────── + +export type GhGraphqlErrorShape = { + type?: string + message?: string + path?: (string | number)[] + extensions?: { code?: string } +} + +export function extractGraphqlErrors(stderr: string, stdout: string): GhGraphqlErrorShape[] { + // `gh api graphql` prints the response JSON to stdout even on GraphQL + // errors, and the stderr carries a summary. Try stdout first; if parsing + // fails, fall back to stderr. + const sources = [stdout, stderr] + for (const src of sources) { + if (!src) {continue} + try { + const parsed = JSON.parse(src) as { errors?: GhGraphqlErrorShape[] } + if (parsed.errors && parsed.errors.length > 0) { + return parsed.errors + } + } catch { + // not JSON — continue + } + } + return [] +} + +export function errorsIndicateParentField( + errors: GhGraphqlErrorShape[], + stderr: string +): boolean { + const lower = stderr.toLowerCase() + // Preview-header shape: gh returns a 4xx with "preview" in the message. + if (lower.includes('preview') && lower.includes('parent')) {return true} + return errors.some((e) => { + const type = (e.type ?? '').toUpperCase() + if (type === 'FIELD_NOT_FOUND' || type === 'UNDEFINED_FIELD' || type === 'FIELD_ERRORS') { + const tail = e.path?.at(-1) + if (tail === 'parent') {return true} + // FIELD_ERRORS often omits `path`; match on message for the parent field. + if ((e.message ?? '').toLowerCase().includes('parent')) {return true} + } + return false + }) +} + +export function classifyProjectError(stderr: string, stdout: string): GitHubProjectViewError { + const errors = extractGraphqlErrors(stderr, stdout) + const s = stderr.toLowerCase() + + // Auth + if (s.includes('authentication required') || s.includes('not logged in') || s.includes('gh auth login')) { + return { + type: 'auth_required', + message: 'Sign in to GitHub to load project tasks. Run `gh auth login`.' + } + } + // Scope + if ( + s.includes('missing required scope') || + s.includes("your token has not been granted") || + (s.includes('resource not accessible') && (s.includes('project') || s.includes('scope'))) + ) { + return { + type: 'scope_missing', + message: + 'GitHub project access needs additional scopes. Run `gh auth refresh -s project -s read:org -s repo`.' + } + } + // Rate limit + if (s.includes('rate limit') || s.includes('api rate limit exceeded')) { + return { type: 'rate_limited', message: 'GitHub rate limit hit. Try again in a few minutes.' } + } + // Network — checked BEFORE not_found because DNS failures surface as + // "could not resolve host", which would otherwise be partially matched by + // the not_found branch's "could not resolve" check. Substring matching here + // is a one-way trapdoor: a real GraphQL "Could not resolve to a User…" + // error always contains "to a", so we tighten the not_found check below to + // require that token. + if ( + s.includes('timeout') || + s.includes('no such host') || + s.includes('network') || + s.includes('could not resolve host') || + s.includes('dial tcp') + ) { + return { type: 'network_error', message: 'Network error — check your connection.' } + } + // Not found + if ( + s.includes('http 404') || + errors.some((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND') || + s.includes('could not resolve to a ') + ) { + const firstNotFound = errors.find((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND') + return { + type: 'not_found', + message: 'Project or view not found.', + details: firstNotFound + ? { path: firstNotFound.path, code: firstNotFound.extensions?.code } + : undefined + } + } + // Validation + if (s.includes('http 422') || s.includes('validation failed')) { + return { type: 'validation_error', message: `Invalid request — ${stderr.trim()}` } + } + // GraphQL error with structured info + if (errors.length > 0) { + const first = errors[0] + return { + type: 'unknown', + message: first.message ?? 'Unknown GraphQL error.', + details: { path: first.path, code: first.extensions?.code } + } + } + // Why: don't leak full stderr to the UI — it can include verbose request + // dumps with header diagnostics. Truncate to the first non-empty line and + // cap length so unexpected diagnostics stay readable but bounded. + const firstLine = stderr + .split('\n') + .map((l) => l.trim()) + .find((l) => l.length > 0) ?? '' + const safe = firstLine.length > 200 ? `${firstLine.slice(0, 200)}…` : firstLine + return { type: 'unknown', message: safe ? `GitHub request failed: ${safe}` : 'GitHub request failed.' } +} + +export function driftError( + reason: string, + details?: { path?: (string | number)[]; code?: string } +): GitHubProjectViewError { + return { type: 'schema_drift', message: `Could not read this project view: ${reason}.`, details } +} + +// Why: the rate-limit circuit breaker short-circuits before we spawn `gh` +// when the cached snapshot says we're below the safety floor. Synthesize the +// same `rate_limited` error shape as the post-hoc classifier so the UI path +// is unchanged. We DO NOT fail open here when there's no cached snapshot — +// rateLimitGuard already handles that case (returns `blocked:false`). +export function rateLimitedError( + blocked: { remaining: number; limit: number; resetAt: number } +): GitHubProjectViewError { + const resetIn = Math.max(0, blocked.resetAt - Math.floor(Date.now() / 1000)) + const mins = Math.ceil(resetIn / 60) + return { + type: 'rate_limited', + message: `GitHub rate limit nearly exhausted (${blocked.remaining}/${blocked.limit} left). Resets in ~${mins}m.` + } +} + +// ─── Low-level gh api graphql invocation ─────────────────────────────── + +export type GraphqlVars = Record + +export async function runGraphql( + query: string, + vars: GraphqlVars, + cwd?: string +): Promise< + | { ok: true; data: T } + | { ok: false; error: GitHubProjectViewError; raw: { stderr: string; stdout: string } } +> { + const guard = rateLimitGuard('graphql') + if (guard.blocked) { + return { ok: false, error: rateLimitedError(guard), raw: { stderr: '', stdout: '' } } + } + // Why: build argv as an array. `-f` for strings (including numbers passed + // as strings), `-F` coerces to typed. We use `-f` uniformly and coerce in + // the query via Int! casts, because `gh` can confuse empty strings. + const args: string[] = ['api', 'graphql', '-f', `query=${query}`] + for (const [k, v] of Object.entries(vars)) { + if (typeof v === 'number' || typeof v === 'boolean') { + args.push('-F', `${k}=${String(v)}`) + } else { + args.push('-f', `${k}=${v}`) + } + } + await acquire() + noteRateLimitSpend('graphql') + try { + const { stdout, stderr } = await ghExecFileAsync(args, { + encoding: 'utf-8', + ...(cwd ? { cwd } : {}) + }) + try { + const parsed = JSON.parse(stdout) as { data?: T; errors?: GhGraphqlErrorShape[] } + if (parsed.errors && parsed.errors.length > 0) { + return { + ok: false, + error: classifyProjectError(stderr, stdout), + raw: { stderr, stdout } + } + } + if (parsed.data === undefined) { + return { + ok: false, + error: driftError('response missing data'), + raw: { stderr, stdout } + } + } + return { ok: true, data: parsed.data } + } catch (parseErr) { + return { + ok: false, + error: driftError( + `failed to parse response (${parseErr instanceof Error ? parseErr.message : String(parseErr)})` + ), + raw: { stderr, stdout } + } + } + } catch (err) { + // gh executable failures (non-zero exit). Read stderr/stdout from the + // exec rejection's explicit fields — `err.message` may truncate stderr. + const { stderr, stdout: maybeStdout } = extractExecError(err) + return { + ok: false, + error: classifyProjectError(stderr, maybeStdout), + raw: { stderr, stdout: maybeStdout } + } + } finally { + release() + } +} + +export async function runRest( + args: string[], + cwd?: string, + bucket: RateLimitBucketKind = 'core', + options?: { expectEmpty?: boolean } +): Promise<{ ok: true; data: T } | { ok: false; error: GitHubProjectViewError }> { + const guard = rateLimitGuard(bucket) + if (guard.blocked) { + return { ok: false, error: rateLimitedError(guard) } + } + await acquire() + noteRateLimitSpend(bucket) + try { + const { stdout, stderr } = await ghExecFileAsync(['api', ...args], { + encoding: 'utf-8', + ...(cwd ? { cwd } : {}) + }) + // Why: 204/empty-body endpoints (DELETE label, DELETE comment) return no + // body. Treat empty stdout as success rather than misclassifying the + // unparseable response as 'unknown' — which the caller would otherwise + // need to special-case and risks masking real failures whose stderr the + // classifier also tags as 'unknown'. + if (options?.expectEmpty && stdout.trim() === '') { + return { ok: true, data: undefined as T } + } + try { + return { ok: true, data: JSON.parse(stdout) as T } + } catch { + return { + ok: false, + error: { type: 'unknown', message: `Unexpected REST response: ${stderr.trim()}` } + } + } + } catch (err) { + const { stderr, stdout: maybeStdout } = extractExecError(err) + return { ok: false, error: classifyProjectError(stderr, maybeStdout) } + } finally { + release() + } +} diff --git a/src/main/github/project-view/mutations.ts b/src/main/github/project-view/mutations.ts new file mode 100644 index 000000000..97cdfeb2a --- /dev/null +++ b/src/main/github/project-view/mutations.ts @@ -0,0 +1,738 @@ +/* eslint-disable max-lines -- Why: slug-addressed mutations + work-item +details share the validate/runRest/runGraphql plumbing with the read path. +Keeping them together preserves a single review surface for the write side. */ +import { + acquire, + release, + extractExecError, + ghExecFileAsync, + rateLimitGuard, + noteRateLimitSpend, + classifyProjectError, + rateLimitedError, + runGraphql, + runRest, + validateSlugArgs, + assertPositiveInt, + type GraphqlVars +} from './internals' +import type { GitHubAssignableUser, GitHubWorkItemDetails, PRComment } from '../../../shared/types' +import type { + AddIssueCommentBySlugArgs, + ClearProjectItemFieldArgs, + DeleteIssueCommentBySlugArgs, + GitHubProjectCommentMutationResult, + GitHubProjectFieldMutationValue, + GitHubProjectMutationResult, + ListAssignableUsersBySlugArgs, + ListAssignableUsersBySlugResult, + ListIssueTypesBySlugArgs, + ListIssueTypesBySlugResult, + ListLabelsBySlugArgs, + ListLabelsBySlugResult, + ProjectWorkItemDetailsBySlugArgs, + ProjectWorkItemDetailsBySlugResult, + UpdateIssueBySlugArgs, + UpdateIssueCommentBySlugArgs, + UpdateIssueTypeBySlugArgs, + UpdatePullRequestBySlugArgs, + UpdateProjectItemFieldArgs +} from '../../../shared/github-project-types' + +// ─── Project field mutations ────────────────────────────────────────── + +class UnknownFieldMutationKindError extends Error { + constructor(kind: string) { + super(`Unknown project field mutation kind: ${kind}`) + } +} + +function graphqlValueForFieldMutation(value: GitHubProjectFieldMutationValue): string { + // Serialize the value fragment for the GraphQL mutation. We use GraphQL + // variables for every dynamic piece, so here we only pick the variable name + // to reference per value kind. + switch (value.kind) { + case 'single-select': + return 'singleSelectOptionId: $value' + case 'iteration': + return 'iterationId: $value' + case 'text': + return 'text: $value' + case 'number': + return 'number: $value' + case 'date': + return 'date: $value' + default: + // Why: defensive default. If a new mutation kind is added to the type + // but not handled here, returning undefined would silently produce a + // broken GraphQL query. Throw so updateProjectItemFieldValue can map it + // to a validation_error rather than dispatching a malformed mutation. + throw new UnknownFieldMutationKindError((value as { kind: string }).kind) + } +} + +function mutationValueVar(value: GitHubProjectFieldMutationValue): { + type: string + val: string | number +} { + switch (value.kind) { + case 'single-select': + return { type: 'String!', val: value.optionId } + case 'iteration': + return { type: 'String!', val: value.iterationId } + case 'text': + return { type: 'String!', val: value.text } + case 'number': + return { type: 'Float!', val: value.number } + case 'date': + return { type: 'Date!', val: value.date } + default: + // Why: see graphqlValueForFieldMutation — surface unknown kinds loudly + // instead of returning undefined and dispatching an invalid mutation. + throw new UnknownFieldMutationKindError((value as { kind: string }).kind) + } +} + +export async function updateProjectItemFieldValue( + args: UpdateProjectItemFieldArgs +): Promise { + if (!args.projectId || !args.itemId || !args.fieldId) { + return { ok: false, error: { type: 'validation_error', message: 'Missing ids.' } } + } + let valFrag: string + let valVar: { type: string; val: string | number } + try { + valFrag = graphqlValueForFieldMutation(args.value) + valVar = mutationValueVar(args.value) + } catch (err) { + if (err instanceof UnknownFieldMutationKindError) { + return { ok: false, error: { type: 'validation_error', message: err.message } } + } + throw err + } + const query = ` + mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!, $value:${valVar.type}) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { ${valFrag} } + }) { projectV2Item { id } } + } + ` + const vars: GraphqlVars = { + projectId: args.projectId, + itemId: args.itemId, + fieldId: args.fieldId, + value: valVar.val + } + const res = await runGraphql(query, vars) + if (!res.ok) {return { ok: false, error: res.error }} + return { ok: true } +} + +export async function clearProjectItemFieldValue( + args: ClearProjectItemFieldArgs +): Promise { + if (!args.projectId || !args.itemId || !args.fieldId) { + return { ok: false, error: { type: 'validation_error', message: 'Missing ids.' } } + } + const query = ` + mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!) { + clearProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + }) { projectV2Item { id } } + } + ` + const res = await runGraphql(query, { + projectId: args.projectId, + itemId: args.itemId, + fieldId: args.fieldId + }) + if (!res.ok) {return { ok: false, error: res.error }} + return { ok: true } +} + +// ─── Slug-addressed issue/PR mutations ──────────────────────────────── + +export async function updateIssueBySlug( + args: UpdateIssueBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) {return v} + const n = assertPositiveInt(args.number, 'number') + if (!n.ok) {return { ok: false, error: n.error }} + if (!args.updates || typeof args.updates !== 'object') { + return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } } + } + const { title, body, state, addLabels, removeLabels, addAssignees, removeAssignees } = args.updates + + // Title / body / state go through PATCH /repos/{owner}/{repo}/issues/{n}. + // Labels/assignees go through their dedicated endpoints. + const base = `repos/${args.owner}/${args.repo}/issues/${args.number}` + + // 1) PATCH body + if (title !== undefined || body !== undefined || state !== undefined) { + const patchArgs: string[] = ['-X', 'PATCH', base] + if (title !== undefined) {patchArgs.push('--raw-field', `title=${title}`)} + if (body !== undefined) {patchArgs.push('--raw-field', `body=${body}`)} + if (state !== undefined) {patchArgs.push('--raw-field', `state=${state}`)} + const r = await runRest(patchArgs) + if (!r.ok) {return { ok: false, error: r.error }} + } + + // 2) Labels — collapse multi-delete fan-out into a single PUT when removing + // >1 label. PUT /labels replaces the entire label set, so we fetch the + // current labels first and compute the resulting set client-side. This + // turns an N-delete + 1-add (=N+1 calls) into 1-fetch + 1-PUT (=2 calls) + // once removeLabels has more than one entry, capping the cost at 2 even + // for a "remove all 20 labels" mutation. + const removeCount = removeLabels?.length ?? 0 + const addCount = addLabels?.length ?? 0 + if (removeCount > 1) { + type RawLabelResp = { name?: string }[] + const fetched = await runRest(['-X', 'GET', `${base}/labels`]) + if (!fetched.ok) {return { ok: false, error: fetched.error }} + const currentNames = new Set( + fetched.data.map((l) => l.name).filter((n): n is string => typeof n === 'string') + ) + for (const l of removeLabels ?? []) {currentNames.delete(l)} + for (const l of addLabels ?? []) {currentNames.add(l)} + if (currentNames.size === 0) { + // Why: `gh api -X PUT` with no `--raw-field` arguments sends an empty + // body — GitHub does NOT interpret that as "clear labels". The + // dedicated DELETE endpoint is the documented way to remove all + // labels in a single call. + const r = await runRest( + ['-X', 'DELETE', `${base}/labels`], + undefined, + 'core', + { expectEmpty: true } + ) + if (!r.ok && r.error.type !== 'not_found') {return { ok: false, error: r.error }} + } else { + const putArgs = ['-X', 'PUT', `${base}/labels`] + for (const name of currentNames) {putArgs.push('--raw-field', `labels[]=${name}`)} + const r = await runRest(putArgs) + if (!r.ok) {return { ok: false, error: r.error }} + } + } else { + if (addCount > 0) { + const restArgs = ['-X', 'POST', `${base}/labels`] + for (const l of addLabels ?? []) {restArgs.push('--raw-field', `labels[]=${l}`)} + const r = await runRest(restArgs) + if (!r.ok) {return { ok: false, error: r.error }} + } + if (removeCount === 1) { + const r = await runRest( + ['-X', 'DELETE', `${base}/labels/${encodeURIComponent(removeLabels![0])}`], + undefined, + 'core', + { expectEmpty: true } + ) + if (!r.ok && r.error.type !== 'not_found') {return { ok: false, error: r.error }} + } + } + + // 3) Assignees — POST and DELETE both accept arrays in a single call, so + // add/remove are at most 2 calls regardless of array size. + if (addAssignees && addAssignees.length > 0) { + const restArgs = ['-X', 'POST', `${base}/assignees`] + for (const u of addAssignees) {restArgs.push('--raw-field', `assignees[]=${u}`)} + const r = await runRest(restArgs) + if (!r.ok) {return { ok: false, error: r.error }} + } + if (removeAssignees && removeAssignees.length > 0) { + const restArgs = ['-X', 'DELETE', `${base}/assignees`] + for (const u of removeAssignees) {restArgs.push('--raw-field', `assignees[]=${u}`)} + const r = await runRest(restArgs) + if (!r.ok) {return { ok: false, error: r.error }} + } + return { ok: true } +} + +export async function updatePullRequestBySlug( + args: UpdatePullRequestBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) {return v} + const n = assertPositiveInt(args.number, 'number') + if (!n.ok) {return { ok: false, error: n.error }} + if (!args.updates || typeof args.updates !== 'object') { + return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } } + } + const patchArgs: string[] = ['-X', 'PATCH', `repos/${args.owner}/${args.repo}/pulls/${args.number}`] + // Why: count fields explicitly rather than inferring from patchArgs.length — + // adding a future header/flag arg silently breaks an array-length check. + let fieldCount = 0 + if (args.updates.title !== undefined) { + patchArgs.push('--raw-field', `title=${args.updates.title}`) + fieldCount++ + } + if (args.updates.body !== undefined) { + patchArgs.push('--raw-field', `body=${args.updates.body}`) + fieldCount++ + } + if (fieldCount === 0) { + // No fields to update — nothing to do. + return { ok: true } + } + const r = await runRest(patchArgs) + if (!r.ok) {return { ok: false, error: r.error }} + return { ok: true } +} + +type RawIssueCommentResponse = { + id?: number + user?: { login?: string; avatar_url?: string; type?: string } | null + body?: string + created_at?: string + html_url?: string +} + +function mapIssueComment(data: RawIssueCommentResponse, fallbackBody: string): PRComment { + return { + id: data.id ?? Date.now(), + author: data.user?.login ?? 'You', + authorAvatarUrl: data.user?.avatar_url ?? '', + body: data.body ?? fallbackBody, + createdAt: data.created_at ?? new Date().toISOString(), + url: data.html_url ?? '', + isBot: data.user?.type === 'Bot' + } +} + +export async function addIssueCommentBySlug( + args: AddIssueCommentBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) {return v} + const n = assertPositiveInt(args.number, 'number') + if (!n.ok) {return { ok: false, error: n.error }} + if (typeof args.body !== 'string' || !args.body.trim()) { + return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } } + } + const r = await runRest([ + '-X', + 'POST', + `repos/${args.owner}/${args.repo}/issues/${args.number}/comments`, + '--raw-field', + `body=${args.body}` + ]) + if (!r.ok) {return { ok: false, error: r.error }} + return { ok: true, comment: mapIssueComment(r.data, args.body) } +} + +export async function updateIssueCommentBySlug( + args: UpdateIssueCommentBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) {return v} + const n = assertPositiveInt(args.commentId, 'commentId') + if (!n.ok) {return { ok: false, error: n.error }} + if (typeof args.body !== 'string' || !args.body.trim()) { + return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } } + } + const r = await runRest([ + '-X', + 'PATCH', + `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`, + '--raw-field', + `body=${args.body}` + ]) + if (!r.ok) {return { ok: false, error: r.error }} + return { ok: true } +} + +export async function deleteIssueCommentBySlug( + args: DeleteIssueCommentBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) {return v} + const n = assertPositiveInt(args.commentId, 'commentId') + if (!n.ok) {return { ok: false, error: n.error }} + const r = await runRest( + ['-X', 'DELETE', `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`], + undefined, + 'core', + { expectEmpty: true } + ) + if (!r.ok) {return { ok: false, error: r.error }} + return { ok: true } +} + +// ─── Slug-addressed picker sources ──────────────────────────────────── + +export async function listLabelsBySlug( + args: ListLabelsBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) {return v} + const guard = rateLimitGuard('core') + if (guard.blocked) {return { ok: false, error: rateLimitedError(guard) }} + await acquire() + // Why: `--paginate` may fan out to multiple pages; we can only reasonably + // estimate a 1-call spend up front. The next probe will reconcile. + noteRateLimitSpend('core') + try { + const { stdout } = await ghExecFileAsync( + ['api', '--paginate', `repos/${args.owner}/${args.repo}/labels`, '--jq', '.[].name'], + { encoding: 'utf-8' } + ) + return { + ok: true, + labels: stdout + .trim() + .split('\n') + .filter((l) => l.length > 0) + } + } catch (err) { + const { stderr, stdout: maybeStdout } = extractExecError(err) + return { ok: false, error: classifyProjectError(stderr, maybeStdout) } + } finally { + release() + } +} + +export async function listAssignableUsersBySlug( + args: ListAssignableUsersBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) {return v} + // Seed logins merge after the fetch so callers can include currently-visible + // assignees even if the repo participant search is sparse. + const result: GitHubAssignableUser[] = [] + const guard = rateLimitGuard('core') + if (guard.blocked) {return { ok: false, error: rateLimitedError(guard) }} + await acquire() + noteRateLimitSpend('core') + try { + const { stdout } = await ghExecFileAsync( + [ + 'api', + '--paginate', + `repos/${args.owner}/${args.repo}/assignees`, + '--jq', + '.[] | {login: .login, name: null, avatarUrl: .avatar_url}' + ], + { encoding: 'utf-8' } + ) + for (const line of stdout.trim().split('\n').filter((l) => l.length > 0)) { + try { + const u = JSON.parse(line) as { login?: string; avatarUrl?: string; name?: string | null } + if (typeof u.login === 'string') { + result.push({ login: u.login, name: u.name ?? null, avatarUrl: u.avatarUrl ?? '' }) + } + } catch { + // skip malformed jq line + } + } + } catch (err) { + const { stderr } = extractExecError(err) + return { ok: false, error: classifyProjectError(stderr, '') } + } finally { + release() + } + if (args.seedLogins) { + const seen = new Set(result.map((u) => u.login)) + for (const login of args.seedLogins) { + if (typeof login === 'string' && !seen.has(login)) { + result.push({ login, name: null, avatarUrl: '' }) + seen.add(login) + } + } + } + return { ok: true, users: result } +} + +// Why: Issue Types are a repo-level taxonomy (Bug/Feature/Task/etc) only +// available on repos opted into typed-issues. Empty list (or schema_drift on +// older GitHub deployments) is the legitimate "this repo doesn't use issue +// types" signal — callers should treat it as "no editor". +export async function listIssueTypesBySlug( + args: ListIssueTypesBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) {return v} + const query = ` + query($owner:String!, $repo:String!) { + repository(owner:$owner, name:$repo) { + issueTypes(first:50) { + nodes { id name color description } + } + } + } + ` + const res = await runGraphql<{ + repository?: { + issueTypes?: { + nodes?: ({ + id?: string + name?: string + color?: string | null + description?: string | null + } | null)[] + } | null + } | null + }>(query, { owner: args.owner, repo: args.repo }) + if (!res.ok) { + // Why: repos without issue types respond with a GraphQL error claiming the + // `issueTypes` field is unknown. Map that to an empty list so the UI shows + // "no editor" instead of an angry banner. + if (res.error.type === 'schema_drift' || res.error.type === 'validation_error') { + return { ok: true, types: [] } + } + return { ok: false, error: res.error } + } + const nodes = res.data.repository?.issueTypes?.nodes ?? [] + const types = nodes + .filter((n): n is NonNullable => n !== null && typeof n.id === 'string' && typeof n.name === 'string') + .map((n) => ({ + id: n.id as string, + name: n.name as string, + color: typeof n.color === 'string' ? n.color : null, + description: typeof n.description === 'string' ? n.description : null + })) + return { ok: true, types } +} + +export async function updateIssueTypeBySlug( + args: UpdateIssueTypeBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) {return v} + const n = assertPositiveInt(args.number, 'number') + if (!n.ok) {return { ok: false, error: n.error }} + // Why: `updateIssueIssueType` is the dedicated mutation; passing null for + // `issueTypeId` clears the type. We resolve the issue id via a lightweight + // GraphQL lookup because the REST endpoint doesn't accept issue types. + const lookup = await runGraphql<{ + repository?: { issue?: { id?: string } | null } | null + }>( + `query($owner:String!, $repo:String!, $num:Int!) { + repository(owner:$owner, name:$repo) { issue(number:$num) { id } } + }`, + { owner: args.owner, repo: args.repo, num: args.number } + ) + if (!lookup.ok) {return { ok: false, error: lookup.error }} + const issueId = lookup.data.repository?.issue?.id + if (!issueId) { + return { ok: false, error: { type: 'not_found', message: 'Issue not found.' } } + } + // Why: build the mutation conditionally so a null clear doesn't have to + // smuggle a null GraphQL variable through `gh api graphql -f`. The + // mutation accepts a literal `null` in the input object directly. + const query = args.issueTypeId + ? ` + mutation($issueId:ID!, $issueTypeId:ID!) { + updateIssueIssueType(input: { issueId: $issueId, issueTypeId: $issueTypeId }) { + issue { id } + } + } + ` + : ` + mutation($issueId:ID!) { + updateIssueIssueType(input: { issueId: $issueId, issueTypeId: null }) { + issue { id } + } + } + ` + const vars: GraphqlVars = args.issueTypeId + ? { issueId, issueTypeId: args.issueTypeId } + : { issueId } + const res = await runGraphql(query, vars) + if (!res.ok) {return { ok: false, error: res.error }} + return { ok: true } +} + +// ─── Slug-addressed work-item details ───────────────────────────────── + +type RawUser = { login?: string; name?: string | null; avatarUrl?: string | null } +type RawLabel = { name?: string; color?: string } +type RawWorkItemContent = { + id?: string + number?: number + title?: string + url?: string + state?: string + stateReason?: string | null + isDraft?: boolean + labels?: { nodes?: RawLabel[] } + assignees?: { nodes?: RawUser[] } +} + +export async function getWorkItemDetailsBySlug( + args: ProjectWorkItemDetailsBySlugArgs +): Promise { + const v = validateSlugArgs(args.owner, args.repo) + if (!v.ok) {return v} + const n = assertPositiveInt(args.number, 'number') + if (!n.ok) {return { ok: false, error: n.error }} + if (args.type !== 'issue' && args.type !== 'pr') { + return { ok: false, error: { type: 'validation_error', message: 'Invalid type.' } } + } + + // Single GraphQL round-trip to fetch the issue/PR summary + comments + labels + assignees. + const contentFrag = + args.type === 'issue' + ? ` + issue(number:$num) { + id number title url state stateReason updatedAt + body + author { login } + labels(first:50) { nodes { name } } + assignees(first:50) { nodes { login } } + participants(first:50) { nodes { login name avatarUrl } } + comments(first:100) { + nodes { + databaseId + author { login avatarUrl __typename } + body createdAt url + } + } + } + ` + : ` + pullRequest(number:$num) { + id number title url state isDraft updatedAt headRefName baseRefName + body + author { login } + labels(first:50) { nodes { name } } + assignees(first:50) { nodes { login } } + participants(first:50) { nodes { login name avatarUrl } } + comments(first:100) { + nodes { + databaseId + author { login avatarUrl __typename } + body createdAt url + } + } + } + ` + const query = ` + query($owner:String!, $repo:String!, $num:Int!) { + repository(owner:$owner, name:$repo) { + ${contentFrag} + } + } + ` + const res = await runGraphql<{ + repository?: { + issue?: RawWorkItemContent & { + updatedAt?: string + body?: string + author?: { login?: string } | null + participants?: { nodes?: RawUser[] } + comments?: { + nodes?: ({ + databaseId?: number + author?: { login?: string; avatarUrl?: string; __typename?: string } | null + body?: string + createdAt?: string + url?: string + } | null)[] + } + } | null + pullRequest?: RawWorkItemContent & { + updatedAt?: string + body?: string + headRefName?: string + baseRefName?: string + author?: { login?: string } | null + participants?: { nodes?: RawUser[] } + comments?: { + nodes?: ({ + databaseId?: number + author?: { login?: string; avatarUrl?: string; __typename?: string } | null + body?: string + createdAt?: string + url?: string + } | null)[] + } + } | null + } | null + }>(query, { owner: args.owner, repo: args.repo, num: args.number }) + if (!res.ok) {return { ok: false, error: res.error }} + const raw = args.type === 'issue' ? res.data.repository?.issue : res.data.repository?.pullRequest + if (!raw) { + return { ok: false, error: { type: 'not_found', message: 'Item not found.' } } + } + + const labels = (raw.labels?.nodes ?? []) + .map((l) => l?.name) + .filter((n): n is string => typeof n === 'string') + const assignees = (raw.assignees?.nodes ?? []) + .map((a) => a?.login) + .filter((l): l is string => typeof l === 'string') + const comments: PRComment[] = [] + for (const c of raw.comments?.nodes ?? []) { + if (!c || typeof c.body !== 'string') {continue} + comments.push({ + id: typeof c.databaseId === 'number' ? c.databaseId : Date.now(), + author: c.author?.login ?? '', + authorAvatarUrl: c.author?.avatarUrl ?? '', + body: c.body, + createdAt: typeof c.createdAt === 'string' ? c.createdAt : '', + url: typeof c.url === 'string' ? c.url : '', + isBot: c.author?.__typename === 'Bot' + }) + } + const participants: GitHubAssignableUser[] = [] + for (const p of raw.participants?.nodes ?? []) { + if (p && typeof p.login === 'string') { + participants.push({ login: p.login, name: p.name ?? null, avatarUrl: p.avatarUrl ?? '' }) + } + } + + const state: 'open' | 'closed' | 'merged' | 'draft' = + args.type === 'pr' + ? raw.isDraft + ? 'draft' + : raw.state === 'MERGED' + ? 'merged' + : raw.state === 'CLOSED' + ? 'closed' + : 'open' + : raw.state === 'CLOSED' + ? 'closed' + : 'open' + + const details: GitHubWorkItemDetails = { + item: { + id: typeof raw.id === 'string' ? raw.id : '', + type: args.type, + number: typeof raw.number === 'number' ? raw.number : args.number, + title: typeof raw.title === 'string' ? raw.title : '', + state, + url: typeof raw.url === 'string' ? raw.url : '', + labels, + updatedAt: + typeof (raw as { updatedAt?: string }).updatedAt === 'string' + ? (raw as { updatedAt: string }).updatedAt + : '', + author: + typeof (raw as { author?: { login?: string } | null }).author?.login === 'string' + ? ((raw as { author: { login: string } }).author.login as string) + : null, + branchName: + args.type === 'pr' && typeof (raw as { headRefName?: string }).headRefName === 'string' + ? ((raw as { headRefName: string }).headRefName as string) + : undefined, + baseRefName: + args.type === 'pr' && typeof (raw as { baseRefName?: string }).baseRefName === 'string' + ? ((raw as { baseRefName: string }).baseRefName as string) + : undefined + }, + body: typeof raw.body === 'string' ? raw.body : '', + comments, + participants, + // Why: PR files/checks/review-thread tabs depend on a local repo path and + // are out of Project-mode slug scope for v1. Omit them here; the dialog + // branches on their absence and hides those tabs. + ...(args.type === 'issue' ? { assignees } : {}) + } + return { ok: true, details } +} + diff --git a/src/main/github/rate-limit.ts b/src/main/github/rate-limit.ts new file mode 100644 index 000000000..138e078f7 --- /dev/null +++ b/src/main/github/rate-limit.ts @@ -0,0 +1,149 @@ +/** + * GitHub API rate-limit probe. + * + * Why: `listWorkItems` fan-out × selected repos plus `countWorkItems` in + * parallel, plus `listAccessibleProjects` org-walk, can chew through the + * core (5000/hr) or search (30/min) buckets quickly. Surfacing the remaining + * budget in the TaskPage header lets users self-regulate before they hit the + * wall — without actually throttling (which would hurt responsiveness in + * the common not-near-the-limit case). The probe itself is exempt from + * rate-limit accounting per GitHub docs. + * + * The result is intentionally minimal: we expose just the counts the UI + * needs (remaining + limit for the three buckets we actually stress). If a + * future feature needs reset-time countdowns we can add resetAt here. + */ +import type { + GetRateLimitResult, + GitHubRateLimitBucket, + GitHubRateLimitSnapshot +} from '../../shared/types' +import { acquire, release } from './gh-utils' +import { ghExecFileAsync } from '../git/runner' + +// Why: GitHub explicitly states `GET /rate_limit` does NOT count against +// any bucket, so the only reason to cache is to avoid spawning a `gh` +// subprocess on every render. 30s is a pragmatic balance — short enough +// that the number in the header feels live, long enough to absorb the +// 1-per-second "is it safe now?" polling pattern UIs tend to fall into. +const RATE_LIMIT_CACHE_TTL_MS = 30_000 +let cached: GitHubRateLimitSnapshot | null = null + +type GhRateLimitPayload = { + resources?: { + core?: { limit?: number; remaining?: number; reset?: number } + search?: { limit?: number; remaining?: number; reset?: number } + graphql?: { limit?: number; remaining?: number; reset?: number } + } +} + +function parseBucket(raw: { + limit?: number + remaining?: number + reset?: number +} | undefined): GitHubRateLimitBucket { + // Why: if a bucket is absent from the response (old gh, partial response), + // return 0/0/now so the UI shows a clear "unknown" state (0/0 is + // unambiguous) rather than a misleading "plenty left" fallback. + return { + limit: typeof raw?.limit === 'number' ? raw.limit : 0, + remaining: typeof raw?.remaining === 'number' ? raw.remaining : 0, + resetAt: typeof raw?.reset === 'number' ? raw.reset : Math.floor(Date.now() / 1000) + } +} + +/** @internal — test-only */ +export function _resetRateLimitCache(): void { + cached = null +} + +// Why: hard-stop thresholds for the circuit breaker. We refuse to issue a new +// gh request when the cached snapshot says the relevant bucket is below this +// floor. Numbers chosen as "enough budget for one user-initiated flow": +// - core/graphql at 50: a typical work-item details fetch + a few mutations +// - search at 2: a search-driven view paginates in chunks of 1; 2 leaves the +// user one safety click without tipping into the 30/min hard limit +// Below the floor, callers get a synthesized rate_limited error and never +// spawn a gh subprocess. +const MIN_REMAINING_CORE = 50 +const MIN_REMAINING_GRAPHQL = 50 +const MIN_REMAINING_SEARCH = 2 + +export type RateLimitBucketKind = 'core' | 'graphql' | 'search' + +/** + * Return a "soft" stop reason if we should refuse to issue a new gh request + * for the given bucket. Returns null when there's no cached snapshot (we + * haven't probed yet — fail open) or when the bucket has enough budget left. + * + * Why: this is the proactive guard the pill alone cannot provide. The pill is + * informational; this function actually blocks the spawn. We deliberately keep + * it advisory (returns a reason, doesn't throw) so callers can format the + * envelope/error in their own shape. + */ +export function rateLimitGuard(bucket: RateLimitBucketKind): { blocked: false } | { + blocked: true + remaining: number + limit: number + resetAt: number +} { + if (!cached) { + return { blocked: false } + } + const b = cached[bucket] + const floor = + bucket === 'core' + ? MIN_REMAINING_CORE + : bucket === 'graphql' + ? MIN_REMAINING_GRAPHQL + : MIN_REMAINING_SEARCH + // Why: only block when we have a positive limit (limit:0 means "unknown" per + // parseBucket fallback — don't block on missing data, that would brick the + // app on a single bad rate_limit response). + if (b.limit > 0 && b.remaining < floor) { + return { blocked: true, remaining: b.remaining, limit: b.limit, resetAt: b.resetAt } + } + return { blocked: false } +} + +/** + * Decrement the cached `remaining` counter for a bucket after a successful + * spawn. Why: the canonical numbers come from the next probe, but between + * probes the cached snapshot would over-report budget if we didn't account + * for the work we just did. The decrement keeps the circuit breaker honest + * during a burst (e.g. paginating items) instead of waiting 30s for the cache + * to expire. + */ +export function noteRateLimitSpend(bucket: RateLimitBucketKind, cost = 1): void { + if (!cached) { + return + } + const b = cached[bucket] + if (b.remaining > 0) { + cached = { ...cached, [bucket]: { ...b, remaining: Math.max(0, b.remaining - cost) } } + } +} + +export async function getRateLimit(options?: { force?: boolean }): Promise { + if (!options?.force && cached && Date.now() - cached.fetchedAt < RATE_LIMIT_CACHE_TTL_MS) { + return { ok: true, snapshot: cached } + } + await acquire() + try { + const { stdout } = await ghExecFileAsync(['api', 'rate_limit'], { encoding: 'utf-8' }) + const parsed = JSON.parse(stdout) as GhRateLimitPayload + const snapshot: GitHubRateLimitSnapshot = { + core: parseBucket(parsed.resources?.core), + search: parseBucket(parsed.resources?.search), + graphql: parseBucket(parsed.resources?.graphql), + fetchedAt: Date.now() + } + cached = snapshot + return { ok: true, snapshot } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { ok: false, error: message } + } finally { + release() + } +} diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 371bf6cd2..419f0f47c 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -32,8 +32,44 @@ import { starOrca } from '../github/client' import { getWorkItemDetails, getPRFileContents } from '../github/work-item-details' +import { getRateLimit } from '../github/rate-limit' import type { GitHubPRFile } from '../../shared/types' import { dispatchWorkItem, type WorkItemArgs } from './github-work-item-args' +import { + getProjectViewTable, + listAccessibleProjects, + resolveProjectRef, + listProjectViews, + getWorkItemDetailsBySlug, + updateProjectItemFieldValue, + clearProjectItemFieldValue, + updateIssueBySlug, + updatePullRequestBySlug, + addIssueCommentBySlug, + updateIssueCommentBySlug, + deleteIssueCommentBySlug, + listLabelsBySlug, + listAssignableUsersBySlug, + listIssueTypesBySlug, + updateIssueTypeBySlug +} from '../github/project-view' +import type { + AddIssueCommentBySlugArgs, + ClearProjectItemFieldArgs, + DeleteIssueCommentBySlugArgs, + GetProjectViewTableArgs, + ListAssignableUsersBySlugArgs, + ListIssueTypesBySlugArgs, + ListLabelsBySlugArgs, + ListProjectViewsArgs, + ProjectWorkItemDetailsBySlugArgs, + ResolveProjectRefArgs, + UpdateIssueBySlugArgs, + UpdateIssueCommentBySlugArgs, + UpdateIssueTypeBySlugArgs, + UpdateProjectItemFieldArgs, + UpdatePullRequestBySlugArgs +} from '../../shared/github-project-types' // Why: returns the full Repo object instead of just the path string so that // callers have access to repo.id for stat tracking and other context. @@ -338,6 +374,84 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi ipcMain.handle('gh:checkOrcaStarred', () => checkOrcaStarred()) ipcMain.handle('gh:starOrca', () => starOrca()) + // Why: `rate_limit` is exempt from GitHub's rate-limit accounting, so + // polling is cheap. A 30s in-process cache still avoids the gh subprocess + // cost on every render — see getRateLimit for the ttl rationale. Force + // parameter lets the renderer bust the cache after a known-expensive op + // (e.g. post-ProjectPicker discovery) without waiting out the ttl. + ipcMain.handle('gh:rateLimit', (_event, args?: { force?: boolean }) => + getRateLimit(args?.force ? { force: true } : undefined) + ) + + // ── GitHub ProjectV2 view handlers ───────────────────────────────── + // Why: registered unconditionally so enabling the experimental flag at + // runtime takes effect without a restart. The renderer gates entry points. + // Handlers never throw across IPC — every failure mode resolves through the + // GitHubProjectViewError envelope. + + ipcMain.handle('gh:listAccessibleProjects', () => listAccessibleProjects()) + + ipcMain.handle('gh:resolveProjectRef', (_event, args: ResolveProjectRefArgs) => + resolveProjectRef(args) + ) + + ipcMain.handle('gh:listProjectViews', (_event, args: ListProjectViewsArgs) => + listProjectViews(args) + ) + + ipcMain.handle('gh:getProjectViewTable', (_event, args: GetProjectViewTableArgs) => + getProjectViewTable(args) + ) + + ipcMain.handle( + 'gh:projectWorkItemDetailsBySlug', + (_event, args: ProjectWorkItemDetailsBySlugArgs) => getWorkItemDetailsBySlug(args) + ) + + ipcMain.handle('gh:updateProjectItemField', (_event, args: UpdateProjectItemFieldArgs) => + updateProjectItemFieldValue(args) + ) + + ipcMain.handle('gh:clearProjectItemField', (_event, args: ClearProjectItemFieldArgs) => + clearProjectItemFieldValue(args) + ) + + ipcMain.handle('gh:updateIssueBySlug', (_event, args: UpdateIssueBySlugArgs) => + updateIssueBySlug(args) + ) + + ipcMain.handle('gh:updatePullRequestBySlug', (_event, args: UpdatePullRequestBySlugArgs) => + updatePullRequestBySlug(args) + ) + + ipcMain.handle('gh:addIssueCommentBySlug', (_event, args: AddIssueCommentBySlugArgs) => + addIssueCommentBySlug(args) + ) + + ipcMain.handle('gh:updateIssueCommentBySlug', (_event, args: UpdateIssueCommentBySlugArgs) => + updateIssueCommentBySlug(args) + ) + + ipcMain.handle('gh:deleteIssueCommentBySlug', (_event, args: DeleteIssueCommentBySlugArgs) => + deleteIssueCommentBySlug(args) + ) + + ipcMain.handle('gh:listLabelsBySlug', (_event, args: ListLabelsBySlugArgs) => + listLabelsBySlug(args) + ) + + ipcMain.handle('gh:listAssignableUsersBySlug', (_event, args: ListAssignableUsersBySlugArgs) => + listAssignableUsersBySlug(args) + ) + + ipcMain.handle('gh:listIssueTypesBySlug', (_event, args: ListIssueTypesBySlugArgs) => + listIssueTypesBySlug(args) + ) + + ipcMain.handle('gh:updateIssueTypeBySlug', (_event, args: UpdateIssueTypeBySlugArgs) => + updateIssueTypeBySlug(args) + ) + // Why: issue-source preference writes go through the generic `repos:update` // IPC (extended in this PR to accept `issueSourcePreference`). Routing // through the same channel keeps a single write path, guarantees the diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 1f6802070..a4214bf15 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -41,6 +41,7 @@ import type { LinearTeam, MarkdownDocument, GitHubIssueUpdate, + GetRateLimitResult, NotificationDispatchRequest, NotificationDispatchResult, OrcaHooks, @@ -61,6 +62,33 @@ import type { WorktreeStartupLaunch, WorkspaceSessionState } from '../shared/types' +import type { + AddIssueCommentBySlugArgs, + ClearProjectItemFieldArgs, + DeleteIssueCommentBySlugArgs, + GetProjectViewTableArgs, + GetProjectViewTableResult, + GitHubProjectCommentMutationResult, + GitHubProjectMutationResult, + ListAccessibleProjectsResult, + ListAssignableUsersBySlugArgs, + ListAssignableUsersBySlugResult, + ListIssueTypesBySlugArgs, + ListIssueTypesBySlugResult, + ListLabelsBySlugArgs, + ListLabelsBySlugResult, + ListProjectViewsArgs, + ListProjectViewsResult, + ProjectWorkItemDetailsBySlugArgs, + ProjectWorkItemDetailsBySlugResult, + ResolveProjectRefArgs, + ResolveProjectRefResult, + UpdateIssueBySlugArgs, + UpdateIssueCommentBySlugArgs, + UpdateIssueTypeBySlugArgs, + UpdatePullRequestBySlugArgs, + UpdateProjectItemFieldArgs +} from '../shared/github-project-types' import type { BrowserSetGrabModeArgs, BrowserSetGrabModeResult, @@ -549,6 +577,47 @@ export type PreloadApi = { listAssignableUsers: (args: { repoPath: string }) => Promise checkOrcaStarred: () => Promise starOrca: () => Promise + /** + * GitHub API rate-limit snapshot. Does NOT consume quota (the + * `rate_limit` endpoint is exempt). Cached 30s server-side — pass + * `force: true` to bust after a known-expensive op. + */ + rateLimit: (args?: { force?: boolean }) => Promise + // ── ProjectV2 (GitHub Projects) ───────────────────────────────── + listAccessibleProjects: () => Promise + resolveProjectRef: (args: ResolveProjectRefArgs) => Promise + listProjectViews: (args: ListProjectViewsArgs) => Promise + getProjectViewTable: (args: GetProjectViewTableArgs) => Promise + projectWorkItemDetailsBySlug: ( + args: ProjectWorkItemDetailsBySlugArgs + ) => Promise + updateProjectItemField: ( + args: UpdateProjectItemFieldArgs + ) => Promise + clearProjectItemField: ( + args: ClearProjectItemFieldArgs + ) => Promise + updateIssueBySlug: (args: UpdateIssueBySlugArgs) => Promise + updatePullRequestBySlug: ( + args: UpdatePullRequestBySlugArgs + ) => Promise + addIssueCommentBySlug: ( + args: AddIssueCommentBySlugArgs + ) => Promise + updateIssueCommentBySlug: ( + args: UpdateIssueCommentBySlugArgs + ) => Promise + deleteIssueCommentBySlug: ( + args: DeleteIssueCommentBySlugArgs + ) => Promise + listLabelsBySlug: (args: ListLabelsBySlugArgs) => Promise + listAssignableUsersBySlug: ( + args: ListAssignableUsersBySlugArgs + ) => Promise + listIssueTypesBySlug: (args: ListIssueTypesBySlugArgs) => Promise + updateIssueTypeBySlug: ( + args: UpdateIssueTypeBySlugArgs + ) => Promise } linear: { connect: (args: { diff --git a/src/preload/index.ts b/src/preload/index.ts index 7e984f8c4..8f91da197 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -13,6 +13,7 @@ import type { CreateWorktreeArgs, CustomSidekick, FsChangedPayload, + GetRateLimitResult, GitHubAssignableUser, GitHubCommentResult, GitHubWorkItem, @@ -24,6 +25,33 @@ import type { } from '../shared/types' import type { RuntimeStatus, RuntimeSyncWindowGraph } from '../shared/runtime-types' import type { RateLimitState } from '../shared/rate-limit-types' +import type { + AddIssueCommentBySlugArgs, + ClearProjectItemFieldArgs, + DeleteIssueCommentBySlugArgs, + GetProjectViewTableArgs, + GetProjectViewTableResult, + GitHubProjectCommentMutationResult, + GitHubProjectMutationResult, + ListAccessibleProjectsResult, + ListAssignableUsersBySlugArgs, + ListAssignableUsersBySlugResult, + ListIssueTypesBySlugArgs, + ListIssueTypesBySlugResult, + ListLabelsBySlugArgs, + ListLabelsBySlugResult, + ListProjectViewsArgs, + ListProjectViewsResult, + ProjectWorkItemDetailsBySlugArgs, + ProjectWorkItemDetailsBySlugResult, + ResolveProjectRefArgs, + ResolveProjectRefResult, + UpdateIssueBySlugArgs, + UpdateIssueCommentBySlugArgs, + UpdateIssueTypeBySlugArgs, + UpdatePullRequestBySlugArgs, + UpdateProjectItemFieldArgs +} from '../shared/github-project-types' import type { SshConnectionState, SshTarget, @@ -589,7 +617,65 @@ const api = { ipcRenderer.invoke('gh:listAssignableUsers', args), checkOrcaStarred: (): Promise => ipcRenderer.invoke('gh:checkOrcaStarred'), - starOrca: (): Promise => ipcRenderer.invoke('gh:starOrca') + starOrca: (): Promise => ipcRenderer.invoke('gh:starOrca'), + + // Why: rate_limit is exempt from rate-limit accounting, but we still pass + // `force` through so callers can bust the 30s in-process cache after a + // known-expensive op (e.g. after ProjectPicker discovery). + rateLimit: (args?: { force?: boolean }): Promise => + ipcRenderer.invoke('gh:rateLimit', args), + + // ── ProjectV2 (GitHub Projects) ─────────────────────────────────── + listAccessibleProjects: (): Promise => + ipcRenderer.invoke('gh:listAccessibleProjects'), + resolveProjectRef: (args: ResolveProjectRefArgs): Promise => + ipcRenderer.invoke('gh:resolveProjectRef', args), + listProjectViews: (args: ListProjectViewsArgs): Promise => + ipcRenderer.invoke('gh:listProjectViews', args), + getProjectViewTable: (args: GetProjectViewTableArgs): Promise => + ipcRenderer.invoke('gh:getProjectViewTable', args), + projectWorkItemDetailsBySlug: ( + args: ProjectWorkItemDetailsBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:projectWorkItemDetailsBySlug', args), + updateProjectItemField: ( + args: UpdateProjectItemFieldArgs + ): Promise => + ipcRenderer.invoke('gh:updateProjectItemField', args), + clearProjectItemField: ( + args: ClearProjectItemFieldArgs + ): Promise => + ipcRenderer.invoke('gh:clearProjectItemField', args), + updateIssueBySlug: (args: UpdateIssueBySlugArgs): Promise => + ipcRenderer.invoke('gh:updateIssueBySlug', args), + updatePullRequestBySlug: ( + args: UpdatePullRequestBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:updatePullRequestBySlug', args), + addIssueCommentBySlug: ( + args: AddIssueCommentBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:addIssueCommentBySlug', args), + updateIssueCommentBySlug: ( + args: UpdateIssueCommentBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:updateIssueCommentBySlug', args), + deleteIssueCommentBySlug: ( + args: DeleteIssueCommentBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:deleteIssueCommentBySlug', args), + listLabelsBySlug: (args: ListLabelsBySlugArgs): Promise => + ipcRenderer.invoke('gh:listLabelsBySlug', args), + listAssignableUsersBySlug: ( + args: ListAssignableUsersBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:listAssignableUsersBySlug', args), + listIssueTypesBySlug: (args: ListIssueTypesBySlugArgs): Promise => + ipcRenderer.invoke('gh:listIssueTypesBySlug', args), + updateIssueTypeBySlug: ( + args: UpdateIssueTypeBySlugArgs + ): Promise => + ipcRenderer.invoke('gh:updateIssueTypeBySlug', args) }, linear: { diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index ea8f3dfe7..2b8a86acc 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -61,7 +61,15 @@ import { type PRCommentGroup } from '@/lib/pr-comment-groups' import { useAppStore } from '@/store' -import { useRepoLabels, useRepoAssignees, useImmediateMutation } from '@/hooks/useIssueMetadata' +import { + useRepoLabels, + useRepoAssignees, + useImmediateMutation +} from '@/hooks/useIssueMetadata' +import { + useRepoLabelsBySlug, + useRepoAssigneesBySlug +} from '@/hooks/useGitHubSlugMetadata' import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator' import type { GitHubOwnerRepo, @@ -131,12 +139,35 @@ const REACTION_EMOJI: Record = { eyes: '👀' } +/** Why: Project-origin rows don't always belong to the active local repo. + * When set, GHEditSection routes label/assignee/state mutations through + * slug-addressed IPCs against `owner`/`repo` instead of through `repoPath`, + * preventing edits from silently landing on the workspace's repo when the + * Project view is showing rows from a different repo. See + * docs/design/github-project-view-tasks.md §Dialog editing from Project rows. + */ +export type GitHubItemDialogProjectOrigin = { + owner: string + repo: string + number: number + type: 'issue' | 'pr' + projectId: string + projectItemId: string + cacheKey: string +} + type GitHubItemDialogProps = { workItem: GitHubWorkItem | null repoPath: string | null /** Called when the user clicks the primary CTA to start work from this item. */ onUse: (item: GitHubWorkItem) => void onClose: () => void + /** Optional Project-origin context. When set, edits in the dialog are + * routed via slug-addressed mutation IPCs against the row's actual repo + * instead of the active workspace's `repoPath`. Both can be set + * simultaneously (Project mode where the row also lives in the active + * workspace) — slug routing wins for writes. */ + projectOrigin?: GitHubItemDialogProjectOrigin } function formatRelativeTime(input: string): string { @@ -1332,7 +1363,18 @@ function ConversationTab({ /> )} - {item.type !== 'pr' ?
{startWorkspaceButton}
: null} + {item.type !== 'pr' ? ( +
+ +
+ ) : null} {rightPanel} @@ -1634,9 +1676,46 @@ function MentionTextarea({ ) } +// Why: when the dialog opens for a Project row whose repo differs from the +// active workspace, mutations must target the row's actual repo via +// slug-addressed IPCs. Otherwise edits silently apply to the workspace's +// repo. The edit IPCs return a structured `{ ok, error }` shape; we adapt +// to a thrown rejection so the existing `useImmediateMutation` flow +// (which expects throws on failure) continues to work unchanged. +async function runIssueUpdate( + args: { + repoPath: string | null + projectOrigin: GitHubItemDialogProjectOrigin | undefined + number: number + updates: Parameters[0]['updates'] + } +): Promise { + if (args.projectOrigin) { + const res = await window.api.gh.updateIssueBySlug({ + owner: args.projectOrigin.owner, + repo: args.projectOrigin.repo, + number: args.number, + updates: args.updates + }) + if (!res.ok) { + throw new Error(res.error.message) + } + return + } + if (!args.repoPath) { + throw new Error('No repo context available for this edit.') + } + await window.api.gh.updateIssue({ + repoPath: args.repoPath, + number: args.number, + updates: args.updates + }) +} + function GHEditSection({ item, repoPath, + projectOrigin, localState, localLabels, onStateChange, @@ -1644,7 +1723,8 @@ function GHEditSection({ assignees }: { item: GitHubWorkItem - repoPath: string + repoPath: string | null + projectOrigin: GitHubItemDialogProjectOrigin | undefined localState: GitHubWorkItem['state'] localLabels: string[] onStateChange: (state: GitHubWorkItem['state']) => void @@ -1656,10 +1736,33 @@ function GHEditSection({ const [localAssignees, setLocalAssignees] = useState(assignees) const hasEditedAssigneesRef = useRef(false) const patchWorkItem = useAppStore((s) => s.patchWorkItem) + const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) const { isPending, run } = useImmediateMutation() - const repoLabels = useRepoLabels(repoPath) - const repoAssignees = useRepoAssignees(repoPath) + // Why: when the dialog opens from a Project view, mutations route through + // *BySlug IPCs and we must keep `projectViewCache` in sync alongside + // `workItemsCache` — `patchWorkItem` only walks the latter, so without this + // helper the Project table would render stale data until manual refresh. + // See docs/design/github-project-view-tasks.md §Dialog editing from Project rows. + const patchProjectRowIfNeeded = useCallback( + (patch: Parameters[2]) => { + if (!projectOrigin) {return} + patchProjectRowContent(projectOrigin.cacheKey, projectOrigin.projectItemId, patch) + }, + [projectOrigin, patchProjectRowContent] + ) + + // Why: when projectOrigin is set we MUST read labels/assignees from the + // row's repo, not from the workspace path — otherwise the popovers list + // values from a different repo than the writes target. + const slugOwner = projectOrigin?.owner ?? null + const slugRepo = projectOrigin?.repo ?? null + const repoLabelsByPath = useRepoLabels(projectOrigin ? null : repoPath) + const repoLabelsBySlug = useRepoLabelsBySlug(slugOwner, slugRepo) + const repoLabels = projectOrigin ? repoLabelsBySlug : repoLabelsByPath + const repoAssigneesByPath = useRepoAssignees(projectOrigin ? null : repoPath) + const repoAssigneesBySlug = useRepoAssigneesBySlug(slugOwner, slugRepo, assignees) + const repoAssignees = projectOrigin ? repoAssigneesBySlug : repoAssigneesByPath // Why: sync local assignees when item changes or when the detail fetch // resolves with real data — but skip if the user already made an @@ -1684,26 +1787,40 @@ function GHEditSection({ const prevState = localState run('state', { mutate: () => - window.api.gh.updateIssue({ + runIssueUpdate({ repoPath, + projectOrigin, number: item.number, updates: { state: newState } }), onOptimistic: () => { onStateChange(newState) patchWorkItem(item.id, { state: newState }) + patchProjectRowIfNeeded({ state: newState }) }, onRevert: () => { onStateChange(prevState) patchWorkItem(item.id, { state: prevState }) + patchProjectRowIfNeeded({ state: prevState }) }, onSuccess: () => { patchWorkItem(item.id, { state: newState }) + patchProjectRowIfNeeded({ state: newState }) }, onError: (err) => toast.error(err) }) }, - [item.id, item.number, localState, repoPath, patchWorkItem, run, onStateChange] + [ + item.id, + item.number, + localState, + repoPath, + projectOrigin, + patchWorkItem, + patchProjectRowIfNeeded, + run, + onStateChange + ] ) const handleLabelToggle = useCallback( @@ -1715,44 +1832,60 @@ function GHEditSection({ if (isAdding) { run('labels', { mutate: () => - window.api.gh.updateIssue({ + runIssueUpdate({ repoPath, + projectOrigin, number: item.number, updates: { addLabels: [label] } }), onOptimistic: () => { onLabelsChange(newLabels) patchWorkItem(item.id, { labels: newLabels }) + patchProjectRowIfNeeded({ labels: newLabels }) }, onSuccess: () => {}, onRevert: () => { onLabelsChange(prevLabels) patchWorkItem(item.id, { labels: prevLabels }) + patchProjectRowIfNeeded({ labels: prevLabels }) }, onError: (err) => toast.error(err) }) } else { run('labels', { mutate: () => - window.api.gh.updateIssue({ + runIssueUpdate({ repoPath, + projectOrigin, number: item.number, updates: { removeLabels: [label] } }), onOptimistic: () => { onLabelsChange(newLabels) patchWorkItem(item.id, { labels: newLabels }) + patchProjectRowIfNeeded({ labels: newLabels }) }, onRevert: () => { onLabelsChange(prevLabels) patchWorkItem(item.id, { labels: prevLabels }) + patchProjectRowIfNeeded({ labels: prevLabels }) }, onSuccess: () => {}, onError: (err) => toast.error(err) }) } }, - [item.id, item.number, localLabels, repoPath, patchWorkItem, run, onLabelsChange] + [ + item.id, + item.number, + localLabels, + repoPath, + projectOrigin, + patchWorkItem, + patchProjectRowIfNeeded, + run, + onLabelsChange + ] ) const handleAssigneeToggle = useCallback( @@ -1767,16 +1900,19 @@ function GHEditSection({ if (isAssigned) { run('assignees', { mutate: () => - window.api.gh.updateIssue({ + runIssueUpdate({ repoPath, + projectOrigin, number: item.number, updates: { removeAssignees: [login] } }), onOptimistic: () => { setLocalAssignees(newAssignees) + patchProjectRowIfNeeded({ assignees: newAssignees }) }, onRevert: () => { setLocalAssignees(prevAssignees) + patchProjectRowIfNeeded({ assignees: prevAssignees }) }, onSuccess: () => {}, onError: (err) => toast.error(err) @@ -1784,23 +1920,26 @@ function GHEditSection({ } else { run('assignees', { mutate: () => - window.api.gh.updateIssue({ + runIssueUpdate({ repoPath, + projectOrigin, number: item.number, updates: { addAssignees: [login] } }), onOptimistic: () => { setLocalAssignees(newAssignees) + patchProjectRowIfNeeded({ assignees: newAssignees }) }, onSuccess: () => {}, onRevert: () => { setLocalAssignees(prevAssignees) + patchProjectRowIfNeeded({ assignees: prevAssignees }) }, onError: (err) => toast.error(err) }) } }, - [item.number, repoPath, localAssignees, run] + [item.number, repoPath, projectOrigin, localAssignees, patchProjectRowIfNeeded, run] ) if (item.type === 'pr') { @@ -2006,7 +2145,7 @@ function GHCommentComposer({ return } el.style.height = 'auto' - el.style.height = `${Math.max(36, Math.min(el.scrollHeight, 96))}px` + el.style.height = `${Math.max(80, Math.min(el.scrollHeight, 240))}px` }, []) const handleSubmit = useCallback(async () => { @@ -2048,12 +2187,7 @@ function GHCommentComposer({ ) return ( -
+
+ ) + })} +
+ ) : null} + {/* Why: the repo combobox filters Items mode by repo. In + Project mode the row set comes from the project's + view filter (server-side), so this control would be + inert — hide it to avoid suggesting it does + something. */} + {githubMode !== 'project' && ( +
+ { + setRepoSelection(next) + void updateSettings({ defaultRepoSelection: [...next] }).catch(() => { + toast.error('Failed to save repo selection.') + }) + }} + onSelectAll={() => { + const allIds = new Set(eligibleRepos.map((r) => r.id)) + setRepoSelection(allIds) + void updateSettings({ defaultRepoSelection: null }).catch(() => { + toast.error('Failed to save repo selection.') + }) + }} + triggerClassName="h-8 w-full rounded-md border border-border/50 bg-muted/50 px-2 text-xs font-medium shadow-sm transition hover:bg-muted/50 focus:ring-2 focus:ring-ring/20 focus:outline-none" + /> +
+ )} +
+ ) : null} + + {taskSource === 'github' && githubMode === 'items' ? (
@@ -1917,6 +1967,13 @@ export default function TaskPage(): React.JSX.Element {
+ {/* Why: GitHub API budget pill is anchored next to the + Refresh button so the "maybe I shouldn't click + refresh again" decision is one glance away. Only + rendered in the GitHub section because Linear has + its own SDK-based quota and doesn't consume gh + budget. */} + + ) +} + +function TypeCell({ + row, + editable, + onEditIssueType +}: { + row: GitHubProjectRow + editable: boolean + onEditIssueType?: (issueType: GitHubIssueType | null) => void +}): React.JSX.Element { + // Why: for issues we surface the repo's `issueType` (Bug/Feature/Task etc) + // when set — that's the editable taxonomy. PR/Draft/Restricted rows render + // the static itemType glyph because there's no equivalent editable type. + if (row.itemType === 'ISSUE') { + return ( + + ) + } + const meta = + row.itemType === 'PULL_REQUEST' + ? { Icon: GitPullRequest, label: 'PR' } + : row.itemType === 'DRAFT_ISSUE' + ? { Icon: FileText, label: 'Draft' } + : { Icon: Lock, label: 'Restricted' } + const { Icon, label } = meta + return ( + + + {label} + + ) +} + +function IssueTypeCell({ + row, + editable, + onEditIssueType +}: { + row: GitHubProjectRow + editable: boolean + onEditIssueType?: (issueType: GitHubIssueType | null) => void +}): React.JSX.Element { + const issueType = row.content.issueType + const [open, setOpen] = useState(false) + const [options, setOptions] = useState([]) + const [loading, setLoading] = useState(false) + const [owner, repo] = (row.content.repository ?? '').split('/') + + React.useEffect(() => { + if (!open || !owner || !repo) {return} + let cancelled = false + setLoading(true) + window.api.gh + .listIssueTypesBySlug({ owner, repo }) + .then((res) => { + if (cancelled) {return} + if (res.ok) {setOptions(res.types)} + }) + .finally(() => { + if (!cancelled) {setLoading(false)} + }) + return () => { + cancelled = true + } + }, [open, owner, repo]) + + const trigger = ( + + + {issueType ? ( + (() => { + const { bg, fg, border } = singleSelectChipColors(issueType.color ?? '') + return ( + + {issueType.name} + + ) + })() + ) : ( + Issue + )} + + ) + + if (!editable) { + return
{trigger}
+ } + + return ( + + + + + + {!owner || !repo ? ( +
+ Row has no repo slug. +
+ ) : loading ? ( +
Loading…
+ ) : options.length === 0 ? ( +
+ This repo has no Issue Types. +
+ ) : ( + options.map((t) => ( + + )) + )} + {issueType ? ( + + ) : null} +
+
+ ) +} + +function SingleSelectCell({ + row, + field, + editable, + onEditField +}: { + row: GitHubProjectRow + field: GitHubProjectField + editable: boolean + onEditField?: (fieldId: string, value: GitHubProjectFieldMutationValue | null) => void +}): React.JSX.Element { + const value = row.fieldValuesByFieldId[field.id] + const [open, setOpen] = useState(false) + const options = field.kind === 'single-select' ? field.options : [] + // Why: GitHub single-select options ship a hue that is too dark to read on + // the app's dark background when used as plain text. Reuse the label-chip + // dark-mode mapping (translucent fill + brightened hue text) so status pills + // stay readable across the same color palette. + const label = + value?.kind === 'single-select' ? ( + (() => { + const { bg, fg, border } = singleSelectChipColors(value.color) + return ( + + {value.name} + + ) + })() + ) : ( + + ) + if (!editable) { + return
{label}
+ } + return ( + + + + + + {options.map((o) => ( + + ))} + + + + ) +} + +function IterationCell({ + row, + field, + editable, + onEditField +}: { + row: GitHubProjectRow + field: GitHubProjectField + editable: boolean + onEditField?: (fieldId: string, value: GitHubProjectFieldMutationValue | null) => void +}): React.JSX.Element { + const value = row.fieldValuesByFieldId[field.id] + const [open, setOpen] = useState(false) + const iterations = field.kind === 'iteration' ? field.iterations : [] + const completed = iterations.filter((it) => it.completed) + const active = iterations.filter((it) => !it.completed) + const label = + value?.kind === 'iteration' ? ( + + {value.title} + + ) : ( + + ) + if (!editable) { + return
{label}
+ } + return ( + + + + + + {completed.length > 0 ? ( +
+ Completed +
+ ) : null} + {completed.map((it) => ( + { + onEditField?.(field.id, { kind: 'iteration', iterationId: it.id }) + setOpen(false) + }} + /> + ))} + {active.length > 0 ? ( +
+ Current & upcoming +
+ ) : null} + {active.map((it) => ( + { + onEditField?.(field.id, { kind: 'iteration', iterationId: it.id }) + setOpen(false) + }} + /> + ))} + +
+
+ ) +} + +function IterationRow({ + iteration, + onClick +}: { + iteration: { title: string; startDate: string; duration: number } + onClick: () => void +}): React.JSX.Element { + return ( + + ) +} + +function TextCell({ + value, + editable, + numeric, + onCommit +}: { + value: string + editable: boolean + numeric?: boolean + onCommit: (next: string) => void +}): React.JSX.Element { + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(value) + if (!editable) { + return {value || '—'} + } + if (!editing) { + return ( + + ) + } + return ( + setDraft(e.target.value)} + onBlur={() => { + setEditing(false) + if (draft !== value) {onCommit(draft)} + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + setEditing(false) + if (draft !== value) {onCommit(draft)} + } else if (e.key === 'Escape') { + e.preventDefault() + setEditing(false) + setDraft(value) + } + }} + className="h-6 text-xs" + /> + ) +} + +function DateCell({ + value, + editable, + onCommit +}: { + value: string + editable: boolean + onCommit: (next: string) => void +}): React.JSX.Element { + // Why: a date fires onChange on every digit/spinner adjustment. + // Committing on each fires a GraphQL mutation per keystroke. Buffer the + // edit locally and commit on blur or Enter — same UX as TextCell. + const [draft, setDraft] = React.useState(value ?? '') + React.useEffect(() => { + setDraft(value ?? '') + }, [value]) + if (!editable) { + return {value || '—'} + } + return ( + setDraft(e.target.value)} + onBlur={() => { + if (draft !== (value ?? '')) {onCommit(draft)} + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + ;(e.target as HTMLInputElement).blur() + } else if (e.key === 'Escape') { + e.preventDefault() + setDraft(value ?? '') + ;(e.target as HTMLInputElement).blur() + } + }} + className="h-6 cursor-pointer rounded border border-border/50 bg-background px-1 text-xs" + /> + ) +} + +function LabelChip({ label }: { label: GitHubProjectLabel }): React.JSX.Element { + // Why: match GitHub's dark-mode label rendering — translucent fill of the + // label color with a brighter foreground derived from the same hue. The + // outline-only chip we had before was hard to read against the dark UI. + const { bg, fg, border } = labelChipColors(label.color) + return ( + + {label.name} + + ) +} + +function UserChip({ user }: { user: GitHubProjectUser }): React.JSX.Element { + if (user.avatarUrl) { + return ( + {user.login} + ) + } + return ( + + {user.login.slice(0, 1).toUpperCase()} + + ) +} + +function AssigneesCell({ + row, + editable, + onEditAssignees +}: { + row: GitHubProjectRow + editable: boolean + onEditAssignees?: (add: string[], remove: string[]) => void +}): React.JSX.Element { + const assignees = row.content.assignees + const [open, setOpen] = useState(false) + const [options, setOptions] = useState([]) + const [loading, setLoading] = useState(false) + + const [owner, repo] = (row.content.repository ?? '').split('/') + + // Why: stabilize the assignee identity used as the seed list. `assignees` + // is a fresh array every parent render, so depending on it directly would + // refire the IPC on every unrelated re-render while the popover is open + // (the same call the rate-limit pill is meant to discourage). Joining the + // sorted logins gives us a stable string identity. + const seedKey = React.useMemo( + () => assignees.map((a) => a.login).sort().join(','), + [assignees] + ) + + // Why: only hit the slug-addressed user list when the popover actually + // opens — the assignable-users query can be expensive for large repos. + React.useEffect(() => { + if (!open || !owner || !repo) {return} + let cancelled = false + setLoading(true) + window.api.gh + .listAssignableUsersBySlug({ + owner, + repo, + seedLogins: seedKey ? seedKey.split(',') : [] + }) + .then((res) => { + if (cancelled) {return} + if (res.ok) {setOptions(res.users)} + }) + .finally(() => { + if (!cancelled) {setLoading(false)} + }) + return () => { + cancelled = true + } + }, [open, owner, repo, seedKey]) + + const labelContent = + assignees.length === 0 ? ( + + ) : ( + assignees.map((u) => ) + ) + + if (!editable) { + return ( +
+ {labelContent} +
+ ) + } + + return ( + + + + + + {!owner || !repo ? ( +
+ Row has no repo slug. +
+ ) : loading ? ( +
Loading…
+ ) : ( + options.map((u) => { + const isOn = assignees.some((a) => a.login === u.login) + return ( + + ) + }) + )} +
+
+ ) +} + +function LabelsCell({ + row, + editable, + onEditLabels +}: { + row: GitHubProjectRow + editable: boolean + onEditLabels?: (add: string[], remove: string[]) => void +}): React.JSX.Element { + const labels = row.content.labels + const [open, setOpen] = useState(false) + const [options, setOptions] = useState([]) + const [loading, setLoading] = useState(false) + + const [owner, repo] = (row.content.repository ?? '').split('/') + + // Why: only fetch the slug-addressed labels list when the popover actually + // opens — listing labels is cheap but still a network round-trip per row. + React.useEffect(() => { + if (!open || !owner || !repo) {return} + let cancelled = false + setLoading(true) + window.api.gh + .listLabelsBySlug({ owner, repo }) + .then((res) => { + if (cancelled) {return} + if (res.ok) {setOptions(res.labels)} + }) + .finally(() => { + if (!cancelled) {setLoading(false)} + }) + return () => { + cancelled = true + } + }, [open, owner, repo]) + + const labelContent = + labels.length === 0 ? ( + + ) : ( + labels.map((l) => ) + ) + + if (!editable) { + return ( +
{labelContent}
+ ) + } + + return ( + + + + + + {!owner || !repo ? ( +
+ Row has no repo slug. +
+ ) : loading ? ( +
Loading…
+ ) : options.length === 0 ? ( +
+ No labels in this repo. +
+ ) : ( + options.map((name) => { + const isOn = labels.some((l) => l.name === name) + return ( + + ) + }) + )} +
+
+ ) +} + +function EmptyCellPlaceholder({ editable }: { editable: boolean }): React.JSX.Element { + // Why: an unset cell still needs to be a visible click target so users can + // assign a value from scratch. The em-dash is intentional — the wrapping + // PopoverTrigger button supplies the hover background that signals + // clickability, so we don't need a wordy "Set value" placeholder. + return ( + + — + + ) +} + +function colorHex(color: string): string { + if (!color) {return 'inherit'} + if (color.startsWith('#')) {return color} + // GitHub returns 6-hex without `#`. + if (/^[0-9a-fA-F]{6}$/.test(color)) {return `#${color}`} + return color +} + +// Why: GitHub single-select fields return color as a keyword like "RED" or +// "PURPLE", not a hex value. Map to Primer's dark-mode option palette so we +// can reuse labelChipColors for the chip styling. +const SINGLE_SELECT_HEX: Record = { + GRAY: '#8b949e', + RED: '#f85149', + ORANGE: '#db6d28', + YELLOW: '#d29922', + GREEN: '#3fb950', + BLUE: '#58a6ff', + PURPLE: '#bc8cff', + PINK: '#db61a2' +} + +function singleSelectChipColors(color: string): { bg: string; fg: string; border: string } { + if (!color) {return labelChipColors('')} + const upper = color.toUpperCase() + const hex = SINGLE_SELECT_HEX[upper] + if (hex) {return labelChipColors(hex)} + return labelChipColors(color) +} + +// Why: GitHub renders labels in dark mode as a low-alpha tint of the label +// color with text re-mapped to a lightness that reads well on the tint. We +// approximate Primer's algorithm so our chips match the GitHub UI. +function labelChipColors(color: string): { bg: string; fg: string; border: string } { + const fallback = { bg: 'rgba(125,125,125,0.2)', fg: '#e6edf3', border: 'rgba(125,125,125,0.4)' } + if (!color) {return fallback} + const hex = color.startsWith('#') ? color.slice(1) : color + if (!/^[0-9a-fA-F]{6}$/.test(hex)) {return fallback} + const r = parseInt(hex.slice(0, 2), 16) + const g = parseInt(hex.slice(2, 4), 16) + const b = parseInt(hex.slice(4, 6), 16) + const [h, s] = rgbToHsl(r, g, b) + // Primer dark-theme label: bg ~18% alpha of base, border ~30%, text lifted + // to L≈85% so it stays bright but keeps the hue. + const bg = `rgba(${r}, ${g}, ${b}, 0.18)` + const border = `rgba(${r}, ${g}, ${b}, 0.3)` + const fg = hslToCss(h, Math.max(s, 0.5), 0.85) + return { bg, fg, border } +} + +function rgbToHsl(r: number, g: number, b: number): [number, number, number] { + const rn = r / 255 + const gn = g / 255 + const bn = b / 255 + const max = Math.max(rn, gn, bn) + const min = Math.min(rn, gn, bn) + const l = (max + min) / 2 + const d = max - min + if (d === 0) {return [0, 0, l]} + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min) + let h = 0 + switch (max) { + case rn: + h = ((gn - bn) / d + (gn < bn ? 6 : 0)) * 60 + break + case gn: + h = ((bn - rn) / d + 2) * 60 + break + default: + h = ((rn - gn) / d + 4) * 60 + } + return [h, s, l] +} + +function hslToCss(h: number, s: number, l: number): string { + return `hsl(${h.toFixed(0)} ${(s * 100).toFixed(0)}% ${(l * 100).toFixed(0)}%)` +} diff --git a/src/renderer/src/components/github-project/ProjectGroupHeader.tsx b/src/renderer/src/components/github-project/ProjectGroupHeader.tsx new file mode 100644 index 000000000..00367b72a --- /dev/null +++ b/src/renderer/src/components/github-project/ProjectGroupHeader.tsx @@ -0,0 +1,54 @@ +import React from 'react' +import { ChevronDown, ChevronRight } from 'lucide-react' +import { cn } from '@/lib/utils' +import { isIterationCurrent, type ProjectGroup } from './group-sort' + +type Props = { + group: ProjectGroup + expanded: boolean + onToggle: () => void +} + +export default function ProjectGroupHeader({ + group, + expanded, + onToggle +}: Props): React.JSX.Element { + const isCurrent = group.iteration ? isIterationCurrent(group.iteration) : false + const dateRange = group.iteration + ? formatDateRange(group.iteration.startDate, group.iteration.duration) + : null + return ( + + ) +} + +function formatDateRange(startDate: string, duration: number): string { + const start = new Date(`${startDate}T00:00:00Z`) + if (Number.isNaN(start.getTime())) {return ''} + const end = new Date(start.getTime() + (duration - 1) * 86_400_000) + const fmt = (d: Date): string => + `${d.getUTCMonth() + 1}/${d.getUTCDate()}` + return `${fmt(start)} – ${fmt(end)}` +} diff --git a/src/renderer/src/components/github-project/ProjectItemSlugDialog.tsx b/src/renderer/src/components/github-project/ProjectItemSlugDialog.tsx new file mode 100644 index 000000000..4b3af2f38 --- /dev/null +++ b/src/renderer/src/components/github-project/ProjectItemSlugDialog.tsx @@ -0,0 +1,47 @@ +// Why: when a Project row's `content.repository` does not match any +// registered Orca repo, the main `GitHubItemDialog` cannot be used in +// repo-backed mode — it requires a `repoPath` for label/assignee pickers +// and conversation details. Per design doc §Dialog editing from Project +// rows, the dialog for unknown-repo rows is allowed to be a simplified +// surface (conversation + title/body/labels/assignees/comments) with +// Files, Checks, and review-thread tabs hidden. This component is that +// simplified surface; it also routes every write through slug-addressed +// mutation helpers and patches the Project table cache on success. +import React from 'react' +import { VisuallyHidden } from 'radix-ui' +import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet' +import type { GitHubItemDialogProjectOrigin } from '@/components/GitHubItemDialog' +import { SlugDialogBody } from './slug-dialog/SlugDialogBody' + +type Props = { + projectOrigin: GitHubItemDialogProjectOrigin | null + onClose: () => void +} + +export default function ProjectItemSlugDialog({ + projectOrigin, + onClose +}: Props): React.JSX.Element { + const open = projectOrigin !== null + + return ( + !o && onClose()}> + e.preventDefault()} + > + + GitHub item + + + Project row preview. + + {projectOrigin ? ( + + ) : null} + + + ) +} diff --git a/src/renderer/src/components/github-project/ProjectPicker.tsx b/src/renderer/src/components/github-project/ProjectPicker.tsx new file mode 100644 index 000000000..33b3171d4 --- /dev/null +++ b/src/renderer/src/components/github-project/ProjectPicker.tsx @@ -0,0 +1,702 @@ +/* eslint-disable max-lines -- Why: project picker handles pinned, recent, browse-all listing, paste-to-add, view selection, and accessibility-related orchestration in one place to keep the entry-point flow coherent. */ +// Why: the picker is the only v1 entry point for switching projects (no +// header tab strip). Pinned + Recent come from settings; Browse all lazy-loads +// from `listAccessibleProjects` and is cached for 5 minutes. Paste-to-add +// accepts org/user project URLs and `owner/number` shorthand. +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { AlertTriangle, ChevronDown, Copy, Loader, Pin, Search } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import { useAppStore } from '@/store' +import type { + GitHubProjectOwnerType, + GitHubProjectSettings, + GitHubProjectSummary, + GitHubProjectViewError, + GitHubProjectViewSummary +} from '../../../../shared/github-project-types' + +export type ResolvedProjectSelection = { + owner: string + ownerType: GitHubProjectOwnerType + projectNumber: number + viewId?: string +} + +type Props = { + activeProject: + | { owner: string; ownerType: GitHubProjectOwnerType; number: number; title?: string } + | null + onSelect: (selection: ResolvedProjectSelection) => void +} + +const BROWSE_CACHE_TTL_MS = 5 * 60_000 +let browseCache: { + fetchedAt: number + projects: GitHubProjectSummary[] + partialFailures?: { owner: string; message: string }[] +} | null = null + +export default function ProjectPicker({ activeProject, onSelect }: Props): React.JSX.Element { + const settings = useAppStore((s) => s.settings) + const updateSettings = useAppStore((s) => s.updateSettings) + const projectSettings: GitHubProjectSettings = settings?.githubProjects ?? { + pinned: [], + recent: [], + lastViewByProject: {}, + activeProject: null + } + + const [open, setOpen] = useState(false) + const [query, setQuery] = useState('') + const [browseLoading, setBrowseLoading] = useState(false) + const [browseError, setBrowseError] = useState(null) + const [browseProjects, setBrowseProjects] = useState( + () => browseCache?.projects ?? [] + ) + // Why: partial-failures are cached alongside projects so dismissing the + // popover and reopening within the 5min window doesn't flicker the + // banner back. Populated only when discovery succeeded but a subset of + // orgs failed (the 504 path the user reported). + const [partialFailures, setPartialFailures] = useState< + { owner: string; message: string }[] + >(() => browseCache?.partialFailures ?? []) + const [pasteInput, setPasteInput] = useState('') + const [pasteError, setPasteError] = useState(null) + const [pasteBusy, setPasteBusy] = useState(false) + + // View-pick step state. + const [viewPickFor, setViewPickFor] = useState(null) + const [viewList, setViewList] = useState([]) + const [viewLoading, setViewLoading] = useState(false) + + const loadBrowse = useCallback(async () => { + if (browseCache && Date.now() - browseCache.fetchedAt < BROWSE_CACHE_TTL_MS) { + setBrowseProjects(browseCache.projects) + setPartialFailures(browseCache.partialFailures ?? []) + return + } + setBrowseLoading(true) + setBrowseError(null) + try { + const res = await window.api.gh.listAccessibleProjects() + if (res.ok) { + browseCache = { + fetchedAt: Date.now(), + projects: res.projects, + partialFailures: res.partialFailures + } + setBrowseProjects(res.projects) + setPartialFailures(res.partialFailures ?? []) + } else { + setBrowseError(res.error) + } + } catch (err) { + setBrowseError({ + type: 'unknown', + message: err instanceof Error ? err.message : 'Failed to list projects' + }) + } finally { + setBrowseLoading(false) + } + }, []) + + useEffect(() => { + if (open && !viewPickFor) { + void loadBrowse() + } + }, [open, viewPickFor, loadBrowse]) + + const updateProjectSettings = useCallback( + async (mutate: (prev: GitHubProjectSettings) => GitHubProjectSettings) => { + const prev = projectSettings + const next = mutate(prev) + // Why: settings deep-merges only notifications; write the full + // githubProjects object so sibling fields (pinned/recent/lastView/active) + // are not clobbered by a partial write. + await updateSettings({ githubProjects: next }) + }, + [projectSettings, updateSettings] + ) + + const commitSelection = useCallback( + async (selection: ResolvedProjectSelection, title: string | null) => { + const key = `${selection.ownerType}:${selection.owner}:${selection.projectNumber}` + await updateProjectSettings((prev) => { + const recent = [ + { + owner: selection.owner, + ownerType: selection.ownerType, + number: selection.projectNumber, + lastOpenedAt: new Date().toISOString() + }, + ...prev.recent.filter( + (r) => + `${r.ownerType}:${r.owner}:${r.number}` !== key + ) + ].slice(0, 10) + const lastViewByProject = { ...prev.lastViewByProject } + if (selection.viewId) { + lastViewByProject[key] = { viewId: selection.viewId } + } + return { + ...prev, + recent, + lastViewByProject, + activeProject: { + owner: selection.owner, + ownerType: selection.ownerType, + number: selection.projectNumber + } + } + }) + onSelect(selection) + setOpen(false) + setQuery('') + setViewPickFor(null) + void title + }, + [onSelect, updateProjectSettings] + ) + + const handleChooseProject = useCallback( + async ( + selection: { + owner: string + ownerType: GitHubProjectOwnerType + number: number + title?: string + // Why: when the paste resolver parsed a /views/{n} URL, the caller + // passes the view number through so we can skip the view-pick step + // and commit directly once listProjectViews returns the matching id. + viewNumber?: number + } + ) => { + const key = `${selection.ownerType}:${selection.owner}:${selection.number}` + const lastView = projectSettings.lastViewByProject[key]?.viewId + // Why: an explicit viewNumber from the URL takes precedence over the + // remembered last view — the user's intent (paste this exact view) wins + // over the heuristic (re-open the last view they used). + if (lastView && selection.viewNumber === undefined) { + await commitSelection( + { + owner: selection.owner, + ownerType: selection.ownerType, + projectNumber: selection.number, + viewId: lastView + }, + selection.title ?? null + ) + return + } + // No prior view (or explicit viewNumber from URL) — load views. + setViewPickFor({ + owner: selection.owner, + ownerType: selection.ownerType, + projectNumber: selection.number + }) + setViewLoading(true) + try { + const res = await window.api.gh.listProjectViews({ + owner: selection.owner, + ownerType: selection.ownerType, + projectNumber: selection.number + }) + if (res.ok) { + setViewList(res.views) + if (selection.viewNumber !== undefined) { + // Why: the URL pinned a specific view number — find its id and + // commit directly, bypassing the view-pick step. If the number + // doesn't match any view (deleted/renumbered), fall through to + // the picker so the user can choose another view. + const match = res.views.find((v) => v.number === selection.viewNumber) + if (match) { + await commitSelection( + { + owner: selection.owner, + ownerType: selection.ownerType, + projectNumber: selection.number, + viewId: match.id + }, + selection.title ?? null + ) + } + } + } else { + setViewList([]) + toast.error(res.error.message) + } + } catch (err) { + // Why: IPC transport errors (channel disconnect, serialization + // failure) propagate as rejected promises and would otherwise become + // unhandled rejections — leaving the picker stuck on the view-pick + // step with a perpetual spinner. Treat as an empty result and toast + // a transport-level message so the user can retry or paste again. + setViewList([]) + toast.error( + `Failed to load views: ${err instanceof Error ? err.message : String(err)}` + ) + } finally { + setViewLoading(false) + } + }, + [commitSelection, projectSettings.lastViewByProject] + ) + + const handlePaste = useCallback(async () => { + const parsed = parseProjectInput(pasteInput.trim()) + if (!parsed) { + setPasteError('Expected a project URL or owner/number') + return + } + setPasteError(null) + setPasteBusy(true) + try { + const res = await window.api.gh.resolveProjectRef({ input: pasteInput.trim() }) + if (!res.ok) { + setPasteError(res.error.message) + return + } + setPasteInput('') + await handleChooseProject({ + owner: res.owner, + ownerType: res.ownerType, + number: res.number, + title: res.title, + // Why: forward the parsed view number from /views/{n} URLs so the + // chooser can skip the view-pick step and commit directly. + ...(res.viewNumber !== undefined ? { viewNumber: res.viewNumber } : {}) + }) + } finally { + setPasteBusy(false) + } + }, [handleChooseProject, pasteInput]) + + const filteredBrowse = useMemo(() => { + const q = query.trim().toLowerCase() + const pinnedKeys = new Set( + projectSettings.pinned.map((p) => `${p.ownerType}:${p.owner}:${p.number}`) + ) + const recentKeys = new Set( + projectSettings.recent.map((r) => `${r.ownerType}:${r.owner}:${r.number}`) + ) + return browseProjects.filter((p) => { + const key = `${p.ownerType}:${p.owner}:${p.number}` + if (pinnedKeys.has(key) || recentKeys.has(key)) {return false} + if (!q) {return true} + return ( + p.title.toLowerCase().includes(q) || + p.owner.toLowerCase().includes(q) || + String(p.number).includes(q) + ) + }) + }, [browseProjects, projectSettings.pinned, projectSettings.recent, query]) + + const buttonLabel = activeProject + ? `${activeProject.owner} / ${activeProject.title ?? `#${activeProject.number}`}` + : 'Choose a project' + + return ( + + + + + + {viewPickFor ? ( + { + await commitSelection({ ...viewPickFor, viewId: view.id }, null) + }} + onBack={() => setViewPickFor(null)} + /> + ) : ( +
+
+
+ + setQuery(e.target.value)} + placeholder="Search projects" + className="h-8 pl-7 text-xs" + /> +
+
+ {browseError ? : null} + {!browseError && partialFailures.length > 0 ? ( + + ) : null} +
+ {projectSettings.pinned.length > 0 ? ( +
+ {projectSettings.pinned.map((p) => { + const key = `${p.ownerType}:${p.owner}:${p.number}` + const knownGood = + projectSettings.lastViewByProject[key]?.viewId != null + const match = browseProjects.find( + (bp) => `${bp.ownerType}:${bp.owner}:${bp.number}` === key + ) + return ( + + handleChooseProject({ + owner: p.owner, + ownerType: p.ownerType, + number: p.number, + title: match?.title + }) + } + onRemovePin={async () => { + await updateProjectSettings((prev) => ({ + ...prev, + pinned: prev.pinned.filter( + (x) => `${x.ownerType}:${x.owner}:${x.number}` !== key + ) + })) + }} + /> + ) + })} +
+ ) : null} + {projectSettings.recent.length > 0 ? ( +
+ {projectSettings.recent + .filter( + (r) => + !projectSettings.pinned.some( + (p) => + p.ownerType === r.ownerType && + p.owner === r.owner && + p.number === r.number + ) + ) + .map((r) => { + const key = `${r.ownerType}:${r.owner}:${r.number}` + const match = browseProjects.find( + (bp) => `${bp.ownerType}:${bp.owner}:${bp.number}` === key + ) + const pinnable = projectSettings.lastViewByProject[key]?.viewId != null + return ( + { + await updateProjectSettings((prev) => ({ + ...prev, + pinned: [ + ...prev.pinned, + { owner: r.owner, ownerType: r.ownerType, number: r.number } + ].slice(0, 20) + })) + }} + onClick={() => + handleChooseProject({ + owner: r.owner, + ownerType: r.ownerType, + number: r.number, + title: match?.title + }) + } + /> + ) + })} +
+ ) : null} +
+ {browseLoading ? ( +
+ Loading… +
+ ) : null} + {filteredBrowse.map((p) => ( + + handleChooseProject({ + owner: p.owner, + ownerType: p.ownerType, + number: p.number, + title: p.title + }) + } + /> + ))} +
+
+
+
+ { + setPasteInput(e.target.value) + setPasteError(null) + }} + onKeyDown={(e) => { + if (e.key === 'Enter') {void handlePaste()} + }} + placeholder="Add by URL or owner/number" + className="h-8 text-xs" + /> + +
+ {pasteError ? ( +
{pasteError}
+ ) : null} +
+
+ )} +
+
+ ) +} + +function Section({ + label, + children +}: { + label: string + children: React.ReactNode +}): React.JSX.Element { + return ( +
+
+ {label} +
+ {children} +
+ ) +} + +function PickerRow({ + title, + subtitle, + onClick, + zombie, + canPin, + onPin, + onRemovePin +}: { + title: string + subtitle: string + onClick: () => void + zombie?: boolean + canPin?: boolean + onPin?: () => void + onRemovePin?: () => void +}): React.JSX.Element { + return ( +
+ + {zombie ? ( +
+ + +
+ ) : null} + {canPin ? ( + + ) : null} +
+ ) +} + +function ViewPickStep({ + loading, + views, + onPick, + onBack +}: { + loading: boolean + views: GitHubProjectViewSummary[] + onPick: (view: GitHubProjectViewSummary) => void | Promise + onBack: () => void +}): React.JSX.Element { + return ( +
+
+ + Choose a view + +
+
+ {loading ? ( +
+ Loading views… +
+ ) : views.length === 0 ? ( +
No views found.
+ ) : ( + views.map((v) => { + const supported = v.layout === 'TABLE_LAYOUT' + return ( + + ) + }) + )} +
+
+ ) +} + +function PartialFailuresBanner({ + failures +}: { + failures: { owner: string; message: string }[] +}): React.JSX.Element { + // Why: a single generic sentence is preferable to enumerating every failed + // owner inline — the list is unbounded and the user only needs to know + // (1) their list is incomplete and (2) paste-to-add is the escape hatch. + // Hover exposes the underlying error messages for debugging. + const summary = + failures.length === 1 && failures[0].owner !== '*' + ? `Couldn't load projects from ${failures[0].owner}.` + : `Some organizations didn't load (${failures.length}).` + const detail = failures.map((f) => `${f.owner === '*' ? 'orgs' : f.owner}: ${f.message}`).join('\n') + return ( +
+
+ +
+
{summary}
+
+ Paste a project URL below to reach missing ones. +
+
+
+
+ ) +} + +function AuthErrorBanner({ error }: { error: GitHubProjectViewError }): React.JSX.Element { + const command = + error.type === 'auth_required' + ? 'gh auth login' + : error.type === 'scope_missing' + ? 'gh auth refresh -s project -s read:org -s repo' + : null + return ( +
+
{error.message}
+ {command ? ( + + ) : null} +
+ ) +} + +function parseProjectInput( + input: string +): { owner: string; number: number; viewNumber?: number } | null { + if (!input) {return null} + // owner/number + const short = /^([A-Za-z0-9][A-Za-z0-9-]*)\/(\d+)$/.exec(input) + if (short) { + return { owner: short[1], number: Number(short[2]) } + } + try { + const url = new URL(input) + if (url.hostname !== 'github.com') {return null} + const parts = url.pathname.split('/').filter(Boolean) + // /orgs/{owner}/projects/{n} or /users/{owner}/projects/{n}[/views/{viewNumber}] + if ((parts[0] === 'orgs' || parts[0] === 'users') && parts[2] === 'projects' && parts[3]) { + const owner = parts[1] + const number = Number(parts[3]) + if (Number.isNaN(number)) {return null} + let viewNumber: number | undefined + if (parts[4] === 'views' && parts[5]) { + const v = Number(parts[5]) + if (!Number.isNaN(v)) {viewNumber = v} + } + return { owner, number, viewNumber } + } + } catch { + return null + } + return null +} diff --git a/src/renderer/src/components/github-project/ProjectRow.tsx b/src/renderer/src/components/github-project/ProjectRow.tsx new file mode 100644 index 000000000..072d8f0b3 --- /dev/null +++ b/src/renderer/src/components/github-project/ProjectRow.tsx @@ -0,0 +1,128 @@ +import React from 'react' +import { ExternalLink, Play } from 'lucide-react' +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import ProjectCell from './ProjectCell' +import type { + GitHubIssueType, + GitHubProjectField, + GitHubProjectFieldMutationValue, + GitHubProjectRow as GitHubProjectRowType +} from '../../../../shared/github-project-types' + +type Props = { + row: GitHubProjectRowType + fields: GitHubProjectField[] + editable: boolean + onOpenDialog?: () => void + onEditField?: (fieldId: string, value: GitHubProjectFieldMutationValue | null) => void + onEditAssignees?: (add: string[], remove: string[]) => void + onEditLabels?: (add: string[], remove: string[]) => void + onEditIssueType?: (issueType: GitHubIssueType | null) => void + onStartWork?: () => void + onOpenInBrowser?: () => void +} + +export default function ProjectRow({ + row, + fields, + editable, + onOpenDialog, + onEditField, + onEditAssignees, + onEditLabels, + onEditIssueType, + onStartWork, + onOpenInBrowser +}: Props): React.JSX.Element { + const disabled = row.itemType === 'REDACTED' + // Why: design doc §Row actions — draft-issue rows have no URL or number, so + // the title is non-interactive. Surface the draft body in a hover card so + // the user can still read context without round-tripping to GitHub. + const draftBody = + row.itemType === 'DRAFT_ISSUE' && row.content.body && row.content.body.trim().length > 0 + ? row.content.body + : null + const rowInner = ( +
+ {fields.map((f) => ( + + ))} +
+ {row.content.url ? ( + + + + + Open in GitHub + + ) : null} + {!disabled && row.itemType !== 'DRAFT_ISSUE' && row.content.number != null ? ( + + + + + Start work + + ) : null} +
+
+ ) + + if (draftBody) { + return ( + + {rowInner} + + {draftBody} + + + ) + } + return rowInner +} + +export function buildGridTemplate(fields: GitHubProjectField[]): string { + // Why: TITLE gets the most space; other columns share equally. The extra + // trailing column is for row-hover action icons. + const cols: string[] = fields.map((f) => + f.dataType === 'TITLE' ? 'minmax(0,3fr)' : 'minmax(80px,1fr)' + ) + cols.push('80px') + return cols.join(' ') +} diff --git a/src/renderer/src/components/github-project/ProjectViewList.tsx b/src/renderer/src/components/github-project/ProjectViewList.tsx new file mode 100644 index 000000000..b9fd0a085 --- /dev/null +++ b/src/renderer/src/components/github-project/ProjectViewList.tsx @@ -0,0 +1,274 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { ArrowDown, ArrowUp, ArrowUpDown, Columns3 } from 'lucide-react' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import ProjectGroupHeader from './ProjectGroupHeader' +import ProjectRow, { buildGridTemplate } from './ProjectRow' +import { groupRows, sortRows } from './group-sort' +import { + getAvailableColumns, + loadHiddenColumns, + saveHiddenColumns +} from './columns' +import type { + GitHubIssueType, + GitHubProjectField, + GitHubProjectFieldMutationValue, + GitHubProjectRow, + GitHubProjectSortDirection, + GitHubProjectTable +} from '../../../../shared/github-project-types' + +type SortOverride = { fieldId: string; direction: GitHubProjectSortDirection } + +type Props = { + table: GitHubProjectTable + onOpenDialog?: (row: GitHubProjectRow) => void + onEditField?: ( + row: GitHubProjectRow, + fieldId: string, + value: GitHubProjectFieldMutationValue | null + ) => void + onEditAssignees?: (row: GitHubProjectRow, add: string[], remove: string[]) => void + onEditLabels?: (row: GitHubProjectRow, add: string[], remove: string[]) => void + onEditIssueType?: (row: GitHubProjectRow, issueType: GitHubIssueType | null) => void + onStartWork?: (row: GitHubProjectRow) => void + onOpenInBrowser?: (row: GitHubProjectRow) => void +} + +export default function ProjectViewList({ + table, + onOpenDialog, + onEditField, + onEditAssignees, + onEditLabels, + onEditIssueType, + onStartWork, + onOpenInBrowser +}: Props): React.JSX.Element { + const [collapsed, setCollapsed] = useState>(() => new Set()) + // Why: column-header clicks override the view's saved sortByFields locally + // without persisting to GitHub — matches GitHub Projects' transient + // header-sort behavior. `null` means "use the view's sort as authored". + const [sortOverride, setSortOverride] = useState(null) + + // Why: include project id so the same view id colliding across projects + // doesn't cross-pollute hidden-column preferences. + const scopeKey = `${table.project.id}:${table.selectedView.id}` + const availableFields = useMemo( + () => getAvailableColumns(table.selectedView), + [table.selectedView] + ) + const [hidden, setHidden] = useState>(() => loadHiddenColumns(scopeKey)) + useEffect(() => { + setHidden(loadHiddenColumns(scopeKey)) + }, [scopeKey]) + const fields = useMemo( + () => availableFields.filter((f) => !hidden.has(f.id)), + [availableFields, hidden] + ) + + const toggleColumn = (fieldId: string): void => { + setHidden((prev) => { + const next = new Set(prev) + if (next.has(fieldId)) {next.delete(fieldId)} + else {next.add(fieldId)} + saveHiddenColumns(scopeKey, next) + return next + }) + } + + const effectiveTable = useMemo(() => { + if (!sortOverride) {return table} + const field = fields.find((f) => f.id === sortOverride.fieldId) + if (!field) {return table} + return { + ...table, + selectedView: { + ...table.selectedView, + sortByFields: [{ field, direction: sortOverride.direction }] + } + } + }, [table, fields, sortOverride]) + + const groups = useMemo(() => { + // Why: sort first, then group. Sorting the flat stream ensures rows within + // each group honor the view's sortByFields too — groupRows preserves input + // order within each bucket. + const sorted = sortRows(effectiveTable, effectiveTable.rows) + return groupRows(effectiveTable, sorted) + }, [effectiveTable]) + + const handleSortClick = (fieldId: string): void => { + setSortOverride((prev) => { + if (!prev || prev.fieldId !== fieldId) {return { fieldId, direction: 'ASC' }} + if (prev.direction === 'ASC') {return { fieldId, direction: 'DESC' }} + return null + }) + } + + if (table.rows.length === 0) { + return ( +
+ No items match this view's filter. +
+ ) + } + + // Why: the visible sort indicator reflects either the local override or the + // first persisted sort from the view, so users see what's actually driving + // row order. + const activeSort: SortOverride | null = sortOverride + ? sortOverride + : effectiveTable.selectedView.sortByFields[0] + ? { + fieldId: effectiveTable.selectedView.sortByFields[0].field.id, + direction: effectiveTable.selectedView.sortByFields[0].direction + } + : null + + return ( +
+
+ ) +} + +function ProjectHeaderRow({ + fields, + availableFields, + hidden, + onToggleColumn, + activeSort, + onSortClick +}: { + fields: GitHubProjectField[] + availableFields: GitHubProjectField[] + hidden: ReadonlySet + onToggleColumn: (fieldId: string) => void + activeSort: SortOverride | null + onSortClick: (fieldId: string) => void +}): React.JSX.Element { + // Why: matches GitHub Projects' fixed column header — sticky so it stays + // pinned while scrolling the rows beneath it. The trailing slot mirrors the + // hover-action column in ProjectRow so columns line up exactly. + return ( +
+ {fields.map((f) => { + const isActive = activeSort?.fieldId === f.id + const Icon = isActive ? (activeSort.direction === 'ASC' ? ArrowUp : ArrowDown) : ArrowUpDown + return ( + + ) + })} +
+ + + + + +
+ Columns +
+ {availableFields.map((f) => { + // Why: TITLE is the only column that anchors the row's identity + // and click target — disallow hiding it so users can't end up + // with a row of metadata they can't open. + const locked = f.dataType === 'TITLE' + const visible = !hidden.has(f.id) + return ( + + ) + })} +
+
+
+
+ ) +} diff --git a/src/renderer/src/components/github-project/ProjectViewWrapper.tsx b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx new file mode 100644 index 000000000..0a67d8ec4 --- /dev/null +++ b/src/renderer/src/components/github-project/ProjectViewWrapper.tsx @@ -0,0 +1,858 @@ +/* eslint-disable max-lines -- Why: top-level Project-mode container coordinates picker, view selection, query overrides, fetch lifecycle, and toolbar interactions; splitting these would fragment shared state. */ +// Why: top-level container for Project mode. Handles the picker, header, +// filter label, count pill, Open-in-GitHub, and all Interaction States +// documented in the design doc. +import React, { useCallback, useEffect, useMemo, useState } from 'react' +import { + Copy, + ExternalLink, + Loader, + RefreshCw, + KanbanSquare, + Map as MapIcon, + Search, + Table as TableIcon, + X +} from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import GitHubItemDialog, { + type GitHubItemDialogProjectOrigin +} from '@/components/GitHubItemDialog' +import { launchWorkItemDirect } from '@/lib/launch-work-item-direct' +import { useRepoSlugIndex } from '@/lib/repo-slug-index' +import { cn } from '@/lib/utils' +import { useAppStore } from '@/store' +import { projectViewCacheKey } from '@/store/slices/github' +import type { + GetProjectViewTableResult, + GitHubIssueType, + GitHubProjectFieldMutationValue, + GitHubProjectRow, + GitHubProjectTable, + GitHubProjectViewError, + GitHubProjectViewSummary +} from '../../../../shared/github-project-types' +import type { GitHubWorkItem } from '../../../../shared/types' +import ProjectPicker, { type ResolvedProjectSelection } from './ProjectPicker' +import ProjectViewList from './ProjectViewList' +import ProjectItemSlugDialog from './ProjectItemSlugDialog' + +type Props = Record + +export default function ProjectViewWrapper(_props: Props = {} as Props): React.JSX.Element { + const settings = useAppStore((s) => s.settings) + const projectViewCache = useAppStore((s) => s.projectViewCache) + const fetchProjectViewTable = useAppStore((s) => s.fetchProjectViewTable) + const updateProjectFieldValue = useAppStore((s) => s.updateProjectFieldValue) + const clearProjectFieldValue = useAppStore((s) => s.clearProjectFieldValue) + const patchProjectIssueOrPr = useAppStore((s) => s.patchProjectIssueOrPr) + const patchProjectRowIssueType = useAppStore((s) => s.patchProjectRowIssueType) + const addRepoFromStore = useAppStore((s) => s.addRepo) + const lookupSlug = useRepoSlugIndex() + + const activeProject = settings?.githubProjects?.activeProject ?? null + const lastViewByProject = settings?.githubProjects?.lastViewByProject ?? {} + + const [loading, setLoading] = useState(false) + const [error, setError] = useState<{ + error: GitHubProjectViewError + totalCount?: number + } | null>(null) + const [parentDroppedToasted, setParentDroppedToasted] = useState>( + () => new Set() + ) + // Why: cache the project's view list per active project so the tab strip + // renders without flicker on re-renders and survives view switches without + // refetching. Keyed by `ownerType:owner:number`. + const [viewListByProject, setViewListByProject] = useState< + Record + >({}) + + // Why: ephemeral search override, scoped to (project, view). Mirrors GitHub + // Projects' search box — pre-populated from `selectedView.filter`, applied + // on Enter/blur, cleared with the X button. The override is NEVER persisted + // to settings or to GitHub (per design doc §"Out of scope" line 36); a tab + // switch or refresh resets to the view's stored filter. Keyed by + // `ownerType:owner:number:viewId`. `undefined` (entry missing) means + // "use the view's filter as-is" so the cache key collapses to the + // unfiltered cache entry. The transient input string lives inside + // `ProjectSearchInput` so typing does not re-render the table. + const [appliedQueryByView, setAppliedQueryByView] = useState>({}) + + const doFetch = useCallback( + async ( + selection: ResolvedProjectSelection, + force = false, + queryOverride?: string + ) => { + setLoading(true) + setError(null) + try { + const res: GetProjectViewTableResult = await fetchProjectViewTable( + { + owner: selection.owner, + ownerType: selection.ownerType, + projectNumber: selection.projectNumber, + ...(selection.viewId ? { viewId: selection.viewId } : {}), + ...(queryOverride !== undefined ? { queryOverride } : {}) + }, + { force } + ) + if (!res.ok) { + setError({ error: res.error, totalCount: res.totalCount }) + } + } finally { + setLoading(false) + } + }, + [fetchProjectViewTable] + ) + + const handleSelect = useCallback( + async (selection: ResolvedProjectSelection) => { + await doFetch(selection, true) + }, + [doFetch] + ) + + // Auto-fetch when activeProject exists and we don't have cached data. + useEffect(() => { + if (!activeProject) {return} + const key = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}` + const viewId = lastViewByProject[key]?.viewId + if (!viewId) {return} + const projectViewKey = `${key}:${viewId}` + const queryOverride = appliedQueryByView[projectViewKey] + const cacheKey = projectViewCacheKey( + activeProject.ownerType, + activeProject.owner, + activeProject.number, + viewId, + queryOverride + ) + if (projectViewCache[cacheKey]?.data) {return} + void doFetch( + { + owner: activeProject.owner, + ownerType: activeProject.ownerType, + projectNumber: activeProject.number, + viewId + }, + false, + queryOverride + ) + }, [activeProject, lastViewByProject, projectViewCache, doFetch, appliedQueryByView]) + + // Load the project's view list whenever the active project changes so the + // tab strip can render. The list is small and rarely changes — fetched once + // per project per session is fine. + useEffect(() => { + if (!activeProject) {return} + const projectKey = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}` + if (viewListByProject[projectKey]) {return} + let cancelled = false + void window.api.gh + .listProjectViews({ + owner: activeProject.owner, + ownerType: activeProject.ownerType, + projectNumber: activeProject.number + }) + .then((res) => { + if (cancelled) {return} + if (res.ok) { + setViewListByProject((prev) => ({ ...prev, [projectKey]: res.views })) + } else { + console.warn('[project-view] listProjectViews failed:', res.error.message) + } + }) + .catch((err) => { + if (cancelled) {return} + // Why: an IPC rejection here would surface as an unhandled rejection + // and dev-tools red — log and fall back to the empty-tabs UI. + console.warn('[project-view] listProjectViews threw:', err) + }) + return () => { + cancelled = true + } + }, [activeProject, viewListByProject]) + + const handleSwitchView = useCallback( + async (viewId: string) => { + if (!activeProject) {return} + const projectKey = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}` + const current = lastViewByProject[projectKey]?.viewId + if (current === viewId) {return} + // Persist the new view selection so reloads & the picker stay in sync. + // Why: read the freshest settings via getState() rather than the closure- + // captured `settings` — between callback creation and invocation another + // mutation (pin/recent update from elsewhere) may have landed, and the + // closure value would clobber it on write. + const freshSettings = useAppStore.getState().settings + const prevSettings = freshSettings?.githubProjects ?? { + pinned: [], + recent: [], + lastViewByProject: {}, + activeProject: null + } + await useAppStore.getState().updateSettings({ + githubProjects: { + ...prevSettings, + lastViewByProject: { + ...prevSettings.lastViewByProject, + [projectKey]: { viewId } + } + } + }) + await doFetch({ + owner: activeProject.owner, + ownerType: activeProject.ownerType, + projectNumber: activeProject.number, + viewId + }) + }, + [activeProject, doFetch, lastViewByProject, settings] + ) + + const currentProjectViewKey = useMemo(() => { + if (!activeProject) {return null} + const key = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}` + const viewId = lastViewByProject[key]?.viewId + if (!viewId) {return null} + return `${key}:${viewId}` + }, [activeProject, lastViewByProject]) + + const currentAppliedOverride = currentProjectViewKey + ? appliedQueryByView[currentProjectViewKey] + : undefined + + const currentCacheKey = useMemo(() => { + if (!activeProject) {return null} + const key = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}` + const viewId = lastViewByProject[key]?.viewId + if (!viewId) {return null} + return projectViewCacheKey( + activeProject.ownerType, + activeProject.owner, + activeProject.number, + viewId, + currentAppliedOverride + ) + }, [activeProject, lastViewByProject, currentAppliedOverride]) + + const table: GitHubProjectTable | null = currentCacheKey + ? (projectViewCache[currentCacheKey]?.data ?? null) + : null + + // Parent-dropped toast, once per table. + useEffect(() => { + if (!table || !currentCacheKey || !table.parentFieldDropped) {return} + if (parentDroppedToasted.has(currentCacheKey)) {return} + toast.message('Sub-issue data is unavailable for your token.') + setParentDroppedToasted((prev) => { + const next = new Set(prev) + next.add(currentCacheKey) + return next + }) + }, [table, currentCacheKey, parentDroppedToasted]) + + const selectedViewUrl = table + ? `${table.project.url}/views/${table.selectedView.number ?? ''}` + : null + + // ── Row action state ──────────────────────────────────────────────── + // Why: when a row matches a registered repo, we open the full + // `GitHubItemDialog` in repo-backed mode; when it doesn't, we open the + // simplified slug-mode dialog. `repoNotInOrca` drives the fallback modal + // from the design doc's `repo-not-in-orca` interaction state. + const [dialogRepoItem, setDialogRepoItem] = useState<{ + workItem: GitHubWorkItem + repoPath: string + origin: GitHubItemDialogProjectOrigin + } | null>(null) + // Why: the slug dialog is only opened for rows whose repo isn't registered + // in Orca (matched repos go through the full GitHubItemDialog above), so + // there's no `matchedRepo` to track here. The repo-not-in-orca modal — + // owned by this parent, not the slug dialog — handles "Start work". + const [slugDialog, setSlugDialog] = useState<{ + origin: GitHubItemDialogProjectOrigin + } | null>(null) + const [repoNotInOrca, setRepoNotInOrca] = useState<{ + owner: string + repo: string + url: string | null + } | null>(null) + + const buildWorkItem = useCallback( + (row: GitHubProjectRow, repoId: string): GitHubWorkItem | null => { + if (row.itemType !== 'ISSUE' && row.itemType !== 'PULL_REQUEST') {return null} + if (row.content.number == null || !row.content.url) {return null} + return { + id: `${row.itemType === 'PULL_REQUEST' ? 'pr' : 'issue'}:${row.content.number}`, + type: row.itemType === 'PULL_REQUEST' ? 'pr' : 'issue', + number: row.content.number, + title: row.content.title, + state: + row.content.state === 'MERGED' + ? 'merged' + : row.content.state === 'CLOSED' + ? 'closed' + : row.content.isDraft + ? 'draft' + : 'open', + url: row.content.url, + labels: row.content.labels.map((l) => l.name), + updatedAt: row.updatedAt, + author: null, + repoId + } + }, + [] + ) + + const buildOrigin = useCallback( + ( + row: GitHubProjectRow, + cacheKey: string, + table: GitHubProjectTable + ): GitHubItemDialogProjectOrigin | null => { + if (row.itemType !== 'ISSUE' && row.itemType !== 'PULL_REQUEST') {return null} + if (row.content.number == null || !row.content.repository) {return null} + const [owner, repo] = row.content.repository.split('/') + if (!owner || !repo) {return null} + return { + owner, + repo, + number: row.content.number, + type: row.itemType === 'PULL_REQUEST' ? 'pr' : 'issue', + projectId: table.project.id, + projectItemId: row.id, + cacheKey + } + }, + [] + ) + + const handleOpenDialog = useCallback( + (row: GitHubProjectRow) => { + if (!currentCacheKey || !table) {return} + const origin = buildOrigin(row, currentCacheKey, table) + if (!origin) { + // Redacted / draft / missing slug — fall back to opening GitHub. + if (row.content.url) {void window.api.shell.openUrl(row.content.url)} + return + } + const matched = lookupSlug(`${origin.owner}/${origin.repo}`) + if (matched) { + const workItem = buildWorkItem(row, matched.id) + if (workItem) { + setDialogRepoItem({ workItem, repoPath: matched.path, origin }) + return + } + } + // Unknown repo — use the simplified slug-mode dialog. + setSlugDialog({ origin }) + }, + [currentCacheKey, table, buildOrigin, lookupSlug, buildWorkItem] + ) + + const handleStartWork = useCallback( + (row: GitHubProjectRow) => { + if (!currentCacheKey || !table) {return} + const origin = buildOrigin(row, currentCacheKey, table) + if (!origin) {return} + const matched = lookupSlug(`${origin.owner}/${origin.repo}`) + if (!matched) { + setRepoNotInOrca({ + owner: origin.owner, + repo: origin.repo, + url: row.content.url ?? null + }) + return + } + const workItem = buildWorkItem(row, matched.id) + if (!workItem) {return} + void launchWorkItemDirect({ + item: workItem, + repoId: matched.id, + openModalFallback: () => { + // Why: Project mode does not own the new-workspace composer modal. + // When `launchWorkItemDirect` wants user input (setupRunPolicy:'ask' + // or agent detection fails), fall back to opening the URL so the + // user keeps a path forward rather than a silent no-op. + if (row.content.url) {void window.api.shell.openUrl(row.content.url)} + } + }) + }, + [currentCacheKey, table, buildOrigin, lookupSlug, buildWorkItem] + ) + + const handleEditAssignees = useCallback( + async (row: GitHubProjectRow, add: string[], remove: string[]) => { + if (!currentCacheKey) {return} + const res = await patchProjectIssueOrPr(currentCacheKey, row.id, { + ...(add.length ? { addAssignees: add } : {}), + ...(remove.length ? { removeAssignees: remove } : {}) + }) + if (!res.ok) {toast.error(res.error.message)} + }, + [currentCacheKey, patchProjectIssueOrPr] + ) + + const handleEditLabels = useCallback( + async (row: GitHubProjectRow, add: string[], remove: string[]) => { + if (!currentCacheKey) {return} + const res = await patchProjectIssueOrPr(currentCacheKey, row.id, { + ...(add.length ? { addLabels: add } : {}), + ...(remove.length ? { removeLabels: remove } : {}) + }) + if (!res.ok) {toast.error(res.error.message)} + }, + [currentCacheKey, patchProjectIssueOrPr] + ) + + const handleEditIssueType = useCallback( + async (row: GitHubProjectRow, issueType: GitHubIssueType | null) => { + if (!currentCacheKey) {return} + const res = await patchProjectRowIssueType(currentCacheKey, row.id, issueType) + if (!res.ok) {toast.error(res.error.message)} + }, + [currentCacheKey, patchProjectRowIssueType] + ) + + const handleEditField = useCallback( + async ( + row: GitHubProjectRow, + fieldId: string, + value: GitHubProjectFieldMutationValue | null + ) => { + if (!currentCacheKey) {return} + const result = + value === null + ? await clearProjectFieldValue(currentCacheKey, row.id, fieldId) + : await updateProjectFieldValue(currentCacheKey, row.id, fieldId, value) + if (!result.ok) { + toast.error(result.error.message) + } + }, + [clearProjectFieldValue, currentCacheKey, updateProjectFieldValue] + ) + + return ( +
+
+ + {currentProjectViewKey ? ( + // Why: render the search input whenever a view is selected — even + // while a refetch is in flight and `table` has briefly cleared for + // the new cache key. Hiding the search box mid-search would make + // it look like the search vanished. `key` keeps the local input + // state stable across (project, view) changes only. + { + if (!activeProject) {return} + const key = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}` + const viewId = lastViewByProject[key]?.viewId + if (!viewId) {return} + setAppliedQueryByView((prev) => { + const next = { ...prev } + if (nextOverride === undefined) { + delete next[currentProjectViewKey] + } else { + next[currentProjectViewKey] = nextOverride + } + return next + }) + // Why: force-fetch on user-initiated apply so the same + // query re-typed (or cache-stale entries within TTL) does + // not silently no-op. + void doFetch( + { + owner: activeProject.owner, + ownerType: activeProject.ownerType, + projectNumber: activeProject.number, + viewId + }, + true, + nextOverride + ) + }} + /> + ) : null} + {table ? ( + <> + + {table.totalCount} + + {selectedViewUrl ? ( + + ) : null} + + + ) : null} +
+ + {activeProject + ? (() => { + const projectKey = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}` + const views = viewListByProject[projectKey] ?? [] + if (views.length === 0) {return null} + const activeViewId = lastViewByProject[projectKey]?.viewId ?? null + return ( + void handleSwitchView(viewId)} + /> + ) + })() + : null} + + {!activeProject ? ( +
+ Choose a project to get started. +
+ ) : loading && !table ? ( +
+ + Loading project view… +
+ ) : error ? ( + { + if (selectedViewUrl) {void window.api.shell.openUrl(selectedViewUrl)} + }} + /> + ) : table ? ( + void handleEditAssignees(row, add, remove)} + onEditLabels={(row, add, remove) => void handleEditLabels(row, add, remove)} + onEditIssueType={(row, issueType) => void handleEditIssueType(row, issueType)} + onOpenInBrowser={(row) => { + if (row.content.url) {void window.api.shell.openUrl(row.content.url)} + }} + onStartWork={handleStartWork} + /> + ) : null} + + {/* Full repo-backed dialog — writes still go through slug-addressed + mutation helpers (see design §Dialog editing from Project rows, line + 707) so a row from another repo cannot accidentally edit the active + workspace. */} + { + const current = dialogRepoItem + setDialogRepoItem(null) + if (!current) {return} + void launchWorkItemDirect({ + item, + repoId: current.workItem.repoId, + openModalFallback: () => { + if (item.url) {void window.api.shell.openUrl(item.url)} + } + }) + }} + onClose={() => setDialogRepoItem(null)} + /> + + {/* Slug-only simplified dialog for rows whose repo isn't added to Orca. + Why: no Start-work affordance lives inside the slug dialog — the + parent's `handleStartWork`/`repoNotInOrca` modal owns that flow, so + having a duplicate (always-disabled or always-routing-to-fallback) + button here would only confuse the user. */} + setSlugDialog(null)} + /> + + {/* repo-not-in-orca prompt: see design doc Interaction States. */} + !open && setRepoNotInOrca(null)} + > + + + Repository not in Orca + + {repoNotInOrca + ? `${repoNotInOrca.owner}/${repoNotInOrca.repo} isn't added to Orca. Add it to start work, or open in GitHub.` + : null} + + + + + {repoNotInOrca?.url ? ( + + ) : null} + + + + +
+ ) +} + +// Why: owns the transient search input string locally so typing does not +// re-render the parent (and therefore not the table). The parent only learns +// the value when the user applies it (Enter/blur/clear), which is the only +// moment that should trigger a refetch. Pre-populated from the view's stored +// filter and remounted (via `key`) when the active project/view changes. +function ProjectSearchInput({ + viewFilter, + appliedOverride, + onApply +}: { + viewFilter: string + appliedOverride: string | undefined + onApply: (nextOverride: string | undefined) => void +}): React.JSX.Element { + const initial = appliedOverride !== undefined ? appliedOverride : viewFilter + const [value, setValue] = useState(initial) + const applied = appliedOverride !== undefined ? appliedOverride : viewFilter + const dirty = value !== applied + + const apply = (next: string): void => { + // Why: when the user reverts to the view's stored filter, drop the + // override so the cache key collapses back onto the unfiltered entry. + onApply(next === viewFilter ? undefined : next) + } + + return ( +
+ + setValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + apply(value) + } else if (e.key === 'Escape') { + setValue(applied) + ;(e.target as HTMLInputElement).blur() + } + }} + onBlur={() => { + if (dirty) {apply(value)} + }} + placeholder={viewFilter || 'GitHub search, e.g. assignee:@me is:open'} + title={viewFilter ? `View filter: ${viewFilter}` : undefined} + className={cn( + 'h-7 rounded-md border-border/50 bg-background pl-8 pr-7 text-[11px]', + dirty && 'border-amber-500/50' + )} + /> + {value && value !== viewFilter ? ( + + ) : null} +
+ ) +} + +function ViewTabStrip({ + views, + activeViewId, + onPick +}: { + views: GitHubProjectViewSummary[] + activeViewId: string | null + onPick: (viewId: string) => void +}): React.JSX.Element { + // Why: emulate GitHub Projects' tab strip — pill-shaped active tab with + // layout icon, sitting on a muted base bar with a bottom border. Inactive + // tabs are flat text; active gets a card background + outline. Disabled + // (non-table) layouts stay visible at low opacity. + return ( +
+ {views.map((v) => { + const supported = v.layout === 'TABLE_LAYOUT' + const active = v.id === activeViewId + const Icon = + v.layout === 'BOARD_LAYOUT' + ? KanbanSquare + : v.layout === 'ROADMAP_LAYOUT' + ? MapIcon + : TableIcon + return ( + + ) + })} +
+ ) +} + +function ErrorState({ + error, + totalCount, + onOpenInGitHub +}: { + error: GitHubProjectViewError + totalCount?: number + onOpenInGitHub: () => void +}): React.JSX.Element { + const command = + error.type === 'auth_required' + ? 'gh auth login' + : error.type === 'scope_missing' + ? 'gh auth refresh -s project -s read:org -s repo' + : null + const copy = + error.type === 'too_large' + ? `This view has ${totalCount ?? 'many'} items — too large to render in Orca. Narrow the view's filter on GitHub.` + : error.type === 'unsupported_layout' + ? 'Orca only renders table views yet. This is a Board or Roadmap view.' + : error.type === 'not_found' + ? 'Could not find this project or view.' + : error.type === 'schema_drift' + ? 'Could not read this project view.' + : error.message + return ( +
+
{copy}
+
+ {command ? ( + + ) : null} + +
+
+ ) +} diff --git a/src/renderer/src/components/github-project/columns.ts b/src/renderer/src/components/github-project/columns.ts new file mode 100644 index 000000000..81d67bb12 --- /dev/null +++ b/src/renderer/src/components/github-project/columns.ts @@ -0,0 +1,71 @@ +// Why: column visibility is a renderer-only preference — GitHub's view +// definition is the source of truth for which fields exist, and we layer a +// local visibility filter on top. Persisted in localStorage (not settings) +// because it's purely cosmetic per device and would otherwise bloat the +// debounced settings write on every checkbox toggle. +import type { + GitHubProjectField, + GitHubProjectView +} from '../../../../shared/github-project-types' + +export const TYPE_FIELD_ID = '__type__' +export const TYPE_FIELD_DATA_TYPE = '__TYPE__' + +// Why: synthetic "Type" column derives from row.itemType — there is no +// matching ProjectV2 field, so we inject it client-side. Inserted right +// after TITLE so users see issue/PR/draft glyphs adjacent to the title. +export const TYPE_FIELD: GitHubProjectField = { + kind: 'field', + id: TYPE_FIELD_ID, + name: 'Type', + dataType: TYPE_FIELD_DATA_TYPE +} + +export function getAvailableColumns(view: GitHubProjectView): GitHubProjectField[] { + const fields = view.fields + const titleIdx = fields.findIndex((f) => f.dataType === 'TITLE') + if (titleIdx === -1) {return [TYPE_FIELD, ...fields]} + return [ + ...fields.slice(0, titleIdx + 1), + TYPE_FIELD, + ...fields.slice(titleIdx + 1) + ] +} + +const STORAGE_KEY = 'orca.githubProject.hiddenColumns' + +type HiddenMap = Record + +function readMap(): HiddenMap { + try { + const raw = window.localStorage.getItem(STORAGE_KEY) + if (!raw) {return {}} + const parsed = JSON.parse(raw) + return parsed && typeof parsed === 'object' ? (parsed as HiddenMap) : {} + } catch { + return {} + } +} + +function writeMap(map: HiddenMap): void { + try { + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(map)) + } catch { + // localStorage may be disabled — visibility just won't persist this session. + } +} + +export function loadHiddenColumns(scopeKey: string): ReadonlySet { + const map = readMap() + return new Set(map[scopeKey] ?? []) +} + +export function saveHiddenColumns(scopeKey: string, hidden: ReadonlySet): void { + const map = readMap() + if (hidden.size === 0) { + delete map[scopeKey] + } else { + map[scopeKey] = Array.from(hidden) + } + writeMap(map) +} diff --git a/src/renderer/src/components/github-project/group-sort.test.ts b/src/renderer/src/components/github-project/group-sort.test.ts new file mode 100644 index 000000000..62bce685e --- /dev/null +++ b/src/renderer/src/components/github-project/group-sort.test.ts @@ -0,0 +1,195 @@ +// Why: cover the bug fixes from the recent review — particularly the NaN +// sort produced when two rows reference unknown single-select option IDs or +// unknown iteration IDs, and the empty-group ordering invariant. +import { describe, expect, it } from 'vitest' +import type { + GitHubProjectField, + GitHubProjectRow, + GitHubProjectSort, + GitHubProjectTable, + GitHubProjectView +} from '../../../../shared/github-project-types' +import { sortRows, groupRows } from './group-sort' + +const singleSelectField: GitHubProjectField = { + kind: 'single-select', + id: 'F_status', + name: 'Status', + dataType: 'SINGLE_SELECT', + options: [ + { id: 'opt_a', name: 'Todo', color: 'GRAY' }, + { id: 'opt_b', name: 'In Progress', color: 'YELLOW' } + ] +} + +const iterationField: GitHubProjectField = { + kind: 'iteration', + id: 'F_iter', + name: 'Iteration', + dataType: 'ITERATION', + iterations: [ + { id: 'iter_1', title: 'Sprint 1', startDate: '2026-01-01', duration: 14, completed: false }, + { id: 'iter_2', title: 'Sprint 2', startDate: '2026-01-15', duration: 14, completed: false } + ] +} + +function makeRow( + id: string, + position: number, + values: GitHubProjectRow['fieldValuesByFieldId'] +): GitHubProjectRow { + return { + id, + itemType: 'ISSUE', + content: { + number: 1, + title: id, + body: null, + url: null, + state: 'open', + stateReason: null, + isDraft: null, + repository: 'acme/repo', + assignees: [], + labels: [], + parentIssue: null, + issueType: null + }, + fieldValuesByFieldId: values, + updatedAt: '2026-01-01T00:00:00Z', + position + } +} + +function makeView(field: GitHubProjectField, sort?: GitHubProjectSort): GitHubProjectView { + return { + id: 'V_1', + number: 1, + name: 'Default', + layout: 'TABLE_LAYOUT', + filter: '', + fields: [field], + groupByFields: [], + sortByFields: sort ? [sort] : [] + } +} + +function makeTable(view: GitHubProjectView, rows: GitHubProjectRow[]): GitHubProjectTable { + return { + project: { + id: 'P', + owner: 'acme', + ownerType: 'organization', + number: 1, + title: 'P', + url: '' + }, + selectedView: view, + rows, + totalCount: rows.length, + parentFieldDropped: false + } +} + +describe('sortRows', () => { + it('orders rows by single-select option order', () => { + const view = makeView(singleSelectField, { + direction: 'ASC', + field: singleSelectField + }) + const rows = [ + makeRow('r2', 1, { + F_status: { kind: 'single-select', fieldId: 'F_status', optionId: 'opt_b', name: 'In Progress', color: 'YELLOW' } + }), + makeRow('r1', 0, { + F_status: { kind: 'single-select', fieldId: 'F_status', optionId: 'opt_a', name: 'Todo', color: 'GRAY' } + }) + ] + const sorted = sortRows(makeTable(view, rows), rows) + expect(sorted.map((r) => r.id)).toEqual(['r1', 'r2']) + }) + + it('does not produce NaN when both rows reference unknown single-select options', () => { + // Why: this was the bug — `Infinity - Infinity = NaN` made sort()'s + // behavior implementation-defined and skipped the row.position + // tie-break. Two orphaned rows must still fall through to position. + const view = makeView(singleSelectField, { + direction: 'ASC', + field: singleSelectField + }) + const rows = [ + makeRow('rB', 5, { + F_status: { kind: 'single-select', fieldId: 'F_status', optionId: 'orphan_2', name: 'Gone', color: 'GRAY' } + }), + makeRow('rA', 1, { + F_status: { kind: 'single-select', fieldId: 'F_status', optionId: 'orphan_1', name: 'Gone', color: 'GRAY' } + }) + ] + const sorted = sortRows(makeTable(view, rows), rows) + // After tie-break by position, rA (position=1) precedes rB (position=5). + expect(sorted.map((r) => r.id)).toEqual(['rA', 'rB']) + }) + + it('does not produce NaN when both rows reference unknown iteration ids', () => { + const view = makeView(iterationField, { + direction: 'ASC', + field: iterationField + }) + const rows = [ + makeRow('rB', 5, { + F_iter: { + kind: 'iteration', + fieldId: 'F_iter', + iterationId: 'gone_b', + title: 'Gone B', + startDate: '2025-01-01', + duration: 14 + } + }), + makeRow('rA', 1, { + F_iter: { + kind: 'iteration', + fieldId: 'F_iter', + iterationId: 'gone_a', + title: 'Gone A', + startDate: '2025-01-01', + duration: 14 + } + }) + ] + const sorted = sortRows(makeTable(view, rows), rows) + expect(sorted.map((r) => r.id)).toEqual(['rA', 'rB']) + }) + + it('places rows missing the sort field after rows that have it', () => { + const view = makeView(singleSelectField, { + direction: 'ASC', + field: singleSelectField + }) + const rows = [ + makeRow('rEmpty', 0, {}), + makeRow('rHas', 1, { + F_status: { kind: 'single-select', fieldId: 'F_status', optionId: 'opt_a', name: 'Todo', color: 'GRAY' } + }) + ] + const sorted = sortRows(makeTable(view, rows), rows) + expect(sorted.map((r) => r.id)).toEqual(['rHas', 'rEmpty']) + }) +}) + +describe('groupRows', () => { + it('places the empty group last', () => { + const view = { + ...makeView(singleSelectField), + groupByFields: [singleSelectField] + } + const rows = [ + makeRow('rNone', 0, {}), + makeRow('rA', 1, { + F_status: { kind: 'single-select', fieldId: 'F_status', optionId: 'opt_a', name: 'Todo', color: 'GRAY' } + }) + ] + const groups = groupRows(makeTable(view, rows), rows) + expect(groups.map((g) => g.key)).toEqual(['opt_a', '__empty__']) + }) +}) diff --git a/src/renderer/src/components/github-project/group-sort.ts b/src/renderer/src/components/github-project/group-sort.ts new file mode 100644 index 000000000..46eca63d6 --- /dev/null +++ b/src/renderer/src/components/github-project/group-sort.ts @@ -0,0 +1,213 @@ +// Why: grouping and sorting of Project rows is deterministic shared logic +// driven by `selectedView` — it must not depend on fetch ordering. Keeping it +// in a pure module makes the comparator easily fixture-testable. +import type { + GitHubProjectField, + GitHubProjectRow, + GitHubProjectSort, + GitHubProjectTable +} from '../../../../shared/github-project-types' + +export type ProjectGroup = { + /** Stable key used for React reconciliation. */ + key: string + /** Human-readable label used in the group header. */ + label: string + /** Iteration metadata for headers that render a date range + Current pill. */ + iteration: { + startDate: string + duration: number + completed: boolean + } | null + rows: GitHubProjectRow[] +} + +const EMPTY_GROUP_KEY = '__empty__' + +// Why: use a finite sentinel instead of Infinity so subtractions in the sort +// comparator stay finite. `Infinity - Infinity` is NaN, which makes +// Array.sort's behavior implementation-defined and skips later tie-breaks. +const UNKNOWN_INDEX_SENTINEL = Number.MAX_SAFE_INTEGER + +function getFieldValueForGrouping( + row: GitHubProjectRow, + field: GitHubProjectField +): { key: string; label: string; orderHint: number; iteration: ProjectGroup['iteration'] } { + const value = row.fieldValuesByFieldId[field.id] + if (!value) { + return { key: EMPTY_GROUP_KEY, label: labelForEmpty(field), orderHint: UNKNOWN_INDEX_SENTINEL, iteration: null } + } + if (field.kind === 'iteration' && value.kind === 'iteration') { + const idx = field.iterations.findIndex((it) => it.id === value.iterationId) + const meta = field.iterations.find((it) => it.id === value.iterationId) + return { + key: value.iterationId, + label: value.title || meta?.title || 'Iteration', + orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx, + iteration: meta + ? { startDate: meta.startDate, duration: meta.duration, completed: meta.completed } + : null + } + } + if (field.kind === 'single-select' && value.kind === 'single-select') { + const idx = field.options.findIndex((o) => o.id === value.optionId) + return { + key: value.optionId, + label: value.name, + orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx, + iteration: null + } + } + // Fallback: use stringified value as both key and label. + const label = deriveStringValue(value) + return { key: `raw:${label}`, label, orderHint: 0, iteration: null } +} + +function labelForEmpty(field: GitHubProjectField): string { + return `No ${field.name}` +} + +function deriveStringValue(value: GitHubProjectRow['fieldValuesByFieldId'][string]): string { + switch (value.kind) { + case 'text': + return value.text + case 'number': + return String(value.number) + case 'date': + return value.date + case 'single-select': + return value.name + case 'iteration': + return value.title + case 'labels': + return value.labels.map((l) => l.name).join(', ') + case 'users': + return value.users.map((u) => u.login).join(', ') + default: + return '' + } +} + +export function groupRows( + table: GitHubProjectTable, + rowsInOrder: GitHubProjectRow[] +): ProjectGroup[] { + const groupField = table.selectedView.groupByFields[0] + if (!groupField) { + return [{ key: 'all', label: '', iteration: null, rows: rowsInOrder }] + } + const buckets = new Map< + string, + { label: string; orderHint: number; iteration: ProjectGroup['iteration']; rows: GitHubProjectRow[] } + >() + for (const row of rowsInOrder) { + const { key, label, orderHint, iteration } = getFieldValueForGrouping(row, groupField) + let bucket = buckets.get(key) + if (!bucket) { + bucket = { label, orderHint, iteration, rows: [] } + buckets.set(key, bucket) + } + bucket.rows.push(row) + } + const entries = Array.from(buckets.entries()) + // Ordering rules per design doc §Grouping. + entries.sort((a, b) => { + // Empty group always last. + if (a[0] === EMPTY_GROUP_KEY) {return 1} + if (b[0] === EMPTY_GROUP_KEY) {return -1} + if (groupField.kind === 'iteration' || groupField.kind === 'single-select') { + return a[1].orderHint - b[1].orderHint + } + return a[1].label.localeCompare(b[1].label) + }) + return entries.map(([key, v]) => ({ + key, + label: v.label, + iteration: v.iteration, + rows: v.rows + })) +} + +function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProjectSort): number { + const field = sort.field + const aValue = a.fieldValuesByFieldId[field.id] + const bValue = b.fieldValuesByFieldId[field.id] + // Missing values sort last (regardless of direction). + if (!aValue && !bValue) {return 0} + if (!aValue) {return 1} + if (!bValue) {return -1} + + let cmp = 0 + if (field.kind === 'single-select' && aValue.kind === 'single-select' && bValue.kind === 'single-select') { + const aIdx = field.options.findIndex((o) => o.id === aValue.optionId) + const bIdx = field.options.findIndex((o) => o.id === bValue.optionId) + cmp = (aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx) + } else if ( + field.kind === 'iteration' && + aValue.kind === 'iteration' && + bValue.kind === 'iteration' + ) { + const aIdx = field.iterations.findIndex((it) => it.id === aValue.iterationId) + const bIdx = field.iterations.findIndex((it) => it.id === bValue.iterationId) + cmp = (aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx) + } else if (aValue.kind === 'number' && bValue.kind === 'number') { + cmp = aValue.number - bValue.number + } else if (aValue.kind === 'date' && bValue.kind === 'date') { + cmp = aValue.date.localeCompare(bValue.date) + } else if (aValue.kind === 'text' && bValue.kind === 'text') { + cmp = aValue.text.localeCompare(bValue.text) + } else if (aValue.kind === 'users' && bValue.kind === 'users') { + const aLogin = aValue.users[0]?.login ?? '' + const bLogin = bValue.users[0]?.login ?? '' + if (!aLogin && !bLogin) {cmp = 0} + else if (!aLogin) {cmp = 1} + else if (!bLogin) {cmp = -1} + else {cmp = aLogin.localeCompare(bLogin)} + } else if (aValue.kind === 'labels' && bValue.kind === 'labels') { + const aName = aValue.labels[0]?.name ?? '' + const bName = bValue.labels[0]?.name ?? '' + if (!aName && !bName) {cmp = 0} + else if (!aName) {cmp = 1} + else if (!bName) {cmp = -1} + else {cmp = aName.localeCompare(bName)} + } else { + // Why: unknown sort-field kind — ignore this sort field and fall through + // to tie-breaks (and eventually row.position). Dev-time warning gated so + // production builds don't spam the console. + if (process.env.NODE_ENV !== 'production') { + // eslint-disable-next-line no-console + console.warn('[projectView] unknown sort-field kind', field) + } + return 0 + } + return sort.direction === 'DESC' ? -cmp : cmp +} + +export function sortRows( + table: GitHubProjectTable, + rows: GitHubProjectRow[] +): GitHubProjectRow[] { + const sorts = table.selectedView.sortByFields + const out = [...rows] + out.sort((a, b) => { + for (const sort of sorts) { + const cmp = compareSort(a, b, sort) + if (cmp !== 0) {return cmp} + } + // Final tie-break: row.position preserves GitHub rank order. + return a.position - b.position + }) + return out +} + +export function isIterationCurrent(iteration: { + startDate: string + duration: number +}): boolean { + // Parse as YYYY-MM-DD in UTC to avoid TZ-shift false negatives near midnight. + const start = new Date(`${iteration.startDate}T00:00:00Z`).getTime() + if (Number.isNaN(start)) {return false} + const end = start + iteration.duration * 86_400_000 + const now = Date.now() + return now >= start && now < end +} diff --git a/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx b/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx new file mode 100644 index 000000000..733a22dbf --- /dev/null +++ b/src/renderer/src/components/github-project/slug-dialog/AssigneesEditor.tsx @@ -0,0 +1,95 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { cn } from '@/lib/utils' +import type { GitHubAssignableUser } from '../../../../../shared/types' + +export function AssigneesEditor({ + owner, + repo, + selected, + disabled, + onChange +}: { + owner: string + repo: string + selected: string[] + disabled?: boolean + onChange: (add: string[], remove: string[]) => void | Promise +}): React.JSX.Element { + const [open, setOpen] = useState(false) + const [users, setUsers] = useState([]) + const [loading, setLoading] = useState(false) + // Why: stabilize the assignee seed identity. `selected` is a fresh array on + // every parent render — depending on it directly would refire the IPC for + // every unrelated re-render while the popover is open. + const seedKey = useMemo(() => selected.slice().sort().join(','), [selected]) + useEffect(() => { + if (!open) {return} + // Why: guard against late responses overwriting newer state when + // owner/repo/seedKey change (or the popover toggles) before the IPC + // resolves. Mirrors the requestIdRef pattern used for the details fetch. + let cancelled = false + setLoading(true) + window.api.gh + .listAssignableUsersBySlug({ + owner, + repo, + seedLogins: seedKey ? seedKey.split(',') : [] + }) + .then((res) => { + if (cancelled) {return} + if (res.ok) {setUsers(res.users)} + }) + .finally(() => { + if (cancelled) {return} + setLoading(false) + }) + return () => { + cancelled = true + } + }, [open, owner, repo, seedKey]) + return ( + !disabled && setOpen(o)}> + + + + + {loading ? ( +
Loading…
+ ) : ( + users.map((u) => { + const isOn = selected.includes(u.login) + return ( + + ) + }) + )} +
+
+ ) +} diff --git a/src/renderer/src/components/github-project/slug-dialog/Comments.tsx b/src/renderer/src/components/github-project/slug-dialog/Comments.tsx new file mode 100644 index 000000000..2081a0865 --- /dev/null +++ b/src/renderer/src/components/github-project/slug-dialog/Comments.tsx @@ -0,0 +1,165 @@ +import React, { useState } from 'react' +import { Send } from 'lucide-react' +import { toast } from 'sonner' +import { Button } from '@/components/ui/button' +import CommentMarkdown from '@/components/sidebar/CommentMarkdown' +import type { PRComment } from '../../../../../shared/types' + +export function CommentsList({ + owner, + repo, + comments, + onChange +}: { + owner: string + repo: string + comments: PRComment[] + onChange: (next: PRComment[]) => void +}): React.JSX.Element { + return ( +
+ {comments.length === 0 ? ( +
No comments yet.
+ ) : ( + comments.map((c) => ( + { + const res = await window.api.gh.deleteIssueCommentBySlug({ + owner, + repo, + commentId: c.id + }) + if (!res.ok) { + toast.error(res.error.message) + return + } + onChange(comments.filter((x) => x.id !== c.id)) + }} + onEdit={async (next) => { + const res = await window.api.gh.updateIssueCommentBySlug({ + owner, + repo, + commentId: c.id, + body: next + }) + if (!res.ok) { + toast.error(res.error.message) + return + } + onChange(comments.map((x) => (x.id === c.id ? { ...x, body: next } : x))) + }} + /> + )) + )} +
+ ) +} + +function CommentRow({ + comment, + onDelete, + onEdit +}: { + owner: string + repo: string + comment: PRComment + onDelete: () => void | Promise + onEdit: (next: string) => void | Promise +}): React.JSX.Element { + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState(comment.body) + return ( +
+
+ {comment.author} +
+ + +
+
+ {editing ? ( +
+