feat: 添加 GitHub 集成命令(issue、share、autofix-pr)

- /issue: 通过 gh CLI 创建 GitHub issue,支持标签/指派
- /share: 会话日志分享到 GitHub Gist,支持密钥脱敏
- /autofix-pr: 自动修复 CI 失败的 PR,进度追踪
- launchCommand: 共享命令启动器

Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-05-09 23:04:23 +08:00
parent 4f0aa8615a
commit 6766f08e47
27 changed files with 5019 additions and 6 deletions

View File

@@ -0,0 +1,84 @@
import React from 'react';
import { Box, Text } from '@anthropic/ink';
import type { Theme } from '../../utils/theme.js';
export type AutofixPhase =
| 'detecting'
| 'checking_eligibility'
| 'acquiring_lock'
| 'launching'
| 'registered'
| 'done'
| 'error';
interface AutofixProgressProps {
phase: AutofixPhase;
target: string;
sessionUrl?: string;
errorMessage?: string;
}
const PHASE_LABELS: Record<AutofixPhase, string> = {
detecting: 'Detecting repository...',
checking_eligibility: 'Checking remote agent eligibility...',
acquiring_lock: 'Acquiring monitor lock...',
launching: 'Launching remote session...',
registered: 'Session registered',
done: 'Autofix launched',
error: 'Error',
};
const PHASE_ORDER: AutofixPhase[] = [
'detecting',
'checking_eligibility',
'acquiring_lock',
'launching',
'registered',
'done',
];
function phaseIndex(phase: AutofixPhase): number {
return PHASE_ORDER.indexOf(phase);
}
/**
* Inline progress component for /autofix-pr.
* Rendered by the REPL alongside the onDone text message.
*/
export function AutofixProgress({ phase, target, sessionUrl, errorMessage }: AutofixProgressProps): React.ReactElement {
const currentIdx = phaseIndex(phase);
const isError = phase === 'error';
return (
<Box flexDirection="column" marginTop={1} marginBottom={1}>
<Box>
<Text bold>Autofix PR </Text>
<Text color={'claude' as keyof Theme}>{target}</Text>
</Box>
{PHASE_ORDER.map((p, i) => {
const isDone = currentIdx > i;
const isActive = currentIdx === i && !isError;
const symbol = isDone ? '✓' : isActive ? '→' : '·';
const color: keyof Theme = isDone ? 'success' : isActive ? 'warning' : 'subtle';
return (
<Box key={p} marginLeft={2}>
<Text color={color}>
{symbol} {PHASE_LABELS[p]}
</Text>
</Box>
);
})}
{isError && errorMessage && (
<Box marginLeft={2} marginTop={1}>
<Text color={'error' as keyof Theme}> {errorMessage}</Text>
</Box>
)}
{sessionUrl && (
<Box marginTop={1} marginLeft={2}>
<Text color={'subtle' as keyof Theme}>Track: </Text>
<Text color={'claude' as keyof Theme}>{sessionUrl}</Text>
</Box>
)}
</Box>
);
}

View File

@@ -0,0 +1,79 @@
/**
* Tests for AutofixProgress.tsx
* Uses src/utils/staticRender to render Ink components to strings.
* Covers: all AutofixPhase values + sessionUrl + errorMessage branches.
*/
import { describe, expect, test } from 'bun:test';
import * as React from 'react';
import { renderToString } from '../../../utils/staticRender.js';
import { AutofixProgress } from '../AutofixProgress.js';
describe('AutofixProgress', () => {
test('renders target in header', async () => {
const out = await renderToString(<AutofixProgress phase="detecting" target="acme/myrepo#42" />);
expect(out).toContain('acme/myrepo#42');
expect(out).toContain('Autofix PR');
});
test('detecting phase shows arrow on detecting step', async () => {
const out = await renderToString(<AutofixProgress phase="detecting" target="owner/repo#1" />);
// detecting step should be active (→) and later steps pending (·)
expect(out).toContain('Detecting repository');
});
test('checking_eligibility phase renders eligibility label', async () => {
const out = await renderToString(<AutofixProgress phase="checking_eligibility" target="owner/repo#2" />);
expect(out).toContain('Checking remote agent eligibility');
});
test('acquiring_lock phase renders lock label', async () => {
const out = await renderToString(<AutofixProgress phase="acquiring_lock" target="owner/repo#3" />);
expect(out).toContain('Acquiring monitor lock');
});
test('launching phase renders launching label', async () => {
const out = await renderToString(<AutofixProgress phase="launching" target="owner/repo#4" />);
expect(out).toContain('Launching remote session');
});
test('registered phase renders registered label', async () => {
const out = await renderToString(<AutofixProgress phase="registered" target="owner/repo#5" />);
expect(out).toContain('Session registered');
});
test('done phase renders done label', async () => {
const out = await renderToString(<AutofixProgress phase="done" target="owner/repo#6" />);
expect(out).toContain('Autofix launched');
});
test('error phase renders error message when provided', async () => {
const out = await renderToString(
<AutofixProgress phase="error" target="owner/repo#7" errorMessage="Something went wrong" />,
);
expect(out).toContain('Something went wrong');
});
test('error phase with errorMessage shows the message', async () => {
const out = await renderToString(
<AutofixProgress phase="error" target="owner/repo#8" errorMessage="session_create_failed" />,
);
expect(out).toContain('session_create_failed');
});
test('error phase without errorMessage does not crash', async () => {
const out = await renderToString(<AutofixProgress phase="error" target="owner/repo#9" />);
expect(out).toContain('owner/repo#9');
});
test('sessionUrl is rendered when provided', async () => {
const url = 'https://claude.ai/session/abc123';
const out = await renderToString(<AutofixProgress phase="done" target="owner/repo#10" sessionUrl={url} />);
expect(out).toContain(url);
expect(out).toContain('Track');
});
test('sessionUrl absent — no Track line shown', async () => {
const out = await renderToString(<AutofixProgress phase="registered" target="owner/repo#11" />);
expect(out).not.toContain('Track');
});
});

View File

@@ -0,0 +1,74 @@
import { beforeAll, describe, expect, mock, test } from 'bun:test'
// Must mock bun:bundle before importing index
mock.module('bun:bundle', () => ({
feature: (_name: string) => true,
}))
let cmd: {
isEnabled?: () => boolean
getBridgeInvocationError?: (args: string) => string | undefined
load?: () => Promise<unknown>
}
let getBridgeInvocationError: ((args: string) => string | undefined) | undefined
beforeAll(async () => {
const mod = await import('../index.js')
cmd = mod.default as typeof cmd
getBridgeInvocationError = cmd.getBridgeInvocationError
})
describe('autofixPr isEnabled', () => {
test('isEnabled returns a boolean', () => {
// In Bun test environment, feature() from bun:bundle is a compile-time macro.
// The mock.module('bun:bundle') intercept is used to allow the import to
// succeed, but the actual macro value is resolved at build time (not runtime).
// In the test runner (non-bundle mode) feature() returns false.
// We just verify the function is callable and returns a boolean.
const result = cmd.isEnabled?.()
expect(typeof result).toBe('boolean')
})
})
describe('autofixPr load', () => {
test('load function exists on the command', () => {
// Just verify load is a function (don't call it — calling it imports
// launchAutofixPr.js which would set process-level mocks interfering
// with launchAutofixPr.test.ts)
expect(typeof cmd.load).toBe('function')
})
})
describe('autofixPr getBridgeInvocationError', () => {
test('empty string returns error', () => {
const err = getBridgeInvocationError?.('')
expect(err).toBe('PR number required, e.g. /autofix-pr 386')
})
test('"stop" returns undefined (no error)', () => {
expect(getBridgeInvocationError?.('stop')).toBeUndefined()
})
test('"off" returns undefined (no error)', () => {
expect(getBridgeInvocationError?.('off')).toBeUndefined()
})
test('digit-only returns undefined (no error)', () => {
expect(getBridgeInvocationError?.('386')).toBeUndefined()
})
test('cross-repo syntax returns undefined (no error)', () => {
expect(
getBridgeInvocationError?.('anthropics/claude-code#999'),
).toBeUndefined()
})
test('invalid args returns error string', () => {
const err = getBridgeInvocationError?.('not valid!!')
expect(err).toMatch(/Invalid args/)
})
test('load is defined as an async function', () => {
expect(typeof cmd.load).toBe('function')
})
})

View File

@@ -0,0 +1,392 @@
import {
afterEach,
beforeAll,
beforeEach,
describe,
expect,
mock,
test,
} from 'bun:test'
import type { LocalJSXCommandCall } from '../../../types/command.js'
import { debugMock } from '../../../../tests/mocks/debug.js'
import { logMock } from '../../../../tests/mocks/log.js'
// ── Mock module-level side effects before any imports ──
mock.module('src/utils/log.ts', logMock)
mock.module('src/utils/debug.ts', debugMock)
mock.module('bun:bundle', () => ({
feature: (_name: string) => true,
}))
// ── Core dependencies ──
type TeleportResult = { id: string; title: string } | null
const teleportMock = mock(
(): Promise<TeleportResult> =>
Promise.resolve({ id: 'session-123', title: 'Autofix PR: acme/myrepo#42' }),
)
mock.module('src/utils/teleport.js', () => ({
teleportToRemote: teleportMock,
// Stubs for other exports — Bun mock-module is process-level, so when
// run combined with teleport-command tests these would otherwise leak as
// undefined and crash. Keep here in sync with utils/teleport.tsx exports
// that any other test in this process might import transitively.
teleportResumeCodeSession: mock(() =>
Promise.resolve({ branch: null, messages: [], error: null }),
),
validateGitState: mock(() => Promise.resolve()),
validateSessionRepository: mock(() => Promise.resolve({ ok: true })),
checkOutTeleportedSessionBranch: mock(() =>
Promise.resolve({ branchName: 'main', branchError: null }),
),
processMessagesForTeleportResume: mock((m: unknown[]) => m),
teleportFromSessionsAPI: mock(() =>
Promise.resolve({ branch: null, messages: [], error: null }),
),
teleportToRemoteWithErrorHandling: mock(() => Promise.resolve(null)),
}))
const registerMock = mock(() => ({
taskId: 'task-abc',
sessionId: 'session-123',
cleanup: () => {},
}))
const checkEligibilityMock = mock(() =>
Promise.resolve({ eligible: true as const }),
)
const getSessionUrlMock = mock(
(id: string) => `https://claude.ai/session/${id}`,
)
mock.module('src/tasks/RemoteAgentTask/RemoteAgentTask.js', () => ({
checkRemoteAgentEligibility: checkEligibilityMock,
registerRemoteAgentTask: registerMock,
getRemoteTaskSessionUrl: getSessionUrlMock,
formatPreconditionError: (e: { type: string }) => e.type,
}))
const detectRepoMock = mock(() =>
Promise.resolve({ host: 'github.com', owner: 'acme', name: 'myrepo' }),
)
mock.module('src/utils/detectRepository.js', () => ({
detectCurrentRepositoryWithHost: detectRepoMock,
}))
const logEventMock = mock(() => {})
mock.module('src/services/analytics/index.js', () => ({
logEvent: logEventMock,
logEventAsync: mock(() => Promise.resolve()),
_resetForTesting: mock(() => {}),
attachAnalyticsSink: mock(() => {}),
stripProtoFields: mock((v: unknown) => v),
}))
const noop = () => {}
mock.module('src/bootstrap/state.js', () => ({
getSessionId: () => 'parent-session-id',
getParentSessionId: () => undefined,
// Additional exports needed by transitive imports (e.g. cwd.ts, sandbox-adapter.ts)
getCwdState: () => '/mock/cwd',
getOriginalCwd: () => '/mock/cwd',
getSessionProjectDir: () => null,
getProjectRoot: () => '/mock/project',
setCwdState: noop,
setOriginalCwd: noop,
setLastAPIRequestMessages: noop,
getIsNonInteractiveSession: () => false,
addSlowOperation: noop,
}))
// Mock skillDetect so initialMessage is deterministic across CI environments
// (real existsSync would depend on .claude/skills/* in the working dir).
mock.module('src/commands/autofix-pr/skillDetect.js', () => ({
detectAutofixSkills: () => [] as string[],
formatSkillsHint: () => '',
}))
// ── Import SUT after mocks ──
let callAutofixPr: LocalJSXCommandCall
let clearActiveMonitor: () => void
let getActiveMonitor: () => unknown
beforeAll(async () => {
const sut = await import('../launchAutofixPr.js')
callAutofixPr = sut.callAutofixPr
const state = await import('../monitorState.js')
clearActiveMonitor = state.clearActiveMonitor
getActiveMonitor = state.getActiveMonitor
})
// Helper context
function makeContext() {
return { abortController: new AbortController() } as Parameters<
typeof callAutofixPr
>[1]
}
const onDone = mock((_result?: string, _opts?: unknown) => {})
beforeEach(() => {
teleportMock.mockClear()
registerMock.mockClear()
detectRepoMock.mockClear()
checkEligibilityMock.mockClear()
logEventMock.mockClear()
onDone.mockClear()
clearActiveMonitor()
})
afterEach(() => {
clearActiveMonitor()
})
describe('callAutofixPr', () => {
test('start with PR number teleports with correct args', async () => {
await callAutofixPr(onDone, makeContext(), '42')
expect(teleportMock).toHaveBeenCalledWith(
expect.objectContaining({
source: 'autofix_pr',
useDefaultEnvironment: true,
githubPr: { owner: 'acme', repo: 'myrepo', number: 42 },
branchName: 'refs/pull/42/head',
skipBundle: true,
}),
)
})
test('teleport call does NOT pass reuseOutcomeBranch (refs/pull/*/head is not pushable)', async () => {
await callAutofixPr(onDone, makeContext(), '42')
expect(teleportMock).toHaveBeenCalled()
expect(teleportMock).not.toHaveBeenCalledWith(
expect.objectContaining({ reuseOutcomeBranch: expect.anything() }),
)
})
test('start registers remote agent task with correct type', async () => {
await callAutofixPr(onDone, makeContext(), '42')
expect(registerMock).toHaveBeenCalledWith(
expect.objectContaining({
remoteTaskType: 'autofix-pr',
isLongRunning: true,
}),
)
})
test('cross-repo syntax matching cwd repo is accepted', async () => {
// detectRepo mock returns acme/myrepo by default — pass a matching
// cross-repo arg and verify teleport is called normally.
await callAutofixPr(onDone, makeContext(), 'acme/myrepo#999')
expect(teleportMock).toHaveBeenCalledWith(
expect.objectContaining({
githubPr: { owner: 'acme', repo: 'myrepo', number: 999 },
}),
)
})
test('cross-repo syntax NOT matching cwd repo is rejected with repo_mismatch', async () => {
// detectRepo mock returns acme/myrepo; pass a mismatching cross-repo arg.
await callAutofixPr(onDone, makeContext(), 'anthropics/claude-code#999')
expect(teleportMock).not.toHaveBeenCalled()
const firstArg = onDone.mock.calls[0]?.[0] as string
expect(firstArg).toMatch(/Cross-repo autofix is not supported/)
})
test('singleton lock blocks second start for different PR', async () => {
await callAutofixPr(onDone, makeContext(), '42')
onDone.mockClear()
await callAutofixPr(onDone, makeContext(), '99')
const firstArg = onDone.mock.calls[0]?.[0] as string
expect(firstArg).toMatch(/already monitoring/)
expect(firstArg).toMatch(/Run \/autofix-pr stop first/)
})
test('same PR number while monitoring returns already monitoring message', async () => {
await callAutofixPr(onDone, makeContext(), '42')
onDone.mockClear()
await callAutofixPr(onDone, makeContext(), '42')
const firstArg = onDone.mock.calls[0]?.[0] as string
expect(firstArg).toMatch(/Already monitoring/)
})
test('stop sub-command clears monitor and calls onDone', async () => {
await callAutofixPr(onDone, makeContext(), '42')
onDone.mockClear()
await callAutofixPr(onDone, makeContext(), 'stop')
expect(getActiveMonitor()).toBeNull()
const firstArg = onDone.mock.calls[0]?.[0] as string
expect(firstArg).toMatch(/Stopped local monitoring/)
})
test('stop with no active monitor reports no active monitor', async () => {
await callAutofixPr(onDone, makeContext(), 'stop')
const firstArg = onDone.mock.calls[0]?.[0] as string
expect(firstArg).toMatch(/No active autofix monitor/)
})
test('freeform prompt returns not supported message', async () => {
await callAutofixPr(onDone, makeContext(), 'please fix the failing test')
const firstArg = onDone.mock.calls[0]?.[0] as string
expect(firstArg).toMatch(/not yet supported/)
})
test('teleport failure calls onDone with error', async () => {
teleportMock.mockImplementationOnce(() => Promise.resolve(null))
await callAutofixPr(onDone, makeContext(), '42')
const firstArg = onDone.mock.calls[0]?.[0] as string
expect(firstArg).toMatch(/Autofix PR failed/)
expect(logEventMock).toHaveBeenCalledWith(
'tengu_autofix_pr_result',
expect.objectContaining({
result: 'failed',
error_code: 'session_create_failed',
}),
)
})
test('repo not on github.com calls onDone with error', async () => {
detectRepoMock.mockImplementationOnce(() =>
Promise.resolve({ host: 'bitbucket.org', owner: 'acme', name: 'myrepo' }),
)
await callAutofixPr(onDone, makeContext(), '42')
const firstArg = onDone.mock.calls[0]?.[0] as string
expect(firstArg).toMatch(/Autofix PR failed/)
})
test('eligibility check blocks non-no_remote_environment errors', async () => {
checkEligibilityMock.mockImplementationOnce(() =>
Promise.resolve({
eligible: false,
errors: [{ type: 'not_authenticated' }],
} as unknown as { eligible: true }),
)
await callAutofixPr(onDone, makeContext(), '42')
const firstArg = onDone.mock.calls[0]?.[0] as string
expect(firstArg).toMatch(/Autofix PR failed/)
expect(teleportMock).not.toHaveBeenCalled()
})
test('invalid args → invalid action message (lines 72-78)', async () => {
// parseAutofixArgs('') returns { action: 'invalid', reason: 'empty' }
await callAutofixPr(onDone, makeContext(), '')
const firstArg = onDone.mock.calls[0]?.[0] as string
expect(firstArg).toMatch(/Invalid args/)
expect(teleportMock).not.toHaveBeenCalled()
})
test('cross-repo with pr_number_out_of_range → invalid action (lines 72-78)', async () => {
// parsePrNumber('0') returns null → invalid action
await callAutofixPr(onDone, makeContext(), 'acme/myrepo#0')
const firstArg = onDone.mock.calls[0]?.[0] as string
expect(firstArg).toMatch(/Invalid args/)
})
test('detectCurrentRepositoryWithHost throws → session_create_failed (lines 70-76)', async () => {
detectRepoMock.mockImplementationOnce(() =>
Promise.reject(new Error('git error: not a repository')),
)
await callAutofixPr(onDone, makeContext(), '42')
const firstArg = onDone.mock.calls[0]?.[0] as string
expect(firstArg).toMatch(/Autofix PR failed/)
expect(teleportMock).not.toHaveBeenCalled()
})
test('detectCurrentRepositoryWithHost returns null → session_create_failed (lines 108-115)', async () => {
detectRepoMock.mockImplementationOnce(() =>
Promise.resolve(
null as unknown as { host: string; owner: string; name: string },
),
)
await callAutofixPr(onDone, makeContext(), '42')
const firstArg = onDone.mock.calls[0]?.[0] as string
expect(firstArg).toMatch(/Autofix PR failed/)
expect(firstArg).toMatch(/Cannot detect GitHub repo/)
expect(teleportMock).not.toHaveBeenCalled()
})
test('teleportToRemote throws → teleport_failed error (lines 253-259)', async () => {
teleportMock.mockImplementationOnce(() =>
Promise.reject(new Error('network timeout')),
)
await callAutofixPr(onDone, makeContext(), '42')
const firstArg = onDone.mock.calls[0]?.[0] as string
expect(firstArg).toMatch(/Autofix PR failed/)
expect(firstArg).toMatch(/teleport failed/)
// Lock must be released
const { getActiveMonitor } = await import('../monitorState.js')
expect(getActiveMonitor()).toBeNull()
})
test('registerRemoteAgentTask throws → registration_failed error (lines 287-296)', async () => {
registerMock.mockImplementationOnce(() => {
throw new Error('registration error: session limit exceeded')
})
await callAutofixPr(onDone, makeContext(), '42')
const firstArg = onDone.mock.calls[0]?.[0] as string
expect(firstArg).toMatch(/Autofix PR failed/)
expect(firstArg).toMatch(/task registration failed/)
// Lock must be released
const { getActiveMonitor } = await import('../monitorState.js')
expect(getActiveMonitor()).toBeNull()
})
test('outer catch: checkRemoteAgentEligibility throws → outer catch (lines 315-323)', async () => {
// checkRemoteAgentEligibility is awaited without an inner try/catch.
// If it throws, the error bubbles to the outermost catch at lines 315-323.
checkEligibilityMock.mockImplementationOnce(() =>
Promise.reject(new Error('unexpected eligibility check error')),
)
await callAutofixPr(onDone, makeContext(), '42')
const firstArg = onDone.mock.calls[0]?.[0] as string
expect(firstArg).toMatch(/Autofix PR failed/)
expect(logEventMock).toHaveBeenCalledWith(
'tengu_autofix_pr_result',
expect.objectContaining({ error_code: 'exception' }),
)
})
test('captureFailMsg called via onBundleFail when teleport returns null (line 237)', async () => {
// When teleportToRemote calls onBundleFail before returning null,
// captureFailMsg captures the message and it's used in the !session branch.
teleportMock.mockImplementationOnce(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
((opts: any) => {
opts?.onBundleFail?.('bundle creation failed: disk full')
return Promise.resolve(null)
}) as unknown as Parameters<
typeof teleportMock.mockImplementationOnce
>[0],
)
await callAutofixPr(onDone, makeContext(), '42')
const firstArg = onDone.mock.calls[0]?.[0] as string
expect(firstArg).toMatch(/Autofix PR failed/)
// The captured message should appear in the error
expect(firstArg).toMatch(/bundle creation failed/)
})
test('eligibility check passes through no_remote_environment error', async () => {
checkEligibilityMock.mockImplementationOnce(() =>
Promise.resolve({
eligible: false,
errors: [{ type: 'no_remote_environment' }],
} as unknown as { eligible: true }),
)
await callAutofixPr(onDone, makeContext(), '42')
// Should still proceed — no_remote_environment is tolerated
expect(teleportMock).toHaveBeenCalled()
})
})
// Cover ../index.ts load() — placed in this test file so all the heavy mocks
// (teleport / detectRepository / RemoteAgentTask / bootstrap-state / analytics /
// skillDetect) are already registered when load() dynamically imports
// launchAutofixPr.js. Doing this in autofix-pr/__tests__/index.test.ts would
// pollute this file's mocks via cross-file ESM symbol binding.
describe('autofix-pr/index.ts load()', () => {
test('load() resolves and exposes call function', async () => {
const { default: cmd } = await import('../index.js')
const loaded = await (
cmd as unknown as { load: () => Promise<{ call: unknown }> }
).load()
expect(loaded.call).toBeDefined()
expect(typeof loaded.call).toBe('function')
})
})

View File

@@ -0,0 +1,79 @@
import { beforeEach, describe, expect, test } from 'bun:test'
import {
clearActiveMonitor,
getActiveMonitor,
isMonitoring,
setActiveMonitor,
trySetActiveMonitor,
} from '../monitorState.js'
function makeState(
overrides?: Partial<Parameters<typeof setActiveMonitor>[0]>,
) {
return {
taskId: 'task-1',
owner: 'acme',
repo: 'myrepo',
prNumber: 42,
abortController: new AbortController(),
startedAt: Date.now(),
...overrides,
}
}
describe('monitorState', () => {
beforeEach(() => {
clearActiveMonitor()
})
test('getActiveMonitor returns null when nothing set', () => {
expect(getActiveMonitor()).toBeNull()
})
test('setActiveMonitor stores state and getActiveMonitor returns it', () => {
const state = makeState()
setActiveMonitor(state)
expect(getActiveMonitor()).toBe(state)
})
test('clearActiveMonitor resets state to null', () => {
setActiveMonitor(makeState())
clearActiveMonitor()
expect(getActiveMonitor()).toBeNull()
})
test('isMonitoring returns true for matching owner/repo/prNumber', () => {
setActiveMonitor(makeState())
expect(isMonitoring('acme', 'myrepo', 42)).toBe(true)
})
test('isMonitoring returns false when not monitoring', () => {
expect(isMonitoring('acme', 'myrepo', 42)).toBe(false)
})
test('setActiveMonitor throws when already active', () => {
setActiveMonitor(makeState())
expect(() => setActiveMonitor(makeState({ prNumber: 99 }))).toThrow(
/Monitor already active/,
)
})
test('clearActiveMonitor calls abort on the controller', () => {
const abortController = new AbortController()
setActiveMonitor(makeState({ abortController }))
clearActiveMonitor()
expect(abortController.signal.aborted).toBe(true)
})
test('trySetActiveMonitor returns true when no active monitor', () => {
expect(trySetActiveMonitor(makeState())).toBe(true)
expect(getActiveMonitor()).not.toBeNull()
})
test('trySetActiveMonitor returns false when monitor already active', () => {
expect(trySetActiveMonitor(makeState({ prNumber: 1 }))).toBe(true)
expect(trySetActiveMonitor(makeState({ prNumber: 2 }))).toBe(false)
// First state remains
expect(getActiveMonitor()?.prNumber).toBe(1)
})
})

View File

@@ -0,0 +1,63 @@
import { describe, expect, test } from 'bun:test'
import { parseAutofixArgs } from '../parseArgs.js'
describe('parseAutofixArgs', () => {
test('empty string returns invalid', () => {
expect(parseAutofixArgs('')).toEqual({ action: 'invalid', reason: 'empty' })
})
test('whitespace-only returns invalid', () => {
expect(parseAutofixArgs(' ')).toEqual({
action: 'invalid',
reason: 'empty',
})
})
test('"stop" returns stop action', () => {
expect(parseAutofixArgs('stop')).toEqual({ action: 'stop' })
})
test('"off" returns stop action', () => {
expect(parseAutofixArgs('off')).toEqual({ action: 'stop' })
})
test('"stop" with surrounding whitespace returns stop action', () => {
expect(parseAutofixArgs(' stop ')).toEqual({ action: 'stop' })
})
test('digit-only string returns start with prNumber', () => {
expect(parseAutofixArgs('386')).toEqual({ action: 'start', prNumber: 386 })
})
test('cross-repo owner/repo#n returns start with owner/repo/prNumber', () => {
expect(parseAutofixArgs('anthropics/claude-code#999')).toEqual({
action: 'start',
owner: 'anthropics',
repo: 'claude-code',
prNumber: 999,
})
})
test('cross-repo with dots in owner/repo', () => {
expect(parseAutofixArgs('my.org/my.repo#42')).toEqual({
action: 'start',
owner: 'my.org',
repo: 'my.repo',
prNumber: 42,
})
})
test('freeform text returns freeform action', () => {
expect(parseAutofixArgs('fix the CI please')).toEqual({
action: 'freeform',
prompt: 'fix the CI please',
})
})
test('invalid pattern (no hash) returns freeform', () => {
expect(parseAutofixArgs('owner/repo')).toEqual({
action: 'freeform',
prompt: 'owner/repo',
})
})
})

View File

@@ -0,0 +1,30 @@
import { randomUUID } from 'node:crypto'
import { getSessionId } from '../../bootstrap/state.js'
import type { SessionId } from '../../types/ids.js'
export type AutofixTeammate = {
agentId: string
agentName: 'autofix-pr'
teamName: '_autofix'
color: undefined
planModeRequired: false
parentSessionId: SessionId
abortController: AbortController
taskId: string
}
export function createAutofixTeammate(
_initialMessage: string,
_target: string,
): AutofixTeammate {
return {
agentId: randomUUID(),
agentName: 'autofix-pr',
teamName: '_autofix',
color: undefined,
planModeRequired: false,
parentSessionId: getSessionId(),
abortController: new AbortController(),
taskId: randomUUID(),
}
}

View File

@@ -1,3 +0,0 @@
import type { Command } from '../../types/command.js'
declare const _default: Command
export default _default

View File

@@ -1 +0,0 @@
export default { isEnabled: () => false, isHidden: true, name: 'stub' }

View File

@@ -0,0 +1,36 @@
import { feature } from 'bun:bundle'
import type { Command } from '../../types/command.js'
// `feature()` from bun:bundle can only appear directly inside an if statement
// or ternary condition (Bun macro restriction). A named function with a
// `return feature(...)` body is the cleanest way to satisfy this constraint
// while keeping the Command object readable.
function isAutofixPrEnabled(): boolean {
return feature('AUTOFIX_PR') ? true : false
}
const autofixPr: Command = {
type: 'local-jsx',
name: 'autofix-pr',
description: 'Auto-fix CI failures on a pull request',
// Avoid `<x>` in hints — REPL markdown renderer eats angle-bracketed
// tokens as HTML tags. Uppercase placeholders survive intact.
argumentHint: 'PR_NUMBER | stop | OWNER/REPO#N',
isEnabled: isAutofixPrEnabled,
isHidden: false,
bridgeSafe: true,
getBridgeInvocationError: (args: string) => {
const trimmed = args.trim()
if (!trimmed) return 'PR number required, e.g. /autofix-pr 386'
if (trimmed === 'stop' || trimmed === 'off') return undefined
if (/^[1-9]\d{0,9}$/.test(trimmed)) return undefined
if (/^[\w.-]+\/[\w.-]+#[1-9]\d{0,9}$/.test(trimmed)) return undefined
return 'Invalid args. Use /autofix-pr <pr-number> | stop | <owner>/<repo>#<n>'
},
load: async () => {
const m = await import('./launchAutofixPr.js')
return { call: m.callAutofixPr }
},
}
export default autofixPr

View File

@@ -0,0 +1,335 @@
// NOTE: subscribePR (KAIROS_GITHUB_WEBHOOKS feature) is omitted here.
// The kairos client is not fully available in this repo. The feature-gated
// call is a nice-to-have and safe to skip — teleport + registerRemoteAgentTask
// is sufficient for the core autofix flow.
import React from 'react'
import { feature } from 'bun:bundle'
import {
type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
logEvent,
} from '../../services/analytics/index.js'
import {
checkRemoteAgentEligibility,
formatPreconditionError,
getRemoteTaskSessionUrl,
registerRemoteAgentTask,
type BackgroundRemoteSessionPrecondition,
} from '../../tasks/RemoteAgentTask/RemoteAgentTask.js'
import type { LocalJSXCommandCall } from '../../types/command.js'
import { detectCurrentRepositoryWithHost } from '../../utils/detectRepository.js'
import { teleportToRemote } from '../../utils/teleport.js'
import { AutofixProgress } from './AutofixProgress.js'
import { createAutofixTeammate } from './inProcessAgent.js'
import {
clearActiveMonitor,
getActiveMonitor,
isMonitoring,
trySetActiveMonitor,
} from './monitorState.js'
import { parseAutofixArgs } from './parseArgs.js'
import { detectAutofixSkills, formatSkillsHint } from './skillDetect.js'
function makeErrorText(message: string, code: string): string {
logEvent('tengu_autofix_pr_result', {
result:
'failed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
error_code:
code as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
return `Autofix PR failed: ${message}`
}
export const callAutofixPr: LocalJSXCommandCall = async (
onDone,
context,
args,
) => {
try {
const parsed = parseAutofixArgs(args)
// 1. stop sub-command
if (parsed.action === 'stop') {
const m = getActiveMonitor()
if (!m) {
onDone('No active autofix monitor.', { display: 'system' })
return null
}
clearActiveMonitor()
// Honest message: the local lock is released and any in-flight
// teleport request is aborted, but a CCR session that has already
// started running on the cloud will continue until it completes or is
// cancelled from claude.ai/code.
onDone(
`Stopped local monitoring of ${m.repo}#${m.prNumber}. Any already-running remote session continues until it finishes or is cancelled from claude.ai/code.`,
{ display: 'system' },
)
return null
}
// 2. invalid
if (parsed.action === 'invalid') {
onDone(
`Invalid args: ${parsed.reason}. Use /autofix-pr <pr-number> | stop | <owner>/<repo>#<n>`,
{
display: 'system',
},
)
return null
}
// 3. freeform — not yet supported
if (parsed.action === 'freeform') {
onDone(
'Freeform prompt mode not yet supported. Use /autofix-pr <pr-number>.',
{
display: 'system',
},
)
return null
}
// 4. start. has_repo_path tracks whether the user supplied an explicit
// owner/repo via cross-repo syntax (vs relying on directory detection).
logEvent('tengu_autofix_pr_started', {
action:
'start' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
has_pr_number:
'true' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
has_repo_path: String(
!!(parsed.owner && parsed.repo),
) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
// 4.1 resolve owner/repo. Always detect cwd repo first because teleport
// takes the git source from the working directory; cross-repo args that
// don't match cwd would silently work on the wrong repo.
let detected: { host: string; owner: string; name: string } | null
try {
detected = await detectCurrentRepositoryWithHost()
} catch {
onDone(
makeErrorText(
'Cannot detect GitHub repo from current directory.',
'session_create_failed',
),
{ display: 'system' },
)
return null
}
if (!detected || detected.host !== 'github.com') {
onDone(
makeErrorText(
'Cannot detect GitHub repo from current directory.',
'session_create_failed',
),
{ display: 'system' },
)
return null
}
// Cross-repo args (owner/repo#n) must match the current working directory;
// teleport's git source is taken from cwd, so a mismatch would create a
// session against the wrong repo. Accept both as a safety check rather
// than as a real cross-repo capability — true cross-repo support requires
// a separate clone path not yet implemented here.
if (
(parsed.owner && parsed.owner !== detected.owner) ||
(parsed.repo && parsed.repo !== detected.name)
) {
onDone(
makeErrorText(
`Cross-repo autofix is not supported from this directory. Run from ${detected.owner}/${detected.name} or pass only the PR number.`,
'repo_mismatch',
),
{ display: 'system' },
)
return null
}
const owner = detected.owner
const repo = detected.name
const { prNumber } = parsed
// 4.2 singleton lock — already monitoring this exact PR
if (isMonitoring(owner, repo, prNumber)) {
logEvent('tengu_autofix_pr_result', {
result:
'success_rc' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
onDone(`Already monitoring ${repo}#${prNumber} in background.`, {
display: 'system',
})
return null
}
// 4.2b note: the existing-different-PR check is folded into the
// trySetActiveMonitor call below. Doing the check + set atomically there
// avoids a TOCTOU window between the read and the write under concurrent
// invocations.
// 4.3 eligibility check (tolerate no_remote_environment, surface real reasons).
// skipBundle:true matches the teleport call below — autofix needs to push
// back to GitHub, which a git bundle cannot do.
const eligibility = await checkRemoteAgentEligibility({ skipBundle: true })
if (!eligibility.eligible) {
// Discriminated union: TypeScript narrows `eligibility` here, no cast needed.
const blockers = eligibility.errors.filter(
(e: BackgroundRemoteSessionPrecondition) =>
e.type !== 'no_remote_environment',
)
if (blockers.length > 0) {
const reasons = blockers.map(formatPreconditionError).join('\n')
onDone(
makeErrorText(
`Remote agent not available:\n${reasons}`,
'session_create_failed',
),
{ display: 'system' },
)
return null
}
}
// 4.4 detect skills
const skills = detectAutofixSkills(process.cwd())
const skillsHint = formatSkillsHint(skills)
// 4.5 compose message
const target = `${owner}/${repo}#${prNumber}`
const branchName = `refs/pull/${prNumber}/head`
const initialMessage = `Auto-fix failing CI checks on PR #${prNumber} in ${owner}/${repo}.${skillsHint}`
// 4.6 in-process teammate
const teammate = createAutofixTeammate(initialMessage, target)
// 4.7 acquire lock atomically BEFORE doing any awaits. This closes the
// TOCTOU race where two concurrent invocations both see active=null and
// both try to create remote sessions.
const lockAcquired = trySetActiveMonitor({
taskId: teammate.taskId,
owner,
repo,
prNumber,
abortController: teammate.abortController,
startedAt: Date.now(),
})
if (!lockAcquired) {
const existing = getActiveMonitor()
onDone(
makeErrorText(
`already monitoring ${existing?.repo}#${existing?.prNumber}. Run /autofix-pr stop first.`,
'rc_already_monitoring_other',
),
{ display: 'system' },
)
return null
}
// 4.8 teleport — wire BOTH onBundleFail and onCreateFail so HTTP-layer
// failures (4xx/5xx, expired token, invalid PR ref) reach the user with
// the upstream message instead of the generic fallback. skipBundle:true
// is required for autofix: the remote container must push back to GitHub,
// which a bundle-cloned source cannot do (teleport.tsx documents this).
// Note: refs/pull/<n>/head is not a pushable ref. We do NOT pass
// reuseOutcomeBranch — the orchestrator generates a claude/* branch and
// the user pushes/PRs from claude.ai/code.
let teleportFailMsg: string | undefined
const captureFailMsg = (msg: string) => {
teleportFailMsg = msg
}
let session: { id: string; title: string } | null = null
try {
session = await teleportToRemote({
initialMessage,
source: 'autofix_pr',
branchName,
skipBundle: true,
title: `Autofix PR: ${target}`,
useDefaultEnvironment: true,
signal: teammate.abortController.signal,
githubPr: { owner, repo, number: prNumber },
onBundleFail: captureFailMsg,
onCreateFail: captureFailMsg,
})
} catch (teleErr: unknown) {
clearActiveMonitor(teammate.taskId)
const teleMsg =
teleErr instanceof Error ? teleErr.message : String(teleErr)
onDone(makeErrorText(`teleport failed: ${teleMsg}`, 'teleport_failed'), {
display: 'system',
})
return null
}
if (!session) {
clearActiveMonitor(teammate.taskId)
onDone(
makeErrorText(
teleportFailMsg ?? 'remote session creation failed.',
'session_create_failed',
),
{ display: 'system' },
)
return null
}
// 4.9 register task. If this throws, release the lock so the user can
// retry — the remote CCR session is already created so we surface a
// dedicated error code.
try {
registerRemoteAgentTask({
remoteTaskType: 'autofix-pr',
session,
command: `/autofix-pr ${prNumber}`,
context,
isLongRunning: true,
remoteTaskMetadata: { owner, repo, prNumber },
})
} catch (regErr: unknown) {
clearActiveMonitor(teammate.taskId)
const regMsg = regErr instanceof Error ? regErr.message : String(regErr)
onDone(
makeErrorText(
`task registration failed: ${regMsg}`,
'registration_failed',
),
{ display: 'system' },
)
return null
}
// 4.10 PR webhook subscription (feature-gated, non-fatal)
if (feature('KAIROS_GITHUB_WEBHOOKS')) {
// kairos client not available in this repo — skip silently
}
// 4.11 success
const sessionUrl = getRemoteTaskSessionUrl(session.id)
logEvent('tengu_autofix_pr_result', {
result:
'success_rc' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
// Also call onDone so callers that listen to the callback get notified.
onDone(`Autofix launched for ${target}. Track: ${sessionUrl}`, {
display: 'system',
})
// Return a React progress UI showing the completed pipeline.
// The REPL renders the returned React element inline alongside the text.
return React.createElement(AutofixProgress, {
phase: 'done',
target,
sessionUrl,
})
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err)
logEvent('tengu_autofix_pr_result', {
result:
'failed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
error_code:
'exception' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
})
onDone(`Autofix PR failed: ${msg}`, { display: 'system' })
return null
}
}

View File

@@ -0,0 +1,59 @@
type MonitorState = {
taskId: string
owner: string
repo: string
prNumber: number
abortController: AbortController
startedAt: number
}
let active: MonitorState | null = null
export function getActiveMonitor(): Readonly<MonitorState> | null {
return active
}
/**
* Atomic check-and-set. Returns true if the lock was acquired, false if a
* monitor is already active. Use this instead of getActiveMonitor + setActiveMonitor
* — those two together race because the caller may await between them.
*/
export function trySetActiveMonitor(state: MonitorState): boolean {
if (active) return false
active = state
return true
}
/**
* Sets the active monitor unconditionally. Throws if a monitor is already
* active. Prefer trySetActiveMonitor for race-free acquisition.
*/
export function setActiveMonitor(state: MonitorState): void {
if (active)
throw new Error(`Monitor already active: ${active.repo}#${active.prNumber}`)
active = state
}
/**
* Releases the active monitor. If `taskId` is provided, only releases when the
* active monitor's taskId matches — prevents a late-arriving cleanup from
* clobbering a freshly-acquired lock owned by a different task.
*/
export function clearActiveMonitor(taskId?: string): void {
if (!active) return
if (taskId && active.taskId !== taskId) return
active.abortController.abort()
active = null
}
export function isMonitoring(
owner: string,
repo: string,
prNumber: number,
): boolean {
return (
active?.owner === owner &&
active?.repo === repo &&
active?.prNumber === prNumber
)
}

View File

@@ -0,0 +1,38 @@
export type ParsedArgs =
| { action: 'stop' }
| { action: 'start'; prNumber: number; owner?: string; repo?: string }
| { action: 'freeform'; prompt: string }
| { action: 'invalid'; reason: string }
/**
* Parse a PR-number string. Restricts to 1..9_999_999_999 (110 digits, no
* leading zero) so we never produce 0, negatives, or unsafe integers.
*/
export function parsePrNumber(raw: string): number | null {
if (!/^[1-9]\d{0,9}$/.test(raw)) return null
const n = Number(raw)
return Number.isSafeInteger(n) ? n : null
}
export function parseAutofixArgs(raw: string): ParsedArgs {
const trimmed = raw.trim()
if (!trimmed) return { action: 'invalid', reason: 'empty' }
if (trimmed === 'stop' || trimmed === 'off') return { action: 'stop' }
const bareNum = parsePrNumber(trimmed)
if (bareNum !== null) {
return { action: 'start', prNumber: bareNum }
}
const cross = trimmed.match(/^([\w.-]+)\/([\w.-]+)#(\d+)$/)
if (cross) {
const crossNum = parsePrNumber(cross[3] as string)
if (crossNum === null)
return { action: 'invalid', reason: 'pr_number_out_of_range' }
return {
action: 'start',
owner: cross[1],
repo: cross[2],
prNumber: crossNum,
}
}
return { action: 'freeform', prompt: trimmed }
}

View File

@@ -0,0 +1,16 @@
import { existsSync } from 'node:fs'
import { join } from 'node:path'
export function detectAutofixSkills(cwd: string): string[] {
const candidates = [
'AUTOFIX.md',
'.claude/skills/autofix.md',
'.claude/skills/autofix-pr/SKILL.md',
]
return candidates.filter(rel => existsSync(join(cwd, rel)))
}
export function formatSkillsHint(skills: string[]): string {
if (skills.length === 0) return ''
return ` Run ${skills.join(' and ')} for custom instructions on how to autofix.`
}