The forge panel opens a changes view in the right sidebar for the checkout's local changes and for the diff of each change request. - ForgePanelController reads local diffs in the branch, uncommitted, unstaged, and staged scopes through Git in the Session's read-only sandbox, change request diffs through the forge provider, and the text of a changed file to expand unchanged lines. Change request patches stay cached by head commit and base branch within maxCachedDiffBytes. A local diff over maxDiffBytes fails as forgePanel/diff-too-large, and a provider read over its output limit fails as too-large, which the view explains with a link to the code host; a gh timeout fails as unavailable. - forge adds the diff, fileText, and fileUrl provider methods and the too-large failure code, and forge-github implements them with gh. - The panel shows a local changes card that opens the view and asks the Agent for a change request. The view collapses files past a line budget, draws lines side by side or in one column with optional wrapping, expands unchanged lines on request, and collapses or expands every file from its toolbar; file headers use a bare chevron. - The checkout row copies the worktree or project directory path, the refresh glyph turns while a refresh runs, change request cards keep their actions while collapsed, and the header entry uses the shared compact button. - client-ui-forge classifies changeRequestKey and FsError as peer-required Host imports, which keeps forge and dsh-fs as its peers.
268 lines
16 KiB
TypeScript
268 lines
16 KiB
TypeScript
import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { GhOutputLimitError, GhTimeoutError, GhUnavailableError } from '../src/gh.ts'
|
|
import type { GhOutput, GhRunner } from '../src/gh.ts'
|
|
import { GitHubProvider } from '../src/provider.ts'
|
|
import { demo, pull, pullRequestNode } from './fixtures.ts'
|
|
|
|
const enterprise = { forge: 'github', host: 'ghe.corp.example', owner: 'team', name: 'svc' }
|
|
const signal = new AbortController().signal
|
|
|
|
function fakeGh(handler: (args: readonly string[]) => GhOutput | Promise<GhOutput>): { run: GhRunner; calls: string[][] } {
|
|
const calls: string[][] = []
|
|
return {
|
|
calls,
|
|
run: async (args) => {
|
|
calls.push([...args])
|
|
return await handler(args)
|
|
},
|
|
}
|
|
}
|
|
|
|
function output(stdout: unknown, exitCode = 0, stderr = ''): GhOutput {
|
|
return { exitCode, stdout: typeof stdout === 'string' ? stdout : JSON.stringify(stdout), stderr }
|
|
}
|
|
|
|
function provider(run: GhRunner): GitHubProvider {
|
|
return new GitHubProvider(run, ['github.com', 'GHE.corp.example'])
|
|
}
|
|
|
|
describe('GitHub provider addressing', () => {
|
|
const subject = provider(fakeGh(() => output('')).run)
|
|
|
|
it('serves remotes and pull request URLs on configured hosts only', () => {
|
|
expect(subject.repositoryFromRemote('[email protected]:octo/demo.git')).toEqual(demo)
|
|
expect(subject.repositoryFromRemote('https://ghe.corp.example/team/svc')).toEqual(enterprise)
|
|
expect(subject.repositoryFromRemote('https://gitlab.com/octo/demo')).toBeUndefined()
|
|
expect(subject.repositoryFromRemote('/local/repo')).toBeUndefined()
|
|
expect(subject.changeRequestFromUrl('https://github.com/octo/demo/pull/4')).toEqual(pull(4))
|
|
expect(subject.changeRequestFromUrl('https://gitlab.com/octo/demo/pull/4')).toBeUndefined()
|
|
expect(subject.changeRequestFromUrl('https://github.com/octo/demo/issues/4')).toBeUndefined()
|
|
})
|
|
|
|
it('builds canonical pull request URLs', () => {
|
|
expect(subject.changeRequestUrl(pull(4, enterprise))).toBe('https://ghe.corp.example/team/svc/pull/4')
|
|
})
|
|
})
|
|
|
|
describe('GitHub provider fetch', () => {
|
|
it('runs one GraphQL request per host and merges the results', async () => {
|
|
const gh = fakeGh((args) => {
|
|
const host = args[3]
|
|
return output({ data: { r0: { p0: pullRequestNode(host === 'github.com' ? 1 : 2) } } })
|
|
})
|
|
|
|
const results = await provider(gh.run).fetch([pull(1), pull(2, enterprise)], signal)
|
|
|
|
expect(gh.calls.map(call => call.slice(0, 5))).toEqual([
|
|
['api', 'graphql', '--hostname', 'github.com', '-f'],
|
|
['api', 'graphql', '--hostname', 'ghe.corp.example', '-f'],
|
|
])
|
|
expect(gh.calls[0]?.[5]).toMatch(/^query=query \{ r0: repository\(owner: "octo", name: "demo"\)/)
|
|
expect(results.get(changeRequestKey(pull(1)))).toMatchObject({ title: 'Change 1' })
|
|
expect(results.get(changeRequestKey(pull(2, enterprise)))).toMatchObject({ title: 'Change 2' })
|
|
})
|
|
|
|
it('maps the JSON of a partial-error response that exits with 1', async () => {
|
|
const gh = fakeGh(() => output({
|
|
data: { r0: { p0: null } },
|
|
errors: [{ type: 'NOT_FOUND', path: ['r0', 'p0'], message: 'Could not resolve to a PullRequest with the number of 1.' }],
|
|
}, 1, 'gh: Could not resolve to a PullRequest with the number of 1.'))
|
|
|
|
const results = await provider(gh.run).fetch([pull(1)], signal)
|
|
|
|
expect(results.get(changeRequestKey(pull(1)))).toEqual({ code: 'not-found', message: 'Could not resolve to a PullRequest with the number of 1.' })
|
|
})
|
|
|
|
it.each([
|
|
['To get started with GitHub CLI, please run: gh auth login', 'unauthenticated'],
|
|
['HTTP 401: Bad credentials (https://api.github.com/graphql)', 'unauthenticated'],
|
|
['error connecting to api.github.com', 'unavailable'],
|
|
])('classifies a failure without JSON: %s', async (stderr, code) => {
|
|
const gh = fakeGh(() => output('', 1, `\n${stderr}\n`))
|
|
|
|
const results = await provider(gh.run).fetch([pull(1), pull(2)], signal)
|
|
|
|
expect([...results.values()]).toEqual([{ code, message: stderr }, { code, message: stderr }])
|
|
})
|
|
|
|
it('names the exit code when gh prints nothing', async () => {
|
|
const results = await provider(fakeGh(() => output('', 4)).run).fetch([pull(1)], signal)
|
|
|
|
expect(results.get(changeRequestKey(pull(1)))).toEqual({ code: 'unavailable', message: 'gh exited with 4' })
|
|
})
|
|
|
|
it('propagates a runner rejection', async () => {
|
|
const subject = provider(async () => {
|
|
throw new Error('aborted')
|
|
})
|
|
|
|
await expect(subject.fetch([pull(1)], signal)).rejects.toThrow('aborted')
|
|
})
|
|
})
|
|
|
|
describe('GitHub provider head branch lookup', () => {
|
|
it('passes the repository and branch as GraphQL variables', async () => {
|
|
const gh = fakeGh(() => output({ data: { repository: { defaultBranchRef: { name: 'master' }, pullRequests: { nodes: [{ number: 5, isCrossRepository: false }] } } } }))
|
|
|
|
await expect(provider(gh.run).findByHead(demo, 'topic', signal)).resolves.toEqual([pull(5)])
|
|
expect(gh.calls[0]?.slice(0, 4)).toEqual(['api', 'graphql', '--hostname', 'github.com'])
|
|
expect(gh.calls[0]?.slice(6)).toEqual(['-f', 'owner=octo', '-f', 'name=demo', '-f', 'branch=topic'])
|
|
})
|
|
|
|
it('reports gh diagnostics when the lookup prints no JSON', async () => {
|
|
await expect(provider(fakeGh(() => output('', 1, 'HTTP 502')).run).findByHead(demo, 'topic', signal))
|
|
.rejects.toThrow('forge-github: head branch lookup failed: HTTP 502')
|
|
await expect(provider(fakeGh(() => output('', 2)).run).findByHead(demo, 'topic', signal))
|
|
.rejects.toThrow('forge-github: head branch lookup failed: gh exited with 2')
|
|
})
|
|
})
|
|
|
|
describe('GitHub provider readiness', () => {
|
|
it('is ready when gh auth status succeeds for the host', async () => {
|
|
const gh = fakeGh(() => output('Logged in to github.com'))
|
|
|
|
await expect(provider(gh.run).readiness('github.com', signal)).resolves.toEqual({ state: 'ready', message: null })
|
|
expect(gh.calls).toEqual([['auth', 'status', '--hostname', 'github.com']])
|
|
})
|
|
|
|
it('reports the first diagnostic line when gh is not signed in', async () => {
|
|
await expect(provider(fakeGh(() => output('', 1, 'You are not logged into any GitHub hosts.\nRun gh auth login')).run).readiness('github.com', signal))
|
|
.resolves.toEqual({ state: 'unauthenticated', message: 'You are not logged into any GitHub hosts.' })
|
|
await expect(provider(fakeGh(() => output('github.com\n X Failed to log in', 1)).run).readiness('github.com', signal))
|
|
.resolves.toEqual({ state: 'unauthenticated', message: 'github.com' })
|
|
await expect(provider(fakeGh(() => output('', 1)).run).readiness('github.com', signal))
|
|
.resolves.toEqual({ state: 'unauthenticated', message: 'gh is not signed in to github.com' })
|
|
})
|
|
|
|
it('is unavailable when gh cannot start and rethrows other failures', async () => {
|
|
await expect(provider(() => Promise.reject(new GhUnavailableError('gh could not start: spawn gh ENOENT')))
|
|
.readiness('github.com', signal)).resolves.toEqual({ state: 'unavailable', message: 'gh could not start: spawn gh ENOENT' })
|
|
await expect(provider(() => Promise.reject(new Error('timed out'))).readiness('github.com', signal)).rejects.toThrow('timed out')
|
|
})
|
|
})
|
|
|
|
describe('GitHub provider actions', () => {
|
|
const head = 'c'.repeat(40)
|
|
|
|
it('marks a pull request ready for review through gh pr ready on its host', async () => {
|
|
const gh = fakeGh(() => output(''))
|
|
|
|
await expect(provider(gh.run).markReady(pull(7, enterprise), signal)).resolves.toBeUndefined()
|
|
expect(gh.calls).toEqual([['pr', 'ready', '7', '--repo', 'ghe.corp.example/team/svc']])
|
|
})
|
|
|
|
it('merges a pull request through the REST merge endpoint with the confirmed head', async () => {
|
|
const gh = fakeGh(() => output({ sha: 'd'.repeat(40), merged: true, message: 'Pull Request successfully merged' }))
|
|
|
|
await expect(provider(gh.run).merge(pull(7), { method: 'squash', headSha: head }, signal)).resolves.toBeUndefined()
|
|
expect(gh.calls).toEqual([[
|
|
'api', '--hostname', 'github.com', '--method', 'PUT', 'repos/octo/demo/pulls/7/merge', '-f', 'merge_method=squash', '-f', `sha=${head}`,
|
|
]])
|
|
})
|
|
|
|
it.each([
|
|
['a moved head', { message: 'Head branch was modified. Review and try the merge again.' }, 'gh: Head branch was modified. Review and try the merge again. (HTTP 409)',
|
|
{ code: 'stale', message: 'Head branch was modified. Review and try the merge again.' }],
|
|
['an unmet merge rule', { message: 'Pull Request is not mergeable' }, 'gh: Pull Request is not mergeable (HTTP 405)',
|
|
{ code: 'failed', message: 'Pull Request is not mergeable' }],
|
|
['rejected credentials', { message: 'Bad credentials' }, 'gh: Bad credentials (HTTP 401)', { code: 'unauthenticated', message: 'Bad credentials' }],
|
|
['an error body without a message', { errors: [] }, 'gh: Validation Failed (HTTP 422)', { code: 'failed', message: 'gh: Validation Failed (HTTP 422)' }],
|
|
['an empty message', { message: ' ' }, 'gh: Not Found (HTTP 404)', { code: 'failed', message: 'gh: Not Found (HTTP 404)' }],
|
|
['no output', '', '', { code: 'failed', message: 'gh exited with 1' }],
|
|
])('reports a merge refused for %s', async (_label, body, stderr, failure) => {
|
|
await expect(provider(fakeGh(() => output(body, 1, stderr)).run).merge(pull(7), { method: 'merge', headSha: head }, signal)).resolves.toEqual(failure)
|
|
})
|
|
|
|
it('reports a failed ready mark by the first diagnostic line, recognizing a missing sign-in', async () => {
|
|
await expect(provider(fakeGh(() => output('', 1, '\nGraphQL: Resource not accessible by integration (markPullRequestReadyForReview)\n')).run)
|
|
.markReady(pull(7), signal)).resolves.toEqual({ code: 'failed', message: 'GraphQL: Resource not accessible by integration (markPullRequestReadyForReview)' })
|
|
await expect(provider(fakeGh(() => output('', 4, 'To get started with GitHub CLI, please run: gh auth login')).run).markReady(pull(7), signal))
|
|
.resolves.toEqual({ code: 'unauthenticated', message: 'To get started with GitHub CLI, please run: gh auth login' })
|
|
})
|
|
|
|
it('reports an action as failed when gh cannot start and rethrows other failures', async () => {
|
|
await expect(provider(() => Promise.reject(new GhUnavailableError('gh could not start: spawn gh ENOENT'))).markReady(pull(7), signal))
|
|
.resolves.toEqual({ code: 'failed', message: 'gh could not start: spawn gh ENOENT' })
|
|
await expect(provider(() => Promise.reject(new Error('aborted'))).merge(pull(7), { method: 'rebase', headSha: head }, signal)).rejects.toThrow('aborted')
|
|
})
|
|
|
|
it('refuses to address a pull request or head commit that cannot be inlined', async () => {
|
|
const gh = fakeGh(() => output(''))
|
|
const subject = provider(gh.run)
|
|
|
|
await expect(subject.markReady(pull(7, { ...demo, owner: 'oc/to' }), signal)).rejects.toThrow('forge-github: invalid repository "oc/to/demo"')
|
|
await expect(subject.merge(pull(0), { method: 'merge', headSha: head }, signal)).rejects.toThrow('forge-github: invalid pull request number 0')
|
|
await expect(subject.merge(pull(7), { method: 'merge', headSha: 'HEAD' }, signal)).rejects.toThrow('forge-github: invalid head commit "HEAD"')
|
|
await expect(subject.merge(pull(7), { method: 'merge', headSha: 'e'.repeat(64) }, signal)).resolves.toBeUndefined()
|
|
expect(gh.calls).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
describe('GitHub provider reads', () => {
|
|
const head = 'c'.repeat(40)
|
|
|
|
it('reads the pull request diff through gh pr diff on its host', async () => {
|
|
const gh = fakeGh(() => output('diff --git a/x b/x\n'))
|
|
|
|
await expect(provider(gh.run).diff(pull(7, enterprise), signal)).resolves.toBe('diff --git a/x b/x\n')
|
|
expect(gh.calls).toEqual([['pr', 'diff', '7', '--repo', 'ghe.corp.example/team/svc', '--color', 'never']])
|
|
})
|
|
|
|
it('reads a file at a commit through the raw contents API, encoding each path segment', async () => {
|
|
const gh = fakeGh(() => output('line\n'))
|
|
|
|
await expect(provider(gh.run).fileText(pull(7), head, 'docs/中文 notes/#1.md', signal)).resolves.toBe('line\n')
|
|
expect(gh.calls).toEqual([[
|
|
'api', '--hostname', 'github.com', '-H', 'Accept: application/vnd.github.raw+json',
|
|
`repos/octo/demo/contents/docs/${encodeURIComponent('中文 notes')}/%231.md?ref=${head}`,
|
|
]])
|
|
})
|
|
|
|
it('builds the web page of a file at a commit', () => {
|
|
expect(provider(fakeGh(() => output('')).run).fileUrl(pull(7), head, 'src/a b.ts')).toBe(`https://github.com/octo/demo/blob/${head}/src/a%20b.ts`)
|
|
})
|
|
|
|
it.each([
|
|
['a missing pull request', '', 'GraphQL: Could not resolve to a PullRequest with the number of 7. (repository.pullRequest)',
|
|
{ code: 'not-found', message: 'GraphQL: Could not resolve to a PullRequest with the number of 7. (repository.pullRequest)' }],
|
|
['a missing file', { message: 'Not Found' }, 'gh: Not Found (HTTP 404)', { code: 'not-found', message: 'Not Found' }],
|
|
// What gh 2.100 prints for a pull request over 300 files.
|
|
['a diff over GitHub\'s size limit', '', [
|
|
'could not find pull request diff: HTTP 406: Sorry, the diff exceeded the maximum number of files (300). Consider using \'List pull '
|
|
+ 'requests files\' API or locally cloning the repository instead. (https://api.github.com/repos/octo/demo/pulls/7)',
|
|
'PullRequest.diff too_large',
|
|
].join('\n'), {
|
|
code: 'too-large',
|
|
message: 'could not find pull request diff: HTTP 406: Sorry, the diff exceeded the maximum number of files (300). Consider using '
|
|
+ '\'List pull requests files\' API or locally cloning the repository instead. (https://api.github.com/repos/octo/demo/pulls/7)',
|
|
}],
|
|
['a file over the contents API limit', {
|
|
message: 'This API returns blobs up to 1 MB in size. The requested blob is too large to fetch via the API.',
|
|
errors: [{ resource: 'Blob', field: 'data', code: 'too_large' }],
|
|
}, 'gh: This API returns blobs up to 1 MB in size. The requested blob is too large to fetch via the API. (HTTP 403)', {
|
|
code: 'too-large', message: 'This API returns blobs up to 1 MB in size. The requested blob is too large to fetch via the API.',
|
|
}],
|
|
['another refusal', { message: 'Server Error' }, 'gh: Server Error (HTTP 502)', { code: 'unavailable', message: 'Server Error' }],
|
|
['a missing sign-in', '', 'To get started with GitHub CLI, please run: gh auth login',
|
|
{ code: 'unauthenticated', message: 'To get started with GitHub CLI, please run: gh auth login' }],
|
|
['no output', '', '', { code: 'unavailable', message: 'gh exited with 1' }],
|
|
])('reports a read refused for %s', async (_label, body, stderr, failure) => {
|
|
await expect(provider(fakeGh(() => output(body, 1, stderr)).run).diff(pull(7), signal)).resolves.toEqual(failure)
|
|
})
|
|
|
|
it('reports a read as unavailable when gh cannot start, rethrows other failures, and refuses an invalid commit', async () => {
|
|
await expect(provider(() => Promise.reject(new GhUnavailableError('gh could not start: spawn gh ENOENT'))).diff(pull(7), signal))
|
|
.resolves.toEqual({ code: 'unavailable', message: 'gh could not start: spawn gh ENOENT' })
|
|
await expect(provider(() => Promise.reject(new Error('aborted'))).fileText(pull(7), head, 'a', signal)).rejects.toThrow('aborted')
|
|
await expect(provider(fakeGh(() => output('')).run).fileText(pull(7), 'main', 'a', signal)).rejects.toThrow('forge-github: invalid commit "main"')
|
|
})
|
|
|
|
it('reports output over maxOutputBytes as too large and a run past timeoutMs as unavailable', async () => {
|
|
await expect(provider(() => Promise.reject(new GhOutputLimitError('gh pr output exceeds maxOutputBytes'))).diff(pull(7), signal))
|
|
.resolves.toEqual({ code: 'too-large', message: 'gh pr output exceeds maxOutputBytes' })
|
|
await expect(provider(() => Promise.reject(new GhTimeoutError('gh api timed out after 30000ms'))).fileText(pull(7), head, 'a', signal))
|
|
.resolves.toEqual({ code: 'unavailable', message: 'gh api timed out after 30000ms' })
|
|
})
|
|
})
|