Add the experimental forge capability seam: - dsh-experimental-forge: provider-neutral repository and change request references, a provider registry, and shared status polling. Watchers of one change request share one loop; each forge has one batched fetch in flight, and the cadence follows check, mergeability, and lifecycle state. - dsh-experimental-forge-github: GitHub provider that runs the GitHub CLI on the host with a scrubbed environment and reads every due pull request of a host in one GraphQL query. - dsh-experimental-forge-session: a host-only projection derives Session bindings from gh pr create and git push shell results and from plugin:forge-session/binding records; push results trigger head-branch discovery of open pull requests, and Session watchers poll bound change requests through ctx.forge.
204 lines
9.1 KiB
TypeScript
204 lines
9.1 KiB
TypeScript
import { changeRequestKey } from '@deepseek-ai/dsh-experimental-forge'
|
|
import type { ChangeRequestSnapshot } from '@deepseek-ai/dsh-experimental-forge'
|
|
import { describe, expect, it } from 'vitest'
|
|
import { mapHeadResponse, mapSnapshotResponse, snapshotQuery } from '../src/graphql.ts'
|
|
import { checkRun, demo, pull, pullRequestNode, statusContext } from './fixtures.ts'
|
|
|
|
const other = { ...demo, name: 'other' }
|
|
|
|
function single(node: unknown): ChangeRequestSnapshot {
|
|
const query = snapshotQuery([pull(1)])
|
|
const result = mapSnapshotResponse({ data: { r0: { p0: node } } }, query).get(changeRequestKey(pull(1)))
|
|
if (result === undefined || 'code' in result) throw new Error(`expected a snapshot, received ${JSON.stringify(result)}`)
|
|
return result
|
|
}
|
|
|
|
describe('snapshot query', () => {
|
|
it('groups change requests by repository and aliases each one', () => {
|
|
const upper = { ...demo, owner: 'Octo', name: 'Demo' }
|
|
const query = snapshotQuery([pull(1), pull(2, upper), pull(1, upper), pull(7, other)])
|
|
|
|
expect([...query.targets]).toEqual([['r0.p0', pull(1)], ['r0.p1', pull(2, upper)], ['r1.p0', pull(7, other)]])
|
|
expect(query.document).toContain('r0: repository(owner: "octo", name: "demo") { p0: pullRequest(number: 1) { ...ChangeRequestFields } p1: pullRequest(number: 2) { ...ChangeRequestFields } }')
|
|
expect(query.document).toContain('r1: repository(owner: "octo", name: "other") { p0: pullRequest(number: 7) { ...ChangeRequestFields } }')
|
|
expect(query.document).toContain('fragment ChangeRequestFields on PullRequest {')
|
|
})
|
|
|
|
it('refuses owners, names, and numbers that cannot be inlined', () => {
|
|
expect(() => snapshotQuery([pull(1, { ...demo, owner: 'oc"to' })])).toThrow('forge-github: invalid repository "oc\\"to/demo"')
|
|
expect(() => snapshotQuery([pull(1.5)])).toThrow('forge-github: invalid pull request number 1.5')
|
|
expect(() => snapshotQuery([pull(0)])).toThrow('forge-github: invalid pull request number 0')
|
|
})
|
|
})
|
|
|
|
describe('snapshot mapping', () => {
|
|
it('maps the pull request fields into a provider-neutral snapshot', () => {
|
|
expect(single(pullRequestNode(1, {}, [checkRun('lint', 'COMPLETED', 'SUCCESS')]))).toEqual({
|
|
ref: pull(1),
|
|
url: 'https://github.com/octo/demo/pull/1',
|
|
title: 'Change 1',
|
|
state: 'open',
|
|
draft: false,
|
|
author: 'octo',
|
|
headBranch: 'topic-1',
|
|
headSha: 'f'.repeat(40),
|
|
baseBranch: 'master',
|
|
mergeability: 'mergeable',
|
|
reviewDecision: 'none',
|
|
checks: [{ name: 'lint', state: 'success', url: 'https://github.com/octo/demo/actions/runs/1/job/lint', workflow: 'ci' }],
|
|
checksRollup: 'success',
|
|
checksTruncated: false,
|
|
unresolvedThreads: 2,
|
|
updatedAt: '2026-10-09T01:02:03Z',
|
|
})
|
|
})
|
|
|
|
it.each([
|
|
['OPEN', 'open'],
|
|
['CLOSED', 'closed'],
|
|
['MERGED', 'merged'],
|
|
])('maps state %s to %s', (state, expected) => {
|
|
expect(single(pullRequestNode(1, { state })).state).toBe(expected)
|
|
})
|
|
|
|
it.each([
|
|
['MERGEABLE', 'mergeable'],
|
|
['CONFLICTING', 'conflicting'],
|
|
['UNKNOWN', 'unknown'],
|
|
])('maps mergeable %s to %s', (mergeable, expected) => {
|
|
expect(single(pullRequestNode(1, { mergeable })).mergeability).toBe(expected)
|
|
})
|
|
|
|
it.each([
|
|
['APPROVED', 'approved'],
|
|
['CHANGES_REQUESTED', 'changes-requested'],
|
|
['REVIEW_REQUIRED', 'review-required'],
|
|
[null, 'none'],
|
|
])('maps review decision %s to %s', (reviewDecision, expected) => {
|
|
expect(single(pullRequestNode(1, { reviewDecision })).reviewDecision).toBe(expected)
|
|
})
|
|
|
|
it.each([
|
|
['COMPLETED', 'SUCCESS', 'success'],
|
|
['COMPLETED', 'FAILURE', 'failure'],
|
|
['COMPLETED', 'TIMED_OUT', 'failure'],
|
|
['COMPLETED', 'STARTUP_FAILURE', 'failure'],
|
|
['COMPLETED', 'ACTION_REQUIRED', 'failure'],
|
|
['COMPLETED', 'CANCELLED', 'cancelled'],
|
|
['COMPLETED', 'SKIPPED', 'skipped'],
|
|
['COMPLETED', 'NEUTRAL', 'neutral'],
|
|
['COMPLETED', 'STALE', 'neutral'],
|
|
['COMPLETED', null, 'neutral'],
|
|
['IN_PROGRESS', null, 'pending'],
|
|
['QUEUED', null, 'pending'],
|
|
])('maps check run %s/%s to %s', (status, conclusion, expected) => {
|
|
expect(single(pullRequestNode(1, {}, [checkRun('build', status, conclusion)])).checks[0]?.state).toBe(expected)
|
|
})
|
|
|
|
it.each([
|
|
['SUCCESS', 'success'],
|
|
['FAILURE', 'failure'],
|
|
['ERROR', 'failure'],
|
|
['PENDING', 'pending'],
|
|
['EXPECTED', 'pending'],
|
|
])('maps status context %s to %s', (state, expected) => {
|
|
expect(single(pullRequestNode(1, {}, [statusContext('deploy', state)])).checks).toEqual([{ name: 'deploy', state: expected, url: null, workflow: null }])
|
|
})
|
|
|
|
it.each([
|
|
['SUCCESS', 'success'],
|
|
['FAILURE', 'failure'],
|
|
['ERROR', 'failure'],
|
|
['PENDING', 'pending'],
|
|
['EXPECTED', 'pending'],
|
|
[null, 'none'],
|
|
])('maps rollup %s to %s', (state, expected) => {
|
|
expect(single(pullRequestNode(1, {}, [], state)).checksRollup).toBe(expected)
|
|
})
|
|
|
|
it('reads workflows only when GitHub reports one and skips unknown contexts', () => {
|
|
const snapshot = single(pullRequestNode(1, {}, [
|
|
checkRun('a', 'COMPLETED', 'SUCCESS', null),
|
|
{ ...checkRun('b', 'COMPLETED', 'SUCCESS'), checkSuite: null },
|
|
{ __typename: 'FutureContext', name: 'c' },
|
|
]))
|
|
|
|
expect(snapshot.checks.map(check => [check.name, check.workflow])).toEqual([['a', null], ['b', null]])
|
|
})
|
|
|
|
it('marks truncated checks, a hidden author, drafts, and a commit without rollup', () => {
|
|
const truncated = pullRequestNode(1, {
|
|
isDraft: true,
|
|
author: null,
|
|
commits: { nodes: [{ commit: { statusCheckRollup: { state: 'PENDING', contexts: { pageInfo: { hasNextPage: true }, nodes: [] } } } }] },
|
|
})
|
|
|
|
expect(single(truncated)).toMatchObject({ draft: true, author: null, checksTruncated: true })
|
|
expect(single(pullRequestNode(1, { commits: { nodes: [] } }))).toMatchObject({ checks: [], checksRollup: 'none', checksTruncated: false })
|
|
})
|
|
|
|
it('maps path-scoped and global errors from the partial-error response', () => {
|
|
const query = snapshotQuery([pull(1), pull(2), pull(7, other)])
|
|
const body = {
|
|
data: { r0: { p0: pullRequestNode(1), p1: null }, r1: null },
|
|
errors: [
|
|
{ type: 'NOT_FOUND', path: ['r0', 'p1'], locations: [{ line: 1, column: 72 }], message: 'Could not resolve to a PullRequest with the number of 2.' },
|
|
{ type: 'FORBIDDEN', path: ['r1'], message: 'Resource not accessible by integration' },
|
|
],
|
|
}
|
|
|
|
const results = mapSnapshotResponse(body, query)
|
|
|
|
expect(results.get(changeRequestKey(pull(1)))).toMatchObject({ state: 'open' })
|
|
expect(results.get(changeRequestKey(pull(2)))).toEqual({ code: 'not-found', message: 'Could not resolve to a PullRequest with the number of 2.' })
|
|
expect(results.get(changeRequestKey(pull(7, other)))).toEqual({ code: 'unavailable', message: 'Resource not accessible by integration' })
|
|
})
|
|
|
|
it('applies an error without a path to every unresolved change request', () => {
|
|
const query = snapshotQuery([pull(1), pull(2)])
|
|
const results = mapSnapshotResponse({ data: null, errors: [{ message: 'API rate limit exceeded' }] }, query)
|
|
|
|
expect([...results.values()]).toEqual([
|
|
{ code: 'unavailable', message: 'API rate limit exceeded' },
|
|
{ code: 'unavailable', message: 'API rate limit exceeded' },
|
|
])
|
|
})
|
|
|
|
it('reports missing nodes, malformed nodes, and malformed bodies', () => {
|
|
const query = snapshotQuery([pull(1), pull(2)])
|
|
|
|
expect([...mapSnapshotResponse({ data: { r0: { p0: null, p1: { title: 7 } } } }, query).values()]).toEqual([
|
|
{ code: 'not-found', message: 'pull request not found' },
|
|
{ code: 'unavailable', message: 'unexpected pull request fields' },
|
|
])
|
|
expect([...mapSnapshotResponse({}, query).values()]).toEqual([
|
|
{ code: 'not-found', message: 'pull request not found' },
|
|
{ code: 'not-found', message: 'pull request not found' },
|
|
])
|
|
expect([...mapSnapshotResponse('not json', query).values()]).toEqual([
|
|
{ code: 'unavailable', message: 'unexpected GitHub response' },
|
|
{ code: 'unavailable', message: 'unexpected GitHub response' },
|
|
])
|
|
})
|
|
})
|
|
|
|
describe('head branch lookup mapping', () => {
|
|
const body = (defaultBranch: string | null, nodes: { number: number; isCrossRepository: boolean }[]) => ({
|
|
data: { repository: { defaultBranchRef: defaultBranch === null ? null : { name: defaultBranch }, pullRequests: { nodes } } },
|
|
})
|
|
|
|
it('returns same-repository pull requests for the branch', () => {
|
|
expect(mapHeadResponse(body('master', [{ number: 9, isCrossRepository: false }, { number: 8, isCrossRepository: true }]), demo, 'topic')).toEqual([pull(9)])
|
|
expect(mapHeadResponse(body(null, [{ number: 9, isCrossRepository: false }]), demo, 'topic')).toEqual([pull(9)])
|
|
})
|
|
|
|
it('returns nothing for the default branch', () => {
|
|
expect(mapHeadResponse(body('master', [{ number: 9, isCrossRepository: false }]), demo, 'master')).toEqual([])
|
|
})
|
|
|
|
it('throws for an invisible repository or a malformed response', () => {
|
|
expect(() => mapHeadResponse({ data: { repository: null } }, demo, 'topic')).toThrow('forge-github: repository octo/demo is not visible')
|
|
expect(() => mapHeadResponse({ data: {} }, demo, 'topic')).toThrow('forge-github: unexpected GitHub response to the head branch lookup')
|
|
})
|
|
})
|