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

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

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

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

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

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

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

---------

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinjing
2026-05-04 23:24:52 -07:00
committed by GitHub
co-authored by Orca
parent d1b26e2eb7
commit 70ffa0a73d
33 changed files with 9462 additions and 71 deletions
+141
View File
@@ -0,0 +1,141 @@
// Why: covers the recent fixes —
// (a) network errors must NOT be misclassified as not_found ("could not
// resolve host" partially overlaps "could not resolve to a"),
// (b) repo slug validation must accept names with leading underscore
// (GitHub allows them, e.g. `_internal`),
// (c) owner slug validation must reject `.`/`_` (GitHub disallows them in
// usernames/orgs),
// (d) parseProjectPaste shorthand owner-only alphabet matches the renderer.
import { describe, expect, it } from 'vitest'
import {
classifyProjectError,
isValidOwnerSlug,
isValidRepoSlug,
parseProjectPaste
} from './project-view'
describe('classifyProjectError', () => {
it('classifies HTTP 404 as not_found', () => {
expect(classifyProjectError('HTTP 404 Not Found', '').type).toBe('not_found')
})
it('classifies "Could not resolve to a User" as not_found', () => {
expect(
classifyProjectError('Could not resolve to a User with the login of foo', '').type
).toBe('not_found')
})
it('classifies "could not resolve host" as network_error, NOT not_found', () => {
// Why: this was the bug — substring "could not resolve" overlaps. The
// network branch must run before not_found, and the not_found check
// must require "to a " to disambiguate.
expect(classifyProjectError('could not resolve host: api.github.com', '').type).toBe(
'network_error'
)
})
it('classifies "dial tcp" timeouts as network_error', () => {
expect(classifyProjectError('dial tcp 140.82.112.3:443: i/o timeout', '').type).toBe(
'network_error'
)
})
it('classifies rate-limit text as rate_limited', () => {
expect(classifyProjectError('API rate limit exceeded for user', '').type).toBe('rate_limited')
})
it('classifies missing-scope as scope_missing', () => {
expect(
classifyProjectError('your token has not been granted the required scopes', '').type
).toBe('scope_missing')
})
it('classifies auth-required when gh is not signed in', () => {
expect(classifyProjectError('gh auth login required', '').type).toBe('auth_required')
})
})
describe('isValidOwnerSlug', () => {
it('accepts plain alphanumerics and hyphens', () => {
expect(isValidOwnerSlug('acme')).toBe(true)
expect(isValidOwnerSlug('acme-co')).toBe(true)
expect(isValidOwnerSlug('user1')).toBe(true)
})
it('rejects underscore (GitHub disallows it in usernames/orgs)', () => {
expect(isValidOwnerSlug('_acme')).toBe(false)
expect(isValidOwnerSlug('acme_co')).toBe(false)
})
it('rejects leading hyphen and dot', () => {
expect(isValidOwnerSlug('-acme')).toBe(false)
expect(isValidOwnerSlug('.acme')).toBe(false)
})
it('rejects empty and slash-containing values', () => {
expect(isValidOwnerSlug('')).toBe(false)
expect(isValidOwnerSlug('a/b')).toBe(false)
expect(isValidOwnerSlug(123)).toBe(false)
})
})
describe('isValidRepoSlug', () => {
it('accepts leading underscore (GitHub allows it for repo names)', () => {
expect(isValidRepoSlug('_internal')).toBe(true)
})
it('accepts leading dot', () => {
expect(isValidRepoSlug('.github')).toBe(true)
})
it('accepts dots, dashes, underscores anywhere', () => {
expect(isValidRepoSlug('repo-name')).toBe(true)
expect(isValidRepoSlug('repo.name')).toBe(true)
expect(isValidRepoSlug('repo_name')).toBe(true)
})
it('rejects reserved single/double dot', () => {
expect(isValidRepoSlug('.')).toBe(false)
expect(isValidRepoSlug('..')).toBe(false)
})
it('rejects path separators and empty', () => {
expect(isValidRepoSlug('a/b')).toBe(false)
expect(isValidRepoSlug('')).toBe(false)
})
})
describe('parseProjectPaste', () => {
it('parses owner/number shorthand', () => {
expect(parseProjectPaste('acme/42')).toEqual({ kind: 'bare', owner: 'acme', number: 42 })
})
it('rejects shorthand with underscore in owner (renderer parity)', () => {
// Why: the renderer's parser uses `[A-Za-z0-9][A-Za-z0-9-]*` for owner
// (matches OWNER_SLUG_RE). Both sides must reject the same inputs.
expect(parseProjectPaste('co_op/45')).toBeNull()
})
it('parses org URL with view number', () => {
expect(
parseProjectPaste('https://github.com/orgs/acme/projects/42/views/3')
).toEqual({ kind: 'org', owner: 'acme', number: 42, viewNumber: 3 })
})
it('parses user URL', () => {
expect(parseProjectPaste('https://github.com/users/octocat/projects/1')).toEqual({
kind: 'user',
owner: 'octocat',
number: 1
})
})
it('rejects URLs whose owner has invalid characters', () => {
expect(parseProjectPaste('https://github.com/orgs/co_op/projects/1')).toBeNull()
})
it('returns null for empty input', () => {
expect(parseProjectPaste('')).toBeNull()
expect(parseProjectPaste(' ')).toBeNull()
})
})
File diff suppressed because it is too large Load Diff
+355
View File
@@ -0,0 +1,355 @@
/* eslint-disable max-lines -- Why: shared infrastructure for project-view —
slug validation, error classification, runGraphql/runRest, and rate-limit
synthesis. Co-located so the read and write paths observe identical
classification semantics. */
// Why: `ghExecFileAsync` (WSL-aware, retry-enabled) is the single spawn site
// for gh calls. The legacy plain `execFileAsync` is NOT used here — routing
// every gh call through the runner gives us transient-5xx retry, WSL path
// translation, and a single hook point for future quota tracking.
import { acquire, release } from '../gh-utils'
import { extractExecError, ghExecFileAsync } from '../../git/runner'
import { rateLimitGuard, noteRateLimitSpend, type RateLimitBucketKind } from '../rate-limit'
import type { GitHubProjectViewError } from '../../../shared/github-project-types'
export { acquire, release, extractExecError, ghExecFileAsync, rateLimitGuard, noteRateLimitSpend }
export type { RateLimitBucketKind }
// ─── Slug validation ──────────────────────────────────────────────────
// Why: GitHub usernames/org logins disallow `_`, `.`, leading `-`. Repo names
// are looser — they allow leading `_`, `.`, `-` (`.` and `..` reserved). We
// validate each separately so untrusted Project row data (`nameWithOwner`)
// can't become an arbitrary REST path while still accepting realistic repo
// names like `_internal` or `.github`.
const OWNER_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/
const REPO_SLUG_RE = /^[A-Za-z0-9._-]+$/
const REPO_SLUG_RESERVED = new Set(['.', '..'])
export function isValidOwnerSlug(value: unknown): value is string {
return typeof value === 'string' && value.length > 0 && OWNER_SLUG_RE.test(value)
}
export function isValidRepoSlug(value: unknown): value is string {
return (
typeof value === 'string' &&
value.length > 0 &&
REPO_SLUG_RE.test(value) &&
!REPO_SLUG_RESERVED.has(value)
)
}
// Backwards-compatible alias for callers that don't distinguish owner vs repo.
// Prefer `isValidOwnerSlug` / `isValidRepoSlug` at new call sites.
export function isValidSlug(value: unknown): value is string {
return isValidOwnerSlug(value) || isValidRepoSlug(value)
}
export function assertSlug(
value: unknown,
field: 'owner' | 'repo'
): { ok: true; slug: string } | { ok: false; error: GitHubProjectViewError } {
const valid = field === 'owner' ? isValidOwnerSlug(value) : isValidRepoSlug(value)
if (!valid) {
return {
ok: false,
error: {
type: 'validation_error',
message: `Invalid ${field}: "${String(value)}" is not a valid GitHub slug.`
}
}
}
return { ok: true, slug: value as string }
}
export function assertPositiveInt(
value: unknown,
field: string
): { ok: true; n: number } | { ok: false; error: GitHubProjectViewError } {
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1) {
return {
ok: false,
error: {
type: 'validation_error',
message: `Invalid ${field}: must be a positive integer.`
}
}
}
return { ok: true, n: value }
}
export function validateSlugArgs(
owner: unknown,
repo: unknown
): { ok: true } | { ok: false; error: GitHubProjectViewError } {
const o = assertSlug(owner, 'owner')
if (!o.ok) {return { ok: false, error: o.error }}
const r = assertSlug(repo, 'repo')
if (!r.ok) {return { ok: false, error: r.error }}
return { ok: true }
}
// ─── Error classification ──────────────────────────────────────────────
export type GhGraphqlErrorShape = {
type?: string
message?: string
path?: (string | number)[]
extensions?: { code?: string }
}
export function extractGraphqlErrors(stderr: string, stdout: string): GhGraphqlErrorShape[] {
// `gh api graphql` prints the response JSON to stdout even on GraphQL
// errors, and the stderr carries a summary. Try stdout first; if parsing
// fails, fall back to stderr.
const sources = [stdout, stderr]
for (const src of sources) {
if (!src) {continue}
try {
const parsed = JSON.parse(src) as { errors?: GhGraphqlErrorShape[] }
if (parsed.errors && parsed.errors.length > 0) {
return parsed.errors
}
} catch {
// not JSON — continue
}
}
return []
}
export function errorsIndicateParentField(
errors: GhGraphqlErrorShape[],
stderr: string
): boolean {
const lower = stderr.toLowerCase()
// Preview-header shape: gh returns a 4xx with "preview" in the message.
if (lower.includes('preview') && lower.includes('parent')) {return true}
return errors.some((e) => {
const type = (e.type ?? '').toUpperCase()
if (type === 'FIELD_NOT_FOUND' || type === 'UNDEFINED_FIELD' || type === 'FIELD_ERRORS') {
const tail = e.path?.at(-1)
if (tail === 'parent') {return true}
// FIELD_ERRORS often omits `path`; match on message for the parent field.
if ((e.message ?? '').toLowerCase().includes('parent')) {return true}
}
return false
})
}
export function classifyProjectError(stderr: string, stdout: string): GitHubProjectViewError {
const errors = extractGraphqlErrors(stderr, stdout)
const s = stderr.toLowerCase()
// Auth
if (s.includes('authentication required') || s.includes('not logged in') || s.includes('gh auth login')) {
return {
type: 'auth_required',
message: 'Sign in to GitHub to load project tasks. Run `gh auth login`.'
}
}
// Scope
if (
s.includes('missing required scope') ||
s.includes("your token has not been granted") ||
(s.includes('resource not accessible') && (s.includes('project') || s.includes('scope')))
) {
return {
type: 'scope_missing',
message:
'GitHub project access needs additional scopes. Run `gh auth refresh -s project -s read:org -s repo`.'
}
}
// Rate limit
if (s.includes('rate limit') || s.includes('api rate limit exceeded')) {
return { type: 'rate_limited', message: 'GitHub rate limit hit. Try again in a few minutes.' }
}
// Network — checked BEFORE not_found because DNS failures surface as
// "could not resolve host", which would otherwise be partially matched by
// the not_found branch's "could not resolve" check. Substring matching here
// is a one-way trapdoor: a real GraphQL "Could not resolve to a User…"
// error always contains "to a", so we tighten the not_found check below to
// require that token.
if (
s.includes('timeout') ||
s.includes('no such host') ||
s.includes('network') ||
s.includes('could not resolve host') ||
s.includes('dial tcp')
) {
return { type: 'network_error', message: 'Network error — check your connection.' }
}
// Not found
if (
s.includes('http 404') ||
errors.some((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND') ||
s.includes('could not resolve to a ')
) {
const firstNotFound = errors.find((e) => (e.type ?? '').toUpperCase() === 'NOT_FOUND')
return {
type: 'not_found',
message: 'Project or view not found.',
details: firstNotFound
? { path: firstNotFound.path, code: firstNotFound.extensions?.code }
: undefined
}
}
// Validation
if (s.includes('http 422') || s.includes('validation failed')) {
return { type: 'validation_error', message: `Invalid request — ${stderr.trim()}` }
}
// GraphQL error with structured info
if (errors.length > 0) {
const first = errors[0]
return {
type: 'unknown',
message: first.message ?? 'Unknown GraphQL error.',
details: { path: first.path, code: first.extensions?.code }
}
}
// Why: don't leak full stderr to the UI — it can include verbose request
// dumps with header diagnostics. Truncate to the first non-empty line and
// cap length so unexpected diagnostics stay readable but bounded.
const firstLine = stderr
.split('\n')
.map((l) => l.trim())
.find((l) => l.length > 0) ?? ''
const safe = firstLine.length > 200 ? `${firstLine.slice(0, 200)}…` : firstLine
return { type: 'unknown', message: safe ? `GitHub request failed: ${safe}` : 'GitHub request failed.' }
}
export function driftError(
reason: string,
details?: { path?: (string | number)[]; code?: string }
): GitHubProjectViewError {
return { type: 'schema_drift', message: `Could not read this project view: ${reason}.`, details }
}
// Why: the rate-limit circuit breaker short-circuits before we spawn `gh`
// when the cached snapshot says we're below the safety floor. Synthesize the
// same `rate_limited` error shape as the post-hoc classifier so the UI path
// is unchanged. We DO NOT fail open here when there's no cached snapshot —
// rateLimitGuard already handles that case (returns `blocked:false`).
export function rateLimitedError(
blocked: { remaining: number; limit: number; resetAt: number }
): GitHubProjectViewError {
const resetIn = Math.max(0, blocked.resetAt - Math.floor(Date.now() / 1000))
const mins = Math.ceil(resetIn / 60)
return {
type: 'rate_limited',
message: `GitHub rate limit nearly exhausted (${blocked.remaining}/${blocked.limit} left). Resets in ~${mins}m.`
}
}
// ─── Low-level gh api graphql invocation ───────────────────────────────
export type GraphqlVars = Record<string, string | number | boolean>
export async function runGraphql<T>(
query: string,
vars: GraphqlVars,
cwd?: string
): Promise<
| { ok: true; data: T }
| { ok: false; error: GitHubProjectViewError; raw: { stderr: string; stdout: string } }
> {
const guard = rateLimitGuard('graphql')
if (guard.blocked) {
return { ok: false, error: rateLimitedError(guard), raw: { stderr: '', stdout: '' } }
}
// Why: build argv as an array. `-f` for strings (including numbers passed
// as strings), `-F` coerces to typed. We use `-f` uniformly and coerce in
// the query via Int! casts, because `gh` can confuse empty strings.
const args: string[] = ['api', 'graphql', '-f', `query=${query}`]
for (const [k, v] of Object.entries(vars)) {
if (typeof v === 'number' || typeof v === 'boolean') {
args.push('-F', `${k}=${String(v)}`)
} else {
args.push('-f', `${k}=${v}`)
}
}
await acquire()
noteRateLimitSpend('graphql')
try {
const { stdout, stderr } = await ghExecFileAsync(args, {
encoding: 'utf-8',
...(cwd ? { cwd } : {})
})
try {
const parsed = JSON.parse(stdout) as { data?: T; errors?: GhGraphqlErrorShape[] }
if (parsed.errors && parsed.errors.length > 0) {
return {
ok: false,
error: classifyProjectError(stderr, stdout),
raw: { stderr, stdout }
}
}
if (parsed.data === undefined) {
return {
ok: false,
error: driftError('response missing data'),
raw: { stderr, stdout }
}
}
return { ok: true, data: parsed.data }
} catch (parseErr) {
return {
ok: false,
error: driftError(
`failed to parse response (${parseErr instanceof Error ? parseErr.message : String(parseErr)})`
),
raw: { stderr, stdout }
}
}
} catch (err) {
// gh executable failures (non-zero exit). Read stderr/stdout from the
// exec rejection's explicit fields — `err.message` may truncate stderr.
const { stderr, stdout: maybeStdout } = extractExecError(err)
return {
ok: false,
error: classifyProjectError(stderr, maybeStdout),
raw: { stderr, stdout: maybeStdout }
}
} finally {
release()
}
}
export async function runRest<T>(
args: string[],
cwd?: string,
bucket: RateLimitBucketKind = 'core',
options?: { expectEmpty?: boolean }
): Promise<{ ok: true; data: T } | { ok: false; error: GitHubProjectViewError }> {
const guard = rateLimitGuard(bucket)
if (guard.blocked) {
return { ok: false, error: rateLimitedError(guard) }
}
await acquire()
noteRateLimitSpend(bucket)
try {
const { stdout, stderr } = await ghExecFileAsync(['api', ...args], {
encoding: 'utf-8',
...(cwd ? { cwd } : {})
})
// Why: 204/empty-body endpoints (DELETE label, DELETE comment) return no
// body. Treat empty stdout as success rather than misclassifying the
// unparseable response as 'unknown' — which the caller would otherwise
// need to special-case and risks masking real failures whose stderr the
// classifier also tags as 'unknown'.
if (options?.expectEmpty && stdout.trim() === '') {
return { ok: true, data: undefined as T }
}
try {
return { ok: true, data: JSON.parse(stdout) as T }
} catch {
return {
ok: false,
error: { type: 'unknown', message: `Unexpected REST response: ${stderr.trim()}` }
}
}
} catch (err) {
const { stderr, stdout: maybeStdout } = extractExecError(err)
return { ok: false, error: classifyProjectError(stderr, maybeStdout) }
} finally {
release()
}
}
+738
View File
@@ -0,0 +1,738 @@
/* eslint-disable max-lines -- Why: slug-addressed mutations + work-item
details share the validate/runRest/runGraphql plumbing with the read path.
Keeping them together preserves a single review surface for the write side. */
import {
acquire,
release,
extractExecError,
ghExecFileAsync,
rateLimitGuard,
noteRateLimitSpend,
classifyProjectError,
rateLimitedError,
runGraphql,
runRest,
validateSlugArgs,
assertPositiveInt,
type GraphqlVars
} from './internals'
import type { GitHubAssignableUser, GitHubWorkItemDetails, PRComment } from '../../../shared/types'
import type {
AddIssueCommentBySlugArgs,
ClearProjectItemFieldArgs,
DeleteIssueCommentBySlugArgs,
GitHubProjectCommentMutationResult,
GitHubProjectFieldMutationValue,
GitHubProjectMutationResult,
ListAssignableUsersBySlugArgs,
ListAssignableUsersBySlugResult,
ListIssueTypesBySlugArgs,
ListIssueTypesBySlugResult,
ListLabelsBySlugArgs,
ListLabelsBySlugResult,
ProjectWorkItemDetailsBySlugArgs,
ProjectWorkItemDetailsBySlugResult,
UpdateIssueBySlugArgs,
UpdateIssueCommentBySlugArgs,
UpdateIssueTypeBySlugArgs,
UpdatePullRequestBySlugArgs,
UpdateProjectItemFieldArgs
} from '../../../shared/github-project-types'
// ─── Project field mutations ──────────────────────────────────────────
class UnknownFieldMutationKindError extends Error {
constructor(kind: string) {
super(`Unknown project field mutation kind: ${kind}`)
}
}
function graphqlValueForFieldMutation(value: GitHubProjectFieldMutationValue): string {
// Serialize the value fragment for the GraphQL mutation. We use GraphQL
// variables for every dynamic piece, so here we only pick the variable name
// to reference per value kind.
switch (value.kind) {
case 'single-select':
return 'singleSelectOptionId: $value'
case 'iteration':
return 'iterationId: $value'
case 'text':
return 'text: $value'
case 'number':
return 'number: $value'
case 'date':
return 'date: $value'
default:
// Why: defensive default. If a new mutation kind is added to the type
// but not handled here, returning undefined would silently produce a
// broken GraphQL query. Throw so updateProjectItemFieldValue can map it
// to a validation_error rather than dispatching a malformed mutation.
throw new UnknownFieldMutationKindError((value as { kind: string }).kind)
}
}
function mutationValueVar(value: GitHubProjectFieldMutationValue): {
type: string
val: string | number
} {
switch (value.kind) {
case 'single-select':
return { type: 'String!', val: value.optionId }
case 'iteration':
return { type: 'String!', val: value.iterationId }
case 'text':
return { type: 'String!', val: value.text }
case 'number':
return { type: 'Float!', val: value.number }
case 'date':
return { type: 'Date!', val: value.date }
default:
// Why: see graphqlValueForFieldMutation — surface unknown kinds loudly
// instead of returning undefined and dispatching an invalid mutation.
throw new UnknownFieldMutationKindError((value as { kind: string }).kind)
}
}
export async function updateProjectItemFieldValue(
args: UpdateProjectItemFieldArgs
): Promise<GitHubProjectMutationResult> {
if (!args.projectId || !args.itemId || !args.fieldId) {
return { ok: false, error: { type: 'validation_error', message: 'Missing ids.' } }
}
let valFrag: string
let valVar: { type: string; val: string | number }
try {
valFrag = graphqlValueForFieldMutation(args.value)
valVar = mutationValueVar(args.value)
} catch (err) {
if (err instanceof UnknownFieldMutationKindError) {
return { ok: false, error: { type: 'validation_error', message: err.message } }
}
throw err
}
const query = `
mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!, $value:${valVar.type}) {
updateProjectV2ItemFieldValue(input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: { ${valFrag} }
}) { projectV2Item { id } }
}
`
const vars: GraphqlVars = {
projectId: args.projectId,
itemId: args.itemId,
fieldId: args.fieldId,
value: valVar.val
}
const res = await runGraphql<unknown>(query, vars)
if (!res.ok) {return { ok: false, error: res.error }}
return { ok: true }
}
export async function clearProjectItemFieldValue(
args: ClearProjectItemFieldArgs
): Promise<GitHubProjectMutationResult> {
if (!args.projectId || !args.itemId || !args.fieldId) {
return { ok: false, error: { type: 'validation_error', message: 'Missing ids.' } }
}
const query = `
mutation($projectId:ID!, $itemId:ID!, $fieldId:ID!) {
clearProjectV2ItemFieldValue(input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
}) { projectV2Item { id } }
}
`
const res = await runGraphql<unknown>(query, {
projectId: args.projectId,
itemId: args.itemId,
fieldId: args.fieldId
})
if (!res.ok) {return { ok: false, error: res.error }}
return { ok: true }
}
// ─── Slug-addressed issue/PR mutations ────────────────────────────────
export async function updateIssueBySlug(
args: UpdateIssueBySlugArgs
): Promise<GitHubProjectMutationResult> {
const v = validateSlugArgs(args.owner, args.repo)
if (!v.ok) {return v}
const n = assertPositiveInt(args.number, 'number')
if (!n.ok) {return { ok: false, error: n.error }}
if (!args.updates || typeof args.updates !== 'object') {
return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } }
}
const { title, body, state, addLabels, removeLabels, addAssignees, removeAssignees } = args.updates
// Title / body / state go through PATCH /repos/{owner}/{repo}/issues/{n}.
// Labels/assignees go through their dedicated endpoints.
const base = `repos/${args.owner}/${args.repo}/issues/${args.number}`
// 1) PATCH body
if (title !== undefined || body !== undefined || state !== undefined) {
const patchArgs: string[] = ['-X', 'PATCH', base]
if (title !== undefined) {patchArgs.push('--raw-field', `title=${title}`)}
if (body !== undefined) {patchArgs.push('--raw-field', `body=${body}`)}
if (state !== undefined) {patchArgs.push('--raw-field', `state=${state}`)}
const r = await runRest<unknown>(patchArgs)
if (!r.ok) {return { ok: false, error: r.error }}
}
// 2) Labels — collapse multi-delete fan-out into a single PUT when removing
// >1 label. PUT /labels replaces the entire label set, so we fetch the
// current labels first and compute the resulting set client-side. This
// turns an N-delete + 1-add (=N+1 calls) into 1-fetch + 1-PUT (=2 calls)
// once removeLabels has more than one entry, capping the cost at 2 even
// for a "remove all 20 labels" mutation.
const removeCount = removeLabels?.length ?? 0
const addCount = addLabels?.length ?? 0
if (removeCount > 1) {
type RawLabelResp = { name?: string }[]
const fetched = await runRest<RawLabelResp>(['-X', 'GET', `${base}/labels`])
if (!fetched.ok) {return { ok: false, error: fetched.error }}
const currentNames = new Set(
fetched.data.map((l) => l.name).filter((n): n is string => typeof n === 'string')
)
for (const l of removeLabels ?? []) {currentNames.delete(l)}
for (const l of addLabels ?? []) {currentNames.add(l)}
if (currentNames.size === 0) {
// Why: `gh api -X PUT` with no `--raw-field` arguments sends an empty
// body — GitHub does NOT interpret that as "clear labels". The
// dedicated DELETE endpoint is the documented way to remove all
// labels in a single call.
const r = await runRest<unknown>(
['-X', 'DELETE', `${base}/labels`],
undefined,
'core',
{ expectEmpty: true }
)
if (!r.ok && r.error.type !== 'not_found') {return { ok: false, error: r.error }}
} else {
const putArgs = ['-X', 'PUT', `${base}/labels`]
for (const name of currentNames) {putArgs.push('--raw-field', `labels[]=${name}`)}
const r = await runRest<unknown>(putArgs)
if (!r.ok) {return { ok: false, error: r.error }}
}
} else {
if (addCount > 0) {
const restArgs = ['-X', 'POST', `${base}/labels`]
for (const l of addLabels ?? []) {restArgs.push('--raw-field', `labels[]=${l}`)}
const r = await runRest<unknown>(restArgs)
if (!r.ok) {return { ok: false, error: r.error }}
}
if (removeCount === 1) {
const r = await runRest<unknown>(
['-X', 'DELETE', `${base}/labels/${encodeURIComponent(removeLabels![0])}`],
undefined,
'core',
{ expectEmpty: true }
)
if (!r.ok && r.error.type !== 'not_found') {return { ok: false, error: r.error }}
}
}
// 3) Assignees — POST and DELETE both accept arrays in a single call, so
// add/remove are at most 2 calls regardless of array size.
if (addAssignees && addAssignees.length > 0) {
const restArgs = ['-X', 'POST', `${base}/assignees`]
for (const u of addAssignees) {restArgs.push('--raw-field', `assignees[]=${u}`)}
const r = await runRest<unknown>(restArgs)
if (!r.ok) {return { ok: false, error: r.error }}
}
if (removeAssignees && removeAssignees.length > 0) {
const restArgs = ['-X', 'DELETE', `${base}/assignees`]
for (const u of removeAssignees) {restArgs.push('--raw-field', `assignees[]=${u}`)}
const r = await runRest<unknown>(restArgs)
if (!r.ok) {return { ok: false, error: r.error }}
}
return { ok: true }
}
export async function updatePullRequestBySlug(
args: UpdatePullRequestBySlugArgs
): Promise<GitHubProjectMutationResult> {
const v = validateSlugArgs(args.owner, args.repo)
if (!v.ok) {return v}
const n = assertPositiveInt(args.number, 'number')
if (!n.ok) {return { ok: false, error: n.error }}
if (!args.updates || typeof args.updates !== 'object') {
return { ok: false, error: { type: 'validation_error', message: 'Updates required.' } }
}
const patchArgs: string[] = ['-X', 'PATCH', `repos/${args.owner}/${args.repo}/pulls/${args.number}`]
// Why: count fields explicitly rather than inferring from patchArgs.length —
// adding a future header/flag arg silently breaks an array-length check.
let fieldCount = 0
if (args.updates.title !== undefined) {
patchArgs.push('--raw-field', `title=${args.updates.title}`)
fieldCount++
}
if (args.updates.body !== undefined) {
patchArgs.push('--raw-field', `body=${args.updates.body}`)
fieldCount++
}
if (fieldCount === 0) {
// No fields to update — nothing to do.
return { ok: true }
}
const r = await runRest<unknown>(patchArgs)
if (!r.ok) {return { ok: false, error: r.error }}
return { ok: true }
}
type RawIssueCommentResponse = {
id?: number
user?: { login?: string; avatar_url?: string; type?: string } | null
body?: string
created_at?: string
html_url?: string
}
function mapIssueComment(data: RawIssueCommentResponse, fallbackBody: string): PRComment {
return {
id: data.id ?? Date.now(),
author: data.user?.login ?? 'You',
authorAvatarUrl: data.user?.avatar_url ?? '',
body: data.body ?? fallbackBody,
createdAt: data.created_at ?? new Date().toISOString(),
url: data.html_url ?? '',
isBot: data.user?.type === 'Bot'
}
}
export async function addIssueCommentBySlug(
args: AddIssueCommentBySlugArgs
): Promise<GitHubProjectCommentMutationResult> {
const v = validateSlugArgs(args.owner, args.repo)
if (!v.ok) {return v}
const n = assertPositiveInt(args.number, 'number')
if (!n.ok) {return { ok: false, error: n.error }}
if (typeof args.body !== 'string' || !args.body.trim()) {
return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } }
}
const r = await runRest<RawIssueCommentResponse>([
'-X',
'POST',
`repos/${args.owner}/${args.repo}/issues/${args.number}/comments`,
'--raw-field',
`body=${args.body}`
])
if (!r.ok) {return { ok: false, error: r.error }}
return { ok: true, comment: mapIssueComment(r.data, args.body) }
}
export async function updateIssueCommentBySlug(
args: UpdateIssueCommentBySlugArgs
): Promise<GitHubProjectMutationResult> {
const v = validateSlugArgs(args.owner, args.repo)
if (!v.ok) {return v}
const n = assertPositiveInt(args.commentId, 'commentId')
if (!n.ok) {return { ok: false, error: n.error }}
if (typeof args.body !== 'string' || !args.body.trim()) {
return { ok: false, error: { type: 'validation_error', message: 'Comment body required.' } }
}
const r = await runRest<unknown>([
'-X',
'PATCH',
`repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`,
'--raw-field',
`body=${args.body}`
])
if (!r.ok) {return { ok: false, error: r.error }}
return { ok: true }
}
export async function deleteIssueCommentBySlug(
args: DeleteIssueCommentBySlugArgs
): Promise<GitHubProjectMutationResult> {
const v = validateSlugArgs(args.owner, args.repo)
if (!v.ok) {return v}
const n = assertPositiveInt(args.commentId, 'commentId')
if (!n.ok) {return { ok: false, error: n.error }}
const r = await runRest<unknown>(
['-X', 'DELETE', `repos/${args.owner}/${args.repo}/issues/comments/${args.commentId}`],
undefined,
'core',
{ expectEmpty: true }
)
if (!r.ok) {return { ok: false, error: r.error }}
return { ok: true }
}
// ─── Slug-addressed picker sources ────────────────────────────────────
export async function listLabelsBySlug(
args: ListLabelsBySlugArgs
): Promise<ListLabelsBySlugResult> {
const v = validateSlugArgs(args.owner, args.repo)
if (!v.ok) {return v}
const guard = rateLimitGuard('core')
if (guard.blocked) {return { ok: false, error: rateLimitedError(guard) }}
await acquire()
// Why: `--paginate` may fan out to multiple pages; we can only reasonably
// estimate a 1-call spend up front. The next probe will reconcile.
noteRateLimitSpend('core')
try {
const { stdout } = await ghExecFileAsync(
['api', '--paginate', `repos/${args.owner}/${args.repo}/labels`, '--jq', '.[].name'],
{ encoding: 'utf-8' }
)
return {
ok: true,
labels: stdout
.trim()
.split('\n')
.filter((l) => l.length > 0)
}
} catch (err) {
const { stderr, stdout: maybeStdout } = extractExecError(err)
return { ok: false, error: classifyProjectError(stderr, maybeStdout) }
} finally {
release()
}
}
export async function listAssignableUsersBySlug(
args: ListAssignableUsersBySlugArgs
): Promise<ListAssignableUsersBySlugResult> {
const v = validateSlugArgs(args.owner, args.repo)
if (!v.ok) {return v}
// Seed logins merge after the fetch so callers can include currently-visible
// assignees even if the repo participant search is sparse.
const result: GitHubAssignableUser[] = []
const guard = rateLimitGuard('core')
if (guard.blocked) {return { ok: false, error: rateLimitedError(guard) }}
await acquire()
noteRateLimitSpend('core')
try {
const { stdout } = await ghExecFileAsync(
[
'api',
'--paginate',
`repos/${args.owner}/${args.repo}/assignees`,
'--jq',
'.[] | {login: .login, name: null, avatarUrl: .avatar_url}'
],
{ encoding: 'utf-8' }
)
for (const line of stdout.trim().split('\n').filter((l) => l.length > 0)) {
try {
const u = JSON.parse(line) as { login?: string; avatarUrl?: string; name?: string | null }
if (typeof u.login === 'string') {
result.push({ login: u.login, name: u.name ?? null, avatarUrl: u.avatarUrl ?? '' })
}
} catch {
// skip malformed jq line
}
}
} catch (err) {
const { stderr } = extractExecError(err)
return { ok: false, error: classifyProjectError(stderr, '') }
} finally {
release()
}
if (args.seedLogins) {
const seen = new Set(result.map((u) => u.login))
for (const login of args.seedLogins) {
if (typeof login === 'string' && !seen.has(login)) {
result.push({ login, name: null, avatarUrl: '' })
seen.add(login)
}
}
}
return { ok: true, users: result }
}
// Why: Issue Types are a repo-level taxonomy (Bug/Feature/Task/etc) only
// available on repos opted into typed-issues. Empty list (or schema_drift on
// older GitHub deployments) is the legitimate "this repo doesn't use issue
// types" signal — callers should treat it as "no editor".
export async function listIssueTypesBySlug(
args: ListIssueTypesBySlugArgs
): Promise<ListIssueTypesBySlugResult> {
const v = validateSlugArgs(args.owner, args.repo)
if (!v.ok) {return v}
const query = `
query($owner:String!, $repo:String!) {
repository(owner:$owner, name:$repo) {
issueTypes(first:50) {
nodes { id name color description }
}
}
}
`
const res = await runGraphql<{
repository?: {
issueTypes?: {
nodes?: ({
id?: string
name?: string
color?: string | null
description?: string | null
} | null)[]
} | null
} | null
}>(query, { owner: args.owner, repo: args.repo })
if (!res.ok) {
// Why: repos without issue types respond with a GraphQL error claiming the
// `issueTypes` field is unknown. Map that to an empty list so the UI shows
// "no editor" instead of an angry banner.
if (res.error.type === 'schema_drift' || res.error.type === 'validation_error') {
return { ok: true, types: [] }
}
return { ok: false, error: res.error }
}
const nodes = res.data.repository?.issueTypes?.nodes ?? []
const types = nodes
.filter((n): n is NonNullable<typeof n> => n !== null && typeof n.id === 'string' && typeof n.name === 'string')
.map((n) => ({
id: n.id as string,
name: n.name as string,
color: typeof n.color === 'string' ? n.color : null,
description: typeof n.description === 'string' ? n.description : null
}))
return { ok: true, types }
}
export async function updateIssueTypeBySlug(
args: UpdateIssueTypeBySlugArgs
): Promise<GitHubProjectMutationResult> {
const v = validateSlugArgs(args.owner, args.repo)
if (!v.ok) {return v}
const n = assertPositiveInt(args.number, 'number')
if (!n.ok) {return { ok: false, error: n.error }}
// Why: `updateIssueIssueType` is the dedicated mutation; passing null for
// `issueTypeId` clears the type. We resolve the issue id via a lightweight
// GraphQL lookup because the REST endpoint doesn't accept issue types.
const lookup = await runGraphql<{
repository?: { issue?: { id?: string } | null } | null
}>(
`query($owner:String!, $repo:String!, $num:Int!) {
repository(owner:$owner, name:$repo) { issue(number:$num) { id } }
}`,
{ owner: args.owner, repo: args.repo, num: args.number }
)
if (!lookup.ok) {return { ok: false, error: lookup.error }}
const issueId = lookup.data.repository?.issue?.id
if (!issueId) {
return { ok: false, error: { type: 'not_found', message: 'Issue not found.' } }
}
// Why: build the mutation conditionally so a null clear doesn't have to
// smuggle a null GraphQL variable through `gh api graphql -f`. The
// mutation accepts a literal `null` in the input object directly.
const query = args.issueTypeId
? `
mutation($issueId:ID!, $issueTypeId:ID!) {
updateIssueIssueType(input: { issueId: $issueId, issueTypeId: $issueTypeId }) {
issue { id }
}
}
`
: `
mutation($issueId:ID!) {
updateIssueIssueType(input: { issueId: $issueId, issueTypeId: null }) {
issue { id }
}
}
`
const vars: GraphqlVars = args.issueTypeId
? { issueId, issueTypeId: args.issueTypeId }
: { issueId }
const res = await runGraphql<unknown>(query, vars)
if (!res.ok) {return { ok: false, error: res.error }}
return { ok: true }
}
// ─── Slug-addressed work-item details ─────────────────────────────────
type RawUser = { login?: string; name?: string | null; avatarUrl?: string | null }
type RawLabel = { name?: string; color?: string }
type RawWorkItemContent = {
id?: string
number?: number
title?: string
url?: string
state?: string
stateReason?: string | null
isDraft?: boolean
labels?: { nodes?: RawLabel[] }
assignees?: { nodes?: RawUser[] }
}
export async function getWorkItemDetailsBySlug(
args: ProjectWorkItemDetailsBySlugArgs
): Promise<ProjectWorkItemDetailsBySlugResult> {
const v = validateSlugArgs(args.owner, args.repo)
if (!v.ok) {return v}
const n = assertPositiveInt(args.number, 'number')
if (!n.ok) {return { ok: false, error: n.error }}
if (args.type !== 'issue' && args.type !== 'pr') {
return { ok: false, error: { type: 'validation_error', message: 'Invalid type.' } }
}
// Single GraphQL round-trip to fetch the issue/PR summary + comments + labels + assignees.
const contentFrag =
args.type === 'issue'
? `
issue(number:$num) {
id number title url state stateReason updatedAt
body
author { login }
labels(first:50) { nodes { name } }
assignees(first:50) { nodes { login } }
participants(first:50) { nodes { login name avatarUrl } }
comments(first:100) {
nodes {
databaseId
author { login avatarUrl __typename }
body createdAt url
}
}
}
`
: `
pullRequest(number:$num) {
id number title url state isDraft updatedAt headRefName baseRefName
body
author { login }
labels(first:50) { nodes { name } }
assignees(first:50) { nodes { login } }
participants(first:50) { nodes { login name avatarUrl } }
comments(first:100) {
nodes {
databaseId
author { login avatarUrl __typename }
body createdAt url
}
}
}
`
const query = `
query($owner:String!, $repo:String!, $num:Int!) {
repository(owner:$owner, name:$repo) {
${contentFrag}
}
}
`
const res = await runGraphql<{
repository?: {
issue?: RawWorkItemContent & {
updatedAt?: string
body?: string
author?: { login?: string } | null
participants?: { nodes?: RawUser[] }
comments?: {
nodes?: ({
databaseId?: number
author?: { login?: string; avatarUrl?: string; __typename?: string } | null
body?: string
createdAt?: string
url?: string
} | null)[]
}
} | null
pullRequest?: RawWorkItemContent & {
updatedAt?: string
body?: string
headRefName?: string
baseRefName?: string
author?: { login?: string } | null
participants?: { nodes?: RawUser[] }
comments?: {
nodes?: ({
databaseId?: number
author?: { login?: string; avatarUrl?: string; __typename?: string } | null
body?: string
createdAt?: string
url?: string
} | null)[]
}
} | null
} | null
}>(query, { owner: args.owner, repo: args.repo, num: args.number })
if (!res.ok) {return { ok: false, error: res.error }}
const raw = args.type === 'issue' ? res.data.repository?.issue : res.data.repository?.pullRequest
if (!raw) {
return { ok: false, error: { type: 'not_found', message: 'Item not found.' } }
}
const labels = (raw.labels?.nodes ?? [])
.map((l) => l?.name)
.filter((n): n is string => typeof n === 'string')
const assignees = (raw.assignees?.nodes ?? [])
.map((a) => a?.login)
.filter((l): l is string => typeof l === 'string')
const comments: PRComment[] = []
for (const c of raw.comments?.nodes ?? []) {
if (!c || typeof c.body !== 'string') {continue}
comments.push({
id: typeof c.databaseId === 'number' ? c.databaseId : Date.now(),
author: c.author?.login ?? '',
authorAvatarUrl: c.author?.avatarUrl ?? '',
body: c.body,
createdAt: typeof c.createdAt === 'string' ? c.createdAt : '',
url: typeof c.url === 'string' ? c.url : '',
isBot: c.author?.__typename === 'Bot'
})
}
const participants: GitHubAssignableUser[] = []
for (const p of raw.participants?.nodes ?? []) {
if (p && typeof p.login === 'string') {
participants.push({ login: p.login, name: p.name ?? null, avatarUrl: p.avatarUrl ?? '' })
}
}
const state: 'open' | 'closed' | 'merged' | 'draft' =
args.type === 'pr'
? raw.isDraft
? 'draft'
: raw.state === 'MERGED'
? 'merged'
: raw.state === 'CLOSED'
? 'closed'
: 'open'
: raw.state === 'CLOSED'
? 'closed'
: 'open'
const details: GitHubWorkItemDetails = {
item: {
id: typeof raw.id === 'string' ? raw.id : '',
type: args.type,
number: typeof raw.number === 'number' ? raw.number : args.number,
title: typeof raw.title === 'string' ? raw.title : '',
state,
url: typeof raw.url === 'string' ? raw.url : '',
labels,
updatedAt:
typeof (raw as { updatedAt?: string }).updatedAt === 'string'
? (raw as { updatedAt: string }).updatedAt
: '',
author:
typeof (raw as { author?: { login?: string } | null }).author?.login === 'string'
? ((raw as { author: { login: string } }).author.login as string)
: null,
branchName:
args.type === 'pr' && typeof (raw as { headRefName?: string }).headRefName === 'string'
? ((raw as { headRefName: string }).headRefName as string)
: undefined,
baseRefName:
args.type === 'pr' && typeof (raw as { baseRefName?: string }).baseRefName === 'string'
? ((raw as { baseRefName: string }).baseRefName as string)
: undefined
},
body: typeof raw.body === 'string' ? raw.body : '',
comments,
participants,
// Why: PR files/checks/review-thread tabs depend on a local repo path and
// are out of Project-mode slug scope for v1. Omit them here; the dialog
// branches on their absence and hides those tabs.
...(args.type === 'issue' ? { assignees } : {})
}
return { ok: true, details }
}
+149
View File
@@ -0,0 +1,149 @@
/**
* GitHub API rate-limit probe.
*
* Why: `listWorkItems` fan-out × selected repos plus `countWorkItems` in
* parallel, plus `listAccessibleProjects` org-walk, can chew through the
* core (5000/hr) or search (30/min) buckets quickly. Surfacing the remaining
* budget in the TaskPage header lets users self-regulate before they hit the
* wall — without actually throttling (which would hurt responsiveness in
* the common not-near-the-limit case). The probe itself is exempt from
* rate-limit accounting per GitHub docs.
*
* The result is intentionally minimal: we expose just the counts the UI
* needs (remaining + limit for the three buckets we actually stress). If a
* future feature needs reset-time countdowns we can add resetAt here.
*/
import type {
GetRateLimitResult,
GitHubRateLimitBucket,
GitHubRateLimitSnapshot
} from '../../shared/types'
import { acquire, release } from './gh-utils'
import { ghExecFileAsync } from '../git/runner'
// Why: GitHub explicitly states `GET /rate_limit` does NOT count against
// any bucket, so the only reason to cache is to avoid spawning a `gh`
// subprocess on every render. 30s is a pragmatic balance — short enough
// that the number in the header feels live, long enough to absorb the
// 1-per-second "is it safe now?" polling pattern UIs tend to fall into.
const RATE_LIMIT_CACHE_TTL_MS = 30_000
let cached: GitHubRateLimitSnapshot | null = null
type GhRateLimitPayload = {
resources?: {
core?: { limit?: number; remaining?: number; reset?: number }
search?: { limit?: number; remaining?: number; reset?: number }
graphql?: { limit?: number; remaining?: number; reset?: number }
}
}
function parseBucket(raw: {
limit?: number
remaining?: number
reset?: number
} | undefined): GitHubRateLimitBucket {
// Why: if a bucket is absent from the response (old gh, partial response),
// return 0/0/now so the UI shows a clear "unknown" state (0/0 is
// unambiguous) rather than a misleading "plenty left" fallback.
return {
limit: typeof raw?.limit === 'number' ? raw.limit : 0,
remaining: typeof raw?.remaining === 'number' ? raw.remaining : 0,
resetAt: typeof raw?.reset === 'number' ? raw.reset : Math.floor(Date.now() / 1000)
}
}
/** @internal — test-only */
export function _resetRateLimitCache(): void {
cached = null
}
// Why: hard-stop thresholds for the circuit breaker. We refuse to issue a new
// gh request when the cached snapshot says the relevant bucket is below this
// floor. Numbers chosen as "enough budget for one user-initiated flow":
// - core/graphql at 50: a typical work-item details fetch + a few mutations
// - search at 2: a search-driven view paginates in chunks of 1; 2 leaves the
// user one safety click without tipping into the 30/min hard limit
// Below the floor, callers get a synthesized rate_limited error and never
// spawn a gh subprocess.
const MIN_REMAINING_CORE = 50
const MIN_REMAINING_GRAPHQL = 50
const MIN_REMAINING_SEARCH = 2
export type RateLimitBucketKind = 'core' | 'graphql' | 'search'
/**
* Return a "soft" stop reason if we should refuse to issue a new gh request
* for the given bucket. Returns null when there's no cached snapshot (we
* haven't probed yet — fail open) or when the bucket has enough budget left.
*
* Why: this is the proactive guard the pill alone cannot provide. The pill is
* informational; this function actually blocks the spawn. We deliberately keep
* it advisory (returns a reason, doesn't throw) so callers can format the
* envelope/error in their own shape.
*/
export function rateLimitGuard(bucket: RateLimitBucketKind): { blocked: false } | {
blocked: true
remaining: number
limit: number
resetAt: number
} {
if (!cached) {
return { blocked: false }
}
const b = cached[bucket]
const floor =
bucket === 'core'
? MIN_REMAINING_CORE
: bucket === 'graphql'
? MIN_REMAINING_GRAPHQL
: MIN_REMAINING_SEARCH
// Why: only block when we have a positive limit (limit:0 means "unknown" per
// parseBucket fallback — don't block on missing data, that would brick the
// app on a single bad rate_limit response).
if (b.limit > 0 && b.remaining < floor) {
return { blocked: true, remaining: b.remaining, limit: b.limit, resetAt: b.resetAt }
}
return { blocked: false }
}
/**
* Decrement the cached `remaining` counter for a bucket after a successful
* spawn. Why: the canonical numbers come from the next probe, but between
* probes the cached snapshot would over-report budget if we didn't account
* for the work we just did. The decrement keeps the circuit breaker honest
* during a burst (e.g. paginating items) instead of waiting 30s for the cache
* to expire.
*/
export function noteRateLimitSpend(bucket: RateLimitBucketKind, cost = 1): void {
if (!cached) {
return
}
const b = cached[bucket]
if (b.remaining > 0) {
cached = { ...cached, [bucket]: { ...b, remaining: Math.max(0, b.remaining - cost) } }
}
}
export async function getRateLimit(options?: { force?: boolean }): Promise<GetRateLimitResult> {
if (!options?.force && cached && Date.now() - cached.fetchedAt < RATE_LIMIT_CACHE_TTL_MS) {
return { ok: true, snapshot: cached }
}
await acquire()
try {
const { stdout } = await ghExecFileAsync(['api', 'rate_limit'], { encoding: 'utf-8' })
const parsed = JSON.parse(stdout) as GhRateLimitPayload
const snapshot: GitHubRateLimitSnapshot = {
core: parseBucket(parsed.resources?.core),
search: parseBucket(parsed.resources?.search),
graphql: parseBucket(parsed.resources?.graphql),
fetchedAt: Date.now()
}
cached = snapshot
return { ok: true, snapshot }
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
return { ok: false, error: message }
} finally {
release()
}
}