[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:
parent
d1b26e2eb7
commit
70ffa0a73d
|
|
@ -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: ''
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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.) ──────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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)}`
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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(' ')
|
||||
}
|
||||
|
|
@ -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'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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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__'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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]
|
||||
)
|
||||
}
|
||||
|
|
@ -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 open↔closed 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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue