The forge panel shows the pull requests a Session created or pushed in a right-sidebar tab, with a Session header button and branch label, and lets a new Session start in its own Git worktree from the New Session options row. The forge bundle ships the panel with the forge registry, the GitHub provider, the Session bindings, and an isolated worktree service. The panel listens to the agent-controller/admission stage. It holds the first message of a Session with a worktree request while it creates and enters the worktree as Agent maintenance, and refuses messages while the worktree the Session works in is gone. A failed or stopped creation returns the message to the composer: the card above the composer shows the failure, sending again retries with a new branch name, and Continue without a worktree withdraws the request. A removed worktree can be checked out again or left for the source checkout. A message that enters without admission still gets its worktree at the first step proposal.
225 lines
9.9 KiB
TypeScript
225 lines
9.9 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',
|
|
additions: 128,
|
|
deletions: 14,
|
|
mergeability: 'mergeable',
|
|
reviewDecision: 'none',
|
|
checks: [{
|
|
name: 'lint',
|
|
state: 'success',
|
|
url: 'https://github.com/octo/demo/actions/runs/1/job/lint',
|
|
workflow: 'ci',
|
|
startedAt: '2026-10-09T01:00:00Z',
|
|
completedAt: '2026-10-09T01:02:07Z',
|
|
}],
|
|
checksRollup: 'success',
|
|
checksTruncated: false,
|
|
unresolvedThreads: 2,
|
|
updatedAt: '2026-10-09T01:02:03Z',
|
|
closedAt: null,
|
|
})
|
|
})
|
|
|
|
it('keeps the merge or close time of a finished pull request', () => {
|
|
expect(single(pullRequestNode(1, { state: 'MERGED', closedAt: '2026-10-09T02:00:00Z' })).closedAt).toBe('2026-10-09T02:00:00Z')
|
|
})
|
|
|
|
it('keeps the start time of a running check and no times for a queued one', () => {
|
|
const snapshot = single(pullRequestNode(1, {}, [checkRun('build', 'IN_PROGRESS', null), checkRun('test', 'QUEUED', null)]))
|
|
expect(snapshot.checks.map(check => [check.startedAt, check.completedAt])).toEqual([['2026-10-09T01:00:00Z', null], [null, null]])
|
|
})
|
|
|
|
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, startedAt: null, completedAt: 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')
|
|
})
|
|
})
|