Files
orca/src/main/github/issues.test.ts
T
971b167548 fix(github): load PR diffs for Enterprise remotes (#8932)
* fix(github): load PR diffs for Enterprise remotes

* fix(github): encode PR content paths by segment

* Fix PR review actions failing on GitHub Enterprise remotes

- Threads GitHub host identity (not just owner/repo) through the client,
  work-item-details, issues, and RPC layers so gh commands target the
  correct Enterprise server instead of silently falling back to github.com
- Adds a shared github-api-repository helper to resolve/host-qualify repo
  identity consistently across REST, GraphQL, and CLI shorthand calls
- Scopes the gh rate-limit breaker and singleton rate-limit snapshot by
  host/runtime so a github.com block or probe can't affect GHES or WSL
- Coalesces concurrent host-auth probes and paginates PR file fetching
  beyond 100 results
- Propagates `host` through renderer PR caches, checks-panel keys, and
  preload IPC types so Enterprise and github.com data never collide

* Route gh host qualification through runner options instead of argv sniff

Move GHES/GH_HOST resolution from parsing --hostname/--repo out of gh argv to an explicit options.host passed through ghExecFileAsync, since SSH-backed repos spawn gh with no cwd and argv sniffing couldn't reliably detect the target host. The runner now injects --hostname and qualifies --repo/-R at spawn time from options.host, and rate-limit scoping/guards use the same explicit host instead of inferring it. Also adds a shared githubRepoIdentityKey helper to keep cache/store keys consistent with the new host-aware repository identity.

* Fix gh CLI GHES host pinning and rate-limit scope leaks

- Pin `--host` on every gh call site so a process-level GH_HOST can't
  silently redirect requests, and qualify `-R`/`-R=` repo shorthand
  alongside the existing `--repo=` handling.
- Check the target scope for an active rate-limit block before each
  WSL/native or host fallback retry, not just on the initial attempt,
  so a blocked scope can't be hit again through a fallback path.
- Compute idempotency once per call instead of re-deriving it after
  fallback reassigns args.

* Fix GitHub Enterprise host identity loss across PR/work-item paths

- Thread `host` through mobile PR RPC params, IPC work-item lookups, and
  RPC schemas so GHES identity survives the renderer/mobile/main boundary
  instead of silently falling back to a same-named github.com repo.
- Qualify `--repo`/`-R` args for github.com too (not just GHES), since
  gh resolves bare shorthand against a process-level GH_HOST that can
  redirect pinned github.com commands.
- Cache `getOriginGitHubApiRepository` to avoid a per-call uncached
  `git remote get-url` round trip on connection-backed repos.
- Add a local-fork fallback in `getWorkItemDetails` so PRs living on a
  base repo (not visible via the origin slug) still resolve via cwd.
- Centralize the github.com-vs-GHES host predicate in
  `isDefaultGitHubHost` so cache keys, quota scoping, and identity
  checks can't drift out of sync.

* Make repository identity host-aware across all GitHub surfaces

Generalize the auth-gated enterprise resolver to any remote and build a
cached hosted-identity family (origin/issue/candidates/source) on top of
it, then migrate every github.com-only consumer: Tasks listing/counting,
branch-to-PR discovery, push targets, fork upstream, issue operations,
Projects, web links, avatars, and PR-link facts. Scope the rate-limit
breaker probe per runtime:host and classify WSL UNC cwds correctly.

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

* Fix expected slug to include host field in GitHub PR link test

Updates the smart-source paste-intent test fixture to match the
repository slug shape that now carries a `host` field, keeping GHES
host identity intact through the paste-intent parsing path.

* Surface per-host gh auth state for GitHub Enterprise

diagnoseGhAuth accepts the host a surface needs credentials for, scopes
the account/scope diagnosis to that host, and reports whether gh has any
login there; GhAuthErrorHelp renders host-qualified login/refresh
commands so an unauthenticated GHES host stops masquerading as a
github.com scope problem. Also fixes the mobile paste-intent expectation
for host-carrying parsed links.

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

* Bound GHES identity caches and preserve non-default ports in host identity

Cap the origin-repo and host-auth caches like ownerRepoCache; keep ports
from remote/link URLs so GHES on a non-default port is a distinct
identity; make positional github.com slugs explicit against GH_HOST;
compare work-item sources by host-aware identity key; bail cwd-less
branch lookups when no repository candidate resolved; thread host
through the renderer work-item slug lookup.

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

* Thread GitHub host through issue detail requests

Incorporates ghes-issue-host-support (ed6bb96ef): one hosted issue
repository identity is resolved before the details fan-out so comments,
timeline, participants, and mention lookups cannot drift across hosts,
with SSH guards so unresolved issue/PR repositories never fall through
to gh's default host.

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

* Scope remaining GitHub rate-limit accounting

* Resolve typed PR lookups across hosted repository candidates

getWorkItem's PR path probes upstream-then-origin hosted candidates
instead of origin alone, so fork checkouts resolve the base repo's PR
with the right host; issue detail resolution reuses the up-front hosted
identity and keeps the SSH unresolved-host guards.

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

* Refactor GitHub repository execution setup

* Carry host on smart-submit link intents

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

* Carry the project host on GitHub item dialog origins

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

* Keep GHES web ports but drop SSH transport ports in host identity

Supersedes PR #9118 on this branch: http(s) remote ports identify the
Enterprise web/API endpoint and are preserved, while ssh/git transport
ports (including ssh.github.com:443) never leak into gh's host identity.
Replaces the ssh.github.com:443 special case with the structural
protocol split and ports the PR's parsing test suite.

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

* Support GitHub Enterprise diffs and mutations with host-scoped caches

Parse GitHub host identity from work-item URLs and carry it through PR/issue mutations, labels, and assignments. Bound rate-limit and scope-probe caches (1024 and 512 entries) to prevent unbounded growth when interacting with multiple GHES instances. Normalize repository identity keys to include host so github.com and GHES slugs don't collide in cache and equality checks.

* Support GitHub Enterprise diffs and mutations with host-scoped caches

- Carry host identity through PR mutations and reads so fork PRs on
  different GHES instances don't collide in cache or state tracking.
- Validate host authentication before routing requests to unconfigured
  Enterprise servers; ambient credentials must never reach untrusted hosts.
- Scope rate-limit guards and spend tracking per host so GHES quota stays
  independent from github.com quota.
- Respect explicit --hostname arguments in gh CLI calls ahead of GH_HOST or
  ambient defaults, so breaker state follows the actual request target.
- Detect implicit WSL runtimes from UNC paths for consistent host auth and
  execution-options scoping across mobile and desktop clients.

* Support GitHub Enterprise work-item diffs with host-scoped execution

Enterprise PRs must use their selected host consistently across diff, comments,
and file-content loads. Validate repository slugs before authenticated execution
to prevent path-injection via renderer overrides. Scope project browsing cache
and rate-limit tracking by host to prevent cross-host pollution. Use parsed
URLs as authoritative over ambient hosts for project resolution.

* Support GitHub Enterprise work-item diffs with host-scoped execution

Preserve host identity on PR/issue work items throughout the mutation and diff
pipeline so Enterprise instances (including ported endpoints like
github.acme.test:8443) can execute mutations without ambiguity. Rate-limit gh
commands by the pre-qualified --repo host, cache auth state per ported host,
and surface Enterprise hosts in project metadata and error messages.

* fix(review): drop dead rateLimitGuard/noteRateLimitSpend re-export

Both callers (project-view.ts, mutations.ts) moved to the host-scoped
repositoryRateLimitGuard/noteRepositoryRateLimitSpend; the bucket-only
re-export in internals.ts had zero importers left.

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

* fix(ci): split Enterprise host work-item tests under max-lines

Move GHES/SSH host-routing cases out of work-item-details.test.ts so
the suite stays within the 800-line test max-lines budget.

* test(github): align mocks with host-scoped repository resolution

- Route origin repository resolution through getOwnerRepoForRemote, not getOwnerRepo, to match production path
- Pin github.com host on origin results so host-less fixtures pass host gate in resolveGitHubApiRepository
- Add generation-based invalidation to prevent stale slug-cache writes from in-flight resolutions
- Fix ref-sync race in ProjectPicker: use useLayoutEffect so committed tree owns browse cache key
- Defer handledCrossRepoUrlRef assignment in SmartWorkspaceNameField until resolution succeeds
- Update Enterprise host routing: found work items must not silently fall back to default host when unresolved
- Normalize GHES avatar URLs: accept explicit port 443 as canonical form, not a fallback trigger

---------

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
2026-07-20 18:55:45 -07:00

541 lines
18 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as GithubApiRepositoryModule from './github-api-repository'
import type * as GithubEnterpriseRepositoryModule from './github-enterprise-repository'
import type * as GhUtils from './gh-utils'
const {
ghExecFileAsyncMock,
getIssueOwnerRepoMock,
resolveIssueSourceMock,
acquireMock,
releaseMock
} = vi.hoisted(() => ({
ghExecFileAsyncMock: vi.fn(),
getIssueOwnerRepoMock: vi.fn(),
resolveIssueSourceMock: vi.fn(),
acquireMock: vi.fn(),
releaseMock: vi.fn()
}))
vi.mock('./gh-utils', async () => {
const actual = await vi.importActual<typeof GhUtils>('./gh-utils')
return {
...actual,
ghExecFileAsync: ghExecFileAsyncMock,
getIssueOwnerRepo: getIssueOwnerRepoMock,
resolveIssueSource: resolveIssueSourceMock,
acquire: acquireMock,
release: releaseMock
}
})
vi.mock('./github-enterprise-repository', async (importOriginal) => ({
...(await importOriginal<typeof GithubEnterpriseRepositoryModule>()),
isGitHubHostAuthenticated: vi.fn().mockResolvedValue(true)
}))
vi.mock('./github-api-repository', async (importOriginal) => {
const actual = await importOriginal<typeof GithubApiRepositoryModule>()
const withDotComHost = <T extends { host?: string } | null | undefined>(repo: T) =>
repo ? { host: 'github.com' as const, ...repo } : repo
return {
...actual,
// Why: these suites drive source resolution through the legacy gh-utils
// mocks; bridge the hosted seams onto the same mocks and pin github.com so
// host-less fixtures still pass resolveGitHubApiRepository's host gate.
getIssueGitHubApiRepository: async (
repoPath: string,
connectionId?: string | null,
localGitOptions?: unknown
) => withDotComHost(await getIssueOwnerRepoMock(repoPath, connectionId, localGitOptions)),
resolveIssueGitHubApiRepositorySource: async (
repoPath: string,
preference: unknown,
connectionId?: string | null,
localGitOptions?: unknown
) => {
const result = await resolveIssueSourceMock(
repoPath,
preference,
connectionId,
localGitOptions
)
return {
...result,
source: withDotComHost(result?.source)
}
}
}
})
import {
addIssueComment,
createIssue,
getIssue,
listAssignableUsers,
listIssues,
listLabels,
updateIssue
} from './issues'
import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository'
// The origin-repository cache is module-level state; reset it so slugs
// resolved by one test cannot leak into the next.
beforeEach(() => {
_resetOriginGitHubApiRepositoryCache()
})
describe('issue source operations', () => {
beforeEach(() => {
ghExecFileAsyncMock.mockReset()
getIssueOwnerRepoMock.mockReset()
resolveIssueSourceMock.mockReset()
acquireMock.mockReset()
releaseMock.mockReset()
acquireMock.mockResolvedValue(undefined)
// Why: preference-aware paths call resolveIssueSource instead of
// getIssueOwnerRepo. Route through the same mock so existing tests that
// set up getIssueOwnerRepoMock continue to work.
resolveIssueSourceMock.mockImplementation(async () => ({
source: await getIssueOwnerRepoMock(),
fellBack: false
}))
})
it('gets a single issue from the issue owner/repo', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 923,
title: 'Use upstream issues',
state: 'open',
html_url: 'https://github.com/stablyai/orca/issues/923',
labels: []
})
})
await expect(getIssue('/repo-root', 923)).resolves.toMatchObject({ number: 923 })
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
['api', '--cache', '300s', 'repos/stablyai/orca/issues/923'],
{ cwd: '/repo-root', host: 'github.com' }
)
})
it('routes local WSL issue operations through repo resolution and gh execution options', async () => {
const localGitOptions = { wslDistro: 'Ubuntu' }
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
resolveIssueSourceMock.mockResolvedValue({
source: { owner: 'stablyai', repo: 'orca' },
fellBack: false
})
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 923,
title: 'Use upstream issues',
state: 'open',
html_url: 'https://github.com/stablyai/orca/issues/923',
labels: []
})
})
.mockResolvedValueOnce({ stdout: '[]' })
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 924,
html_url: 'https://github.com/stablyai/orca/issues/924'
})
})
.mockResolvedValueOnce({ stdout: '' })
.mockResolvedValueOnce({
stdout: JSON.stringify({
id: 1,
user: { login: 'octo', avatar_url: '', type: 'User' },
body: 'Comment',
created_at: '2026-06-16T00:00:00.000Z',
html_url: 'https://github.com/stablyai/orca/issues/923#issuecomment-1'
})
})
.mockResolvedValueOnce({ stdout: 'bug\nfrontend\n' })
.mockResolvedValueOnce({ stdout: '{"login":"octo","avatar_url":""}\n' })
await getIssue('/repo-root', 923, null, localGitOptions)
await listIssues('/repo-root', 5, undefined, null, localGitOptions)
await createIssue(
'/repo-root',
'New issue',
'Body',
undefined,
null,
undefined,
localGitOptions
)
await updateIssue('/repo-root', 923, { body: 'Updated' }, null, localGitOptions)
await addIssueComment('/repo-root', 923, 'Comment', null, null, localGitOptions)
await listLabels('/repo-root', undefined, null, localGitOptions)
await listAssignableUsers('/repo-root', undefined, null, localGitOptions)
expect(getIssueOwnerRepoMock).toHaveBeenCalledWith('/repo-root', null, localGitOptions)
expect(resolveIssueSourceMock).toHaveBeenCalledWith(
'/repo-root',
undefined,
null,
localGitOptions
)
expect(ghExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe(
true
)
})
it('routes PR conversation comments to the supplied Enterprise host', async () => {
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
id: 9,
user: { login: 'octo', avatar_url: '', type: 'User' },
body: 'Enterprise comment',
created_at: '2026-07-16T00:00:00.000Z',
html_url: 'https://github.acme-corp.com/team/orca/pull/7#issuecomment-9'
})
})
await expect(
addIssueComment('/remote/repo', 7, 'Enterprise comment', 'ssh-1', {
owner: 'team',
repo: 'orca',
host: 'github.acme-corp.com'
})
).resolves.toMatchObject({ ok: true })
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
[
'api',
'-X',
'POST',
'repos/team/orca/issues/7/comments',
'--raw-field',
'body=Enterprise comment'
],
expect.objectContaining({ host: 'github.acme-corp.com' })
)
})
it('lists issues from the issue owner/repo', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await expect(listIssues('/repo-root', 5)).resolves.toEqual({ items: [] })
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
[
'api',
'--cache',
'120s',
'repos/stablyai/orca/issues?per_page=5&state=open&sort=updated&direction=desc'
],
{ cwd: '/repo-root', host: 'github.com' }
)
})
it('surfaces a classified permission_denied error instead of collapsing to empty', async () => {
// Why: parent design doc §3 — a 403 on a private upstream must not
// masquerade as "No issues". The envelope carries an error the UI can
// render as a banner with retry, not a silent empty list.
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
ghExecFileAsyncMock.mockRejectedValueOnce(
new Error('HTTP 403: Resource not accessible by integration')
)
const result = await listIssues('/repo-root', 5)
expect(result.items).toEqual([])
expect(result.error?.type).toBe('permission_denied')
})
it('creates issues in the issue owner/repo', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 924,
html_url: 'https://github.com/stablyai/orca/issues/924'
})
})
await expect(createIssue('/repo-root', 'New issue', 'Body')).resolves.toEqual({
ok: true,
number: 924,
url: 'https://github.com/stablyai/orca/issues/924'
})
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
[
'api',
'-X',
'POST',
'repos/stablyai/orca/issues',
'--raw-field',
'title=New issue',
'--raw-field',
'body=Body'
],
{ cwd: '/repo-root', host: 'github.com' }
)
})
it('creates issues with labels and assignees', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 925,
html_url: 'https://github.com/stablyai/orca/issues/925'
})
})
await expect(
createIssue('/repo-root', 'New issue', 'Body', undefined, undefined, {
labels: ['bug', 'frontend'],
assignees: ['octo']
})
).resolves.toEqual({
ok: true,
number: 925,
url: 'https://github.com/stablyai/orca/issues/925'
})
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
[
'api',
'-X',
'POST',
'repos/stablyai/orca/issues',
'--raw-field',
'title=New issue',
'--raw-field',
'body=Body',
'--raw-field',
'labels[]=bug',
'--raw-field',
'labels[]=frontend',
'--raw-field',
'assignees[]=octo'
],
{ cwd: '/repo-root', host: 'github.com' }
)
})
it('recovers issue 7704 oversized inline-image creation', async () => {
const imagePrefix = 'data:image/png;base64,'
const body = imagePrefix + 'x'.repeat(133596 - imagePrefix.length)
expect(body).toContain('data:image')
expect(body).toHaveLength(133596)
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
ghExecFileAsyncMock
.mockRejectedValueOnce(new Error('HTTP 422: body is too long (maximum is 65536 characters)'))
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 926,
html_url: 'https://github.com/stablyai/orca/issues/926'
})
})
.mockResolvedValueOnce({ stdout: '' })
await expect(
createIssue('/repo-root', 'Image issue', body, undefined, undefined, {
labels: ['bug'],
assignees: ['octo']
})
).resolves.toEqual({
ok: true,
number: 926,
url: 'https://github.com/stablyai/orca/issues/926'
})
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
expect.arrayContaining([
`body=${body}`,
'title=Image issue',
'labels[]=bug',
'assignees[]=octo'
]),
{ cwd: '/repo-root', host: 'github.com' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
expect.arrayContaining(['body=', 'title=Image issue', 'labels[]=bug', 'assignees[]=octo']),
{ cwd: '/repo-root', host: 'github.com' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
3,
['api', '-X', 'PATCH', 'repos/stablyai/orca/issues/926', '--raw-field', `body=${body}`],
{ cwd: '/repo-root', host: 'github.com' }
)
})
it('recognizes the oversized-body response from structured gh stderr', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
ghExecFileAsyncMock
.mockRejectedValueOnce(
Object.assign(new Error('Command failed: gh'), {
stderr: 'gh: body is too long (maximum is 65536 characters) (HTTP 422)'
})
)
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 929,
html_url: 'https://github.com/stablyai/orca/issues/929'
})
})
.mockResolvedValueOnce({ stdout: '' })
await expect(createIssue('/repo-root', 'Image issue', 'data:image')).resolves.toEqual({
ok: true,
number: 929,
url: 'https://github.com/stablyai/orca/issues/929'
})
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3)
})
it('does not retry unrelated create failures', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 422: assignees is invalid'))
await expect(createIssue('/repo-root', 'Invalid issue', 'Body')).resolves.toEqual({
ok: false,
error: 'HTTP 422: assignees is invalid'
})
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('stops when placeholder create fails during oversized-body recovery', async () => {
const body = `data:image/png;base64,${'x'.repeat(100)}`
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
ghExecFileAsyncMock
.mockRejectedValueOnce(new Error('body is too long (maximum is 65536 characters)'))
.mockRejectedValueOnce(new Error('HTTP 500: create failed'))
await expect(createIssue('/repo-root', 'Placeholder failure', body)).resolves.toEqual({
ok: false,
error: 'HTTP 500: create failed'
})
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
})
it('preserves fields during oversized-body recovery', async () => {
const localGitOptions = { wslDistro: 'Ubuntu' }
const body = `data:image/png;base64,${'x'.repeat(100)}`
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
ghExecFileAsyncMock
.mockRejectedValueOnce(new Error('body is too long (maximum is 65536 characters)'))
.mockResolvedValueOnce({ stdout: JSON.stringify({ number: 927, url: 'issue-url' }) })
.mockResolvedValueOnce({ stdout: '' })
await expect(
createIssue(
'/repo-root',
'Fields issue',
body,
undefined,
null,
{
labels: ['bug'],
assignees: ['octo']
},
localGitOptions
)
).resolves.toEqual({ ok: true, number: 927, url: 'issue-url' })
const firstCreateArgs = [...ghExecFileAsyncMock.mock.calls[0][0]]
const fallbackCreateArgs = [...ghExecFileAsyncMock.mock.calls[1][0]]
firstCreateArgs[firstCreateArgs.indexOf(`body=${body}`)] = 'body='
expect(fallbackCreateArgs).toEqual(firstCreateArgs)
expect(ghExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe(
true
)
expect(resolveIssueSourceMock).toHaveBeenCalledWith(
'/repo-root',
undefined,
null,
localGitOptions
)
})
it('reports partial success when oversized-body patch fails', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
ghExecFileAsyncMock
.mockRejectedValueOnce(new Error('body is too long (maximum is 65536 characters)'))
.mockResolvedValueOnce({
stdout: JSON.stringify({
number: 928,
html_url: 'https://github.com/stablyai/orca/issues/928'
})
})
.mockRejectedValueOnce(new Error('HTTP 500: update failed'))
await expect(createIssue('/repo-root', 'Partial issue', 'data:image')).resolves.toEqual({
ok: true,
number: 928,
url: 'https://github.com/stablyai/orca/issues/928',
bodySaveWarning:
'Issue https://github.com/stablyai/orca/issues/928 was created, but saving its body failed: HTTP 500: update failed'
})
})
it('updates issue body through the REST issue endpoint', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
await expect(updateIssue('/repo-root', 924, { body: 'Updated body' })).resolves.toEqual({
ok: true
})
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
['api', '-X', 'PATCH', 'repos/stablyai/orca/issues/924', '--raw-field', 'body=Updated body'],
{ cwd: '/repo-root', host: 'github.com' }
)
})
it('closes issues with completed, not planned, and duplicate reasons', async () => {
getIssueOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' })
ghExecFileAsyncMock.mockResolvedValue({ stdout: '' })
await expect(
updateIssue('/repo-root', 924, { state: 'closed', stateReason: 'completed' })
).resolves.toEqual({ ok: true })
await expect(
updateIssue('/repo-root', 925, { state: 'closed', stateReason: 'not_planned' })
).resolves.toEqual({ ok: true })
await expect(
updateIssue('/repo-root', 926, {
state: 'closed',
stateReason: 'duplicate',
duplicateOf: 99
})
).resolves.toEqual({ ok: true })
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
['issue', 'close', '924', '--repo', 'stablyai/orca', '--reason', 'completed'],
{ cwd: '/repo-root', host: 'github.com' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
['issue', 'close', '925', '--repo', 'stablyai/orca', '--reason', 'not planned'],
{ cwd: '/repo-root', host: 'github.com' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
3,
['issue', 'close', '926', '--repo', 'stablyai/orca', '--duplicate-of', '99'],
{ cwd: '/repo-root', host: 'github.com' }
)
})
it('reopens issues through gh issue reopen', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' })
await expect(updateIssue('/repo-root', 924, { state: 'open' })).resolves.toEqual({
ok: true
})
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
['issue', 'reopen', '924', '--repo', 'stablyai/orca'],
{ cwd: '/repo-root', host: 'github.com' }
)
})
})