[feat] Add a full Github project view under "Tasks" (#1424)

* WIP: auto-review-fix iteration 1 (project-view.ts fixes applied)

Co-authored-by: Orca <help@stably.ai>

* refactor(github-project): split project-view and slug-dialog into modules

Co-authored-by: Orca <help@stably.ai>

* fix(lint): resolve oxlint errors in project-view modules

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing 2026-05-04 23:24:52 -07:00 committed by GitHub
parent d1b26e2eb7
commit 70ffa0a73d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 9462 additions and 71 deletions

View File

@ -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: ''
})
})
})

View File

@ -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 <linuxPath> &&`
// 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 <distro> -- 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<GitExecOptions, 'cwd'> & { 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:
* <seconds>" 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<void> {
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.) ──────────────────────────

View File

@ -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()
})
})

File diff suppressed because it is too large Load Diff

View File

@ -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<string, string | number | boolean>
export async function runGraphql<T>(
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<T>(
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()
}
}

View File

@ -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<GitHubProjectMutationResult> {
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<unknown>(query, vars)
if (!res.ok) {return { ok: false, error: res.error }}
return { ok: true }
}
export async function clearProjectItemFieldValue(
args: ClearProjectItemFieldArgs
): Promise<GitHubProjectMutationResult> {
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<unknown>(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<GitHubProjectMutationResult> {
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<unknown>(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<RawLabelResp>(['-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<unknown>(
['-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<unknown>(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<unknown>(restArgs)
if (!r.ok) {return { ok: false, error: r.error }}
}
if (removeCount === 1) {
const r = await runRest<unknown>(
['-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<unknown>(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<unknown>(restArgs)
if (!r.ok) {return { ok: false, error: r.error }}
}
return { ok: true }
}
export async function updatePullRequestBySlug(
args: UpdatePullRequestBySlugArgs
): Promise<GitHubProjectMutationResult> {
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<unknown>(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<GitHubProjectCommentMutationResult> {
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<RawIssueCommentResponse>([
'-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<GitHubProjectMutationResult> {
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<unknown>([
'-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<GitHubProjectMutationResult> {
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<unknown>(
['-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<ListLabelsBySlugResult> {
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<ListAssignableUsersBySlugResult> {
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<ListIssueTypesBySlugResult> {
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<typeof n> => 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<GitHubProjectMutationResult> {
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<unknown>(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<ProjectWorkItemDetailsBySlugResult> {
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 }
}

View File

@ -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<GetRateLimitResult> {
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()
}
}

View File

@ -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

View File

@ -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<GitHubAssignableUser[]>
checkOrcaStarred: () => Promise<boolean | null>
starOrca: () => Promise<boolean>
/**
* 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<GetRateLimitResult>
// ── ProjectV2 (GitHub Projects) ─────────────────────────────────
listAccessibleProjects: () => Promise<ListAccessibleProjectsResult>
resolveProjectRef: (args: ResolveProjectRefArgs) => Promise<ResolveProjectRefResult>
listProjectViews: (args: ListProjectViewsArgs) => Promise<ListProjectViewsResult>
getProjectViewTable: (args: GetProjectViewTableArgs) => Promise<GetProjectViewTableResult>
projectWorkItemDetailsBySlug: (
args: ProjectWorkItemDetailsBySlugArgs
) => Promise<ProjectWorkItemDetailsBySlugResult>
updateProjectItemField: (
args: UpdateProjectItemFieldArgs
) => Promise<GitHubProjectMutationResult>
clearProjectItemField: (
args: ClearProjectItemFieldArgs
) => Promise<GitHubProjectMutationResult>
updateIssueBySlug: (args: UpdateIssueBySlugArgs) => Promise<GitHubProjectMutationResult>
updatePullRequestBySlug: (
args: UpdatePullRequestBySlugArgs
) => Promise<GitHubProjectMutationResult>
addIssueCommentBySlug: (
args: AddIssueCommentBySlugArgs
) => Promise<GitHubProjectCommentMutationResult>
updateIssueCommentBySlug: (
args: UpdateIssueCommentBySlugArgs
) => Promise<GitHubProjectMutationResult>
deleteIssueCommentBySlug: (
args: DeleteIssueCommentBySlugArgs
) => Promise<GitHubProjectMutationResult>
listLabelsBySlug: (args: ListLabelsBySlugArgs) => Promise<ListLabelsBySlugResult>
listAssignableUsersBySlug: (
args: ListAssignableUsersBySlugArgs
) => Promise<ListAssignableUsersBySlugResult>
listIssueTypesBySlug: (args: ListIssueTypesBySlugArgs) => Promise<ListIssueTypesBySlugResult>
updateIssueTypeBySlug: (
args: UpdateIssueTypeBySlugArgs
) => Promise<GitHubProjectMutationResult>
}
linear: {
connect: (args: {

View File

@ -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<boolean | null> => ipcRenderer.invoke('gh:checkOrcaStarred'),
starOrca: (): Promise<boolean> => ipcRenderer.invoke('gh:starOrca')
starOrca: (): Promise<boolean> => 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<GetRateLimitResult> =>
ipcRenderer.invoke('gh:rateLimit', args),
// ── ProjectV2 (GitHub Projects) ───────────────────────────────────
listAccessibleProjects: (): Promise<ListAccessibleProjectsResult> =>
ipcRenderer.invoke('gh:listAccessibleProjects'),
resolveProjectRef: (args: ResolveProjectRefArgs): Promise<ResolveProjectRefResult> =>
ipcRenderer.invoke('gh:resolveProjectRef', args),
listProjectViews: (args: ListProjectViewsArgs): Promise<ListProjectViewsResult> =>
ipcRenderer.invoke('gh:listProjectViews', args),
getProjectViewTable: (args: GetProjectViewTableArgs): Promise<GetProjectViewTableResult> =>
ipcRenderer.invoke('gh:getProjectViewTable', args),
projectWorkItemDetailsBySlug: (
args: ProjectWorkItemDetailsBySlugArgs
): Promise<ProjectWorkItemDetailsBySlugResult> =>
ipcRenderer.invoke('gh:projectWorkItemDetailsBySlug', args),
updateProjectItemField: (
args: UpdateProjectItemFieldArgs
): Promise<GitHubProjectMutationResult> =>
ipcRenderer.invoke('gh:updateProjectItemField', args),
clearProjectItemField: (
args: ClearProjectItemFieldArgs
): Promise<GitHubProjectMutationResult> =>
ipcRenderer.invoke('gh:clearProjectItemField', args),
updateIssueBySlug: (args: UpdateIssueBySlugArgs): Promise<GitHubProjectMutationResult> =>
ipcRenderer.invoke('gh:updateIssueBySlug', args),
updatePullRequestBySlug: (
args: UpdatePullRequestBySlugArgs
): Promise<GitHubProjectMutationResult> =>
ipcRenderer.invoke('gh:updatePullRequestBySlug', args),
addIssueCommentBySlug: (
args: AddIssueCommentBySlugArgs
): Promise<GitHubProjectCommentMutationResult> =>
ipcRenderer.invoke('gh:addIssueCommentBySlug', args),
updateIssueCommentBySlug: (
args: UpdateIssueCommentBySlugArgs
): Promise<GitHubProjectMutationResult> =>
ipcRenderer.invoke('gh:updateIssueCommentBySlug', args),
deleteIssueCommentBySlug: (
args: DeleteIssueCommentBySlugArgs
): Promise<GitHubProjectMutationResult> =>
ipcRenderer.invoke('gh:deleteIssueCommentBySlug', args),
listLabelsBySlug: (args: ListLabelsBySlugArgs): Promise<ListLabelsBySlugResult> =>
ipcRenderer.invoke('gh:listLabelsBySlug', args),
listAssignableUsersBySlug: (
args: ListAssignableUsersBySlugArgs
): Promise<ListAssignableUsersBySlugResult> =>
ipcRenderer.invoke('gh:listAssignableUsersBySlug', args),
listIssueTypesBySlug: (args: ListIssueTypesBySlugArgs): Promise<ListIssueTypesBySlugResult> =>
ipcRenderer.invoke('gh:listIssueTypesBySlug', args),
updateIssueTypeBySlug: (
args: UpdateIssueTypeBySlugArgs
): Promise<GitHubProjectMutationResult> =>
ipcRenderer.invoke('gh:updateIssueTypeBySlug', args)
},
linear: {

View File

@ -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<GitHubReaction['content'], string> = {
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' ? <div className="pt-1">{startWorkspaceButton}</div> : null}
{item.type !== 'pr' ? (
<div className="flex justify-start pt-1">
<Button
onClick={() => onUse(item)}
className="gap-2"
aria-label="Start workspace from issue"
>
Start workspace from issue
<ArrowRight className="size-4" />
</Button>
</div>
) : null}
</div>
{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<typeof window.api.gh.updateIssue>[0]['updates']
}
): Promise<void> {
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<string[]>(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<typeof patchProjectRowContent>[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 (
<div
className={cn(
'flex items-center gap-2 rounded-lg border border-border/50 bg-background/30 p-2',
className
)}
>
<div className={cn('flex items-start gap-2', className)}>
<MentionTextarea
textareaRef={textareaRef}
value={body}
@ -2063,10 +2197,10 @@ function GHCommentComposer({
}}
onKeyDown={handleKeyDown}
placeholder="Add a comment…"
rows={1}
rows={4}
mentionOptions={mentionOptions}
wrapperClassName="flex min-h-9 items-center"
className="scrollbar-sleek block h-9 max-h-[96px] min-h-9 w-full resize-none overflow-y-auto rounded-md border border-input bg-transparent px-3 py-2 text-[13px] leading-5 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
wrapperClassName="flex min-h-20 items-stretch"
className="scrollbar-sleek block h-20 max-h-[240px] min-h-20 w-full resize-none overflow-y-auto rounded-md border border-input bg-card px-3 py-2 text-[13px] leading-5 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button
size="icon"
@ -2150,6 +2284,7 @@ function WorkItemIssueSourceIndicator({
export default function GitHubItemDialog({
workItem,
repoPath,
projectOrigin,
onUse,
onClose
}: GitHubItemDialogProps): React.JSX.Element {
@ -2304,7 +2439,7 @@ export default function GitHubItemDialog({
<SheetContent
side="right"
showCloseButton={false}
className="flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-[960px] lg:max-w-[1100px] xl:max-w-[1280px]"
className="flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-[640px] lg:max-w-[760px] xl:max-w-[900px]"
onOpenAutoFocus={(event) => {
// Why: focusing the first actionable element inside the drawer
// causes the "Start workspace" action to receive focus and
@ -2329,15 +2464,15 @@ export default function GitHubItemDialog({
<div className="flex h-full min-h-0 flex-col">
<div className="flex-none border-b border-border/60 px-4 py-3">
<div className="flex items-start gap-2">
<Icon className="mt-1 size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<span className="font-mono text-[12px] text-muted-foreground">
#{workItem.number}
</span>
<h2 className="mt-1 text-[15px] font-semibold leading-tight text-foreground">
{workItem.title}
<h2 className="mt-1 flex items-start gap-2 text-[15px] font-semibold leading-tight text-foreground">
<Icon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0">{workItem.title}</span>
</h2>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-muted-foreground">
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 pl-6 text-[11px] text-muted-foreground">
<span>{workItem.author ?? 'unknown'}</span>
<span>· {formatRelativeTime(workItem.updatedAt)}</span>
{workItem.branchName && (
@ -2387,10 +2522,11 @@ export default function GitHubItemDialog({
</div>
</div>
{repoPath && (
{(repoPath || projectOrigin) && (
<GHEditSection
item={workItem}
repoPath={repoPath}
projectOrigin={projectOrigin}
localState={localState}
localLabels={localLabels}
onStateChange={setLocalState}

View File

@ -59,8 +59,10 @@ import TeamMultiCombobox from '@/components/ui/team-multi-combobox'
import RepoDotLabel from '@/components/repo/RepoDotLabel'
import IssueSourceIndicator, { sameGitHubOwnerRepo } from '@/components/github/IssueSourceIndicator'
import IssueSourceSelector, { issueSourceChipClass } from '@/components/github/IssueSourceSelector'
import GitHubRateLimitPill from '@/components/github/GitHubRateLimitPill'
import { stripRepoQualifiers } from '../../../shared/task-query'
import GitHubItemDialog from '@/components/GitHubItemDialog'
import ProjectViewWrapper from '@/components/github-project/ProjectViewWrapper'
import LinearItemDrawer from '@/components/LinearItemDrawer'
import { cn } from '@/lib/utils'
import {
@ -786,6 +788,19 @@ export default function TaskPage(): React.JSX.Element {
}
}, [settings?.defaultTaskSource, pageData.taskSource])
// Why: Project mode is a sub-tab within the GitHub source. Visible whenever
// the user is on the GitHub task source — actual entry into Project mode is
// gated on a non-null `activeProject` once they pick one.
const projectModeVisible = taskSource === 'github'
const [githubMode, setGithubMode] = useState<'items' | 'project'>('items')
useEffect(() => {
// Snap back to items if the user leaves the GitHub task source while
// sitting in Project mode.
if (!projectModeVisible && githubMode === 'project') {
setGithubMode('items')
}
}, [projectModeVisible, githubMode])
const [taskSearchInput, setTaskSearchInput] = useState(initialTaskQuery)
const [appliedTaskSearch, setAppliedTaskSearch] = useState(initialTaskQuery)
const [activeTaskPreset, setActiveTaskPreset] = useState<TaskViewPresetId | null>(
@ -1838,27 +1853,8 @@ export default function TaskPage(): React.JSX.Element {
)
})}
</div>
<div className="w-[200px]">
{taskSource === 'github' ? (
<RepoMultiCombobox
repos={eligibleRepos}
selected={repoSelection}
onChange={(next) => {
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"
/>
) : availableTeams.length > 0 ? (
{taskSource === 'linear' && availableTeams.length > 0 ? (
<div className="w-[200px]">
<TeamMultiCombobox
teams={availableTeams}
selected={linearTeamSelection}
@ -1878,11 +1874,65 @@ export default function TaskPage(): React.JSX.Element {
}}
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}
</div>
</div>
) : null}
</div>
{taskSource === 'github' ? (
<div className="flex items-center gap-2">
{projectModeVisible ? (
<div className="flex items-center gap-1 text-xs">
{(['items', 'project'] as const).map((mode) => {
const active = githubMode === mode
return (
<button
key={mode}
type="button"
onClick={() => setGithubMode(mode)}
className={cn(
'rounded-md border px-2 py-1 text-xs transition',
active
? 'border-border/50 bg-foreground/90 text-background'
: 'border-border/50 bg-transparent text-foreground hover:bg-muted/50'
)}
>
{mode === 'items' ? 'Issues/PRs' : 'Project'}
</button>
)
})}
</div>
) : 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' && (
<div className="w-[200px]">
<RepoMultiCombobox
repos={eligibleRepos}
selected={repoSelection}
onChange={(next) => {
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"
/>
</div>
)}
</div>
) : null}
{taskSource === 'github' && githubMode === 'items' ? (
<div className="rounded-md rounded-b-none border border-border/50 bg-muted/50 p-3 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
@ -1917,6 +1967,13 @@ export default function TaskPage(): React.JSX.Element {
</div>
<div className="flex shrink-0 items-center gap-2">
{/* 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. */}
<GitHubRateLimitPill />
<Tooltip>
<TooltipTrigger asChild>
<Button
@ -2063,7 +2120,7 @@ export default function TaskPage(): React.JSX.Element {
)
})()}
</div>
) : linearStatus.connected ? (
) : taskSource === 'linear' && linearStatus.connected ? (
<div className="rounded-md rounded-b-none border border-border/50 bg-muted/50 p-3 shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap gap-2">
@ -2183,7 +2240,11 @@ export default function TaskPage(): React.JSX.Element {
</section>
</div>
{taskSource === 'github' ? (
{taskSource === 'github' && githubMode === 'project' ? (
<div className="mt-3 flex min-h-0 max-h-full flex-col rounded-md border border-border/50 bg-muted/50 overflow-hidden shadow-sm">
<ProjectViewWrapper />
</div>
) : taskSource === 'github' ? (
<div className="flex min-h-0 max-h-full flex-col rounded-md border border-t-0 border-border/50 bg-muted/50 overflow-hidden rounded-t-none shadow-sm">
<div className="flex-none grid grid-cols-[80px_minmax(0,3fr)_minmax(110px,0.8fr)_100px_110px_112px] gap-3 border-b border-border/50 px-3 py-2 text-[10px] font-medium uppercase tracking-[0.16em] text-muted-foreground">
<span>ID</span>

File diff suppressed because it is too large Load Diff

View File

@ -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 (
<button
type="button"
onClick={onToggle}
className={cn(
'flex w-full items-center gap-2 border-b border-border/50 bg-muted/40 px-3 py-1.5 text-left text-xs',
'hover:bg-muted/60'
)}
>
{expanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
<span className="font-medium">{group.label || 'All'}</span>
<span className="rounded-full border border-border/50 bg-background px-1.5 text-[10px] text-muted-foreground">
{group.rows.length}
</span>
{dateRange ? (
<span className="text-[10px] text-muted-foreground">{dateRange}</span>
) : null}
{isCurrent ? (
<span className="rounded-full border border-emerald-500/30 bg-emerald-500/10 px-1.5 text-[10px] text-emerald-700 dark:text-emerald-300">
Current
</span>
) : null}
</button>
)
}
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)}`
}

View File

@ -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 (
<Sheet open={open} onOpenChange={(o) => !o && onClose()}>
<SheetContent
side="right"
showCloseButton={false}
className="flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-[720px] lg:max-w-[860px]"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<VisuallyHidden.Root asChild>
<SheetTitle>GitHub item</SheetTitle>
</VisuallyHidden.Root>
<VisuallyHidden.Root asChild>
<SheetDescription>Project row preview.</SheetDescription>
</VisuallyHidden.Root>
{projectOrigin ? (
<SlugDialogBody projectOrigin={projectOrigin} onClose={onClose} />
) : null}
</SheetContent>
</Sheet>
)
}

View File

@ -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<GitHubProjectViewError | null>(null)
const [browseProjects, setBrowseProjects] = useState<GitHubProjectSummary[]>(
() => 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<string | null>(null)
const [pasteBusy, setPasteBusy] = useState(false)
// View-pick step state.
const [viewPickFor, setViewPickFor] = useState<ResolvedProjectSelection | null>(null)
const [viewList, setViewList] = useState<GitHubProjectViewSummary[]>([])
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 (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 gap-1 border-border/50 bg-transparent text-xs"
>
<span className="truncate">{buttonLabel}</span>
<ChevronDown className="size-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[360px] p-0" align="start">
{viewPickFor ? (
<ViewPickStep
loading={viewLoading}
views={viewList}
onPick={async (view) => {
await commitSelection({ ...viewPickFor, viewId: view.id }, null)
}}
onBack={() => setViewPickFor(null)}
/>
) : (
<div className="flex flex-col">
<div className="border-b border-border/50 p-2">
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search projects"
className="h-8 pl-7 text-xs"
/>
</div>
</div>
{browseError ? <AuthErrorBanner error={browseError} /> : null}
{!browseError && partialFailures.length > 0 ? (
<PartialFailuresBanner failures={partialFailures} />
) : null}
<div className="max-h-[340px] overflow-y-auto p-1">
{projectSettings.pinned.length > 0 ? (
<Section label="Pinned">
{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 (
<PickerRow
key={key}
title={match?.title ?? `#${p.number}`}
subtitle={`${p.owner}`}
zombie={!knownGood}
onClick={() =>
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
)
}))
}}
/>
)
})}
</Section>
) : null}
{projectSettings.recent.length > 0 ? (
<Section label="Recent">
{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 (
<PickerRow
key={key}
title={match?.title ?? `#${r.number}`}
subtitle={r.owner}
canPin={pinnable}
onPin={async () => {
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
})
}
/>
)
})}
</Section>
) : null}
<Section label={browseLoading ? 'Browse all (loading…)' : 'Browse all'}>
{browseLoading ? (
<div className="flex items-center gap-2 px-2 py-2 text-xs text-muted-foreground">
<Loader className="size-3 animate-spin" /> Loading
</div>
) : null}
{filteredBrowse.map((p) => (
<PickerRow
key={`${p.ownerType}:${p.owner}:${p.number}`}
title={p.title}
subtitle={p.owner}
onClick={() =>
handleChooseProject({
owner: p.owner,
ownerType: p.ownerType,
number: p.number,
title: p.title
})
}
/>
))}
</Section>
</div>
<div className="border-t border-border/50 p-2">
<div className="flex gap-2">
<Input
value={pasteInput}
onChange={(e) => {
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"
/>
<Button
size="sm"
onClick={() => void handlePaste()}
disabled={pasteBusy || !pasteInput.trim()}
className="h-8"
>
Add
</Button>
</div>
{pasteError ? (
<div className="mt-1 text-[11px] text-destructive">{pasteError}</div>
) : null}
</div>
</div>
)}
</PopoverContent>
</Popover>
)
}
function Section({
label,
children
}: {
label: string
children: React.ReactNode
}): React.JSX.Element {
return (
<div className="py-1">
<div className="px-2 pb-0.5 text-[10px] uppercase tracking-wide text-muted-foreground">
{label}
</div>
{children}
</div>
)
}
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 (
<div className="group flex items-center gap-2 rounded px-2 py-1 hover:bg-muted/50">
<button type="button" onClick={onClick} className="flex flex-1 min-w-0 flex-col text-left">
<span className="truncate text-sm">{title}</span>
<span className="truncate text-[10px] text-muted-foreground">{subtitle}</span>
</button>
{zombie ? (
<div className="flex items-center gap-1">
<AlertTriangle className="size-3.5 text-amber-500" />
<button
type="button"
className="text-[10px] text-muted-foreground hover:text-foreground"
onClick={onRemovePin}
>
Remove pin
</button>
</div>
) : null}
{canPin ? (
<button
type="button"
title="Pin"
className="opacity-0 group-hover:opacity-100"
onClick={onPin}
>
<Pin className="size-3.5" />
</button>
) : null}
</div>
)
}
function ViewPickStep({
loading,
views,
onPick,
onBack
}: {
loading: boolean
views: GitHubProjectViewSummary[]
onPick: (view: GitHubProjectViewSummary) => void | Promise<void>
onBack: () => void
}): React.JSX.Element {
return (
<div className="flex flex-col">
<div className="flex items-center justify-between border-b border-border/50 p-2">
<button
type="button"
onClick={onBack}
className="text-xs text-muted-foreground hover:text-foreground"
>
Back
</button>
<span className="text-xs font-medium">Choose a view</span>
<span />
</div>
<div className="max-h-[340px] overflow-y-auto p-1">
{loading ? (
<div className="flex items-center gap-2 px-2 py-2 text-xs text-muted-foreground">
<Loader className="size-3 animate-spin" /> Loading views
</div>
) : views.length === 0 ? (
<div className="px-2 py-2 text-xs text-muted-foreground">No views found.</div>
) : (
views.map((v) => {
const supported = v.layout === 'TABLE_LAYOUT'
return (
<button
key={v.id}
type="button"
disabled={!supported}
onClick={() => void onPick(v)}
className={cn(
'flex w-full flex-col items-start rounded px-2 py-1 text-left',
supported
? 'hover:bg-muted/50'
: 'cursor-not-allowed opacity-50'
)}
>
<span className="text-sm">{v.name}</span>
<span className="text-[10px] text-muted-foreground">
{v.layout === 'TABLE_LAYOUT'
? 'Table'
: v.layout === 'BOARD_LAYOUT'
? 'Board (unsupported)'
: 'Roadmap (unsupported)'}
</span>
</button>
)
})
)}
</div>
</div>
)
}
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 (
<div
className="border-b border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200"
title={detail}
>
<div className="flex items-start gap-1.5">
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
<div>
<div>{summary}</div>
<div className="mt-0.5 text-[11px] opacity-80">
Paste a project URL below to reach missing ones.
</div>
</div>
</div>
</div>
)
}
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 (
<div className="border-b border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
<div>{error.message}</div>
{command ? (
<button
type="button"
onClick={async () => {
try {
await window.api.ui.writeClipboardText(command)
toast.success('Command copied')
} catch {
toast.error('Failed to copy')
}
}}
className="mt-1 inline-flex items-center gap-1 rounded border border-amber-500/30 bg-amber-500/10 px-1.5 py-0.5 text-[11px] hover:bg-amber-500/20"
>
<Copy className="size-3" /> Copy command
</button>
) : null}
</div>
)
}
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
}

View File

@ -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 = (
<div
className={cn(
'group grid items-center gap-3 border-b border-border/30 px-3 py-2 hover:bg-muted/30',
disabled && 'opacity-60'
)}
style={{ gridTemplateColumns: buildGridTemplate(fields) }}
>
{fields.map((f) => (
<ProjectCell
key={f.id}
row={row}
field={f}
editable={editable}
onEditField={onEditField}
onEditAssignees={onEditAssignees}
onEditLabels={onEditLabels}
onEditIssueType={onEditIssueType}
onOpenDialog={f.dataType === 'TITLE' ? onOpenDialog : undefined}
/>
))}
<div className="flex items-center justify-end gap-1 opacity-0 transition group-hover:opacity-100">
{row.content.url ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onOpenInBrowser}
aria-label="Open in GitHub"
className="rounded p-1 hover:bg-muted"
>
<ExternalLink className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Open in GitHub</TooltipContent>
</Tooltip>
) : null}
{!disabled && row.itemType !== 'DRAFT_ISSUE' && row.content.number != null ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={onStartWork}
aria-label="Start work"
className="rounded p-1 hover:bg-muted"
>
<Play className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>Start work</TooltipContent>
</Tooltip>
) : null}
</div>
</div>
)
if (draftBody) {
return (
<HoverCard openDelay={150}>
<HoverCardTrigger asChild>{rowInner}</HoverCardTrigger>
<HoverCardContent
align="start"
sideOffset={4}
className="max-h-80 w-96 overflow-y-auto whitespace-pre-wrap text-xs"
>
{draftBody}
</HoverCardContent>
</HoverCard>
)
}
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(' ')
}

View File

@ -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<ReadonlySet<string>>(() => 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<SortOverride | null>(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<ReadonlySet<string>>(() => 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<GitHubProjectTable>(() => {
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 (
<div className="flex min-h-[120px] items-center justify-center p-6 text-sm text-muted-foreground">
No items match this view&apos;s filter.
</div>
)
}
// 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 (
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto">
<ProjectHeaderRow
fields={fields}
availableFields={availableFields}
hidden={hidden}
onToggleColumn={toggleColumn}
activeSort={activeSort}
onSortClick={handleSortClick}
/>
{groups.map((g) => {
const expanded = !collapsed.has(g.key)
return (
<div key={g.key}>
{table.selectedView.groupByFields[0] ? (
<ProjectGroupHeader
group={g}
expanded={expanded}
onToggle={() => {
setCollapsed((prev) => {
const next = new Set(prev)
if (next.has(g.key)) {next.delete(g.key)}
else {next.add(g.key)}
return next
})
}}
/>
) : null}
{expanded
? g.rows.map((row) => (
<ProjectRow
key={row.id}
row={row}
fields={fields}
editable
onOpenDialog={() => onOpenDialog?.(row)}
onEditField={(fieldId, value) => onEditField?.(row, fieldId, value)}
onEditAssignees={(add, remove) => onEditAssignees?.(row, add, remove)}
onEditLabels={(add, remove) => onEditLabels?.(row, add, remove)}
onEditIssueType={(issueType) => onEditIssueType?.(row, issueType)}
onStartWork={() => onStartWork?.(row)}
onOpenInBrowser={() => onOpenInBrowser?.(row)}
/>
))
: null}
</div>
)
})}
</div>
)
}
function ProjectHeaderRow({
fields,
availableFields,
hidden,
onToggleColumn,
activeSort,
onSortClick
}: {
fields: GitHubProjectField[]
availableFields: GitHubProjectField[]
hidden: ReadonlySet<string>
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 (
<div
className="sticky top-0 z-10 grid items-center gap-3 border-b border-border/60 bg-background/95 px-3 py-2 text-[11px] font-medium uppercase tracking-wide text-muted-foreground backdrop-blur"
style={{ gridTemplateColumns: buildGridTemplate(fields) }}
>
{fields.map((f) => {
const isActive = activeSort?.fieldId === f.id
const Icon = isActive ? (activeSort.direction === 'ASC' ? ArrowUp : ArrowDown) : ArrowUpDown
return (
<button
key={f.id}
type="button"
onClick={() => onSortClick(f.id)}
className={cn(
'group flex min-w-0 items-center gap-1 truncate text-left uppercase tracking-wide hover:text-foreground',
isActive && 'text-foreground'
)}
aria-label={`Sort by ${f.name}`}
>
<span className="truncate">{f.name}</span>
<Icon
className={cn(
'size-3 shrink-0 transition-opacity',
isActive ? 'opacity-100' : 'opacity-0 group-hover:opacity-60'
)}
/>
</button>
)
})}
<div className="flex items-center justify-end">
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label="Configure columns"
className="rounded p-1 text-muted-foreground hover:bg-muted hover:text-foreground"
>
<Columns3 className="size-3.5" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-56 p-1">
<div className="px-2 py-1 text-[10px] uppercase tracking-wide text-muted-foreground">
Columns
</div>
{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 (
<label
key={f.id}
className={cn(
'flex w-full cursor-pointer items-center gap-2 rounded px-2 py-1 text-xs hover:bg-muted/50',
locked && 'cursor-not-allowed opacity-60'
)}
>
<input
type="checkbox"
checked={visible}
disabled={locked}
onChange={() => onToggleColumn(f.id)}
className="size-3.5"
/>
<span className="truncate">{f.name}</span>
</label>
)
})}
</PopoverContent>
</Popover>
</div>
</div>
)
}

View File

@ -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<string, never>
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<ReadonlySet<string>>(
() => 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<string, GitHubProjectViewSummary[]>
>({})
// 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<Record<string, string>>({})
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 (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex flex-none items-center gap-2 border-b border-border/50 bg-muted/30 px-3 py-2">
<ProjectPicker
activeProject={
activeProject && table
? {
owner: activeProject.owner,
ownerType: activeProject.ownerType,
number: activeProject.number,
title: table.project.title
}
: activeProject
? {
owner: activeProject.owner,
ownerType: activeProject.ownerType,
number: activeProject.number
}
: null
}
onSelect={handleSelect}
/>
{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.
<ProjectSearchInput
key={currentProjectViewKey}
viewFilter={table?.selectedView.filter ?? ''}
appliedOverride={appliedQueryByView[currentProjectViewKey]}
onApply={(nextOverride) => {
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 ? (
<>
<span className="ml-auto rounded-full border border-border/50 bg-background px-2 py-0.5 text-[11px]">
{table.totalCount}
</span>
{selectedViewUrl ? (
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={() => void window.api.shell.openUrl(selectedViewUrl)}
aria-label="Open view in GitHub"
>
<ExternalLink className="size-3.5" />
</Button>
) : null}
<Button
variant="outline"
size="icon"
className="h-7 w-7"
onClick={() => {
if (!activeProject || !currentCacheKey) {return}
const key = `${activeProject.ownerType}:${activeProject.owner}:${activeProject.number}`
const viewId = lastViewByProject[key]?.viewId
if (!viewId) {return}
void doFetch(
{
owner: activeProject.owner,
ownerType: activeProject.ownerType,
projectNumber: activeProject.number,
viewId
},
true,
currentAppliedOverride
)
}}
aria-label="Refresh"
>
<RefreshCw className={cn('size-3.5', loading && 'animate-spin')} />
</Button>
</>
) : null}
</div>
{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 (
<ViewTabStrip
views={views}
activeViewId={activeViewId}
onPick={(viewId) => void handleSwitchView(viewId)}
/>
)
})()
: null}
{!activeProject ? (
<div className="flex flex-1 items-center justify-center p-8 text-sm text-muted-foreground">
Choose a project to get started.
</div>
) : loading && !table ? (
<div className="flex flex-1 items-center justify-center p-8 text-sm text-muted-foreground">
<Loader className="mr-2 size-4 animate-spin" />
Loading project view
</div>
) : error ? (
<ErrorState
error={error.error}
totalCount={error.totalCount}
onOpenInGitHub={() => {
if (selectedViewUrl) {void window.api.shell.openUrl(selectedViewUrl)}
}}
/>
) : table ? (
<ProjectViewList
table={table}
onOpenDialog={handleOpenDialog}
onEditField={handleEditField}
onEditAssignees={(row, add, remove) => 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. */}
<GitHubItemDialog
workItem={dialogRepoItem?.workItem ?? null}
repoPath={dialogRepoItem?.repoPath ?? null}
projectOrigin={dialogRepoItem?.origin}
onUse={(item) => {
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. */}
<ProjectItemSlugDialog
projectOrigin={slugDialog?.origin ?? null}
onClose={() => setSlugDialog(null)}
/>
{/* repo-not-in-orca prompt: see design doc Interaction States. */}
<Dialog
open={repoNotInOrca !== null}
onOpenChange={(open) => !open && setRepoNotInOrca(null)}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Repository not in Orca</DialogTitle>
<DialogDescription>
{repoNotInOrca
? `${repoNotInOrca.owner}/${repoNotInOrca.repo} isn't added to Orca. Add it to start work, or open in GitHub.`
: null}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:justify-end">
<Button variant="ghost" onClick={() => setRepoNotInOrca(null)}>
Cancel
</Button>
{repoNotInOrca?.url ? (
<Button
variant="outline"
onClick={() => {
if (repoNotInOrca.url) {void window.api.shell.openUrl(repoNotInOrca.url)}
setRepoNotInOrca(null)
}}
>
Open in GitHub
</Button>
) : null}
<Button
onClick={async () => {
// Why: `addRepo` opens the OS folder picker — it's the only
// non-destructive way to register a repo today. Auto-cloning
// from a row click is out of v1 scope (design doc §Row
// actions). Close the modal regardless so the user isn't
// trapped if they cancel the picker.
setRepoNotInOrca(null)
await addRepoFromStore()
}}
>
Add repo
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
// 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<string>(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 (
<div className="relative min-w-[280px] flex-1 max-w-xl">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={value}
onChange={(e) => 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 ? (
<button
type="button"
aria-label="Reset to view filter"
onClick={() => {
setValue(viewFilter)
apply(viewFilter)
}}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground transition hover:text-foreground"
>
<X className="size-3.5" />
</button>
) : null}
</div>
)
}
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 (
<div className="flex flex-none items-end gap-1 overflow-x-auto border-b border-border/50 bg-muted/20 px-3 pt-3">
{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 (
<button
key={v.id}
type="button"
disabled={!supported}
onClick={() => onPick(v.id)}
title={
supported
? v.name
: `${v.name}${
v.layout === 'BOARD_LAYOUT' ? 'Board' : 'Roadmap'
} layouts aren't supported in Orca yet. Open this view on GitHub to see it, or switch to a Table view to work with it here.`
}
className={cn(
'inline-flex shrink-0 items-center gap-1.5 rounded-t-md border-x border-t px-3 py-1.5 text-xs',
active
? '-mb-px border-border/60 bg-background text-foreground'
: 'border-transparent text-muted-foreground hover:bg-background/40 hover:text-foreground',
!supported && 'cursor-not-allowed opacity-50 hover:bg-transparent hover:text-muted-foreground'
)}
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className={cn(active && 'font-medium')}>{v.name}</span>
</button>
)
})}
</div>
)
}
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 (
<div className="flex flex-1 flex-col items-start gap-3 p-6 text-sm">
<div className="text-muted-foreground">{copy}</div>
<div className="flex gap-2">
{command ? (
<Button
size="sm"
variant="outline"
onClick={async () => {
try {
await window.api.ui.writeClipboardText(command)
toast.success('Command copied')
} catch {
toast.error('Failed to copy')
}
}}
>
<Copy className="mr-1 size-3.5" /> Copy command
</Button>
) : null}
<Button size="sm" variant="outline" onClick={onOpenInGitHub}>
<ExternalLink className="mr-1 size-3.5" /> Open in GitHub
</Button>
</div>
</div>
)
}

View File

@ -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<string, string[]>
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<string> {
const map = readMap()
return new Set(map[scopeKey] ?? [])
}
export function saveHiddenColumns(scopeKey: string, hidden: ReadonlySet<string>): void {
const map = readMap()
if (hidden.size === 0) {
delete map[scopeKey]
} else {
map[scopeKey] = Array.from(hidden)
}
writeMap(map)
}

View File

@ -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__'])
})
})

View File

@ -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
}

View File

@ -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<void>
}): React.JSX.Element {
const [open, setOpen] = useState(false)
const [users, setUsers] = useState<GitHubAssignableUser[]>([])
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 (
<Popover open={open} onOpenChange={(o) => !disabled && setOpen(o)}>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
className="rounded-md border border-border/50 bg-muted/30 px-2 py-0.5 text-[11px] hover:bg-muted disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-muted/30"
>
Assignees: {selected.length === 0 ? 'none' : selected.join(', ')}
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-1">
{loading ? (
<div className="px-2 py-1 text-xs text-muted-foreground">Loading</div>
) : (
users.map((u) => {
const isOn = selected.includes(u.login)
return (
<button
key={u.login}
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1 text-xs hover:bg-muted/50"
onClick={() => {
if (isOn) {void onChange([], [u.login])}
else {void onChange([u.login], [])}
}}
>
<span
className={cn(
'inline-block size-2 rounded-full',
isOn ? 'bg-primary' : 'bg-muted-foreground/40'
)}
/>
{u.avatarUrl ? (
<img src={u.avatarUrl} alt="" className="size-4 rounded-full" />
) : null}
{u.login}
</button>
)
})
)}
</PopoverContent>
</Popover>
)
}

View File

@ -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 (
<div className="flex flex-col gap-3">
{comments.length === 0 ? (
<div className="text-xs italic text-muted-foreground">No comments yet.</div>
) : (
comments.map((c) => (
<CommentRow
key={c.id}
owner={owner}
repo={repo}
comment={c}
onDelete={async () => {
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)))
}}
/>
))
)}
</div>
)
}
function CommentRow({
comment,
onDelete,
onEdit
}: {
owner: string
repo: string
comment: PRComment
onDelete: () => void | Promise<void>
onEdit: (next: string) => void | Promise<void>
}): React.JSX.Element {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(comment.body)
return (
<div className="rounded border border-border/50 bg-muted/20 p-3">
<div className="mb-1 flex items-center justify-between text-[11px] text-muted-foreground">
<span>{comment.author}</span>
<div className="flex gap-2">
<button type="button" className="hover:underline" onClick={() => { setDraft(comment.body); setEditing(true) }}>
Edit
</button>
<button type="button" className="hover:underline" onClick={() => void onDelete()}>
Delete
</button>
</div>
</div>
{editing ? (
<div className="flex flex-col gap-2">
<textarea
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
className="min-h-[80px] w-full rounded border border-border/50 bg-background p-2 text-sm"
/>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => {
setEditing(false)
void onEdit(draft)
}}
>
Save
</Button>
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}>
Cancel
</Button>
</div>
</div>
) : (
<CommentMarkdown content={comment.body} />
)}
</div>
)
}
export function NewCommentForm({
owner,
repo,
number,
onAdded
}: {
owner: string
repo: string
number: number
onAdded: (c: PRComment) => void
}): React.JSX.Element {
const [draft, setDraft] = useState('')
const [submitting, setSubmitting] = useState(false)
return (
<div className="flex flex-col gap-2">
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
placeholder="Write a comment…"
className="min-h-[80px] w-full rounded border border-border/50 bg-background p-2 text-sm"
/>
<div className="flex justify-end">
<Button
size="sm"
disabled={!draft.trim() || submitting}
onClick={async () => {
const body = draft.trim()
if (!body) {return}
setSubmitting(true)
try {
const res = await window.api.gh.addIssueCommentBySlug({ owner, repo, number, body })
if (!res.ok) {
toast.error(res.error.message)
return
}
onAdded(res.comment)
setDraft('')
} finally {
setSubmitting(false)
}
}}
>
<Send className="mr-1 size-3.5" /> Comment
</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,78 @@
import React, { useEffect, useState } from 'react'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
export function LabelsEditor({
owner,
repo,
selected,
disabled,
onChange
}: {
owner: string
repo: string
selected: string[]
disabled?: boolean
onChange: (add: string[], remove: string[]) => void | Promise<void>
}): React.JSX.Element {
const [open, setOpen] = useState(false)
const [options, setOptions] = useState<string[]>([])
const [loading, setLoading] = useState(false)
useEffect(() => {
if (!open) {return}
// Why: guard against late responses overwriting newer state when the
// popover toggles owner/repo (or closes/reopens) while the IPC is still
// in flight. Mirrors the requestIdRef pattern used for the details fetch.
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) {return}
setLoading(false)
})
return () => {
cancelled = true
}
}, [open, owner, repo])
return (
<Popover open={open} onOpenChange={(o) => !disabled && setOpen(o)}>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
className="rounded-md border border-border/50 bg-muted/30 px-2 py-0.5 text-[11px] hover:bg-muted disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-muted/30"
>
Labels: {selected.length === 0 ? 'none' : selected.join(', ')}
</button>
</PopoverTrigger>
<PopoverContent className="w-64 p-1">
{loading ? (
<div className="px-2 py-1 text-xs text-muted-foreground">Loading</div>
) : (
options.map((name) => {
const isOn = selected.includes(name)
return (
<button
key={name}
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1 text-xs hover:bg-muted/50"
onClick={() => {
if (isOn) {void onChange([], [name])}
else {void onChange([name], [])}
}}
>
<span className={cn('inline-block size-2 rounded-full', isOn ? 'bg-primary' : 'bg-muted-foreground/40')} />
{name}
</button>
)
})
)}
</PopoverContent>
</Popover>
)
}

View File

@ -0,0 +1,282 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { CircleDot, ExternalLink, GitPullRequest, LoaderCircle, X } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import CommentMarkdown from '@/components/sidebar/CommentMarkdown'
import { useAppStore } from '@/store'
import type { GitHubWorkItemDetails } from '../../../../../shared/types'
import type { GitHubItemDialogProjectOrigin } from '@/components/GitHubItemDialog'
import { LabelsEditor } from './LabelsEditor'
import { AssigneesEditor } from './AssigneesEditor'
import { CommentsList, NewCommentForm } from './Comments'
export function SlugDialogBody({
projectOrigin,
onClose
}: {
projectOrigin: GitHubItemDialogProjectOrigin
onClose: () => void
}): React.JSX.Element {
const { owner, repo, number, type, cacheKey } = projectOrigin
const patchProjectIssueOrPr = useAppStore((s) => s.patchProjectIssueOrPr)
const projectViewCache = useAppStore((s) => s.projectViewCache)
// Why: the Project row is the source of truth for the list-side columns;
// reading it reactively here keeps the dialog in sync with optimistic
// patches applied by the table (e.g. inline assignee edits).
const row = useMemo(() => {
const table = projectViewCache[cacheKey]?.data
if (!table) {return null}
return table.rows.find(
(r) => r.content.number === number && r.content.repository?.toLowerCase() === `${owner}/${repo}`.toLowerCase()
) ?? null
}, [projectViewCache, cacheKey, owner, repo, number])
const [details, setDetails] = useState<GitHubWorkItemDetails | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const requestIdRef = useRef(0)
useEffect(() => {
requestIdRef.current += 1
const rid = requestIdRef.current
setLoading(true)
setError(null)
setDetails(null)
window.api.gh
.projectWorkItemDetailsBySlug({ owner, repo, number, type })
.then((res) => {
if (rid !== requestIdRef.current) {return}
if (res.ok) {
setDetails(res.details)
} else {
setError(res.error.message)
}
})
.catch((err) => {
if (rid !== requestIdRef.current) {return}
setError(err instanceof Error ? err.message : 'Failed to load details')
})
.finally(() => {
if (rid !== requestIdRef.current) {return}
setLoading(false)
})
}, [owner, repo, number, type])
const title = row?.content.title ?? details?.item.title ?? ''
const url = row?.content.url ?? details?.item.url ?? null
const Icon = type === 'pr' ? GitPullRequest : CircleDot
const [editingTitle, setEditingTitle] = useState(false)
const [titleDraft, setTitleDraft] = useState('')
const commitTitle = useCallback(async () => {
const next = titleDraft.trim()
setEditingTitle(false)
if (!next || next === title) {return}
// Why: without a row id we can't address the project item — the helper
// would just return "Row not found" and toast-spam the user. The title
// button is also disabled in this case (see render below).
if (!row) {return}
const res = await patchProjectIssueOrPr(cacheKey, row.id, { title: next })
if (!res.ok) {toast.error(res.error.message)}
}, [titleDraft, title, patchProjectIssueOrPr, cacheKey, row])
const [editingBody, setEditingBody] = useState(false)
const [bodyDraft, setBodyDraft] = useState('')
const body = details?.body ?? ''
const commitBody = useCallback(async () => {
setEditingBody(false)
if (bodyDraft === body) {return}
// Why: same reason as commitTitle — bail rather than ask the helper to
// patch a missing row. The body button is also disabled when row is null.
if (!row) {return}
const res = await patchProjectIssueOrPr(cacheKey, row.id, { body: bodyDraft })
if (!res.ok) {
toast.error(res.error.message)
return
}
setDetails((prev) => (prev ? { ...prev, body: bodyDraft } : prev))
}, [bodyDraft, body, patchProjectIssueOrPr, cacheKey, row])
const labels = row?.content.labels.map((l) => l.name) ?? []
const assignees = row?.content.assignees.map((u) => u.login) ?? []
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex-none border-b border-border/60 px-4 py-3">
<div className="flex items-start gap-2">
<Icon className="mt-1 size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
<span className="font-mono">
{owner}/{repo}#{number}
</span>
</div>
{editingTitle ? (
<Input
autoFocus
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={() => void commitTitle()}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
void commitTitle()
} else if (e.key === 'Escape') {
e.preventDefault()
setEditingTitle(false)
}
}}
className="mt-1 h-8"
/>
) : (
<button
type="button"
disabled={!row}
className="mt-1 text-left text-[15px] font-semibold leading-tight hover:underline disabled:cursor-not-allowed disabled:no-underline disabled:opacity-80"
onClick={() => {
setTitleDraft(title)
setEditingTitle(true)
}}
>
{title || 'Untitled'}
</button>
)}
</div>
<div className="flex items-center gap-1">
{url ? (
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => void window.api.shell.openUrl(url)}
aria-label="Open in GitHub"
>
<ExternalLink className="size-3.5" />
</Button>
) : null}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onClose}
aria-label="Close"
>
<X className="size-3.5" />
</Button>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-3 text-[11px]">
<LabelsEditor
owner={owner}
repo={repo}
selected={labels}
disabled={!row}
onChange={async (add, remove) => {
// Why: bail rather than call the helper with an empty id —
// see commitTitle above. Trigger is also disabled when !row.
if (!row) {return}
const res = await patchProjectIssueOrPr(cacheKey, row.id, {
...(add.length ? { addLabels: add } : {}),
...(remove.length ? { removeLabels: remove } : {})
})
if (!res.ok) {toast.error(res.error.message)}
}}
/>
<AssigneesEditor
owner={owner}
repo={repo}
selected={assignees}
disabled={!row}
onChange={async (add, remove) => {
if (!row) {return}
const res = await patchProjectIssueOrPr(cacheKey, row.id, {
...(add.length ? { addAssignees: add } : {}),
...(remove.length ? { removeAssignees: remove } : {})
})
if (!res.ok) {toast.error(res.error.message)}
}}
/>
</div>
</div>
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-3">
{loading && !details ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LoaderCircle className="size-4 animate-spin" /> Loading
</div>
) : error ? (
<div className="text-sm text-destructive">{error}</div>
) : details ? (
<div className="flex flex-col gap-4">
<section>
{editingBody ? (
<div className="flex flex-col gap-2">
<textarea
autoFocus
value={bodyDraft}
onChange={(e) => setBodyDraft(e.target.value)}
className="min-h-[140px] w-full rounded border border-border/50 bg-background p-2 text-sm"
/>
<div className="flex gap-2">
<Button size="sm" onClick={() => void commitBody()}>
Save
</Button>
<Button size="sm" variant="ghost" onClick={() => setEditingBody(false)}>
Cancel
</Button>
</div>
</div>
) : body ? (
<button
type="button"
disabled={!row}
className="block w-full text-left disabled:cursor-not-allowed"
onClick={() => {
setBodyDraft(body)
setEditingBody(true)
}}
>
<CommentMarkdown content={body} variant="document" />
</button>
) : (
<button
type="button"
disabled={!row}
className="text-xs italic text-muted-foreground hover:underline disabled:cursor-not-allowed disabled:no-underline"
onClick={() => {
setBodyDraft('')
setEditingBody(true)
}}
>
Add a description
</button>
)}
</section>
<section className="flex flex-col gap-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
Comments
</h3>
<CommentsList
owner={owner}
repo={repo}
comments={details.comments}
onChange={(next) => setDetails((d) => (d ? { ...d, comments: next } : d))}
/>
<NewCommentForm
owner={owner}
repo={repo}
number={number}
onAdded={(c) =>
setDetails((d) => (d ? { ...d, comments: [...d.comments, c] } : d))
}
/>
</section>
</div>
) : null}
</div>
</div>
)
}

View File

@ -0,0 +1,208 @@
/**
* Compact GitHub API rate-limit indicator for the TaskPage header.
*
* Why: TaskPage fans out GitHub requests on every preset click, search
* debounce, and repo-selection change (×N selected repos × 2 halves for
* count). Users with large repo selections or heavy usage can exhaust the
* search API's 30/min budget in a few clicks without knowing.
*
* Display policy: stay invisible during normal use. Only render when a
* bucket drops to <25% remaining (warn) or <10% (crit). At healthy levels
* the budget is not actionable information and surfacing it just trains
* users to ignore the pill (or worry needlessly about ambiguous numbers
* like "30/30"). The probe still runs in the background so we can show
* the pill the moment something becomes actionable.
*
* This is an indicator, not a throttle we deliberately don't block the
* user from making requests when counts are low. Blocking would hurt the
* hot path (well under quota) more than it helps the cold path (user can
* also just wait for the reset, which is always < 1 hour).
*/
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Gauge } from 'lucide-react'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { cn } from '@/lib/utils'
import type { GetRateLimitResult, GitHubRateLimitSnapshot } from '../../../../shared/types'
// Why: 60s client-side cadence. Aligns with typical user action rhythm
// (click → paint → click) without polling faster than GitHub's counters
// move for typical request volumes. The server-side 30s cache absorbs
// any faster-than-this polling.
const REFRESH_INTERVAL_MS = 60_000
type BucketKey = 'core' | 'search' | 'graphql'
type BucketMeta = {
key: BucketKey
label: string
description: string
}
const BUCKETS: BucketMeta[] = [
{ key: 'core', label: 'REST', description: 'REST API (5000/hr)' },
{ key: 'search', label: 'Search', description: 'Search API (30/min)' },
{ key: 'graphql', label: 'GraphQL', description: 'GraphQL (5000 pts/hr)' }
]
function formatReset(resetAt: number): string {
// Why: resetAt is seconds, Date.now() is ms. Compute the gap in seconds so
// "<1min" / "in 45s" / "in 12min" reads naturally without cognitive math.
const deltaSec = Math.max(0, resetAt - Math.floor(Date.now() / 1000))
if (deltaSec < 60) {
return `${deltaSec}s`
}
const mins = Math.round(deltaSec / 60)
return `${mins}m`
}
function toneFor(remaining: number, limit: number): 'ok' | 'warn' | 'crit' {
if (limit <= 0) {
return 'ok'
}
const pct = remaining / limit
if (pct < 0.1) {
return 'crit'
}
if (pct < 0.25) {
return 'warn'
}
return 'ok'
}
function worstTone(snapshot: GitHubRateLimitSnapshot): 'ok' | 'warn' | 'crit' {
const tones = BUCKETS.map((b) => toneFor(snapshot[b.key].remaining, snapshot[b.key].limit))
if (tones.includes('crit')) {
return 'crit'
}
if (tones.includes('warn')) {
return 'warn'
}
return 'ok'
}
function tightestBucket(snapshot: GitHubRateLimitSnapshot): BucketMeta {
// Why: "N left" in the pill shows the bucket closest to exhaustion by
// ratio — that's the actionable one. Absolute remaining would always
// favor GraphQL (5000 pts) over Search (30) even when Search is 1 away.
let worst = BUCKETS[0]
let worstPct = 1
for (const b of BUCKETS) {
const { remaining, limit } = snapshot[b.key]
const pct = limit > 0 ? remaining / limit : 1
if (pct < worstPct) {
worstPct = pct
worst = b
}
}
return worst
}
export default function GitHubRateLimitPill(): React.JSX.Element | null {
const [snapshot, setSnapshot] = useState<GitHubRateLimitSnapshot | null>(null)
const [hasError, setHasError] = useState(false)
// Why: StrictMode double-invokes effects in dev. Without this guard the
// first mount fires two rate_limit IPCs back-to-back — benign (exempt
// endpoint, cached) but noisy in logs. Tracks the latest in-flight token
// so stale responses from an unmounted instance are dropped.
const latestToken = useRef(0)
const fetchSnapshot = useCallback(async (force: boolean): Promise<void> => {
const token = ++latestToken.current
try {
const res = (await window.api.gh.rateLimit(force ? { force: true } : undefined)) as
| GetRateLimitResult
| undefined
if (token !== latestToken.current) {
return
}
if (res?.ok) {
setSnapshot(res.snapshot)
setHasError(false)
} else {
setHasError(true)
}
} catch {
if (token !== latestToken.current) {
return
}
setHasError(true)
}
}, [])
useEffect(() => {
void fetchSnapshot(false)
const handle = window.setInterval(() => {
void fetchSnapshot(false)
}, REFRESH_INTERVAL_MS)
return () => window.clearInterval(handle)
}, [fetchSnapshot])
// Why: silently render nothing on error or before first load. The pill is
// informational — surfacing a red error here would mislead users into
// thinking their actual gh workflow is broken when only the probe failed.
if (!snapshot || hasError) {
return null
}
const tone = worstTone(snapshot)
// Why: hide the pill entirely at healthy levels. It only earns screen
// real estate when the user is actually approaching a wall — otherwise
// "30/30" is ambiguous noise that users can't act on.
if (tone === 'ok') {
return null
}
const tight = tightestBucket(snapshot)
const tightBucket = snapshot[tight.key]
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => void fetchSnapshot(true)}
aria-label="GitHub rate limit"
className={cn(
'inline-flex h-6 items-center gap-1 rounded-md border px-1.5 text-[10px] font-medium transition',
tone === 'crit' &&
'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300 hover:bg-red-500/20',
tone === 'warn' &&
'border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300 hover:bg-amber-500/20'
)}
>
<Gauge className="size-3" />
<span>
{/* Why: "N left" is unambiguous; "N/M" reads as either
used-of-total or remaining-of-total depending on the user's
prior. We only show this pill when low, so the count is
already actionable no need for the denominator here. */}
{tightBucket.remaining} {tight.label.toLowerCase()} left · resets in{' '}
{formatReset(tightBucket.resetAt)}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6} className="text-xs">
<div className="font-medium">GitHub API budget</div>
<div className="mt-1 flex flex-col gap-0.5 font-mono">
{BUCKETS.map((b) => {
const v = snapshot[b.key]
const t = toneFor(v.remaining, v.limit)
return (
<div key={b.key} className="flex items-center justify-between gap-3">
<span>{b.description}</span>
<span
className={cn(
t === 'crit' && 'text-red-400',
t === 'warn' && 'text-amber-400'
)}
>
{v.remaining} of {v.limit} left · resets in {formatReset(v.resetAt)}
</span>
</div>
)
})}
</div>
<div className="mt-1 text-muted-foreground">Click to refresh</div>
</TooltipContent>
</Tooltip>
)
}

View File

@ -0,0 +1,171 @@
// Why: when the dialog opens for a Project row whose repo differs from the
// active workspace, label/assignee lookups must target the row's repo via
// slug-addressed IPCs (`listLabelsBySlug` / `listAssignableUsersBySlug`),
// not via the workspace path. These hooks live in their own module so the
// existing repoPath-keyed hooks stay focused on the local-workspace flow
// and so this file remains under the lint line cap.
import { useEffect, useRef, useState } from 'react'
import type { GitHubAssignableUser } from '../../../shared/types'
type MetadataState<T> = {
data: T
loading: boolean
error: string | null
}
const METADATA_TTL = 300_000 // 5 min
type CachedMetadata<T> = { data: T; fetchedAt: number }
const slugLabelCache = new Map<string, CachedMetadata<string[]>>()
const slugAssigneeCache = new Map<string, CachedMetadata<GitHubAssignableUser[]>>()
function isCacheFresh<T>(cache: Map<string, CachedMetadata<T>>, key: string): boolean {
const entry = cache.get(key)
return !!entry && Date.now() - entry.fetchedAt < METADATA_TTL
}
export function clearGitHubSlugMetadataCache(): void {
slugLabelCache.clear()
slugAssigneeCache.clear()
}
export function useRepoLabelsBySlug(
owner: string | null,
repo: string | null
): MetadataState<string[]> {
const [state, setState] = useState<MetadataState<string[]>>({
data: [],
loading: false,
error: null
})
const activeKeyRef = useRef<string | null>(null)
useEffect(() => {
if (!owner || !repo) {
return
}
const key = `${owner}/${repo}`
const cached = slugLabelCache.get(key)
if (cached && isCacheFresh(slugLabelCache, key)) {
// Why: always seed state from cache. A remount with the same key
// resets local state to defaults but `activeKeyRef.current` from the
// new ref instance is null on first run — the previous gate that
// skipped setState when keys matched dropped cached data on remount.
setState({ data: cached.data, loading: false, error: null })
activeKeyRef.current = key
return
}
activeKeyRef.current = key
const requestKey = key
setState((s) => ({
...s,
data: s.data.length ? ([] as typeof s.data) : s.data,
loading: true,
error: null
}))
window.api.gh
.listLabelsBySlug({ owner, repo })
.then((res) => {
if (activeKeyRef.current !== requestKey) {
return
}
if (!res.ok) {
setState((s) => ({ ...s, loading: false, error: res.error.message }))
return
}
const data = res.labels
slugLabelCache.set(key, { data, fetchedAt: Date.now() })
setState({ data, loading: false, error: null })
})
.catch((err) => {
if (activeKeyRef.current !== requestKey) {
return
}
activeKeyRef.current = null
setState((s) => ({
...s,
loading: false,
error: err instanceof Error ? err.message : 'Failed to load labels'
}))
})
}, [owner, repo])
return state
}
export function useRepoAssigneesBySlug(
owner: string | null,
repo: string | null,
seedLogins?: string[]
): MetadataState<GitHubAssignableUser[]> {
const [state, setState] = useState<MetadataState<GitHubAssignableUser[]>>({
data: [],
loading: false,
error: null
})
const activeKeyRef = useRef<string | null>(null)
// Why: seedLogins is a new array reference each parent render. Stabilize on
// the joined-string identity so the effect doesn't re-fire on every render
// — this is the assignee popover refetch-storm fix.
const seedKey = (seedLogins ?? []).slice().sort().join(',')
useEffect(() => {
if (!owner || !repo) {
return
}
const key = `${owner}/${repo}#${seedKey}`
const cached = slugAssigneeCache.get(key)
if (cached && isCacheFresh(slugAssigneeCache, key)) {
// Why: see useRepoLabelsBySlug — always seed state from cache so a
// remount with the same key picks up cached data instead of staying
// at the empty default.
setState({ data: cached.data, loading: false, error: null })
activeKeyRef.current = key
return
}
activeKeyRef.current = key
const requestKey = key
setState((s) => ({
...s,
data: s.data.length ? ([] as typeof s.data) : s.data,
loading: true,
error: null
}))
window.api.gh
.listAssignableUsersBySlug({
owner,
repo,
...(seedKey ? { seedLogins: seedKey.split(',') } : {})
})
.then((res) => {
if (activeKeyRef.current !== requestKey) {
return
}
if (!res.ok) {
setState((s) => ({ ...s, loading: false, error: res.error.message }))
return
}
const data = res.users
slugAssigneeCache.set(key, { data, fetchedAt: Date.now() })
setState({ data, loading: false, error: null })
})
.catch((err) => {
if (activeKeyRef.current !== requestKey) {
return
}
activeKeyRef.current = null
setState((s) => ({
...s,
loading: false,
error: err instanceof Error ? err.message : 'Failed to load assignees'
}))
})
}, [owner, repo, seedKey])
return state
}

View File

@ -0,0 +1,109 @@
// Why: Project mode rows carry a GitHub `owner/repo` slug, but Orca's
// `state.repos` stores only absolute paths. Before any repo-context action
// (opening the item dialog in repo-backed mode, launching a worktree) can
// dispatch correctly, we need a renderer-side index mapping slug → Repo.
//
// The index is built lazily from `window.api.gh.repoSlug({ repoPath })` —
// the main-process resolver that reads `git remote` and classifies the
// remote into `owner/repo`. Repos whose slug cannot be resolved (no GitHub
// remote, SSH lookup failure) are excluded; the design doc (§Row actions)
// says to keep the unknown-repo fallback in that case.
//
// The index rebuilds only when `state.repos` changes — adding or removing
// a repo is rare enough that a full re-resolution is simpler than per-id
// invalidation, and the underlying IPC result is itself cached by the main
// process (`repoSlug` reads `.git/config`).
import { useEffect, useMemo, useRef, useState } from 'react'
import { useAppStore } from '@/store'
import type { Repo } from '../../../shared/types'
/** Lowercased `owner/repo` Repo. Case folded because GitHub treats slugs
* case-insensitively but displays the canonical casing; the lookup side
* uses the row's `content.repository` which may or may not match the
* canonical casing depending on when the project item was indexed. */
type SlugIndex = Map<string, Repo>
/** Module-scope cache keyed by repo.id. A Repo that has already failed
* resolution is not retried on re-mount; the value in the map is `null`
* to record the negative result so we don't keep poking `git remote` for
* repos that will never match. */
const slugByRepoId = new Map<string, string | null>()
/** Drop a repo's cached slug result. Call when a repo is removed or its
* remote URL is known to have changed (e.g. after `git remote set-url`),
* so the next index build re-resolves rather than serving a stale entry. */
export function clearRepoSlugCacheEntry(repoId: string): void {
slugByRepoId.delete(repoId)
}
/** Clear the entire slug cache. Useful for tests or full repo-list resets. */
export function clearRepoSlugCache(): void {
slugByRepoId.clear()
}
async function resolveRepoSlug(repo: Repo): Promise<string | null> {
if (slugByRepoId.has(repo.id)) {
return slugByRepoId.get(repo.id) ?? null
}
try {
const result = await window.api.gh.repoSlug({ repoPath: repo.path })
if (!result) {
slugByRepoId.set(repo.id, null)
return null
}
const slug = `${result.owner}/${result.repo}`.toLowerCase()
slugByRepoId.set(repo.id, slug)
return slug
} catch {
// Why: treat any IPC failure as "not resolvable" rather than propagating —
// design doc §Row actions: "If gh:repoSlug fails for a repo, exclude it".
slugByRepoId.set(repo.id, null)
return null
}
}
async function buildIndex(repos: Repo[]): Promise<SlugIndex> {
// Why: evict cached entries for repos that no longer exist in state so
// the cache cannot grow unbounded across long sessions where users add
// and remove repos. Without this, every removed repo's id (and its
// negative-cached null) lingers forever.
const liveIds = new Set(repos.map((r) => r.id))
for (const id of slugByRepoId.keys()) {
if (!liveIds.has(id)) {slugByRepoId.delete(id)}
}
const next: SlugIndex = new Map()
const results = await Promise.all(
repos.map(async (r) => ({ repo: r, slug: await resolveRepoSlug(r) }))
)
for (const { repo, slug } of results) {
if (slug) {next.set(slug, repo)}
}
return next
}
/** Returns a lookup function `(slug) => Repo | null`. The lookup is stable
* across renders until `state.repos` changes; callers in deep trees can
* treat it as referentially equal inside a single render cycle. */
export function useRepoSlugIndex(): (slug: string | null | undefined) => Repo | null {
const repos = useAppStore((s) => s.repos)
const [index, setIndex] = useState<SlugIndex>(() => new Map())
// Why: track the current repos snapshot so the effect can ignore stale
// resolutions when repos change mid-flight.
const generationRef = useRef(0)
useEffect(() => {
const gen = ++generationRef.current
void buildIndex(repos).then((next) => {
if (gen !== generationRef.current) {return}
setIndex(next)
})
}, [repos])
return useMemo(
() => (slug: string | null | undefined): Repo | null => {
if (!slug) {return null}
return index.get(slug.toLowerCase()) ?? null
},
[index]
)
}

View File

@ -14,9 +14,231 @@ import type {
Worktree,
GitHubWorkItem
} from '../../../../shared/types'
import type {
GetProjectViewTableArgs,
GetProjectViewTableResult,
GitHubProjectFieldMutationValue,
GitHubProjectMutationResult,
GitHubProjectRow,
GitHubProjectTable,
GitHubProjectViewError
} from '../../../../shared/github-project-types'
import { sortWorkItemsByUpdatedAt, PER_REPO_FETCH_LIMIT } from '../../../../shared/work-items'
import { syncPRChecksStatus } from './github-checks'
// ─── ProjectV2 cache types ────────────────────────────────────────────
// Why: declared separately from CacheEntry<T> (not a generified E parameter)
// because project-view has a single GraphQL source — no issue/PR-source
// fallback — and the error union is distinct. Shared structural shape only.
export type ProjectViewCacheEntry<T> = {
data: T | null
fetchedAt: number
error?: GitHubProjectViewError
}
export type ProjectRowContentUpdate = {
title?: string
body?: string
addLabels?: string[]
removeLabels?: string[]
addAssignees?: string[]
removeAssignees?: string[]
}
/** Optimistic, IPC-free patch shape for `projectViewCache` rows.
* Why: the dialog already issues mutations via slug-addressed IPCs and only
* needs to keep the Project table view in sync optimistically. Replacing
* `addLabels`/`removeLabels` deltas with full `labels`/`assignees` arrays
* matches what the dialog's local state already tracks (`localLabels`,
* `localAssignees`) and avoids redundant set-merge logic at the call site. */
export type ProjectRowContentPatch = {
title?: string
body?: string
/** Why: accept the renderer's lowercase work-item state vocabulary
* ('open' | 'closed' | 'merged' | 'draft') and translate to GitHub's
* UPPERCASE row.content.state when applying. The reducer only writes
* what callers send; merged/draft are passed through for completeness
* even though the dialog edits only flip openclosed today. */
state?: 'open' | 'closed' | 'merged' | 'draft'
labels?: string[]
assignees?: string[]
}
// Why: queryOverride participates in the cache key so an overridden search
// does not clobber the default-view cache entry, and vice versa. `undefined`
// means "use the view's stored filter" — the unfiltered cache entry. An
// empty string is a *distinct* override meaning "no filter", which produces
// different rows when the view's stored filter is non-empty, so it gets its
// own cache key.
function queryOverrideKeyPart(queryOverride: string | undefined): string {
if (queryOverride === undefined) {return ''}
return `:q=${queryOverride}`
}
export function projectViewCacheKey(
ownerType: GetProjectViewTableArgs['ownerType'],
owner: string,
projectNumber: number,
resolvedViewId: string,
queryOverride?: string
): string {
return `github-project:${ownerType}:${owner}:${projectNumber}:${resolvedViewId}${queryOverrideKeyPart(queryOverride)}`
}
function projectViewRequestKey(args: GetProjectViewTableArgs): string {
// Why: callers without `viewId` can't compute the resolved cache key up
// front. Use the input-arg signature for inflight dedup; the resolved
// cache key is only known after the main-process IPC returns.
const selector = args.viewId
? `id:${args.viewId}`
: args.viewNumber !== undefined
? `num:${args.viewNumber}`
: args.viewName
? `name:${args.viewName}`
: 'default'
return `${args.ownerType}:${args.owner}:${args.projectNumber}:${selector}${queryOverrideKeyPart(args.queryOverride)}`
}
// Why: module-scope inflight map — must mirror `inflightWorkItemsRequests`
// (dedup + force-refresh semantics). Reuses the work-item concurrency gate:
// the gate exists to bound `gh` subprocess pressure at the renderer boundary,
// and project-view fetches pressure the same subprocess budget. Two separate
// gates would let concurrent Project + work-item fetches blow past the cap.
const inflightProjectViewRequests = new Map<
string,
{ promise: Promise<GetProjectViewTableResult>; force: boolean }
>()
// Why: derive an optimistic GitHubProjectFieldValue from a mutation value so
// the patched row re-renders immediately. Single-select and iteration lookups
// consult the field config on the cached table; the result is best-effort and
// is overwritten by the authoritative payload on next refresh.
function optimisticFieldValueFromMutation(
table: GitHubProjectTable,
fieldId: string,
value: GitHubProjectFieldMutationValue
): GitHubProjectTable['rows'][number]['fieldValuesByFieldId'][string] | null {
const field = table.selectedView.fields.find((f) => f.id === fieldId)
switch (value.kind) {
case 'single-select': {
if (field?.kind === 'single-select') {
const option = field.options.find((o) => o.id === value.optionId)
if (option) {
return {
kind: 'single-select',
fieldId,
optionId: option.id,
name: option.name,
color: option.color
}
}
}
return {
kind: 'single-select',
fieldId,
optionId: value.optionId,
name: '',
color: ''
}
}
case 'iteration': {
if (field?.kind === 'iteration') {
const iteration = field.iterations.find((i) => i.id === value.iterationId)
if (iteration) {
return {
kind: 'iteration',
fieldId,
iterationId: iteration.id,
title: iteration.title,
startDate: iteration.startDate,
duration: iteration.duration
}
}
}
return {
kind: 'iteration',
fieldId,
iterationId: value.iterationId,
title: '',
startDate: '',
duration: 0
}
}
case 'text':
return { kind: 'text', fieldId, text: value.text }
case 'number':
return { kind: 'number', fieldId, number: value.number }
case 'date':
return { kind: 'date', fieldId, date: value.date }
default:
return null
}
}
function applyRowPatch(
set: (fn: (s: AppState) => Partial<AppState>) => void,
cacheKey: string,
rowId: string,
nextRow: GitHubProjectRow
): void {
set((s) => {
const entry = s.projectViewCache[cacheKey]
if (!entry?.data) {
return {}
}
const rowIndex = entry.data.rows.findIndex((r) => r.id === rowId)
if (rowIndex === -1) {
return {}
}
const rows = [...entry.data.rows]
rows[rowIndex] = nextRow
return {
projectViewCache: {
...s.projectViewCache,
[cacheKey]: {
...entry,
data: { ...entry.data, rows }
}
}
}
})
}
function rollbackRowIfPresent(
set: (fn: (s: AppState) => Partial<AppState>) => void,
get: () => AppState,
cacheKey: string,
rowId: string,
previousRow: GitHubProjectRow
): void {
// Why: the cache entry may have moved (rapid project switch) or the row may
// no longer exist by the time the mutation response returns. Skip rollback
// in that case — resurrecting stale data into a newly selected project would
// show the wrong row.
const entry = get().projectViewCache[cacheKey]
if (!entry?.data) {
return
}
const stillPresent = entry.data.rows.some((r) => r.id === rowId)
if (!stillPresent) {
return
}
applyRowPatch(set, cacheKey, rowId, previousRow)
}
function parseSlugAndNumber(
row: GitHubProjectRow
): { owner: string; repo: string; number: number } | null {
if (!row.content.repository || row.content.number == null) {
return null
}
const parts = row.content.repository.split('/')
if (parts.length !== 2 || !parts[0] || !parts[1]) {
return null
}
return { owner: parts[0], repo: parts[1], number: row.content.number }
}
export type WorkItemsCacheSources = {
issues: GitHubOwnerRepo | null
prs: GitHubOwnerRepo | null
@ -311,6 +533,44 @@ export type GitHubSlice = {
repoPath: string,
preference: IssueSourcePreference
) => Promise<void>
// ── ProjectV2 view cache ─────────────────────────────────────────────
projectViewCache: Record<string, ProjectViewCacheEntry<GitHubProjectTable>>
fetchProjectViewTable: (
args: GetProjectViewTableArgs,
options?: FetchOptions
) => Promise<GetProjectViewTableResult>
updateProjectFieldValue: (
cacheKey: string,
rowId: string,
fieldId: string,
value: GitHubProjectFieldMutationValue
) => Promise<GitHubProjectMutationResult>
clearProjectFieldValue: (
cacheKey: string,
rowId: string,
fieldId: string
) => Promise<GitHubProjectMutationResult>
patchProjectIssueOrPr: (
cacheKey: string,
rowId: string,
updates: ProjectRowContentUpdate
) => Promise<GitHubProjectMutationResult>
patchProjectRowIssueType: (
cacheKey: string,
rowId: string,
issueType: { id: string; name: string; color: string | null; description: string | null } | null
) => Promise<GitHubProjectMutationResult>
/** Optimistic, IPC-free patcher for a single `projectViewCache` row's
* `content`. Used by GitHubItemDialog when `projectOrigin` is set so the
* Project table re-renders immediately after dialog edits `patchWorkItem`
* alone only walks `workItemsCache` and would leave the Project view stale
* until the next refresh. The actual write is dispatched separately via
* the slug-addressed update IPCs. */
patchProjectRowContent: (
cacheKey: string,
rowId: string,
patch: ProjectRowContentPatch
) => void
}
export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (set, get) => ({
@ -320,6 +580,364 @@ export const createGitHubSlice: StateCreator<AppState, [], [], GitHubSlice> = (s
commentsCache: {},
workItemsCache: {},
workItemsInvalidationNonce: 0,
projectViewCache: {},
fetchProjectViewTable: async (args, options) => {
const requestKey = projectViewRequestKey(args)
// Fast path: when the caller supplies `viewId`, we already know the
// resolved cache key and can serve a fresh entry directly.
const maybeKnownKey = args.viewId
? projectViewCacheKey(
args.ownerType,
args.owner,
args.projectNumber,
args.viewId,
args.queryOverride
)
: null
if (!options?.force && maybeKnownKey) {
const cached = get().projectViewCache[maybeKnownKey]
if (cached?.data && Date.now() - cached.fetchedAt < WORK_ITEMS_CACHE_TTL) {
return { ok: true, data: cached.data }
}
}
const existing = inflightProjectViewRequests.get(requestKey)
if (existing) {
// Why: mirror fetchWorkItems force-refresh semantics — a forcing caller
// must not silently dedupe to a non-forcing in-flight request; wait for
// that to settle (result discarded) and then issue a fresh forced call.
if (options?.force && !existing.force) {
await existing.promise.catch(() => {})
} else {
return existing.promise
}
}
const request = (async (): Promise<GetProjectViewTableResult> => {
await acquireWorkItemSlot()
try {
const envelope = await window.api.gh.getProjectViewTable(args)
if (envelope.ok) {
const table = envelope.data
const key = projectViewCacheKey(
table.project.ownerType,
table.project.owner,
table.project.number,
table.selectedView.id,
args.queryOverride
)
set((s) => ({
projectViewCache: {
...s.projectViewCache,
[key]: { data: table, fetchedAt: Date.now() }
}
}))
} else if (maybeKnownKey) {
// Only stamp the error onto the cache when we have a resolved key
// (i.e. caller supplied viewId). Otherwise we have nowhere to write
// it — the renderer classifies the error directly from the envelope.
set((s) => ({
projectViewCache: {
...s.projectViewCache,
[maybeKnownKey]: {
data: s.projectViewCache[maybeKnownKey]?.data ?? null,
fetchedAt: Date.now(),
error: envelope.error
}
}
}))
}
return envelope
} catch (err) {
// Why: IPC boundary must not throw across the promise — wrap any
// unexpected error in the classified envelope so the renderer has
// a single shape to render.
console.error('Failed to fetch GitHub project view:', err)
return {
ok: false,
error: {
type: 'unknown',
message: err instanceof Error ? err.message : 'Failed to fetch project view'
}
}
} finally {
releaseWorkItemSlot()
inflightProjectViewRequests.delete(requestKey)
}
})()
inflightProjectViewRequests.set(requestKey, {
promise: request,
force: Boolean(options?.force)
})
return request
},
updateProjectFieldValue: async (cacheKey, rowId, fieldId, value) => {
const state = get()
const entry = state.projectViewCache[cacheKey]
const table = entry?.data
if (!table) {
return {
ok: false,
error: { type: 'unknown', message: 'Project view not loaded' }
}
}
const rowIndex = table.rows.findIndex((r) => r.id === rowId)
if (rowIndex === -1) {
return {
ok: false,
error: { type: 'unknown', message: 'Row not found' }
}
}
const previousRow = table.rows[rowIndex]
// Optimistic patch: build a field value matching the mutation shape.
const nextField = optimisticFieldValueFromMutation(table, fieldId, value)
const optimisticFieldValues = { ...previousRow.fieldValuesByFieldId }
if (nextField) {
optimisticFieldValues[fieldId] = nextField
}
const optimisticRow: GitHubProjectRow = {
...previousRow,
fieldValuesByFieldId: optimisticFieldValues
}
applyRowPatch(set, cacheKey, rowId, optimisticRow)
const result = await window.api.gh.updateProjectItemField({
projectId: table.project.id,
itemId: rowId,
fieldId,
value
})
if (!result.ok) {
rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow)
}
return result
},
clearProjectFieldValue: async (cacheKey, rowId, fieldId) => {
const state = get()
const entry = state.projectViewCache[cacheKey]
const table = entry?.data
if (!table) {
return {
ok: false,
error: { type: 'unknown', message: 'Project view not loaded' }
}
}
const rowIndex = table.rows.findIndex((r) => r.id === rowId)
if (rowIndex === -1) {
return {
ok: false,
error: { type: 'unknown', message: 'Row not found' }
}
}
const previousRow = table.rows[rowIndex]
const optimisticFieldValues = { ...previousRow.fieldValuesByFieldId }
delete optimisticFieldValues[fieldId]
const optimisticRow: GitHubProjectRow = {
...previousRow,
fieldValuesByFieldId: optimisticFieldValues
}
applyRowPatch(set, cacheKey, rowId, optimisticRow)
const result = await window.api.gh.clearProjectItemField({
projectId: table.project.id,
itemId: rowId,
fieldId
})
if (!result.ok) {
rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow)
}
return result
},
patchProjectIssueOrPr: async (cacheKey, rowId, updates) => {
const state = get()
const entry = state.projectViewCache[cacheKey]
const table = entry?.data
if (!table) {
return {
ok: false,
error: { type: 'unknown', message: 'Project view not loaded' }
}
}
const rowIndex = table.rows.findIndex((r) => r.id === rowId)
if (rowIndex === -1) {
return {
ok: false,
error: { type: 'unknown', message: 'Row not found' }
}
}
const previousRow = table.rows[rowIndex]
const { owner, repo, number } = parseSlugAndNumber(previousRow) ?? {}
if (!owner || !repo || !number) {
return {
ok: false,
error: {
type: 'validation_error',
message: 'Row has no owner/repo/number — cannot patch underlying item'
}
}
}
// Optimistic content patch.
const nextContent = { ...previousRow.content }
if (updates.title !== undefined) {nextContent.title = updates.title}
if (updates.body !== undefined) {nextContent.body = updates.body}
if (updates.addLabels || updates.removeLabels) {
const next = new Map(nextContent.labels.map((l) => [l.name, l]))
for (const name of updates.addLabels ?? []) {
if (!next.has(name)) {next.set(name, { name, color: '808080' })}
}
for (const name of updates.removeLabels ?? []) {
next.delete(name)
}
nextContent.labels = Array.from(next.values())
}
if (updates.addAssignees || updates.removeAssignees) {
const next = new Map(nextContent.assignees.map((u) => [u.login, u]))
for (const login of updates.addAssignees ?? []) {
if (!next.has(login)) {next.set(login, { login, name: null, avatarUrl: null })}
}
for (const login of updates.removeAssignees ?? []) {
next.delete(login)
}
nextContent.assignees = Array.from(next.values())
}
const optimisticRow: GitHubProjectRow = { ...previousRow, content: nextContent }
applyRowPatch(set, cacheKey, rowId, optimisticRow)
// Why: PRs and issues both accept label/assignee edits through the issue
// endpoint — GitHub PRs are issues for labels/assignees. Title/body for
// PRs goes through updatePullRequestBySlug; for issues through
// updateIssueBySlug. We dispatch both as needed.
let envelope: GitHubProjectMutationResult = { ok: true }
if (
previousRow.itemType === 'PULL_REQUEST' &&
(updates.title !== undefined || updates.body !== undefined)
) {
const prRes = await window.api.gh.updatePullRequestBySlug({
owner,
repo,
number,
updates: {
...(updates.title !== undefined ? { title: updates.title } : {}),
...(updates.body !== undefined ? { body: updates.body } : {})
}
})
if (!prRes.ok) {envelope = prRes}
}
if (
envelope.ok &&
(updates.addLabels?.length ||
updates.removeLabels?.length ||
updates.addAssignees?.length ||
updates.removeAssignees?.length ||
(previousRow.itemType === 'ISSUE' &&
(updates.title !== undefined || updates.body !== undefined)))
) {
const issueRes = await window.api.gh.updateIssueBySlug({
owner,
repo,
number,
updates: {
...(updates.title !== undefined ? { title: updates.title } : {}),
...(updates.body !== undefined ? { body: updates.body } : {}),
...(updates.addLabels ? { addLabels: updates.addLabels } : {}),
...(updates.removeLabels ? { removeLabels: updates.removeLabels } : {}),
...(updates.addAssignees ? { addAssignees: updates.addAssignees } : {}),
...(updates.removeAssignees ? { removeAssignees: updates.removeAssignees } : {})
}
})
if (!issueRes.ok) {envelope = issueRes}
}
if (!envelope.ok) {
rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow)
}
return envelope
},
patchProjectRowIssueType: async (cacheKey, rowId, issueType) => {
const state = get()
const entry = state.projectViewCache[cacheKey]
const table = entry?.data
if (!table) {
return { ok: false, error: { type: 'unknown', message: 'Project view not loaded' } }
}
const row = table.rows.find((r) => r.id === rowId)
if (!row) {
return { ok: false, error: { type: 'unknown', message: 'Row not found' } }
}
if (row.itemType !== 'ISSUE') {
return {
ok: false,
error: { type: 'validation_error', message: 'Issue Type can only be set on Issues.' }
}
}
const { owner, repo, number } = parseSlugAndNumber(row) ?? {}
if (!owner || !repo || !number) {
return {
ok: false,
error: { type: 'validation_error', message: 'Row has no owner/repo/number.' }
}
}
const previousRow = row
const optimistic: GitHubProjectRow = {
...previousRow,
content: { ...previousRow.content, issueType }
}
applyRowPatch(set, cacheKey, rowId, optimistic)
const res = await window.api.gh.updateIssueTypeBySlug({
owner,
repo,
number,
issueTypeId: issueType?.id ?? null
})
if (!res.ok) {
rollbackRowIfPresent(set, get, cacheKey, rowId, previousRow)
}
return res
},
patchProjectRowContent: (cacheKey, rowId, patch) => {
const state = get()
const entry = state.projectViewCache[cacheKey]
const table = entry?.data
if (!table) {
return
}
const previousRow = table.rows.find((r) => r.id === rowId)
if (!previousRow) {
return
}
const nextContent = { ...previousRow.content }
if (patch.title !== undefined) {nextContent.title = patch.title}
if (patch.body !== undefined) {nextContent.body = patch.body}
if (patch.state !== undefined) {
// Why: ProjectV2 row.state mirrors GitHub's UPPERCASE state enum
// ('OPEN' | 'CLOSED' | 'MERGED'). The dialog tracks lowercase
// ('open' | 'closed') matching `GitHubWorkItem['state']`. Translate
// here so the optimistic patch matches the canonical row shape and
// the next authoritative fetch overwrites cleanly.
nextContent.state = patch.state.toUpperCase()
}
if (patch.labels !== undefined) {
const existingByName = new Map(previousRow.content.labels.map((l) => [l.name, l]))
nextContent.labels = patch.labels.map(
(name) => existingByName.get(name) ?? { name, color: '808080' }
)
}
if (patch.assignees !== undefined) {
const existingByLogin = new Map(previousRow.content.assignees.map((u) => [u.login, u]))
nextContent.assignees = patch.assignees.map(
(login) => existingByLogin.get(login) ?? { login, name: null, avatarUrl: null }
)
}
const nextRow: GitHubProjectRow = { ...previousRow, content: nextContent }
applyRowPatch(set, cacheKey, rowId, nextRow)
},
getCachedWorkItems: (repoPath, limit, query) => {
const key = workItemsCacheKey(repoPath, limit, query)

View File

@ -203,7 +203,17 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
// Why: off by default — opt-in cosmetic joke feature. Leaving the default
// false keeps the overlay unmounted for users who never enable it.
experimentalSidekick: false,
experimentalWorktreeSymlinks: false
experimentalWorktreeSymlinks: false,
// Why: hydrate an empty default so the renderer's optional-chained reads
// (`settings?.githubProjects?.activeProject`) land on a stable shape
// instead of `undefined`. Upgraded profiles inherit this via the
// `{ ...defaults, ...parsed }` merge in persistence.ts.
githubProjects: {
pinned: [],
recent: [],
lastViewByProject: {},
activeProject: null
}
}
}

View File

@ -0,0 +1,433 @@
/* eslint-disable max-lines -- Why: this module is the single source of truth for ProjectV2 shapes (settings, IPC payloads, view/field/value types) shared across main, preload, and renderer; splitting risks circular type imports. */
// Why: ProjectV2 shapes are distinct enough from the issue/PR work-item types
// that we keep them in a dedicated module. Preload and main-process callers
// import from here directly — do not re-export through `./types.ts` just to
// match the existing import block; routing through the issue types module
// would obscure ownership of the Project surface.
import type {
GitHubAssignableUser,
GitHubIssueUpdate,
GitHubWorkItemDetails,
PRComment
} from './types'
export type GitHubProjectViewLayout = 'TABLE_LAYOUT' | 'BOARD_LAYOUT' | 'ROADMAP_LAYOUT'
export type GitHubProjectOwnerType = 'organization' | 'user'
// Why: anything outside this union must render as an empty cell — the
// normalizer must never throw on an unknown dataType. The `(string & {})`
// branch preserves unknown values verbatim for debuggability while still
// satisfying the distinct field-kind discriminants below.
export type GitHubProjectFieldDataType =
| 'TITLE'
| 'ASSIGNEES'
| 'LABELS'
| 'LINKED_PULL_REQUESTS'
| 'REVIEWERS'
| 'REPOSITORY'
| 'MILESTONE'
| 'PARENT_ISSUE'
| 'SUB_ISSUES_PROGRESS'
| 'TRACKS'
| 'TRACKED_BY'
| 'ISSUE_TYPE'
| 'TEXT'
| 'NUMBER'
| 'DATE'
| 'SINGLE_SELECT'
| 'ITERATION'
export type GitHubProjectSingleSelectOption = {
id: string
name: string
color: string
}
export type GitHubProjectIteration = {
id: string
title: string
/** YYYY-MM-DD — GitHub returns a calendar date, not an ISO timestamp. */
startDate: string
/** Length in days. */
duration: number
/** True when GitHub returned this iteration under `completedIterations`. */
completed: boolean
}
export type GitHubProjectField =
| {
kind: 'field'
id: string
name: string
dataType:
| Exclude<GitHubProjectFieldDataType, 'SINGLE_SELECT' | 'ITERATION'>
| (string & {})
}
| {
kind: 'single-select'
id: string
name: string
dataType: 'SINGLE_SELECT'
options: GitHubProjectSingleSelectOption[]
}
| {
kind: 'iteration'
id: string
name: string
dataType: 'ITERATION'
iterations: GitHubProjectIteration[]
}
export type GitHubProjectSortDirection = 'ASC' | 'DESC'
export type GitHubProjectSort = {
direction: GitHubProjectSortDirection
field: GitHubProjectField
}
export type GitHubProjectView = {
id: string
number: number
name: string
layout: GitHubProjectViewLayout
/** Normalized to '' when GitHub returns null. Why: passing null through as
* `$q` in the items query would change the query shape between filtered
* and unfiltered views; the empty string keeps the GraphQL shape stable. */
filter: string
fields: GitHubProjectField[]
groupByFields: GitHubProjectField[]
sortByFields: GitHubProjectSort[]
}
export type GitHubProjectUser = {
login: string
name: string | null
avatarUrl: string | null
}
export type GitHubProjectLabel = {
name: string
color: string
}
export type GitHubProjectParentIssue = {
number: number
title: string
url: string
}
// Why: GitHub Issue Types are a repo-level taxonomy (Bug/Feature/Task/etc).
// Only repos opted into typed-issues expose a non-empty list. We carry both
// id and human-readable name so the picker can reflect updates without a
// re-fetch and the cell can render the chosen name with its color.
export type GitHubIssueType = {
id: string
name: string
color: string | null
description: string | null
}
export type GitHubProjectFieldValue =
| {
kind: 'single-select'
fieldId: string
optionId: string
name: string
color: string
}
| {
kind: 'iteration'
fieldId: string
iterationId: string
title: string
startDate: string
duration: number
}
| { kind: 'text'; fieldId: string; text: string }
| { kind: 'number'; fieldId: string; number: number }
| { kind: 'date'; fieldId: string; date: string }
| { kind: 'labels'; fieldId: string; labels: GitHubProjectLabel[] }
| { kind: 'users'; fieldId: string; users: GitHubProjectUser[] }
export type GitHubProjectRowItemType = 'ISSUE' | 'PULL_REQUEST' | 'DRAFT_ISSUE' | 'REDACTED'
export type GitHubProjectRow = {
id: string
itemType: GitHubProjectRowItemType
content: {
number: number | null
title: string
/** DraftIssue body and optional detail-cache patch target; list rows do
* not render issue/PR body. */
body: string | null
url: string | null
state: string | null
/** Issue stateReason; null for PR/draft. Why: closed-as-not-planned needs
* a different glyph than a regular closed issue. */
stateReason: string | null
/** PullRequest.isDraft; null otherwise. */
isDraft: boolean | null
/** nameWithOwner, e.g. 'stablyai/orca'. */
repository: string | null
assignees: GitHubProjectUser[]
labels: GitHubProjectLabel[]
parentIssue: GitHubProjectParentIssue | null
/** Issue.issueType when set; null on PRs/drafts/redacted or when unset. */
issueType: GitHubIssueType | null
}
fieldValuesByFieldId: Record<string, GitHubProjectFieldValue>
updatedAt: string
/** Original fetched order (zero-based index in the fully paginated
* POSITION ASC stream). Used as the final tie-break so equal sort values
* keep GitHub rank order. */
position: number
}
export type GitHubProjectTable = {
project: {
id: string
owner: string
ownerType: GitHubProjectOwnerType
number: number
title: string
url: string
}
selectedView: GitHubProjectView
rows: GitHubProjectRow[]
/** Echoes ProjectV2.items.totalCount for the view filter. */
totalCount: number
/** True when the `parent` retry fallback fired. The UI can hint
* "sub-issues unavailable" without claiming a hard error. */
parentFieldDropped: boolean
}
export type GitHubProjectSummary = {
id: string
owner: string
ownerType: GitHubProjectOwnerType
number: number
title: string
url: string
source: 'viewer' | `org:${string}`
}
export type GitHubProjectViewSummary = {
id: string
number: number
name: string
layout: GitHubProjectViewLayout
}
export type GitHubProjectSettings = {
pinned: { owner: string; ownerType: GitHubProjectOwnerType; number: number }[]
recent: {
owner: string
ownerType: GitHubProjectOwnerType
number: number
lastOpenedAt: string
}[]
lastViewByProject: Record<string, { viewId: string }>
activeProject: { owner: string; ownerType: GitHubProjectOwnerType; number: number } | null
}
// ─── Classified errors ─────────────────────────────────────────────────
export type GitHubProjectViewErrorType =
| 'auth_required'
| 'scope_missing'
| 'not_found'
| 'unsupported_layout'
| 'too_large'
| 'schema_drift'
| 'validation_error'
| 'network_error'
| 'rate_limited'
| 'unknown'
export type GitHubProjectViewError = {
type: GitHubProjectViewErrorType
message: string
/** Populated when the error is classifiable from a GraphQL response. Never
* includes tokens or full command stdout. */
details?: { path?: (string | number)[]; code?: string }
}
export type GetProjectViewTableResult =
| { ok: true; data: GitHubProjectTable }
| {
ok: false
error: GitHubProjectViewError
/** Populated for the `too_large` case and best-effort for
* `unsupported_layout` when a cheap count-only query succeeds. */
totalCount?: number
}
export type ListAccessibleProjectsResult =
| {
ok: true
projects: GitHubProjectSummary[]
/** Why: per-org discovery can partially fail (a single org 504s while
* the rest succeed). The picker renders a banner listing the affected
* org logins so the user knows their list is incomplete and can paste
* a URL to reach missing projects. Empty when discovery was clean. */
partialFailures?: { owner: string; message: string }[]
}
| { ok: false; error: GitHubProjectViewError }
export type ResolveProjectRefResult =
| {
ok: true
owner: string
ownerType: GitHubProjectOwnerType
number: number
title: string
// Why: when the input is a /views/{n} URL, surface the parsed view
// number so the picker can skip the view-pick step and commit the
// selection directly. Absent for owner/number shorthand and project
// URLs without a /views/ segment.
viewNumber?: number
}
| { ok: false; error: GitHubProjectViewError }
export type ListProjectViewsResult =
| { ok: true; views: GitHubProjectViewSummary[] }
| { ok: false; error: GitHubProjectViewError }
export type ProjectWorkItemDetailsBySlugResult =
| { ok: true; details: GitHubWorkItemDetails }
| { ok: false; error: GitHubProjectViewError }
// ─── Mutations ─────────────────────────────────────────────────────────
export type GitHubProjectMutationResult =
| { ok: true }
| { ok: false; error: GitHubProjectViewError }
export type GitHubProjectCommentMutationResult =
| { ok: true; comment: PRComment }
| { ok: false; error: GitHubProjectViewError }
export type GitHubProjectFieldMutationValue =
| { kind: 'single-select'; optionId: string }
| { kind: 'iteration'; iterationId: string }
| { kind: 'text'; text: string }
| { kind: 'number'; number: number }
/** YYYY-MM-DD. */
| { kind: 'date'; date: string }
export type ListLabelsBySlugResult =
| { ok: true; labels: string[] }
| { ok: false; error: GitHubProjectViewError }
export type ListAssignableUsersBySlugResult =
| { ok: true; users: GitHubAssignableUser[] }
| { ok: false; error: GitHubProjectViewError }
export type ListIssueTypesBySlugResult =
| { ok: true; types: GitHubIssueType[] }
| { ok: false; error: GitHubProjectViewError }
// ─── IPC arg shapes (shared between main, preload, renderer) ──────────
export type GetProjectViewTableArgs = {
owner: string
ownerType: GitHubProjectOwnerType
projectNumber: number
/** View selection precedence: viewId > viewNumber > viewName > first
* TABLE_LAYOUT view. */
viewId?: string
viewNumber?: number
viewName?: string
/** Ephemeral GitHub-search-syntax query that replaces the view's filter for
* this fetch only. The view's stored filter on GitHub is not modified.
* Empty string and undefined both mean "use the view's filter as-is". */
queryOverride?: string
}
export type ProjectWorkItemDetailsBySlugArgs = {
owner: string
repo: string
number: number
type: 'issue' | 'pr'
}
export type UpdateProjectItemFieldArgs = {
projectId: string
itemId: string
fieldId: string
value: GitHubProjectFieldMutationValue
}
export type ClearProjectItemFieldArgs = {
projectId: string
itemId: string
fieldId: string
}
export type UpdateIssueBySlugArgs = {
owner: string
repo: string
number: number
updates: GitHubIssueUpdate & { body?: string }
}
export type UpdatePullRequestBySlugArgs = {
owner: string
repo: string
number: number
updates: { title?: string; body?: string }
}
export type AddIssueCommentBySlugArgs = {
owner: string
repo: string
number: number
body: string
}
export type UpdateIssueCommentBySlugArgs = {
owner: string
repo: string
commentId: number
body: string
}
export type DeleteIssueCommentBySlugArgs = {
owner: string
repo: string
commentId: number
}
export type ListLabelsBySlugArgs = {
owner: string
repo: string
}
export type ListAssignableUsersBySlugArgs = {
owner: string
repo: string
seedLogins?: string[]
}
export type ListIssueTypesBySlugArgs = {
owner: string
repo: string
}
export type UpdateIssueTypeBySlugArgs = {
owner: string
repo: string
number: number
/** null clears the issue type. */
issueTypeId: string | null
}
export type ResolveProjectRefArgs = {
input: string
}
export type ListProjectViewsArgs = {
owner: string
ownerType: GitHubProjectOwnerType
projectNumber: number
}

View File

@ -1,5 +1,6 @@
/* eslint-disable max-lines */
import type { SshTarget } from './ssh-types'
import type { GitHubProjectSettings } from './github-project-types'
// ─── Repo ────────────────────────────────────────────────────────────
export type RepoKind = 'git' | 'folder'
@ -645,6 +646,11 @@ export type LinearComment = {
export type GitHubIssueUpdate = {
state?: 'open' | 'closed'
title?: string
// Why: body writes are driven by the Project-mode slug-addressed path
// (`updateIssueBySlug`) because `gh issue edit` does not consistently
// cover every body-edit case the dialog needs; the repoPath-based
// `updateIssue` flow keeps ignoring `body` for backward compatibility.
body?: string
addLabels?: string[]
removeLabels?: string[]
addAssignees?: string[]
@ -677,6 +683,33 @@ export type ClassifiedError = {
// can continue using the short local name.
export type GitHubOwnerRepo = { owner: string; repo: string }
/**
* GitHub API rate-limit buckets surfaced in the TaskPage header so users can
* see remaining budget before they hit the wall. `core` = REST (5000/hr),
* `search` = Search API (30/min hit by countWorkItems), `graphql` =
* GraphQL (5000 points/hr hit by project-view + discovery). All three are
* the buckets this app actually stresses; other buckets (e.g. code_search)
* are not surfaced because we don't touch them.
*/
export type GitHubRateLimitBucket = {
remaining: number
limit: number
/** Unix epoch seconds when the window resets. */
resetAt: number
}
export type GitHubRateLimitSnapshot = {
core: GitHubRateLimitBucket
search: GitHubRateLimitBucket
graphql: GitHubRateLimitBucket
/** Unix epoch ms the snapshot was produced (for "fetched Xs ago" copy). */
fetchedAt: number
}
export type GetRateLimitResult =
| { ok: true; snapshot: GitHubRateLimitSnapshot }
| { ok: false; error: string }
/**
* Envelope for `gh:listWorkItems`. Carries resolved issue/PR sources so the
* renderer can render the "Issues from owner/repo" indicator without an
@ -1170,6 +1203,11 @@ export type GlobalSettings = {
* configuration surface and edge cases (conflicts with existing paths,
* cleanup on worktree delete) are still being worked out. */
experimentalWorktreeSymlinks: boolean
/** GitHub Project mode state pinned/recent/active project, last selected
* view per project. Optional because profiles created before this feature
* landed won't have the key; `getDefaultSettings()` hydrates the empty
* default via the persistence merge. */
githubProjects?: GitHubProjectSettings
/** Anonymous product-telemetry state. Optional because the one-shot
* migration in `Store.load()` is what populates it on first boot of the
* telemetry release; before migration runs, the field is absent. After