mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 00:02:29 +00:00
refactor(shared): group worktree, github, and linear modules into folders (#14437)
`src/shared` is a flat directory of ~1,150 entries. The worktree, github, and
linear domains accounted for 71 of them, so finding the module you wanted meant
scanning a wall of same-prefixed filenames.
Move each domain into its own folder and drop the now-redundant prefix:
src/shared/github-pr-types.ts -> src/shared/github/pull-request-types.ts
src/shared/worktree-id.ts -> src/shared/worktree/id.ts
src/shared/linear-links.ts -> src/shared/linear/links.ts
This follows the existing `network/` and `new-workspace/` convention in the
same directory, which also drop the prefix inside the folder.
Whole clusters move, including tests. Foldering only part of a domain would be
worse than flat: a reader would have to check both `github/` and the flat
directory, and `github-auth-types.ts` / `github-project-types.ts` are type
modules that belong with the rest. No files with these prefixes remain flat.
Import specifiers were rewritten by resolving each one to an absolute path and
recomputing it, not by string substitution, so the `@/../../shared/...` alias
forms are handled correctly. 501 specifiers across 298 files.
Two things `tsc` cannot catch, handled explicitly:
- `github-project-types.ts` carries its own `max-lines` bypass, so its baseline
entry is REPOINTED to the new path rather than pruned. Pruning would drop the
bypass and then flag the new path as a fresh violation. Ratchet stays at 345.
- `mobile/` is outside `pnpm typecheck` and cannot be typechecked here
(`mobile/node_modules` is empty). Instead every relative specifier in the repo
was resolved against the filesystem: 174 unresolved before this change and 174
after — identical, so nothing broke in mobile either.
The pinned `tests/e2e/.cross-version-checkouts` fixtures are deliberately NOT
rewritten; they are a snapshot of an older release and still reference the old
paths.
Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches).
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { classifyGitHubUnavailable, isGitHubUnavailableError } from './api-availability'
|
||||
|
||||
describe('classifyGitHubUnavailable', () => {
|
||||
it('classifies HTTP 5xx outages as server_error', () => {
|
||||
expect(classifyGitHubUnavailable('HTTP 503: Service Unavailable')).toBe('server_error')
|
||||
expect(classifyGitHubUnavailable('gh: Command failed: HTTP 502 Bad Gateway')).toBe(
|
||||
'server_error'
|
||||
)
|
||||
expect(classifyGitHubUnavailable('GitHub API error: 500 Internal Server Error')).toBe(
|
||||
'server_error'
|
||||
)
|
||||
expect(classifyGitHubUnavailable('The service is temporarily unavailable')).toBe('server_error')
|
||||
})
|
||||
|
||||
it('classifies transport failures as network', () => {
|
||||
for (const message of [
|
||||
'request to https://api.github.com failed, reason: getaddrinfo ENOTFOUND api.github.com',
|
||||
'error connecting to api.github.com\ncheck your internet connection or GitHub status',
|
||||
'dial tcp: lookup api.github.com: no such host',
|
||||
'connect ETIMEDOUT 140.82.112.5:443',
|
||||
'TimeoutError: request aborted',
|
||||
'NetworkError when attempting to fetch resource',
|
||||
'network unavailable',
|
||||
'fetch failed',
|
||||
'socket hang up',
|
||||
'could not resolve host: api.github.com',
|
||||
'connection refused'
|
||||
]) {
|
||||
expect(classifyGitHubUnavailable(message)).toBe('network')
|
||||
}
|
||||
})
|
||||
|
||||
it('classifies rate limiting as rate_limited (even when it carries HTTP 403)', () => {
|
||||
expect(classifyGitHubUnavailable('HTTP 403: API rate limit exceeded')).toBe('rate_limited')
|
||||
expect(classifyGitHubUnavailable('You have exceeded a secondary rate limit')).toBe(
|
||||
'rate_limited'
|
||||
)
|
||||
expect(classifyGitHubUnavailable('HTTP 429 Too Many Requests')).toBe('rate_limited')
|
||||
})
|
||||
|
||||
it('returns null for non-reachability failures', () => {
|
||||
expect(classifyGitHubUnavailable('HTTP 403: Resource not accessible by integration')).toBeNull()
|
||||
expect(classifyGitHubUnavailable('HTTP 404: Not Found')).toBeNull()
|
||||
expect(classifyGitHubUnavailable('could not resolve to a Repository with the name')).toBeNull()
|
||||
expect(
|
||||
classifyGitHubUnavailable(
|
||||
"GraphQL: Could not resolve to a Repository with the name 'network'."
|
||||
)
|
||||
).toBeNull()
|
||||
expect(classifyGitHubUnavailable('gh auth login required')).toBeNull()
|
||||
expect(classifyGitHubUnavailable('')).toBeNull()
|
||||
})
|
||||
|
||||
it('does not misread unrelated 3-digit numbers as a server outage', () => {
|
||||
expect(classifyGitHubUnavailable('found 512 pull requests')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isGitHubUnavailableError', () => {
|
||||
it('detects reachability failures from Error objects and strings', () => {
|
||||
expect(isGitHubUnavailableError(new Error('HTTP 503: Service Unavailable'))).toBe(true)
|
||||
expect(isGitHubUnavailableError('fetch failed')).toBe(true)
|
||||
expect(isGitHubUnavailableError(new Error('HTTP 404: Not Found'))).toBe(false)
|
||||
expect(isGitHubUnavailableError(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
// Why: a GitHub outage, a dropped network, or a rate-limit all surface as
|
||||
// unstructured gh/Octokit error text. Detecting them from one shared place lets
|
||||
// both the main process (PR-refresh classification) and the renderer (Tasks
|
||||
// work-item fan-out) attribute the failure to GitHub — not to Orca — using the
|
||||
// exact same rules, so the two surfaces never disagree about whether GitHub is
|
||||
// reachable. Returns null for anything that is NOT a reachability problem
|
||||
// (auth, permission, 404): those are user-actionable, not "GitHub is down".
|
||||
|
||||
export type GitHubUnavailableKind = 'server_error' | 'network' | 'rate_limited'
|
||||
|
||||
// Rate-limit first: a primary rate-limit response also carries "HTTP 403", so
|
||||
// it must win over any 4xx/permission interpretation downstream.
|
||||
const RATE_LIMITED_PATTERN =
|
||||
/rate limit|secondary rate limit|abuse detection|\bhttp[\s/]*429\b|\b429 too many requests\b/i
|
||||
|
||||
// Server-side outage. Anchor on "HTTP 5xx" or named 5xx statuses rather than a
|
||||
// bare 3-digit match so unrelated numbers in stderr can't be misread as an
|
||||
// outage.
|
||||
const SERVER_ERROR_PATTERN =
|
||||
/\bhttp[\s/]*5\d\d\b|\b5\d\d\s+(?:internal server error|bad gateway|service unavailable|gateway time-?out)\b|\binternal server error\b|\bbad gateway\b|\bservice unavailable\b|\bgateway time-?out\b|\bserver error\b|\btemporarily unavailable\b/i
|
||||
|
||||
// Transport-level failures — DNS, refused/reset connections, timeouts. Covers
|
||||
// both Node (ENOTFOUND/ECONNRESET) and the gh Go client ("dial tcp", "i/o
|
||||
// timeout", "no such host") shapes.
|
||||
const NETWORK_PATTERN =
|
||||
/timeout|\btimed out\b|\bno such host\b|could not resolve host|could not resolve to a host|\bnetwork(?:error| (?:error|unavailable|unreachable|request failed))\b|\bconnection (?:refused|reset)\b|\berror connecting to\b|\bfailed to connect to\b|\bdial tcp\b|\bi\/o timeout\b|\bfetch failed\b|\bsocket hang up\b|ENOTFOUND|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ENETUNREACH/i
|
||||
|
||||
/**
|
||||
* Classify a gh/Octokit error message as a GitHub-reachability problem, or
|
||||
* null when it is not one (auth, permission, 404, validation, etc.).
|
||||
*/
|
||||
export function classifyGitHubUnavailable(message: string): GitHubUnavailableKind | null {
|
||||
if (!message) {
|
||||
return null
|
||||
}
|
||||
if (RATE_LIMITED_PATTERN.test(message)) {
|
||||
return 'rate_limited'
|
||||
}
|
||||
if (SERVER_ERROR_PATTERN.test(message)) {
|
||||
return 'server_error'
|
||||
}
|
||||
if (NETWORK_PATTERN.test(message)) {
|
||||
return 'network'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** True when the message indicates GitHub itself is unreachable/unavailable. */
|
||||
export function isGitHubUnavailableError(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : String(error ?? '')
|
||||
return classifyGitHubUnavailable(message) !== null
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Shared types for `gh auth status` diagnostics surfaced to the renderer.
|
||||
*/
|
||||
|
||||
export type GhAuthAccount = {
|
||||
host: string
|
||||
user: string
|
||||
/** True when this is the account gh would use for the next call. */
|
||||
active: boolean
|
||||
/**
|
||||
* If gh reports the credential came from an environment variable, the
|
||||
* variable's name. Null when the credential came from the keyring/file
|
||||
* config. Env-token accounts can't be refreshed by `gh auth refresh`.
|
||||
*/
|
||||
envToken: 'GITHUB_TOKEN' | 'GH_TOKEN' | null
|
||||
source: 'env' | 'keyring'
|
||||
scopes: string[]
|
||||
}
|
||||
|
||||
export type GhAuthDiagnostic = {
|
||||
/** False when gh CLI is not installed / not on PATH. */
|
||||
ghAvailable: boolean
|
||||
activeAccount: GhAuthAccount | null
|
||||
accounts: GhAuthAccount[]
|
||||
/**
|
||||
* Whether the Electron main process itself sees GITHUB_TOKEN/GH_TOKEN in
|
||||
* its environment. Distinct from `activeAccount.envToken` because gh may
|
||||
* report an env source even when the variable was set in a parent shell
|
||||
* that didn't propagate to Electron, and vice versa.
|
||||
*/
|
||||
envTokenInProcess: 'GITHUB_TOKEN' | 'GH_TOKEN' | null
|
||||
missingScopes: string[]
|
||||
requiredScopes: string[]
|
||||
/**
|
||||
* True when there's a non-env keyring account on the same/another host
|
||||
* that the user could fall back to by unsetting the env var.
|
||||
*/
|
||||
hasKeyringFallback: boolean
|
||||
/**
|
||||
* The GitHub host the caller needs credentials for (e.g. a GHES origin).
|
||||
* Null when the probe ran without host context.
|
||||
*/
|
||||
requiredHost: string | null
|
||||
/** Whether gh has any account for `requiredHost`; null without host context. */
|
||||
requiredHostAuthenticated: boolean | null
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export const GITHUB_CHECK_DETAILS_HOST_TIMEOUT_MS = 25_000
|
||||
export const GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE = 'Timed out loading check details.'
|
||||
|
||||
export function isGitHubCheckDetailsTimeout(error: unknown): boolean {
|
||||
return error instanceof Error && error.message.endsWith(GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
export type PRCheckDetail = {
|
||||
name: string
|
||||
status: 'queued' | 'in_progress' | 'completed'
|
||||
conclusion:
|
||||
| 'success'
|
||||
| 'failure'
|
||||
| 'cancelled'
|
||||
| 'timed_out'
|
||||
| 'neutral'
|
||||
| 'skipped'
|
||||
| 'pending'
|
||||
// Why: a check suite needing manual action (e.g. a workflow awaiting "Approve
|
||||
// and run") has no check run and is absent from statusCheckRollup, yet blocks
|
||||
// auto-merge (GitHub returns "unstable status"). Surface it as its own state.
|
||||
| 'action_required'
|
||||
| null
|
||||
url: string | null
|
||||
checkRunId?: number
|
||||
workflowRunId?: number
|
||||
// Why: the GitLab job trace API is addressed by numeric job id only, so the
|
||||
// Checks panel cannot load a job log without carrying it on the row.
|
||||
gitlabJobId?: number
|
||||
}
|
||||
|
||||
export type PRCheckAnnotation = {
|
||||
path: string | null
|
||||
startLine: number | null
|
||||
endLine: number | null
|
||||
annotationLevel: string | null
|
||||
title: string | null
|
||||
message: string
|
||||
rawDetails: string | null
|
||||
}
|
||||
|
||||
export type PRCheckStep = {
|
||||
name: string
|
||||
status: string | null
|
||||
conclusion: string | null
|
||||
startedAt: string | null
|
||||
completedAt: string | null
|
||||
}
|
||||
|
||||
export type PRCheckJob = {
|
||||
id: number | null
|
||||
name: string
|
||||
status: string | null
|
||||
conclusion: string | null
|
||||
startedAt: string | null
|
||||
completedAt: string | null
|
||||
url: string | null
|
||||
logTail: string | null
|
||||
steps: PRCheckStep[]
|
||||
}
|
||||
|
||||
export type PRCheckRunDetails = {
|
||||
name: string
|
||||
status: PRCheckDetail['status'] | (string & {}) | null
|
||||
conclusion: PRCheckDetail['conclusion'] | (string & {}) | null
|
||||
url: string | null
|
||||
detailsUrl: string | null
|
||||
startedAt: string | null
|
||||
completedAt: string | null
|
||||
title: string | null
|
||||
summary: string | null
|
||||
text: string | null
|
||||
annotations: PRCheckAnnotation[]
|
||||
jobs: PRCheckJob[]
|
||||
}
|
||||
|
||||
export type GitHubRerunPRChecksResult = { ok: true; count: number } | { ok: false; error: string }
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { GitHubRepositoryIdentity } from './pull-request-types'
|
||||
|
||||
export type GitHubReactionContent =
|
||||
| '+1'
|
||||
| '-1'
|
||||
| 'laugh'
|
||||
| 'confused'
|
||||
| 'heart'
|
||||
| 'hooray'
|
||||
| 'rocket'
|
||||
| 'eyes'
|
||||
|
||||
export type GitHubReaction = {
|
||||
content: GitHubReactionContent
|
||||
count: number
|
||||
viewerHasReacted?: boolean
|
||||
}
|
||||
|
||||
export type PRComment = {
|
||||
id: number
|
||||
author: string
|
||||
authorAvatarUrl: string
|
||||
body: string
|
||||
createdAt: string
|
||||
url: string
|
||||
reactions?: GitHubReaction[]
|
||||
/** GraphQL node ID for GitHub comments that support reaction mutations. */
|
||||
reactionSubjectId?: string
|
||||
/** File path for inline review comments (absent for top-level conversation comments). */
|
||||
path?: string
|
||||
/** GraphQL node ID of the review thread — present only for inline review comments.
|
||||
* Used to resolve/unresolve the thread via GitHub's GraphQL API. */
|
||||
threadId?: string
|
||||
/** Whether the review thread has been resolved. Only meaningful when threadId is set. */
|
||||
isResolved?: boolean
|
||||
/** True when GitHub no longer maps the thread to the current diff. */
|
||||
isOutdated?: boolean
|
||||
/** End line of the review annotation (1-based). */
|
||||
line?: number
|
||||
/** Start line of the review annotation range (1-based). Absent for single-line comments. */
|
||||
startLine?: number
|
||||
/** True when GitHub identifies the author as a bot (REST `user.type === 'Bot'` or
|
||||
* GraphQL `__typename === 'Bot'`). Preferred over login-string heuristics because
|
||||
* third-party review bots (e.g. qodo-ai-reviewer, coderabbitai) don't follow a
|
||||
* predictable naming convention. Absent when the data source can't report it
|
||||
* (non-GitHub fallbacks via `gh pr view`). */
|
||||
isBot?: boolean
|
||||
}
|
||||
|
||||
export type GitHubIssueTimelineTarget = {
|
||||
type: 'issue' | 'pr'
|
||||
number: number
|
||||
title: string
|
||||
url: string
|
||||
repository?: string
|
||||
}
|
||||
|
||||
export type GitHubIssueTimelineItem = {
|
||||
id: string
|
||||
event:
|
||||
| 'assigned'
|
||||
| 'unassigned'
|
||||
| 'mentioned'
|
||||
| 'cross-referenced'
|
||||
| 'closed'
|
||||
| 'reopened'
|
||||
| 'moved_columns_in_project'
|
||||
actor: string
|
||||
actorAvatarUrl: string
|
||||
createdAt: string
|
||||
assignee?: string
|
||||
source?: GitHubIssueTimelineTarget
|
||||
closer?: GitHubIssueTimelineTarget
|
||||
stateReason?: string | null
|
||||
previousColumnName?: string | null
|
||||
columnName?: string | null
|
||||
projectName?: string | null
|
||||
}
|
||||
|
||||
export type GitHubCommentResult = { ok: true; comment: PRComment } | { ok: false; error: string }
|
||||
|
||||
export type GitHubPRReviewCommentInput = {
|
||||
repoPath: string
|
||||
prRepo?: GitHubRepositoryIdentity | null
|
||||
prNumber: number
|
||||
commitId: string
|
||||
path: string
|
||||
line: number
|
||||
startLine?: number
|
||||
body: string
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Why shared: main's terminal side-effect tracker emits pr-link facts
|
||||
// (terminal-side-effect-authority.md, slice 3) and needs the same GitHub URL
|
||||
// parsing core the renderer link picker uses.
|
||||
const GH_ITEM_PATH_RE = /^\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:\/.*)?$/i
|
||||
|
||||
export type RepoSlug = {
|
||||
owner: string
|
||||
repo: string
|
||||
host?: string
|
||||
}
|
||||
|
||||
export type GitHubIssueOrPRLink = {
|
||||
slug: RepoSlug
|
||||
number: number
|
||||
type: 'issue' | 'pr'
|
||||
}
|
||||
|
||||
export function buildGitHubRepoUrl(slug: RepoSlug | null | undefined): string | null {
|
||||
if (!slug?.owner || !slug.repo) {
|
||||
return null
|
||||
}
|
||||
// Why: hosted identities carry the GHES host; links must point at that
|
||||
// server, not github.com.
|
||||
const host = slug.host ?? 'github.com'
|
||||
return `https://${host}/${encodeURIComponent(slug.owner)}/${encodeURIComponent(slug.repo)}`
|
||||
}
|
||||
|
||||
function matchGitHubItemPath(url: URL): RegExpExecArray | null {
|
||||
return GH_ITEM_PATH_RE.exec(url.pathname.replace(/\/+$/, ''))
|
||||
}
|
||||
|
||||
function parseGitHubItemNumber(value: string): number | null {
|
||||
const parsed = Number.parseInt(value, 10)
|
||||
return parsed > 0 ? parsed : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a GitHub issue/PR reference from plain input.
|
||||
* Supports issue/PR numbers (e.g. "42"), "#42", and full GitHub URLs.
|
||||
*/
|
||||
export function parseGitHubIssueOrPRNumber(input: string): number | null {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
const numeric = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed
|
||||
if (/^\d+$/.test(numeric)) {
|
||||
return parseGitHubItemNumber(numeric)
|
||||
}
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(trimmed)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = matchGitHubItemPath(url)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
return parseGitHubItemNumber(match[4])
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an owner/repo slug plus issue/PR number from a GitHub URL. Returns
|
||||
* null for anything that isn't a recognizable GitHub-shaped issue or pull URL.
|
||||
*/
|
||||
export function parseGitHubIssueOrPRLink(input: string): GitHubIssueOrPRLink | null {
|
||||
const trimmed = input.trim()
|
||||
if (!trimmed) {
|
||||
return null
|
||||
}
|
||||
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(trimmed)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
return null
|
||||
}
|
||||
|
||||
const match = matchGitHubItemPath(url)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const number = parseGitHubItemNumber(match[4])
|
||||
if (number === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
// Why: the URL proves the host — GHES links must keep their server identity
|
||||
// instead of being treated as github.com slugs.
|
||||
// Why: GHES installations on non-default ports require the full URL host;
|
||||
// `hostname` would silently redirect later API calls to port 443.
|
||||
slug: { owner: match[1], repo: match[2], host: url.host },
|
||||
type: match[3].toLowerCase() === 'pull' ? 'pr' : 'issue',
|
||||
number
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// Why: grouping and sorting of Project rows is deterministic shared logic
|
||||
// driven by `selectedView` — it must not depend on fetch ordering. Keeping it
|
||||
// in a pure shared module lets desktop and mobile render Project views the same.
|
||||
import type {
|
||||
GitHubProjectField,
|
||||
GitHubProjectRow,
|
||||
GitHubProjectSort,
|
||||
GitHubProjectTable
|
||||
} from './project-types'
|
||||
|
||||
export type ProjectGroup = {
|
||||
/** Stable key used for React reconciliation. */
|
||||
key: string
|
||||
/** Human-readable label used in the group header. */
|
||||
label: string
|
||||
/** Iteration metadata for headers that render a date range + Current pill. */
|
||||
iteration: {
|
||||
startDate: string
|
||||
duration: number
|
||||
completed: boolean
|
||||
} | null
|
||||
rows: GitHubProjectRow[]
|
||||
}
|
||||
|
||||
const EMPTY_GROUP_KEY = '__empty__'
|
||||
|
||||
// Why: use a finite sentinel instead of Infinity so subtractions in the sort
|
||||
// comparator stay finite. `Infinity - Infinity` is NaN, which makes
|
||||
// Array.sort's behavior implementation-defined and skips later tie-breaks.
|
||||
const UNKNOWN_INDEX_SENTINEL = Number.MAX_SAFE_INTEGER
|
||||
|
||||
// Preserve the mobile mirror's fallback for partial ordering metadata.
|
||||
function getFieldValueForGrouping(
|
||||
row: GitHubProjectRow,
|
||||
field: GitHubProjectField
|
||||
): { key: string; label: string; orderHint: number; iteration: ProjectGroup['iteration'] } {
|
||||
const value = row.fieldValuesByFieldId[field.id]
|
||||
if (!value) {
|
||||
return {
|
||||
key: EMPTY_GROUP_KEY,
|
||||
label: labelForEmpty(field),
|
||||
orderHint: UNKNOWN_INDEX_SENTINEL,
|
||||
iteration: null
|
||||
}
|
||||
}
|
||||
if (field.kind === 'iteration' && value.kind === 'iteration') {
|
||||
const iterations = field.iterations ?? []
|
||||
const idx = iterations.findIndex((iteration) => iteration.id === value.iterationId)
|
||||
const meta = iterations.find((iteration) => iteration.id === value.iterationId)
|
||||
return {
|
||||
key: value.iterationId,
|
||||
label: value.title || meta?.title || 'Iteration',
|
||||
orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx,
|
||||
iteration: meta
|
||||
? { startDate: meta.startDate, duration: meta.duration, completed: meta.completed }
|
||||
: null
|
||||
}
|
||||
}
|
||||
if (field.kind === 'single-select' && value.kind === 'single-select') {
|
||||
const idx = (field.options ?? []).findIndex((option) => option.id === value.optionId)
|
||||
return {
|
||||
key: value.optionId,
|
||||
label: value.name,
|
||||
orderHint: idx === -1 ? UNKNOWN_INDEX_SENTINEL - 1 : idx,
|
||||
iteration: null
|
||||
}
|
||||
}
|
||||
const label = deriveStringValue(value)
|
||||
return { key: `raw:${label}`, label, orderHint: 0, iteration: null }
|
||||
}
|
||||
|
||||
function labelForEmpty(field: GitHubProjectField): string {
|
||||
return `No ${field.name}`
|
||||
}
|
||||
|
||||
function deriveStringValue(value: GitHubProjectRow['fieldValuesByFieldId'][string]): string {
|
||||
switch (value.kind) {
|
||||
case 'text':
|
||||
return value.text
|
||||
case 'number':
|
||||
return String(value.number)
|
||||
case 'date':
|
||||
return value.date
|
||||
case 'single-select':
|
||||
return value.name
|
||||
case 'iteration':
|
||||
return value.title
|
||||
case 'labels':
|
||||
return value.labels.map((l) => l.name).join(', ')
|
||||
case 'users':
|
||||
return value.users.map((u) => u.login).join(', ')
|
||||
}
|
||||
}
|
||||
|
||||
export function groupRows(
|
||||
table: GitHubProjectTable,
|
||||
rowsInOrder: GitHubProjectRow[]
|
||||
): ProjectGroup[] {
|
||||
const groupField = table.selectedView.groupByFields[0]
|
||||
if (!groupField) {
|
||||
return [{ key: 'all', label: '', iteration: null, rows: rowsInOrder }]
|
||||
}
|
||||
const buckets = new Map<
|
||||
string,
|
||||
{
|
||||
label: string
|
||||
orderHint: number
|
||||
iteration: ProjectGroup['iteration']
|
||||
rows: GitHubProjectRow[]
|
||||
}
|
||||
>()
|
||||
for (const row of rowsInOrder) {
|
||||
const { key, label, orderHint, iteration } = getFieldValueForGrouping(row, groupField)
|
||||
let bucket = buckets.get(key)
|
||||
if (!bucket) {
|
||||
bucket = { label, orderHint, iteration, rows: [] }
|
||||
buckets.set(key, bucket)
|
||||
}
|
||||
bucket.rows.push(row)
|
||||
}
|
||||
const entries = Array.from(buckets.entries())
|
||||
// Ordering rules per design doc §Grouping.
|
||||
entries.sort((a, b) => {
|
||||
if (a[0] === EMPTY_GROUP_KEY) {
|
||||
return 1
|
||||
}
|
||||
if (b[0] === EMPTY_GROUP_KEY) {
|
||||
return -1
|
||||
}
|
||||
if (groupField.kind === 'iteration' || groupField.kind === 'single-select') {
|
||||
return a[1].orderHint - b[1].orderHint
|
||||
}
|
||||
return a[1].label.localeCompare(b[1].label)
|
||||
})
|
||||
return entries.map(([key, v]) => ({
|
||||
key,
|
||||
label: v.label,
|
||||
iteration: v.iteration,
|
||||
rows: v.rows
|
||||
}))
|
||||
}
|
||||
|
||||
function compareSort(a: GitHubProjectRow, b: GitHubProjectRow, sort: GitHubProjectSort): number {
|
||||
const field = sort.field
|
||||
const aValue = a.fieldValuesByFieldId[field.id]
|
||||
const bValue = b.fieldValuesByFieldId[field.id]
|
||||
if (!aValue && !bValue) {
|
||||
return 0
|
||||
}
|
||||
if (!aValue) {
|
||||
return 1
|
||||
}
|
||||
if (!bValue) {
|
||||
return -1
|
||||
}
|
||||
|
||||
let cmp = 0
|
||||
if (
|
||||
field.kind === 'single-select' &&
|
||||
aValue.kind === 'single-select' &&
|
||||
bValue.kind === 'single-select'
|
||||
) {
|
||||
const options = field.options ?? []
|
||||
const aIdx = options.findIndex((option) => option.id === aValue.optionId)
|
||||
const bIdx = options.findIndex((option) => option.id === bValue.optionId)
|
||||
cmp =
|
||||
(aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx)
|
||||
} else if (
|
||||
field.kind === 'iteration' &&
|
||||
aValue.kind === 'iteration' &&
|
||||
bValue.kind === 'iteration'
|
||||
) {
|
||||
const iterations = field.iterations ?? []
|
||||
const aIdx = iterations.findIndex((iteration) => iteration.id === aValue.iterationId)
|
||||
const bIdx = iterations.findIndex((iteration) => iteration.id === bValue.iterationId)
|
||||
cmp =
|
||||
(aIdx === -1 ? UNKNOWN_INDEX_SENTINEL : aIdx) - (bIdx === -1 ? UNKNOWN_INDEX_SENTINEL : bIdx)
|
||||
} else if (aValue.kind === 'number' && bValue.kind === 'number') {
|
||||
cmp = aValue.number - bValue.number
|
||||
} else if (aValue.kind === 'date' && bValue.kind === 'date') {
|
||||
cmp = aValue.date.localeCompare(bValue.date)
|
||||
} else if (aValue.kind === 'text' && bValue.kind === 'text') {
|
||||
cmp = aValue.text.localeCompare(bValue.text)
|
||||
} else if (aValue.kind === 'users' && bValue.kind === 'users') {
|
||||
const aLogin = aValue.users[0]?.login ?? ''
|
||||
const bLogin = bValue.users[0]?.login ?? ''
|
||||
if (!aLogin && !bLogin) {
|
||||
cmp = 0
|
||||
} else if (!aLogin) {
|
||||
cmp = 1
|
||||
} else if (!bLogin) {
|
||||
cmp = -1
|
||||
} else {
|
||||
cmp = aLogin.localeCompare(bLogin)
|
||||
}
|
||||
} else if (aValue.kind === 'labels' && bValue.kind === 'labels') {
|
||||
const aName = aValue.labels[0]?.name ?? ''
|
||||
const bName = bValue.labels[0]?.name ?? ''
|
||||
if (!aName && !bName) {
|
||||
cmp = 0
|
||||
} else if (!aName) {
|
||||
cmp = 1
|
||||
} else if (!bName) {
|
||||
cmp = -1
|
||||
} else {
|
||||
cmp = aName.localeCompare(bName)
|
||||
}
|
||||
} else {
|
||||
// Why: unknown sort-field kind — ignore this sort field and fall through
|
||||
// to tie-breaks (and eventually row.position).
|
||||
return 0
|
||||
}
|
||||
return sort.direction === 'DESC' ? -cmp : cmp
|
||||
}
|
||||
|
||||
export function sortRows(table: GitHubProjectTable, rows: GitHubProjectRow[]): GitHubProjectRow[] {
|
||||
const sorts = table.selectedView.sortByFields
|
||||
const out = [...rows]
|
||||
out.sort((a, b) => {
|
||||
for (const sort of sorts) {
|
||||
const cmp = compareSort(a, b, sort)
|
||||
if (cmp !== 0) {
|
||||
return cmp
|
||||
}
|
||||
}
|
||||
return (a.position ?? UNKNOWN_INDEX_SENTINEL) - (b.position ?? UNKNOWN_INDEX_SENTINEL)
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
export function isIterationCurrent(iteration: { startDate: string; duration: number }): boolean {
|
||||
const start = new Date(`${iteration.startDate}T00:00:00Z`).getTime()
|
||||
if (Number.isNaN(start)) {
|
||||
return false
|
||||
}
|
||||
const end = start + iteration.duration * 86_400_000
|
||||
const now = Date.now()
|
||||
return now >= start && now < end
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { githubProjectHost, githubProjectIdentityKey } from './project-identity'
|
||||
|
||||
describe('GitHub project identity', () => {
|
||||
it('keeps legacy github.com keys while isolating Enterprise hosts and ports', () => {
|
||||
const project = { owner: 'Acme', ownerType: 'organization' as const, number: 7 }
|
||||
|
||||
expect(githubProjectIdentityKey(project)).toBe('organization:acme:7')
|
||||
expect(githubProjectIdentityKey({ ...project, host: 'github.com' })).toBe('organization:acme:7')
|
||||
expect(githubProjectIdentityKey({ ...project, host: ' GitHub.com ' })).toBe(
|
||||
'organization:acme:7'
|
||||
)
|
||||
expect(githubProjectIdentityKey({ ...project, host: 'GHE.EXAMPLE:8443' })).toBe(
|
||||
'ghe.example:8443:organization:acme:7'
|
||||
)
|
||||
})
|
||||
|
||||
it('pins host-less projects to github.com', () => {
|
||||
expect(githubProjectHost()).toBe('github.com')
|
||||
expect(githubProjectHost('ghe.example:8443')).toBe('ghe.example:8443')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
import { isDefaultGitHubHost } from './repository-identity-key'
|
||||
|
||||
export type GitHubProjectIdentity = {
|
||||
owner: string
|
||||
ownerType: 'organization' | 'user'
|
||||
number: number
|
||||
host?: string
|
||||
}
|
||||
|
||||
/** Stable settings/cache identity. Default-host keys intentionally retain the
|
||||
* legacy shape so existing github.com project preferences survive upgrades. */
|
||||
export function githubProjectIdentityKey(project: GitHubProjectIdentity): string {
|
||||
const projectKey = `${project.ownerType}:${project.owner.toLowerCase()}:${project.number}`
|
||||
const host = project.host?.trim().toLowerCase()
|
||||
return host && !isDefaultGitHubHost(host) ? `${host}:${projectKey}` : projectKey
|
||||
}
|
||||
|
||||
/** Project API calls must pin github.com too; otherwise process GH_HOST can
|
||||
* redirect a host-less persisted project to an Enterprise server. */
|
||||
export function githubProjectHost(host?: string | null): string {
|
||||
const trimmed = host?.trim()
|
||||
return trimmed || 'github.com'
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
GITHUB_PROJECT_REF_INPUT_MAX_BYTES,
|
||||
getGitHubProjectRefInputByteLength,
|
||||
hasBoundedGitHubProjectRefInputText,
|
||||
isGitHubProjectRefInputTooLarge
|
||||
} from './project-ref-input'
|
||||
|
||||
describe('GitHub project reference input limits', () => {
|
||||
it('allows normal project references below the byte budget', () => {
|
||||
expect(
|
||||
isGitHubProjectRefInputTooLarge('https://github.com/orgs/acme/projects/42/views/3')
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('measures UTF-8 bytes instead of JavaScript string length', () => {
|
||||
expect(getGitHubProjectRefInputByteLength('\u00e9')).toBe(2)
|
||||
})
|
||||
|
||||
it('rejects oversized pasted project references', () => {
|
||||
expect(isGitHubProjectRefInputTooLarge('x'.repeat(GITHUB_PROJECT_REF_INPUT_MAX_BYTES))).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
isGitHubProjectRefInputTooLarge('x'.repeat(GITHUB_PROJECT_REF_INPUT_MAX_BYTES + 1))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects multibyte project references whose character count is below the limit', () => {
|
||||
const reference = '😀'.repeat(Math.floor(GITHUB_PROJECT_REF_INPUT_MAX_BYTES / 4) + 1)
|
||||
|
||||
expect(reference.length).toBeLessThan(GITHUB_PROJECT_REF_INPUT_MAX_BYTES)
|
||||
expect(isGitHubProjectRefInputTooLarge(reference)).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects oversized whitespace before submit checks trim the reference', () => {
|
||||
const oversizedWhitespace = ' '.repeat(GITHUB_PROJECT_REF_INPUT_MAX_BYTES + 1)
|
||||
|
||||
expect(isGitHubProjectRefInputTooLarge(oversizedWhitespace)).toBe(true)
|
||||
expect(hasBoundedGitHubProjectRefInputText(oversizedWhitespace)).toBe(false)
|
||||
expect(hasBoundedGitHubProjectRefInputText(' acme/42 ')).toBe(true)
|
||||
expect(hasBoundedGitHubProjectRefInputText(' ')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getClipboardTextByteLength, isClipboardTextByteLengthOverLimit } from '../clipboard-text'
|
||||
|
||||
export const GITHUB_PROJECT_REF_INPUT_MAX_BYTES = 2 * 1024
|
||||
export const GITHUB_PROJECT_REF_INPUT_TOO_LARGE_ERROR = 'Project reference is too large to resolve.'
|
||||
|
||||
export function getGitHubProjectRefInputByteLength(input: string): number {
|
||||
return getClipboardTextByteLength(input)
|
||||
}
|
||||
|
||||
export function isGitHubProjectRefInputTooLarge(
|
||||
input: string,
|
||||
maxBytes = GITHUB_PROJECT_REF_INPUT_MAX_BYTES
|
||||
): boolean {
|
||||
return isClipboardTextByteLengthOverLimit(input, maxBytes)
|
||||
}
|
||||
|
||||
export function hasBoundedGitHubProjectRefInputText(input: string): boolean {
|
||||
return !isGitHubProjectRefInputTooLarge(input) && /\S/.test(input)
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
/* eslint-disable max-lines -- Why: this module is the single source of truth for ProjectV2 shapes (settings, IPC payloads, view/field/value types) shared across main, preload, and renderer; splitting risks circular type imports. */
|
||||
// Why: ProjectV2 shapes are distinct enough from the issue/PR work-item types
|
||||
// that we keep them in a dedicated module. Preload and main-process callers
|
||||
// import from here directly — do not re-export through `./types.ts` just to
|
||||
// match the existing import block; routing through the issue types module
|
||||
// would obscure ownership of the Project surface.
|
||||
import type {
|
||||
GitHubAssignableUser,
|
||||
GitHubIssueUpdate,
|
||||
GitHubWorkItemDetails,
|
||||
PRComment
|
||||
} from '../types'
|
||||
|
||||
export type GitHubProjectViewLayout = 'TABLE_LAYOUT' | 'BOARD_LAYOUT' | 'ROADMAP_LAYOUT'
|
||||
export type GitHubProjectOwnerType = 'organization' | 'user'
|
||||
|
||||
// Why: anything outside this union must render as an empty cell — the
|
||||
// normalizer must never throw on an unknown dataType. The `(string & {})`
|
||||
// branch preserves unknown values verbatim for debuggability while still
|
||||
// satisfying the distinct field-kind discriminants below.
|
||||
export type GitHubProjectFieldDataType =
|
||||
| 'TITLE'
|
||||
| 'ASSIGNEES'
|
||||
| 'LABELS'
|
||||
| 'LINKED_PULL_REQUESTS'
|
||||
| 'REVIEWERS'
|
||||
| 'REPOSITORY'
|
||||
| 'MILESTONE'
|
||||
| 'PARENT_ISSUE'
|
||||
| 'SUB_ISSUES_PROGRESS'
|
||||
| 'TRACKS'
|
||||
| 'TRACKED_BY'
|
||||
| 'ISSUE_TYPE'
|
||||
| 'TEXT'
|
||||
| 'NUMBER'
|
||||
| 'DATE'
|
||||
| 'SINGLE_SELECT'
|
||||
| 'ITERATION'
|
||||
|
||||
export type GitHubProjectSingleSelectOption = {
|
||||
id: string
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export type GitHubProjectIteration = {
|
||||
id: string
|
||||
title: string
|
||||
/** YYYY-MM-DD — GitHub returns a calendar date, not an ISO timestamp. */
|
||||
startDate: string
|
||||
/** Length in days. */
|
||||
duration: number
|
||||
/** True when GitHub returned this iteration under `completedIterations`. */
|
||||
completed: boolean
|
||||
}
|
||||
|
||||
export type GitHubProjectField =
|
||||
| {
|
||||
kind: 'field'
|
||||
id: string
|
||||
name: string
|
||||
dataType: Exclude<GitHubProjectFieldDataType, 'SINGLE_SELECT' | 'ITERATION'> | (string & {})
|
||||
}
|
||||
| {
|
||||
kind: 'single-select'
|
||||
id: string
|
||||
name: string
|
||||
dataType: 'SINGLE_SELECT'
|
||||
options: GitHubProjectSingleSelectOption[]
|
||||
}
|
||||
| {
|
||||
kind: 'iteration'
|
||||
id: string
|
||||
name: string
|
||||
dataType: 'ITERATION'
|
||||
iterations: GitHubProjectIteration[]
|
||||
}
|
||||
|
||||
export type GitHubProjectSortDirection = 'ASC' | 'DESC'
|
||||
|
||||
export type GitHubProjectSort = {
|
||||
direction: GitHubProjectSortDirection
|
||||
field: GitHubProjectField
|
||||
}
|
||||
|
||||
export type GitHubProjectView = {
|
||||
id: string
|
||||
number: number
|
||||
name: string
|
||||
layout: GitHubProjectViewLayout
|
||||
/** Normalized to '' when GitHub returns null. Why: passing null through as
|
||||
* `$q` in the items query would change the query shape between filtered
|
||||
* and unfiltered views; the empty string keeps the GraphQL shape stable. */
|
||||
filter: string
|
||||
fields: GitHubProjectField[]
|
||||
groupByFields: GitHubProjectField[]
|
||||
sortByFields: GitHubProjectSort[]
|
||||
}
|
||||
|
||||
export type GitHubProjectUser = {
|
||||
login: string
|
||||
name: string | null
|
||||
avatarUrl: string | null
|
||||
}
|
||||
|
||||
export type GitHubProjectLabel = {
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
|
||||
export type GitHubProjectParentIssue = {
|
||||
number: number
|
||||
title: string
|
||||
url: string
|
||||
}
|
||||
|
||||
// Why: GitHub Issue Types are a repo-level taxonomy (Bug/Feature/Task/etc).
|
||||
// Only repos opted into typed-issues expose a non-empty list. We carry both
|
||||
// id and human-readable name so the picker can reflect updates without a
|
||||
// re-fetch and the cell can render the chosen name with its color.
|
||||
export type GitHubIssueType = {
|
||||
id: string
|
||||
name: string
|
||||
color: string | null
|
||||
description: string | null
|
||||
}
|
||||
|
||||
export type GitHubProjectFieldValue =
|
||||
| {
|
||||
kind: 'single-select'
|
||||
fieldId: string
|
||||
optionId: string
|
||||
name: string
|
||||
color: string
|
||||
}
|
||||
| {
|
||||
kind: 'iteration'
|
||||
fieldId: string
|
||||
iterationId: string
|
||||
title: string
|
||||
startDate: string
|
||||
duration: number
|
||||
}
|
||||
| { kind: 'text'; fieldId: string; text: string }
|
||||
| { kind: 'number'; fieldId: string; number: number }
|
||||
| { kind: 'date'; fieldId: string; date: string }
|
||||
| { kind: 'labels'; fieldId: string; labels: GitHubProjectLabel[] }
|
||||
| { kind: 'users'; fieldId: string; users: GitHubProjectUser[] }
|
||||
|
||||
export type GitHubProjectRowItemType = 'ISSUE' | 'PULL_REQUEST' | 'DRAFT_ISSUE' | 'REDACTED'
|
||||
|
||||
export type GitHubProjectRow = {
|
||||
id: string
|
||||
itemType: GitHubProjectRowItemType
|
||||
content: {
|
||||
number: number | null
|
||||
title: string
|
||||
/** DraftIssue body and optional detail-cache patch target; list rows do
|
||||
* not render issue/PR body. */
|
||||
body: string | null
|
||||
url: string | null
|
||||
state: string | null
|
||||
/** Issue stateReason; null for PR/draft. Why: closed-as-not-planned needs
|
||||
* a different glyph than a regular closed issue. */
|
||||
stateReason: string | null
|
||||
/** PullRequest.isDraft; null otherwise. */
|
||||
isDraft: boolean | null
|
||||
/** nameWithOwner, e.g. 'stablyai/orca'. */
|
||||
repository: string | null
|
||||
assignees: GitHubProjectUser[]
|
||||
labels: GitHubProjectLabel[]
|
||||
parentIssue: GitHubProjectParentIssue | null
|
||||
/** Issue.issueType when set; null on PRs/drafts/redacted or when unset. */
|
||||
issueType: GitHubIssueType | null
|
||||
}
|
||||
fieldValuesByFieldId: Record<string, GitHubProjectFieldValue>
|
||||
updatedAt: string
|
||||
/** Original fetched order (zero-based index in the fully paginated
|
||||
* POSITION ASC stream). Used as the final tie-break so equal sort values
|
||||
* keep GitHub rank order. */
|
||||
position: number
|
||||
}
|
||||
|
||||
export type GitHubProjectTable = {
|
||||
project: {
|
||||
id: string
|
||||
host?: string
|
||||
owner: string
|
||||
ownerType: GitHubProjectOwnerType
|
||||
number: number
|
||||
title: string
|
||||
url: string
|
||||
}
|
||||
selectedView: GitHubProjectView
|
||||
rows: GitHubProjectRow[]
|
||||
/** Echoes ProjectV2.items.totalCount for the view filter. */
|
||||
totalCount: number
|
||||
/** True when the `parent` retry fallback fired. The UI can hint
|
||||
* "sub-issues unavailable" without claiming a hard error. */
|
||||
parentFieldDropped: boolean
|
||||
}
|
||||
|
||||
export type GitHubProjectSummary = {
|
||||
id: string
|
||||
host?: string
|
||||
owner: string
|
||||
ownerType: GitHubProjectOwnerType
|
||||
number: number
|
||||
title: string
|
||||
url: string
|
||||
source: 'viewer' | `org:${string}`
|
||||
}
|
||||
|
||||
export type GitHubProjectViewSummary = {
|
||||
id: string
|
||||
number: number
|
||||
name: string
|
||||
layout: GitHubProjectViewLayout
|
||||
}
|
||||
|
||||
export type GitHubProjectSettings = {
|
||||
pinned: { owner: string; ownerType: GitHubProjectOwnerType; number: number; host?: string }[]
|
||||
recent: {
|
||||
owner: string
|
||||
ownerType: GitHubProjectOwnerType
|
||||
number: number
|
||||
host?: string
|
||||
lastOpenedAt: string
|
||||
}[]
|
||||
lastViewByProject: Record<string, { viewId: string }>
|
||||
activeProject: {
|
||||
owner: string
|
||||
ownerType: GitHubProjectOwnerType
|
||||
number: number
|
||||
host?: string
|
||||
} | null
|
||||
}
|
||||
|
||||
// ─── Classified errors ─────────────────────────────────────────────────
|
||||
|
||||
export type GitHubProjectViewErrorType =
|
||||
| 'auth_required'
|
||||
| 'scope_missing'
|
||||
| 'not_found'
|
||||
| 'unsupported_layout'
|
||||
| 'too_large'
|
||||
| 'schema_drift'
|
||||
| 'validation_error'
|
||||
| 'network_error'
|
||||
| 'rate_limited'
|
||||
| 'unknown'
|
||||
|
||||
export type GitHubProjectViewError = {
|
||||
type: GitHubProjectViewErrorType
|
||||
message: string
|
||||
/** Populated when the error is classifiable from a GraphQL response. Never
|
||||
* includes tokens or full command stdout. */
|
||||
details?: { path?: (string | number)[]; code?: string }
|
||||
}
|
||||
|
||||
export type GetProjectViewTableResult =
|
||||
| { ok: true; data: GitHubProjectTable }
|
||||
| {
|
||||
ok: false
|
||||
error: GitHubProjectViewError
|
||||
/** Populated for the `too_large` case and best-effort for
|
||||
* `unsupported_layout` when a cheap count-only query succeeds. */
|
||||
totalCount?: number
|
||||
}
|
||||
|
||||
export type ListAccessibleProjectsResult =
|
||||
| {
|
||||
ok: true
|
||||
projects: GitHubProjectSummary[]
|
||||
/** Why: per-org discovery can partially fail (a single org 504s while
|
||||
* the rest succeed). The picker renders a banner listing the affected
|
||||
* org logins so the user knows their list is incomplete and can paste
|
||||
* a URL to reach missing projects. Empty when discovery was clean. */
|
||||
partialFailures?: { owner: string; message: string }[]
|
||||
}
|
||||
| { ok: false; error: GitHubProjectViewError }
|
||||
|
||||
export type ResolveProjectRefResult =
|
||||
| {
|
||||
ok: true
|
||||
owner: string
|
||||
ownerType: GitHubProjectOwnerType
|
||||
number: number
|
||||
title: string
|
||||
host?: string
|
||||
// Why: when the input is a /views/{n} URL, surface the parsed view
|
||||
// number so the picker can skip the view-pick step and commit the
|
||||
// selection directly. Absent for owner/number shorthand and project
|
||||
// URLs without a /views/ segment.
|
||||
viewNumber?: number
|
||||
}
|
||||
| { ok: false; error: GitHubProjectViewError }
|
||||
|
||||
export type ListProjectViewsResult =
|
||||
| { ok: true; views: GitHubProjectViewSummary[] }
|
||||
| { ok: false; error: GitHubProjectViewError }
|
||||
|
||||
export type ProjectWorkItemDetailsBySlugResult =
|
||||
| { ok: true; details: GitHubWorkItemDetails }
|
||||
| { ok: false; error: GitHubProjectViewError }
|
||||
|
||||
// ─── Mutations ─────────────────────────────────────────────────────────
|
||||
|
||||
export type GitHubProjectMutationResult =
|
||||
| { ok: true }
|
||||
| { ok: false; error: GitHubProjectViewError }
|
||||
|
||||
export type GitHubProjectCommentMutationResult =
|
||||
| { ok: true; comment: PRComment }
|
||||
| { ok: false; error: GitHubProjectViewError }
|
||||
|
||||
export type GitHubProjectFieldMutationValue =
|
||||
| { kind: 'single-select'; optionId: string }
|
||||
| { kind: 'iteration'; iterationId: string }
|
||||
| { kind: 'text'; text: string }
|
||||
| { kind: 'number'; number: number }
|
||||
/** YYYY-MM-DD. */
|
||||
| { kind: 'date'; date: string }
|
||||
|
||||
export type ListLabelsBySlugResult =
|
||||
| { ok: true; labels: string[] }
|
||||
| { ok: false; error: GitHubProjectViewError }
|
||||
|
||||
export type ListAssignableUsersBySlugResult =
|
||||
| { ok: true; users: GitHubAssignableUser[] }
|
||||
| { ok: false; error: GitHubProjectViewError }
|
||||
|
||||
export type ListIssueTypesBySlugResult =
|
||||
| { ok: true; types: GitHubIssueType[] }
|
||||
| { ok: false; error: GitHubProjectViewError }
|
||||
|
||||
// ─── IPC arg shapes (shared between main, preload, renderer) ──────────
|
||||
|
||||
export type GetProjectViewTableArgs = {
|
||||
owner: string
|
||||
ownerType: GitHubProjectOwnerType
|
||||
projectNumber: number
|
||||
/** GitHub host (e.g. GHES); absent means github.com. */
|
||||
host?: string
|
||||
/** View selection precedence: viewId > viewNumber > viewName > first
|
||||
* TABLE_LAYOUT view. */
|
||||
viewId?: string
|
||||
viewNumber?: number
|
||||
viewName?: string
|
||||
/** Ephemeral GitHub-search-syntax query that replaces the view's filter for
|
||||
* this fetch only. The view's stored filter on GitHub is not modified.
|
||||
* `undefined` uses the view's saved filter; `''` explicitly clears it for
|
||||
* this fetch and gets a distinct renderer cache key. */
|
||||
queryOverride?: string
|
||||
}
|
||||
|
||||
export type ProjectWorkItemDetailsBySlugArgs = {
|
||||
owner: string
|
||||
repo: string
|
||||
host?: string
|
||||
number: number
|
||||
type: 'issue' | 'pr'
|
||||
}
|
||||
|
||||
export type UpdateProjectItemFieldArgs = {
|
||||
projectId: string
|
||||
host?: string
|
||||
itemId: string
|
||||
fieldId: string
|
||||
value: GitHubProjectFieldMutationValue
|
||||
}
|
||||
|
||||
export type ClearProjectItemFieldArgs = {
|
||||
projectId: string
|
||||
host?: string
|
||||
itemId: string
|
||||
fieldId: string
|
||||
}
|
||||
|
||||
export type UpdateIssueBySlugArgs = {
|
||||
owner: string
|
||||
repo: string
|
||||
host?: string
|
||||
number: number
|
||||
updates: GitHubIssueUpdate & { body?: string }
|
||||
}
|
||||
|
||||
export type UpdatePullRequestBySlugArgs = {
|
||||
owner: string
|
||||
repo: string
|
||||
host?: string
|
||||
number: number
|
||||
updates: { title?: string; body?: string; state?: 'open' | 'closed' }
|
||||
}
|
||||
|
||||
export type AddIssueCommentBySlugArgs = {
|
||||
owner: string
|
||||
repo: string
|
||||
host?: string
|
||||
number: number
|
||||
body: string
|
||||
}
|
||||
|
||||
export type UpdateIssueCommentBySlugArgs = {
|
||||
owner: string
|
||||
repo: string
|
||||
host?: string
|
||||
commentId: number
|
||||
body: string
|
||||
}
|
||||
|
||||
export type DeleteIssueCommentBySlugArgs = {
|
||||
owner: string
|
||||
repo: string
|
||||
host?: string
|
||||
commentId: number
|
||||
}
|
||||
|
||||
export type ListLabelsBySlugArgs = {
|
||||
owner: string
|
||||
repo: string
|
||||
host?: string
|
||||
}
|
||||
|
||||
export type ListAssignableUsersBySlugArgs = {
|
||||
owner: string
|
||||
repo: string
|
||||
host?: string
|
||||
seedLogins?: string[]
|
||||
}
|
||||
|
||||
export type ListIssueTypesBySlugArgs = {
|
||||
owner: string
|
||||
repo: string
|
||||
host?: string
|
||||
}
|
||||
|
||||
export type UpdateIssueTypeBySlugArgs = {
|
||||
owner: string
|
||||
repo: string
|
||||
host?: string
|
||||
number: number
|
||||
/** null clears the issue type. */
|
||||
issueTypeId: string | null
|
||||
}
|
||||
|
||||
export type ResolveProjectRefArgs = {
|
||||
input: string
|
||||
host?: string
|
||||
}
|
||||
|
||||
export type ListProjectViewsArgs = {
|
||||
owner: string
|
||||
ownerType: GitHubProjectOwnerType
|
||||
projectNumber: number
|
||||
host?: string
|
||||
}
|
||||
|
||||
export type ListAccessibleProjectsArgs = {
|
||||
/** GitHub host (e.g. GHES); absent means github.com. */
|
||||
host?: string
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
canEnableGitHubPRAutoMerge,
|
||||
canShowGitHubPRAutoMergeControl,
|
||||
type GitHubPRAutoMergeAvailabilityInput
|
||||
} from './pull-request-auto-merge-availability'
|
||||
|
||||
function pr(
|
||||
overrides: Partial<GitHubPRAutoMergeAvailabilityInput> = {}
|
||||
): GitHubPRAutoMergeAvailabilityInput {
|
||||
return {
|
||||
state: 'open',
|
||||
mergeable: 'MERGEABLE',
|
||||
mergeStateStatus: 'CLEAN',
|
||||
autoMergeAllowed: true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('github PR auto-merge availability', () => {
|
||||
it('does not offer auto-merge for directly mergeable PRs with only optional checks pending', () => {
|
||||
expect(canEnableGitHubPRAutoMerge(pr())).toBe(false)
|
||||
expect(canShowGitHubPRAutoMergeControl(pr())).toBe(false)
|
||||
})
|
||||
|
||||
it('offers auto-merge for requirement-blocked PRs', () => {
|
||||
expect(
|
||||
canEnableGitHubPRAutoMerge(
|
||||
pr({ mergeable: 'UNKNOWN', mergeStateStatus: 'BLOCKED', reviewDecision: 'REVIEW_REQUIRED' })
|
||||
)
|
||||
).toBe(true)
|
||||
expect(canEnableGitHubPRAutoMerge(pr({ mergeStateStatus: 'BLOCKED' }))).toBe(true)
|
||||
expect(
|
||||
canShowGitHubPRAutoMergeControl(pr({ mergeable: 'UNKNOWN', mergeStateStatus: 'BLOCKED' }))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps merge-queue branches available for merge-when-ready', () => {
|
||||
expect(canEnableGitHubPRAutoMerge(pr({ mergeQueueRequired: true }))).toBe(false)
|
||||
expect(canShowGitHubPRAutoMergeControl(pr({ mergeQueueRequired: true }))).toBe(true)
|
||||
expect(
|
||||
canShowGitHubPRAutoMergeControl(pr({ autoMergeAllowed: false, mergeQueueRequired: true }))
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps enabled auto-merge visible so users can disable it', () => {
|
||||
expect(canEnableGitHubPRAutoMerge(pr({ autoMergeEnabled: true }))).toBe(false)
|
||||
expect(canShowGitHubPRAutoMergeControl(pr({ autoMergeEnabled: true }))).toBe(true)
|
||||
})
|
||||
|
||||
it('suppresses closed, draft, disallowed, conflicting, and unstable PRs', () => {
|
||||
expect(
|
||||
canShowGitHubPRAutoMergeControl(pr({ state: 'draft', mergeStateStatus: 'BLOCKED' }))
|
||||
).toBe(false)
|
||||
expect(canShowGitHubPRAutoMergeControl(pr({ state: 'closed', autoMergeEnabled: true }))).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
canShowGitHubPRAutoMergeControl(
|
||||
pr({ autoMergeAllowed: false, mergeable: 'UNKNOWN', mergeStateStatus: 'BLOCKED' })
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
canShowGitHubPRAutoMergeControl(pr({ mergeable: 'CONFLICTING', mergeStateStatus: 'DIRTY' }))
|
||||
).toBe(false)
|
||||
expect(
|
||||
canEnableGitHubPRAutoMerge(pr({ mergeable: 'UNKNOWN', mergeStateStatus: 'UNSTABLE' }))
|
||||
).toBe(false)
|
||||
expect(
|
||||
canShowGitHubPRAutoMergeControl(pr({ mergeable: 'UNKNOWN', mergeStateStatus: 'UNSTABLE' }))
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { PRMergeableState, PRReviewDecision, PRState } from '../types'
|
||||
|
||||
export type GitHubPRAutoMergeAvailabilityInput = {
|
||||
state: PRState | 'open' | 'closed' | 'merged' | 'draft'
|
||||
mergeable?: PRMergeableState
|
||||
mergeStateStatus?: string | null
|
||||
reviewDecision?: PRReviewDecision | null
|
||||
autoMergeEnabled?: boolean
|
||||
autoMergeAllowed?: boolean | null
|
||||
mergeQueueRequired?: boolean | null
|
||||
}
|
||||
|
||||
function isOpenPR(item: GitHubPRAutoMergeAvailabilityInput): boolean {
|
||||
return item.state === 'open'
|
||||
}
|
||||
|
||||
function isConflicting(item: GitHubPRAutoMergeAvailabilityInput): boolean {
|
||||
return item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'DIRTY'
|
||||
}
|
||||
|
||||
function isUnstable(item: GitHubPRAutoMergeAvailabilityInput): boolean {
|
||||
return item.mergeStateStatus === 'UNSTABLE'
|
||||
}
|
||||
|
||||
function hasReviewRequirement(item: GitHubPRAutoMergeAvailabilityInput): boolean {
|
||||
return item.reviewDecision === 'REVIEW_REQUIRED' || item.reviewDecision === 'CHANGES_REQUESTED'
|
||||
}
|
||||
|
||||
function canMergeImmediately(item: GitHubPRAutoMergeAvailabilityInput): boolean {
|
||||
if (item.mergeStateStatus === 'BLOCKED' || item.mergeStateStatus === 'BEHIND') {
|
||||
return false
|
||||
}
|
||||
return item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN'
|
||||
}
|
||||
|
||||
function canRequestWhenReady(item: GitHubPRAutoMergeAvailabilityInput): boolean {
|
||||
// Why: GitHub auto-merge waits on unmet requirements; UNSTABLE is rejected
|
||||
// by the mutation rather than becoming a waitable auto-merge request.
|
||||
if (!isOpenPR(item) || isConflicting(item) || isUnstable(item)) {
|
||||
return false
|
||||
}
|
||||
if (item.mergeQueueRequired === true) {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
item.autoMergeAllowed !== false && (hasReviewRequirement(item) || !canMergeImmediately(item))
|
||||
)
|
||||
}
|
||||
|
||||
export function canEnableGitHubPRAutoMerge(item: GitHubPRAutoMergeAvailabilityInput): boolean {
|
||||
return (
|
||||
item.autoMergeEnabled !== true && item.mergeQueueRequired !== true && canRequestWhenReady(item)
|
||||
)
|
||||
}
|
||||
|
||||
export function canShowGitHubPRAutoMergeControl(item: GitHubPRAutoMergeAvailabilityInput): boolean {
|
||||
// Why: GitHub auto-merge waits for branch requirements, not arbitrary optional CI.
|
||||
// Keep already-enabled PRs visible so users can disable the setting.
|
||||
return isOpenPR(item) && (item.autoMergeEnabled === true || canRequestWhenReady(item))
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PRInfo, PRRefreshOutcome } from '../types'
|
||||
import { normalizeGitHubPRForBranchOutcome } from './pull-request-for-branch-outcome'
|
||||
|
||||
const PR = {
|
||||
number: 42,
|
||||
title: 'Feature',
|
||||
state: 'merged',
|
||||
url: 'https://github.com/acme/orca/pull/42',
|
||||
checksStatus: 'success',
|
||||
updatedAt: '2026-08-04T22:46:08Z',
|
||||
mergeable: 'UNKNOWN'
|
||||
} as PRInfo
|
||||
|
||||
describe('normalizeGitHubPRForBranchOutcome', () => {
|
||||
it('preserves current classified outcomes', () => {
|
||||
const outcome: PRRefreshOutcome = { kind: 'found', pr: PR, fetchedAt: 10 }
|
||||
expect(normalizeGitHubPRForBranchOutcome(outcome, 20)).toBe(outcome)
|
||||
})
|
||||
|
||||
it('normalizes legacy PRInfo and null responses', () => {
|
||||
expect(normalizeGitHubPRForBranchOutcome(PR, 20)).toEqual({
|
||||
kind: 'found',
|
||||
pr: PR,
|
||||
fetchedAt: 20
|
||||
})
|
||||
expect(normalizeGitHubPRForBranchOutcome(null, 20)).toEqual({
|
||||
kind: 'no-pr',
|
||||
fetchedAt: 20
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves classified upstream errors', () => {
|
||||
const outcome: PRRefreshOutcome = {
|
||||
kind: 'upstream-error',
|
||||
errorType: 'network',
|
||||
message: 'network unavailable',
|
||||
fetchedAt: 10
|
||||
}
|
||||
expect(normalizeGitHubPRForBranchOutcome(outcome, 20)).toBe(outcome)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { PRInfo, PRRefreshOutcome } from '../types'
|
||||
|
||||
export type GitHubPRForBranchResponse = PRRefreshOutcome | PRInfo | null
|
||||
|
||||
// Legacy hosts return PRInfo|null; current hosts return a classified refresh outcome.
|
||||
export function normalizeGitHubPRForBranchOutcome(
|
||||
response: GitHubPRForBranchResponse,
|
||||
fetchedAt = Date.now()
|
||||
): PRRefreshOutcome {
|
||||
if (response && typeof response === 'object' && 'kind' in response) {
|
||||
return response
|
||||
}
|
||||
return response ? { kind: 'found', pr: response, fetchedAt } : { kind: 'no-pr', fetchedAt }
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
normalizeGitHubPRMergeMethodSettings,
|
||||
resolveGitHubPRMergeMethods
|
||||
} from './pull-request-merge-methods'
|
||||
|
||||
describe('GitHub PR merge methods', () => {
|
||||
it('keeps the historical squash-first fallback when repository metadata is missing', () => {
|
||||
expect(resolveGitHubPRMergeMethods()).toEqual({
|
||||
defaultMethod: 'squash',
|
||||
defaultLabel: 'Squash and merge',
|
||||
methods: [
|
||||
{ method: 'squash', label: 'Squash and merge' },
|
||||
{ method: 'merge', label: 'Create merge commit' },
|
||||
{ method: 'rebase', label: 'Rebase and merge' }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('uses GitHub viewer defaults and hides methods disabled by the repository', () => {
|
||||
const settings = normalizeGitHubPRMergeMethodSettings({
|
||||
defaultMethod: 'REBASE',
|
||||
mergeCommitAllowed: false,
|
||||
rebaseMergeAllowed: true,
|
||||
squashMergeAllowed: true
|
||||
})
|
||||
|
||||
expect(resolveGitHubPRMergeMethods(settings)).toEqual({
|
||||
defaultMethod: 'rebase',
|
||||
defaultLabel: 'Rebase and merge',
|
||||
methods: [
|
||||
{ method: 'rebase', label: 'Rebase and merge' },
|
||||
{ method: 'squash', label: 'Squash and merge' }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to an allowed method when GitHub returns a disabled default', () => {
|
||||
const settings = normalizeGitHubPRMergeMethodSettings({
|
||||
defaultMethod: 'SQUASH',
|
||||
mergeCommitAllowed: true,
|
||||
rebaseMergeAllowed: false,
|
||||
squashMergeAllowed: false
|
||||
})
|
||||
|
||||
expect(settings?.defaultMethod).toBe('merge')
|
||||
expect(resolveGitHubPRMergeMethods(settings).methods).toEqual([
|
||||
{ method: 'merge', label: 'Create merge commit' }
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { GitHubPRMergeMethod, GitHubPRMergeMethodSettings } from '../types'
|
||||
|
||||
export const GITHUB_PR_MERGE_METHODS = ['squash', 'merge', 'rebase'] as const
|
||||
|
||||
export const GITHUB_PR_MERGE_METHOD_LABELS: Record<GitHubPRMergeMethod, string> = {
|
||||
squash: 'Squash and merge',
|
||||
merge: 'Create merge commit',
|
||||
rebase: 'Rebase and merge'
|
||||
}
|
||||
|
||||
export type GitHubPRMergeMethodOption = {
|
||||
method: GitHubPRMergeMethod
|
||||
label: string
|
||||
}
|
||||
|
||||
export type GitHubPRMergeMethodPresentation = {
|
||||
defaultMethod: GitHubPRMergeMethod
|
||||
defaultLabel: string
|
||||
methods: GitHubPRMergeMethodOption[]
|
||||
}
|
||||
|
||||
function allMethodsAllowed(): Record<GitHubPRMergeMethod, boolean> {
|
||||
return {
|
||||
squash: true,
|
||||
merge: true,
|
||||
rebase: true
|
||||
}
|
||||
}
|
||||
|
||||
export function mapGitHubDefaultMergeMethod(value: unknown): GitHubPRMergeMethod | null {
|
||||
switch (typeof value === 'string' ? value.toUpperCase() : '') {
|
||||
case 'MERGE':
|
||||
return 'merge'
|
||||
case 'SQUASH':
|
||||
return 'squash'
|
||||
case 'REBASE':
|
||||
return 'rebase'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeGitHubPRMergeMethodSettings(args: {
|
||||
defaultMethod: unknown
|
||||
mergeCommitAllowed: unknown
|
||||
rebaseMergeAllowed: unknown
|
||||
squashMergeAllowed: unknown
|
||||
}): GitHubPRMergeMethodSettings | undefined {
|
||||
const allowedMethods = {
|
||||
squash: args.squashMergeAllowed === true,
|
||||
merge: args.mergeCommitAllowed === true,
|
||||
rebase: args.rebaseMergeAllowed === true
|
||||
}
|
||||
const defaultMethod = mapGitHubDefaultMergeMethod(args.defaultMethod)
|
||||
const firstAllowedMethod = GITHUB_PR_MERGE_METHODS.find((method) => allowedMethods[method])
|
||||
const resolvedDefault =
|
||||
defaultMethod && allowedMethods[defaultMethod]
|
||||
? defaultMethod
|
||||
: (firstAllowedMethod ?? defaultMethod)
|
||||
if (!resolvedDefault) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
defaultMethod: resolvedDefault,
|
||||
allowedMethods
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveGitHubPRMergeMethods(
|
||||
settings?: GitHubPRMergeMethodSettings | null
|
||||
): GitHubPRMergeMethodPresentation {
|
||||
const allowedMethods = settings?.allowedMethods ?? allMethodsAllowed()
|
||||
const firstAllowedMethod = GITHUB_PR_MERGE_METHODS.find((method) => allowedMethods[method])
|
||||
const defaultMethod =
|
||||
settings?.defaultMethod && allowedMethods[settings.defaultMethod]
|
||||
? settings.defaultMethod
|
||||
: (firstAllowedMethod ?? 'squash')
|
||||
const orderedMethods = [
|
||||
defaultMethod,
|
||||
...GITHUB_PR_MERGE_METHODS.filter((method) => method !== defaultMethod)
|
||||
].filter((method) => allowedMethods[method])
|
||||
const methods = (orderedMethods.length > 0 ? orderedMethods : GITHUB_PR_MERGE_METHODS).map(
|
||||
(method) => ({
|
||||
method,
|
||||
label: GITHUB_PR_MERGE_METHOD_LABELS[method]
|
||||
})
|
||||
)
|
||||
return {
|
||||
defaultMethod,
|
||||
defaultLabel: GITHUB_PR_MERGE_METHOD_LABELS[defaultMethod],
|
||||
methods
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { RepoKind } from '../repo-types'
|
||||
import type { CheckStatus, PRInfo, PRMergeableState, PRState } from './pull-request-types'
|
||||
|
||||
/**
|
||||
* Discriminates a classified GitHub PR-refresh failure. The renderer maps these
|
||||
* to stable, non-destructive empty-state copy; a `hard` subset (auth, permission,
|
||||
* repo_unavailable, gh_unavailable) means the existing-review lookup is currently
|
||||
* impossible and must hide the Create composer.
|
||||
*/
|
||||
export type PRRefreshErrorType =
|
||||
| 'rate_limited'
|
||||
| 'auth'
|
||||
| 'network'
|
||||
| 'permission'
|
||||
| 'repo_unavailable'
|
||||
| 'gh_unavailable'
|
||||
| 'server_error'
|
||||
| 'unknown'
|
||||
|
||||
// Backward-compatible name used by outage-copy consumers added on main.
|
||||
export type PRRefreshUpstreamErrorType = PRRefreshErrorType
|
||||
|
||||
export type PRRefreshOutcome =
|
||||
| { kind: 'found'; pr: PRInfo; fetchedAt: number }
|
||||
| { kind: 'no-pr'; fetchedAt: number }
|
||||
| {
|
||||
kind: 'upstream-error'
|
||||
errorType: PRRefreshErrorType
|
||||
message: string
|
||||
fetchedAt: number
|
||||
// Unified retry schedule (see docs/reference/pr-panel-refresh-guidance.md).
|
||||
// `nextAutoRetryAt`: earliest time main expects to auto-retry this key.
|
||||
// `retryDisabledUntil`: earliest time a manual Retry / refreshPRNow is
|
||||
// accepted (rate-limit gates only, never ordinary network/auth backoff).
|
||||
nextAutoRetryAt?: number
|
||||
retryDisabledUntil?: number
|
||||
}
|
||||
|
||||
export type GitHubPRRefreshReason = 'visible' | 'active' | 'post-push' | 'manual' | 'swr'
|
||||
|
||||
export type GitHubPRRefreshEnqueueResult =
|
||||
| { kind: 'queued' }
|
||||
| { kind: 'skipped'; skippedReason: 'validation-denied' | 'validation-backoff' }
|
||||
| { kind: 'fallback' }
|
||||
|
||||
export type GitHubPRRefreshAlias = {
|
||||
cacheKey: string
|
||||
repoId?: string
|
||||
repoPath: string
|
||||
branch: string
|
||||
worktreeId?: string
|
||||
connectionId?: string | null
|
||||
executionHostId?: string | null
|
||||
linkedPRNumber?: number | null
|
||||
fallbackPRNumber?: number | null
|
||||
fallbackPRSource?: 'explicit' | 'pr-cache' | 'hosted-review' | null
|
||||
// Why: request-time worktree HEAD. Merged branch-matched PRs are only visible
|
||||
// for heads that belong to the PR, and refresh consumers need this snapshot to
|
||||
// clear a durable linked PR once main confirms the head diverged.
|
||||
currentHeadOid?: string | null
|
||||
}
|
||||
|
||||
export type GitHubPRRefreshCandidate = GitHubPRRefreshAlias & {
|
||||
repoKind: RepoKind
|
||||
repoId: string
|
||||
isBare?: boolean
|
||||
isArchived?: boolean
|
||||
connectionId?: string | null
|
||||
executionHostId?: string | null
|
||||
connectionState?: 'connected' | 'disconnected' | 'unknown'
|
||||
cachedFetchedAt?: number | null
|
||||
cachedHasPR?: boolean | null
|
||||
cachedPRState?: PRState | null
|
||||
cachedChecksStatus?: CheckStatus | null
|
||||
cachedMergeable?: PRMergeableState | null
|
||||
cachedMergeStateStatus?: string | null
|
||||
localGitOptions?: { wslDistro?: string }
|
||||
}
|
||||
|
||||
export type GitHubPRRefreshSkippedReason =
|
||||
| 'fresh'
|
||||
| 'not-git'
|
||||
| 'bare'
|
||||
| 'archived'
|
||||
| 'disconnected'
|
||||
| 'remote'
|
||||
| 'rate-limit'
|
||||
| 'capacity'
|
||||
|
||||
type GitHubPRRefreshEventBase = {
|
||||
sequence: number
|
||||
reason: GitHubPRRefreshReason
|
||||
aliases: GitHubPRRefreshAlias[]
|
||||
requestStartedAt?: number
|
||||
}
|
||||
|
||||
export type GitHubPRRefreshEvent =
|
||||
| (GitHubPRRefreshEventBase & {
|
||||
outcome: PRRefreshOutcome
|
||||
status?: never
|
||||
pausedUntil?: never
|
||||
skippedReason?: never
|
||||
})
|
||||
| (GitHubPRRefreshEventBase & {
|
||||
status: 'queued' | 'in-flight'
|
||||
outcome?: never
|
||||
pausedUntil?: never
|
||||
skippedReason?: never
|
||||
})
|
||||
| (GitHubPRRefreshEventBase & {
|
||||
status: 'paused'
|
||||
pausedUntil: number
|
||||
skippedReason: 'rate-limit'
|
||||
outcome?: never
|
||||
})
|
||||
| (GitHubPRRefreshEventBase & {
|
||||
status: 'skipped'
|
||||
skippedReason: GitHubPRRefreshSkippedReason
|
||||
outcome?: never
|
||||
pausedUntil?: never
|
||||
})
|
||||
@@ -0,0 +1,157 @@
|
||||
export type PRState = 'open' | 'closed' | 'merged' | 'draft'
|
||||
export type IssueState = 'open' | 'closed'
|
||||
export type CheckStatus = 'pending' | 'success' | 'failure' | 'neutral'
|
||||
|
||||
export type PRMergeableState = 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'
|
||||
export type PRReviewDecision = 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED'
|
||||
|
||||
export type PRConflictSummary = {
|
||||
baseRef: string
|
||||
baseCommit: string
|
||||
commitsBehind: number
|
||||
files: string[]
|
||||
localMergeState?: 'clean'
|
||||
}
|
||||
|
||||
// Why: host must survive renderer/RPC boundaries so Enterprise review actions
|
||||
// cannot silently fall back to a same-named repository on github.com.
|
||||
export type GitHubRepositoryIdentity = { owner: string; repo: string; host?: string }
|
||||
|
||||
export type GitHubPRMergeMethod = 'merge' | 'squash' | 'rebase'
|
||||
|
||||
export type GitHubPRMergeMethodSettings = {
|
||||
defaultMethod: GitHubPRMergeMethod
|
||||
allowedMethods: Record<GitHubPRMergeMethod, boolean>
|
||||
}
|
||||
|
||||
export type GitHubPRStackEntry = {
|
||||
position: number
|
||||
number: number
|
||||
title: string
|
||||
url: string
|
||||
updatedAt?: string
|
||||
state: PRState
|
||||
checksStatus: CheckStatus
|
||||
mergeable: PRMergeableState
|
||||
reviewDecision?: PRReviewDecision | null
|
||||
mergeStateStatus?: string | null
|
||||
headRefName?: string
|
||||
headSha?: string
|
||||
}
|
||||
|
||||
export type GitHubPRStack = {
|
||||
number: number
|
||||
position: number
|
||||
size: number
|
||||
baseRefName: string
|
||||
baseSha?: string
|
||||
entries?: GitHubPRStackEntry[]
|
||||
}
|
||||
|
||||
export type PRInfo = {
|
||||
number: number
|
||||
title: string
|
||||
state: PRState
|
||||
url: string
|
||||
checksStatus: CheckStatus
|
||||
updatedAt: string
|
||||
mergeable: PRMergeableState
|
||||
reviewDecision?: PRReviewDecision | null
|
||||
autoMergeEnabled?: boolean
|
||||
autoMergeAllowed?: boolean | null
|
||||
mergeQueueRequired?: boolean | null
|
||||
mergeMethodSettings?: GitHubPRMergeMethodSettings
|
||||
mergeStateStatus?: string | null
|
||||
/** GitHub-registered stack metadata. Absent for ordinary dependent PR chains. */
|
||||
stack?: GitHubPRStack
|
||||
// Why: check-runs are keyed by the PR head commit, not the mutable branch name.
|
||||
// Keeping the head SHA in cached PR metadata lets the checks panel poll the
|
||||
// correct commit without re-querying GitHub or guessing from local branch refs.
|
||||
headSha?: string
|
||||
// Why: a merged branch-matched PR stays visible when the worktree head is one
|
||||
// of the PR's own commits (behind update-branch/web commits). Cache staleness
|
||||
// checks must honor that confirmation without re-querying GitHub.
|
||||
confirmedContainedHeadOid?: string
|
||||
// Why: the worktree HEAD OID this merged linked PR was confirmed to have
|
||||
// diverged from (a definite not-contained probe). Head-scoped, not a bare
|
||||
// boolean, so a PR-number-coalesced refresh broadcast cannot clear a sibling
|
||||
// worktree whose own head is still on the PR's line of work. Clearing a
|
||||
// durable linked PR requires this positive signal for that exact head, never
|
||||
// the mere absence of a containment confirmation after a rate-limit/error.
|
||||
headDivergedFromMergedPRAtOid?: string
|
||||
/** Target branch name for PR-created worktree compare-base repair. */
|
||||
baseRefName?: string
|
||||
/** PR head branch name. Lets linked-PR consumers detect that the worktree
|
||||
* has switched to a different branch and the durable link is stale. */
|
||||
headRefName?: string
|
||||
prRepo?: GitHubRepositoryIdentity
|
||||
headRepo?: GitHubRepositoryIdentity
|
||||
conflictSummary?: PRConflictSummary
|
||||
}
|
||||
|
||||
export type IssueInfo = {
|
||||
number: number
|
||||
title: string
|
||||
state: IssueState
|
||||
url: string
|
||||
labels: string[]
|
||||
/** Full markdown body when fetched through the single-issue endpoint. */
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type GitHubViewer = {
|
||||
login: string
|
||||
email: string | null
|
||||
}
|
||||
|
||||
export type GitHubAssignableUser = {
|
||||
login: string
|
||||
name: string | null
|
||||
avatarUrl: string
|
||||
}
|
||||
|
||||
export type ProviderCheckSummary = {
|
||||
state: 'success' | 'failure' | 'pending' | 'neutral' | 'none'
|
||||
total: number
|
||||
passed: number
|
||||
failed: number
|
||||
pending: number
|
||||
neutral: number
|
||||
}
|
||||
|
||||
export type GitHubPRReviewSummary = {
|
||||
login: string
|
||||
state?: string | null
|
||||
avatarUrl?: string | null
|
||||
}
|
||||
|
||||
export type GitHubPRFileViewedState = 'DISMISSED' | 'VIEWED' | 'UNVIEWED'
|
||||
|
||||
export type GitHubPRFile = {
|
||||
path: string
|
||||
oldPath?: string
|
||||
status: 'added' | 'modified' | 'removed' | 'renamed' | 'copied' | 'changed' | 'unchanged'
|
||||
additions: number
|
||||
deletions: number
|
||||
/** GitHub marks files above its diff size limit as binary-like; we skip content fetches for these. */
|
||||
isBinary: boolean
|
||||
/** Modified-side line numbers that GitHub accepts for inline review comments. */
|
||||
reviewCommentLineNumbers?: number[]
|
||||
/** GitHub's per-viewer review state. DISMISSED means new changes arrived after the file was viewed. */
|
||||
viewerViewedState?: GitHubPRFileViewedState
|
||||
}
|
||||
|
||||
export type GitHubPRFileContents = {
|
||||
original: string
|
||||
modified: string
|
||||
originalIsBinary: boolean
|
||||
modifiedIsBinary: boolean
|
||||
originalTooLarge?: boolean
|
||||
modifiedTooLarge?: boolean
|
||||
}
|
||||
|
||||
// Why: declared here as a shared shape so IPC return envelopes and renderer
|
||||
// slices can reference the same structural type without importing from main.
|
||||
// Aliased as `OwnerRepo` in `src/main/github/gh-utils.ts` so main call sites
|
||||
// can continue using the short local name.
|
||||
export type GitHubOwnerRepo = GitHubRepositoryIdentity
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* GitHub API rate-limit buckets surfaced in the TaskPage header so users can
|
||||
* see remaining budget before they hit the wall. `core` = REST (5000/hr),
|
||||
* `search` = Search API (30/min — hit by countWorkItems), `graphql` =
|
||||
* GraphQL (5000 points/hr — hit by project-view + discovery). All three are
|
||||
* the buckets this app actually stresses; other buckets (e.g. code_search)
|
||||
* are not surfaced because we don't touch them.
|
||||
*/
|
||||
export type GitHubRateLimitBucket = {
|
||||
remaining: number
|
||||
limit: number
|
||||
/** Unix epoch seconds when the window resets. */
|
||||
resetAt: number
|
||||
}
|
||||
|
||||
export type GitHubRateLimitSnapshot = {
|
||||
core: GitHubRateLimitBucket
|
||||
search: GitHubRateLimitBucket
|
||||
graphql: GitHubRateLimitBucket
|
||||
/** Unix epoch ms the snapshot was produced (for "fetched Xs ago" copy). */
|
||||
fetchedAt: number
|
||||
}
|
||||
|
||||
export type GetRateLimitResult =
|
||||
| { ok: true; snapshot: GitHubRateLimitSnapshot }
|
||||
| { ok: false; error: string }
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { githubRepoIdentityKey, isDefaultGitHubHost } from './repository-identity-key'
|
||||
|
||||
describe('GitHub repository identity', () => {
|
||||
it('normalizes case and harmless surrounding whitespace without merging GHES hosts', () => {
|
||||
expect(isDefaultGitHubHost(' GitHub.com ')).toBe(true)
|
||||
expect(githubRepoIdentityKey({ owner: 'Acme', repo: 'Widgets', host: ' GitHub.com ' })).toBe(
|
||||
'acme/widgets'
|
||||
)
|
||||
expect(
|
||||
githubRepoIdentityKey({ owner: 'Acme', repo: 'Widgets', host: ' GHE.EXAMPLE:8443 ' })
|
||||
).toBe('ghe.example:8443/acme/widgets')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
// Why: the github.com-vs-GHES boundary is the core invariant of Enterprise
|
||||
// support — cache identity, quota scoping, and exec-host routing must all
|
||||
// agree on it, so the predicate lives here once.
|
||||
export function isDefaultGitHubHost(host?: string): boolean {
|
||||
return !host?.trim() || host.trim().toLowerCase() === 'github.com'
|
||||
}
|
||||
|
||||
// Why: cache keys and equality checks for GitHub repos must include the host,
|
||||
// or a GHES repo and a same-named github.com repo would collide. github.com is
|
||||
// omitted so pre-Enterprise host-less keys stay stable.
|
||||
export function githubRepoIdentityKey(repo: {
|
||||
owner: string
|
||||
repo: string
|
||||
host?: string
|
||||
}): string {
|
||||
const slug = `${repo.owner.toLowerCase()}/${repo.repo.toLowerCase()}`
|
||||
const host = repo.host?.trim().toLowerCase()
|
||||
return host && !isDefaultGitHubHost(host) ? `${host}/${slug}` : slug
|
||||
}
|
||||
|
||||
// Why: callers that only kept the key (not the identity it came from) still need
|
||||
// its host segment to scope a second, host-less identity into the same namespace.
|
||||
// `owner` and `repo` never contain `/`, so a three-segment key is host-qualified.
|
||||
// `undefined` means github.com, so never pass a key that may be unresolved.
|
||||
export function githubHostFromIdentityKey(key: string | null | undefined): string | undefined {
|
||||
const segments = key?.split('/') ?? []
|
||||
return segments.length === 3 ? segments[0] : undefined
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { ClassifiedError } from '../classified-error'
|
||||
import type { PRCheckDetail } from './check-types'
|
||||
import type { GitHubIssueTimelineItem, PRComment } from './comment-types'
|
||||
import type {
|
||||
GitHubAssignableUser,
|
||||
GitHubOwnerRepo,
|
||||
GitHubPRFile,
|
||||
GitHubPRMergeMethodSettings,
|
||||
GitHubPRReviewSummary,
|
||||
GitHubRepositoryIdentity,
|
||||
PRMergeableState,
|
||||
PRReviewDecision,
|
||||
ProviderCheckSummary
|
||||
} from './pull-request-types'
|
||||
|
||||
export type GitHubWorkItem = {
|
||||
id: string
|
||||
type: 'issue' | 'pr'
|
||||
number: number
|
||||
title: string
|
||||
state: 'open' | 'closed' | 'merged' | 'draft'
|
||||
url: string
|
||||
labels: string[]
|
||||
updatedAt: string
|
||||
author: string | null
|
||||
// Why: GHE user logins don't exist on github.com, so the github.com/{login}.png
|
||||
// fallback 404s. Carry the API-provided avatar_url so github.com + Enterprise
|
||||
// both render; absent on the gh-pr-view path (gh omits avatar), then the UI
|
||||
// falls back to the login URL and finally an initials placeholder. See #8784.
|
||||
authorAvatarUrl?: string
|
||||
branchName?: string
|
||||
baseRefName?: string
|
||||
// Why: PR checks are keyed by head commit; carrying this lets task rows use
|
||||
// the cached check-runs endpoint instead of one `gh pr checks` call per row.
|
||||
headSha?: string
|
||||
prRepo?: GitHubRepositoryIdentity
|
||||
additions?: number
|
||||
deletions?: number
|
||||
changedFiles?: number
|
||||
reviewDecision?: PRReviewDecision | null
|
||||
reviewRequests?: GitHubAssignableUser[]
|
||||
latestReviews?: GitHubPRReviewSummary[]
|
||||
assignees?: GitHubAssignableUser[]
|
||||
checksSummary?: ProviderCheckSummary
|
||||
mergeable?: PRMergeableState
|
||||
autoMergeEnabled?: boolean
|
||||
autoMergeAllowed?: boolean | null
|
||||
mergeQueueRequired?: boolean | null
|
||||
mergeMethodSettings?: GitHubPRMergeMethodSettings
|
||||
mergeStateStatus?: string | null
|
||||
maintainerCanModify?: boolean
|
||||
// Why: true when a PR's head lives on a fork (headRepositoryOwner !== selected repo owner).
|
||||
// The Start-from picker passes this to resolvePrBase so fork heads use
|
||||
// refs/pull/<N>/head for creation and a separate PR-head push target.
|
||||
isCrossRepository?: boolean
|
||||
/** Why: required because the cross-repo view merges items from every selected
|
||||
* repo — the table row's repo pill and the "open in browser" fallback need
|
||||
* to know which repo an item came from. Stamped by the renderer fetcher
|
||||
* (`fetchWorkItems`) and by optimistic stubs on the new-issue path. */
|
||||
repoId: string
|
||||
}
|
||||
|
||||
export type GitHubWorkItemDetails = {
|
||||
// Why: main-process doesn't know Orca's Repo.id, so this inner item omits
|
||||
// repoId. The renderer stamps it when routing the details through the store.
|
||||
item: Omit<GitHubWorkItem, 'repoId'>
|
||||
body: string
|
||||
comments: PRComment[]
|
||||
/** Issue-only provider activity such as assignment, references, project moves, and state changes. */
|
||||
timelineItems?: GitHubIssueTimelineItem[]
|
||||
/** Only set for PRs. Head/base SHAs used by the Files tab to fetch per-file content. */
|
||||
headSha?: string
|
||||
baseSha?: string
|
||||
/** GraphQL node ID required by GitHub's file-viewed mutations. Only set for PRs. */
|
||||
pullRequestId?: string
|
||||
checks?: PRCheckDetail[]
|
||||
files?: GitHubPRFile[]
|
||||
/** Only set for PRs. True when the file fetch failed (rate limit, auth,
|
||||
* unresolved remote) rather than the PR genuinely having no changed files. */
|
||||
filesUnavailable?: boolean
|
||||
participants?: GitHubAssignableUser[]
|
||||
/** Logins of current assignees. Only set for issues. */
|
||||
assignees?: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Envelope for `gh:listWorkItems`. Carries resolved issue/PR sources so the
|
||||
* renderer can render the "Issues from owner/repo" indicator without an
|
||||
* extra IPC round-trip, and per-source classified errors so the UI can show
|
||||
* a retryable banner when (e.g.) a private upstream 403s.
|
||||
*
|
||||
* Why piggyback instead of adding `gh:resolveWorkItemSources`: the renderer
|
||||
* already round-trips this endpoint on every Tasks refresh, and the source
|
||||
* data is a 2-field-per-side metadata add — cheaper than another IPC call.
|
||||
*
|
||||
* Invariant: `items` always contains whatever succeeded; `errors.issues` indicates
|
||||
* the issues-side fetch failed, but any PR-side items that succeeded are still
|
||||
* present in `items`. Consumers should render `items` alongside the error banner.
|
||||
*/
|
||||
export type ListWorkItemsResult<T> = {
|
||||
items: T[]
|
||||
sources: {
|
||||
issues: GitHubOwnerRepo | null
|
||||
prs: GitHubOwnerRepo | null
|
||||
/** Raw `origin` remote resolved for this repo, independent of the
|
||||
* user's preference. Required-nullable so the renderer can compare raw
|
||||
* remote candidates without inferring origin from the effective PR
|
||||
* source. */
|
||||
originCandidate: GitHubOwnerRepo | null
|
||||
/** Raw `upstream` remote resolved for this repo, independent of the
|
||||
* user's preference. Present so the renderer's issue-source selector
|
||||
* can always decide whether to render (upstream exists & differs from
|
||||
* origin) and show both slugs in its tooltips, even when the user has
|
||||
* picked 'origin' and `sources.issues` has collapsed onto origin. */
|
||||
upstreamCandidate: GitHubOwnerRepo | null
|
||||
}
|
||||
errors?: {
|
||||
issues?: ClassifiedError
|
||||
prs?: ClassifiedError
|
||||
}
|
||||
/** True when the user's per-repo preference was `'upstream'` but no upstream
|
||||
* remote is configured, so the resolver fell back to origin. Renderer uses
|
||||
* this to surface a one-time-per-session toast. Omitted when absent so
|
||||
* existing consumers and test fixtures don't care about it.
|
||||
* Typed as `?: true` (not `?: boolean`) to encode the invariant "present
|
||||
* iff fell-back" — an explicit `false` write would be a bug, so make it a
|
||||
* compile error. */
|
||||
issueSourceFellBack?: true
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
GITHUB_WORK_ITEMS_QUERY_MAX_BYTES,
|
||||
isGitHubWorkItemsQueryTooLarge
|
||||
} from './work-items-query-bounds'
|
||||
|
||||
describe('shared GitHub work item query bounds', () => {
|
||||
it('allows normal GitHub search syntax', () => {
|
||||
expect(isGitHubWorkItemsQueryTooLarge('is:issue is:open label:bug')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects oversized pasted work item queries by byte length', () => {
|
||||
expect(isGitHubWorkItemsQueryTooLarge('x'.repeat(GITHUB_WORK_ITEMS_QUERY_MAX_BYTES))).toBe(
|
||||
false
|
||||
)
|
||||
expect(isGitHubWorkItemsQueryTooLarge('x'.repeat(GITHUB_WORK_ITEMS_QUERY_MAX_BYTES + 1))).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects multibyte pasted queries whose character count is below the limit', () => {
|
||||
const query = '😀'.repeat(Math.floor(GITHUB_WORK_ITEMS_QUERY_MAX_BYTES / 4) + 1)
|
||||
|
||||
expect(query.length).toBeLessThan(GITHUB_WORK_ITEMS_QUERY_MAX_BYTES)
|
||||
expect(isGitHubWorkItemsQueryTooLarge(query)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,16 @@
|
||||
import { isClipboardTextByteLengthOverLimit } from '../clipboard-text'
|
||||
|
||||
export const GITHUB_WORK_ITEMS_QUERY_MAX_BYTES = 8 * 1024
|
||||
|
||||
/**
|
||||
* The Search API's free-text 422 wording is the only signal separating a
|
||||
* permanently unreachable page (past the first 1000 matches) from a transient failure.
|
||||
*/
|
||||
export const GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN = /first 1000 search results/i
|
||||
|
||||
export function isGitHubWorkItemsQueryTooLarge(
|
||||
query: string,
|
||||
maxBytes = GITHUB_WORK_ITEMS_QUERY_MAX_BYTES
|
||||
): boolean {
|
||||
return isClipboardTextByteLengthOverLimit(query, maxBytes)
|
||||
}
|
||||
Reference in New Issue
Block a user