mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 08:02:02 +00:00
[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:
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user