mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-18 22:35:51 +00:00
- /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>
336 lines
12 KiB
TypeScript
336 lines
12 KiB
TypeScript
// 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
|
|
}
|
|
}
|