Surface GitHub check suites awaiting approval (#6717)
* Surface GitHub check suites awaiting approval to unblock merge - Query the check-suites API endpoint to find suites with an "action_required" conclusion, which are often workflows awaiting "Approve and run" and do not have any associated check runs. - Map the "action_required" status distinctly instead of treating it as a standard failure or omitting it entirely. - Update the UI to render these suites with a warning icon, a dedicated "Action required" label, and a localized hint explaining that manual approval is required on GitHub. - Count "action_required" checks as failed/blocking when deriving overall PR and task statuses so the UI does not report all checks passing. * Enhance visibility and handling of action-required PR check suites * Include check suite IDs in pending approval check names and URLs to allow navigating directly to the specific workflow run. * Add an "action required" count badge to PR dialog and page checks tabs. * Prioritize action-required checks in the checks preview summary. * Use correct check run state for the action-required fallback hint in the right sidebar details panel. * Add translations for the new status across all supported locales.
This commit is contained in:
parent
82d275c3ac
commit
1c30d28113
|
|
@ -92,19 +92,21 @@ describe('getPRChecks', () => {
|
|||
|
||||
it('queries check-runs by PR head SHA when GitHub remote metadata is available', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
check_runs: [
|
||||
{
|
||||
name: 'build',
|
||||
status: 'completed',
|
||||
conclusion: 'success',
|
||||
html_url: 'https://github.com/acme/widgets/actions/runs/1',
|
||||
details_url: null
|
||||
}
|
||||
]
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
check_runs: [
|
||||
{
|
||||
name: 'build',
|
||||
status: 'completed',
|
||||
conclusion: 'success',
|
||||
html_url: 'https://github.com/acme/widgets/actions/runs/1',
|
||||
details_url: null
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ check_suites: [] }) })
|
||||
|
||||
const checks = await getPRChecks('/repo-root', 42, 'head-oid')
|
||||
|
||||
|
|
@ -123,10 +125,82 @@ describe('getPRChecks', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('falls back to gh pr checks when the head SHA has no check runs', async () => {
|
||||
it('surfaces an action_required check suite that has no check run', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
check_runs: [
|
||||
{
|
||||
name: 'track-community-pr',
|
||||
status: 'completed',
|
||||
conclusion: 'success',
|
||||
html_url: 'https://github.com/acme/widgets/actions/runs/1',
|
||||
details_url: null
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
check_suites: [
|
||||
{
|
||||
id: 1000,
|
||||
status: 'completed',
|
||||
conclusion: 'success',
|
||||
app: { name: 'GitHub Actions' }
|
||||
},
|
||||
{
|
||||
id: 1001,
|
||||
status: 'completed',
|
||||
conclusion: 'action_required',
|
||||
app: { name: 'GitHub Actions' }
|
||||
},
|
||||
{
|
||||
id: 1002,
|
||||
status: 'completed',
|
||||
conclusion: 'action_required',
|
||||
app: { name: 'GitHub Actions' }
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
const checks = await getPRChecks('/repo-root', 42, 'head-oid')
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
['api', '--cache', '60s', 'repos/acme/widgets/commits/head-oid/check-suites?per_page=100'],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
expect(checks).toEqual([
|
||||
{
|
||||
name: 'track-community-pr',
|
||||
status: 'completed',
|
||||
conclusion: 'success',
|
||||
url: 'https://github.com/acme/widgets/actions/runs/1',
|
||||
workflowRunId: 1
|
||||
},
|
||||
{
|
||||
name: 'GitHub Actions #1001',
|
||||
status: 'completed',
|
||||
conclusion: 'action_required',
|
||||
url: 'https://github.com/acme/widgets/commits/head-oid/checks#check-suite-1001'
|
||||
},
|
||||
{
|
||||
name: 'GitHub Actions #1002',
|
||||
status: 'completed',
|
||||
conclusion: 'action_required',
|
||||
url: 'https://github.com/acme/widgets/commits/head-oid/checks#check-suite-1002'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to gh pr checks when the head SHA has no check runs or suites', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ check_runs: [] }) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ check_suites: [] }) })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{ name: 'verify', state: 'PENDING', link: 'https://example.com/verify' }
|
||||
|
|
@ -136,7 +210,7 @@ describe('getPRChecks', () => {
|
|||
const checks = await getPRChecks('/repo-root', 42, 'head-oid')
|
||||
|
||||
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
3,
|
||||
['pr', 'checks', '42', '--json', 'name,state,link', '--repo', 'acme/widgets'],
|
||||
{ cwd: '/repo-root' }
|
||||
)
|
||||
|
|
@ -151,39 +225,76 @@ describe('getPRChecks', () => {
|
|||
])
|
||||
})
|
||||
|
||||
it('maps remaining completed GitHub conclusions to failure', async () => {
|
||||
it('maps stale and startup_failure conclusions to failure and action_required to its own state', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
check_runs: [
|
||||
{
|
||||
name: 'needs-approval',
|
||||
status: 'completed',
|
||||
conclusion: 'action_required',
|
||||
html_url: 'https://github.com/acme/widgets/actions/runs/1',
|
||||
details_url: null
|
||||
},
|
||||
{
|
||||
name: 'old-run',
|
||||
status: 'completed',
|
||||
conclusion: 'stale',
|
||||
html_url: 'https://github.com/acme/widgets/actions/runs/2',
|
||||
details_url: null
|
||||
},
|
||||
{
|
||||
name: 'boot',
|
||||
status: 'completed',
|
||||
conclusion: 'startup_failure',
|
||||
html_url: 'https://github.com/acme/widgets/actions/runs/3',
|
||||
details_url: null
|
||||
}
|
||||
]
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
check_runs: [
|
||||
{
|
||||
name: 'needs-approval',
|
||||
status: 'completed',
|
||||
conclusion: 'action_required',
|
||||
html_url: 'https://github.com/acme/widgets/actions/runs/1',
|
||||
details_url: null
|
||||
},
|
||||
{
|
||||
name: 'old-run',
|
||||
status: 'completed',
|
||||
conclusion: 'stale',
|
||||
html_url: 'https://github.com/acme/widgets/actions/runs/2',
|
||||
details_url: null
|
||||
},
|
||||
{
|
||||
name: 'boot',
|
||||
status: 'completed',
|
||||
conclusion: 'startup_failure',
|
||||
html_url: 'https://github.com/acme/widgets/actions/runs/3',
|
||||
details_url: null
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ check_suites: [] }) })
|
||||
|
||||
const checks = await getPRChecks('/repo-root', 42, 'head-oid')
|
||||
|
||||
expect(checks.map((check) => check.conclusion)).toEqual(['failure', 'failure', 'failure'])
|
||||
expect(checks.map((check) => check.conclusion)).toEqual([
|
||||
'action_required',
|
||||
'failure',
|
||||
'failure'
|
||||
])
|
||||
})
|
||||
|
||||
it('surfaces an action_required suite even when there are zero check runs', async () => {
|
||||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ check_runs: [] }) })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
check_suites: [
|
||||
{
|
||||
id: 1001,
|
||||
status: 'completed',
|
||||
conclusion: 'action_required',
|
||||
app: { name: 'GitHub Actions' }
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
const checks = await getPRChecks('/repo-root', 42, 'head-oid')
|
||||
|
||||
// Why: must not fall through to `gh pr checks` — the suite is the only signal.
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
||||
expect(checks).toEqual([
|
||||
{
|
||||
name: 'GitHub Actions #1001',
|
||||
status: 'completed',
|
||||
conclusion: 'action_required',
|
||||
url: 'https://github.com/acme/widgets/commits/head-oid/checks#check-suite-1001'
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('treats gh pr checks "no checks reported" as an empty check list', async () => {
|
||||
|
|
@ -191,6 +302,7 @@ describe('getPRChecks', () => {
|
|||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ check_runs: [] }) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ check_suites: [] }) })
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('Command failed: gh pr checks 42'), {
|
||||
stderr: "no checks reported on the 'codex/keybindings-toml' branch\n",
|
||||
|
|
@ -210,6 +322,7 @@ describe('getPRChecks', () => {
|
|||
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ check_runs: [] }) })
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ check_suites: [] }) })
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error('Command failed: gh pr checks 42'), {
|
||||
stderr: 'GraphQL: Could not resolve to a PullRequest',
|
||||
|
|
@ -291,6 +404,7 @@ describe('getPRChecks', () => {
|
|||
]
|
||||
})
|
||||
})
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify({ check_suites: [] }) })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3057,15 +3057,28 @@ export async function getPRChecks(
|
|||
details_url: string | null
|
||||
}[]
|
||||
}
|
||||
if (data.check_runs.length > 0) {
|
||||
return data.check_runs.map((d) => ({
|
||||
name: d.name,
|
||||
status: mapCheckRunRESTStatus(d.status),
|
||||
conclusion: mapCheckRunRESTConclusion(d.status, d.conclusion),
|
||||
url: d.details_url || d.html_url || null,
|
||||
...(typeof d.id === 'number' ? { checkRunId: d.id } : {}),
|
||||
workflowRunId: parseActionsRunId(d.details_url || d.html_url || null)
|
||||
}))
|
||||
const checkRuns: PRCheckDetail[] = data.check_runs.map((d) => ({
|
||||
name: d.name,
|
||||
status: mapCheckRunRESTStatus(d.status),
|
||||
conclusion: mapCheckRunRESTConclusion(d.status, d.conclusion),
|
||||
url: d.details_url || d.html_url || null,
|
||||
...(typeof d.id === 'number' ? { checkRunId: d.id } : {}),
|
||||
workflowRunId: parseActionsRunId(d.details_url || d.html_url || null)
|
||||
}))
|
||||
// Why: a workflow awaiting "Approve and run" produces a check SUITE with
|
||||
// no check run, and is absent from statusCheckRollup — so without this
|
||||
// the panel shows "no checks"/"all passing" while auto-merge fails with
|
||||
// "unstable status". Surface them as their own check rows. Fetch even
|
||||
// when there are zero check runs, since that is the exact case GitHub
|
||||
// leaves the PR unstable with nothing to show.
|
||||
const pendingApprovalChecks = await getPendingApprovalCheckSuites(
|
||||
ownerRepo,
|
||||
headSha,
|
||||
ghOptions,
|
||||
options?.noCache
|
||||
)
|
||||
if (checkRuns.length > 0 || pendingApprovalChecks.length > 0) {
|
||||
return [...checkRuns, ...pendingApprovalChecks]
|
||||
}
|
||||
} catch (err) {
|
||||
// Why: a PR can outlive the cached head SHA after force-pushes or remote
|
||||
|
|
@ -3086,6 +3099,88 @@ export async function getPRChecks(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch check SUITES that need manual action (e.g. a workflow awaiting approval).
|
||||
* These have no check run and are absent from statusCheckRollup, yet they keep a
|
||||
* PR in "unstable status" and block auto-merge. We surface one synthetic check
|
||||
* row per such suite so both check panels show it.
|
||||
*/
|
||||
async function getPendingApprovalCheckSuites(
|
||||
ownerRepo: OwnerRepo,
|
||||
headSha: string,
|
||||
ghOptions: GhExecOptions,
|
||||
noCache?: boolean
|
||||
): Promise<PRCheckDetail[]> {
|
||||
const cacheArgs = noCache ? [] : ['--cache', '60s']
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...cacheArgs,
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/commits/${encodeURIComponent(headSha)}/check-suites?per_page=100`
|
||||
],
|
||||
ghOptions
|
||||
)
|
||||
noteRateLimitSpend('core')
|
||||
const data = JSON.parse(stdout) as {
|
||||
check_suites?: {
|
||||
id?: number | null
|
||||
status: string | null
|
||||
conclusion: string | null
|
||||
app?: { name?: string | null; slug?: string | null } | null
|
||||
}[]
|
||||
}
|
||||
return (data.check_suites ?? [])
|
||||
.filter((suite) => suite.conclusion?.toLowerCase() === 'action_required')
|
||||
.map((suite, index) => ({
|
||||
name: getPendingApprovalCheckSuiteName(suite, headSha, index),
|
||||
status: 'completed' as const,
|
||||
conclusion: 'action_required' as const,
|
||||
// Why: check suites expose no per-PR details URL; the checks tab is the
|
||||
// closest actionable destination for approving the run.
|
||||
url: getPendingApprovalCheckSuiteUrl(ownerRepo, headSha, suite.id)
|
||||
}))
|
||||
} catch (err) {
|
||||
// Why: this is a best-effort enrichment; a failed suites lookup must not
|
||||
// blank out the check runs we already fetched successfully.
|
||||
console.warn('getPendingApprovalCheckSuites failed:', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function getPendingApprovalCheckSuiteName(
|
||||
suite: {
|
||||
id?: number | null
|
||||
app?: { name?: string | null; slug?: string | null } | null
|
||||
},
|
||||
headSha: string,
|
||||
index: number
|
||||
): string {
|
||||
const appName = suite.app?.name ?? suite.app?.slug ?? null
|
||||
const suiteId = typeof suite.id === 'number' && Number.isFinite(suite.id) ? `#${suite.id}` : null
|
||||
if (appName && suiteId) {
|
||||
return `${appName} ${suiteId}`
|
||||
}
|
||||
if (appName) {
|
||||
return appName
|
||||
}
|
||||
if (suiteId) {
|
||||
return suiteId
|
||||
}
|
||||
return `${headSha.slice(0, 12)}:${index + 1}`
|
||||
}
|
||||
|
||||
function getPendingApprovalCheckSuiteUrl(
|
||||
ownerRepo: OwnerRepo,
|
||||
headSha: string,
|
||||
suiteId: number | null | undefined
|
||||
): string {
|
||||
const base = `https://github.com/${ownerRepo.owner}/${ownerRepo.repo}/commits/${headSha}/checks`
|
||||
return typeof suiteId === 'number' && Number.isFinite(suiteId)
|
||||
? `${base}#check-suite-${suiteId}`
|
||||
: base
|
||||
}
|
||||
|
||||
function nullableString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 ? value : null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ const conclusionMap: Record<string, PRCheckDetail['conclusion']> = {
|
|||
timed_out: 'timed_out',
|
||||
skipped: 'skipped',
|
||||
neutral: 'neutral',
|
||||
action_required: 'failure',
|
||||
action_required: 'action_required',
|
||||
stale: 'failure',
|
||||
startup_failure: 'failure'
|
||||
}
|
||||
|
|
@ -61,7 +61,10 @@ export function mapCheckConclusion(state: string): PRCheckDetail['conclusion'] {
|
|||
if (s === 'FAILURE' || s === 'FAIL') {
|
||||
return 'failure'
|
||||
}
|
||||
if (s === 'ACTION_REQUIRED' || s === 'STALE' || s === 'STARTUP_FAILURE') {
|
||||
if (s === 'ACTION_REQUIRED') {
|
||||
return 'action_required'
|
||||
}
|
||||
if (s === 'STALE' || s === 'STARTUP_FAILURE') {
|
||||
return 'failure'
|
||||
}
|
||||
if (s === 'CANCELLED') {
|
||||
|
|
@ -134,6 +137,9 @@ export function deriveCheckStatus(rollup: unknown[] | null | undefined): CheckSt
|
|||
conclusion === 'FAILURE' ||
|
||||
conclusion === 'TIMED_OUT' ||
|
||||
conclusion === 'CANCELLED' ||
|
||||
// Why: action_required (e.g. an unapproved workflow run) blocks merge until
|
||||
// someone acts; treat it as needs-attention rather than a silent pass.
|
||||
conclusion === 'ACTION_REQUIRED' ||
|
||||
state === 'FAILURE' ||
|
||||
state === 'ERROR'
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -4284,6 +4284,7 @@ function CommentReplyForm({
|
|||
const CHECK_SORT_ORDER: Record<string, number> = {
|
||||
failure: 0,
|
||||
timed_out: 0,
|
||||
action_required: 0,
|
||||
cancelled: 1,
|
||||
pending: 2,
|
||||
neutral: 3,
|
||||
|
|
@ -4309,6 +4310,9 @@ function getCheckStatusLabel(check: PRCheckDetail): string {
|
|||
if (conclusion === 'timed_out') {
|
||||
return 'Timed out'
|
||||
}
|
||||
if (conclusion === 'action_required') {
|
||||
return 'Action required'
|
||||
}
|
||||
if (conclusion === 'neutral') {
|
||||
return 'Neutral'
|
||||
}
|
||||
|
|
@ -4327,6 +4331,7 @@ function getCheckStatusLabel(check: PRCheckDetail): string {
|
|||
function getCheckCounts(checks: PRCheckDetail[]): {
|
||||
passing: number
|
||||
failing: number
|
||||
needsAction: number
|
||||
pending: number
|
||||
skipped: number
|
||||
neutral: number
|
||||
|
|
@ -4336,6 +4341,8 @@ function getCheckCounts(checks: PRCheckDetail[]): {
|
|||
const conclusion = getCheckConclusion(check)
|
||||
if (conclusion === 'success') {
|
||||
counts.passing += 1
|
||||
} else if (conclusion === 'action_required') {
|
||||
counts.needsAction += 1
|
||||
} else if (['failure', 'cancelled', 'timed_out'].includes(conclusion)) {
|
||||
counts.failing += 1
|
||||
} else if (conclusion === 'skipped') {
|
||||
|
|
@ -4347,7 +4354,7 @@ function getCheckCounts(checks: PRCheckDetail[]): {
|
|||
}
|
||||
return counts
|
||||
},
|
||||
{ passing: 0, failing: 0, pending: 0, skipped: 0, neutral: 0 }
|
||||
{ passing: 0, failing: 0, needsAction: 0, pending: 0, skipped: 0, neutral: 0 }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -4359,6 +4366,11 @@ function getChecksSummaryLabel(checks: PRCheckDetail[]): string {
|
|||
if (counts.failing > 0) {
|
||||
return `${counts.failing} ${counts.failing === 1 ? 'check' : 'checks'} failing`
|
||||
}
|
||||
// Why: action_required (e.g. a workflow awaiting approval) blocks merge but is
|
||||
// not a failure; call it out distinctly so users know a manual step is needed.
|
||||
if (counts.needsAction > 0) {
|
||||
return `${counts.needsAction} ${counts.needsAction === 1 ? 'check needs' : 'checks need'} action`
|
||||
}
|
||||
if (counts.pending > 0) {
|
||||
return `${counts.pending} ${counts.pending === 1 ? 'check' : 'checks'} pending`
|
||||
}
|
||||
|
|
@ -4436,19 +4448,23 @@ function ChecksTab({
|
|||
const SummaryIcon =
|
||||
counts.failing > 0
|
||||
? CHECK_ICON.failure
|
||||
: counts.pending > 0
|
||||
? CHECK_ICON.pending
|
||||
: list.length > 0
|
||||
? CHECK_ICON.success
|
||||
: CircleDashed
|
||||
: counts.needsAction > 0
|
||||
? CHECK_ICON.action_required
|
||||
: counts.pending > 0
|
||||
? CHECK_ICON.pending
|
||||
: list.length > 0
|
||||
? CHECK_ICON.success
|
||||
: CircleDashed
|
||||
const summaryColor =
|
||||
counts.failing > 0
|
||||
? CHECK_COLOR.failure
|
||||
: counts.pending > 0
|
||||
? CHECK_COLOR.pending
|
||||
: list.length > 0
|
||||
? CHECK_COLOR.success
|
||||
: 'text-muted-foreground'
|
||||
: counts.needsAction > 0
|
||||
? CHECK_COLOR.action_required
|
||||
: counts.pending > 0
|
||||
? CHECK_COLOR.pending
|
||||
: list.length > 0
|
||||
? CHECK_COLOR.success
|
||||
: 'text-muted-foreground'
|
||||
const canFixBrokenChecks = Boolean((repoId ?? item.repoId) && failedChecks.length > 0)
|
||||
|
||||
const handleRefresh = useCallback(async (): Promise<PRCheckDetail[] | null> => {
|
||||
|
|
@ -5052,10 +5068,15 @@ function ChecksTab({
|
|||
|
||||
{!state?.error && !hasOutput && !hasAnnotations && !hasJobs && (
|
||||
<div className="text-[12px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.GitHubItemDialog.744197c84d',
|
||||
'No inline output is available for this check.'
|
||||
)}
|
||||
{getCheckConclusion(check) === 'action_required'
|
||||
? translate(
|
||||
'auto.components.GitHubItemDialog.checkActionRequiredHint',
|
||||
'Needs a manual action on GitHub (e.g. approving the run) to unblock merging.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.GitHubItemDialog.744197c84d',
|
||||
'No inline output is available for this check.'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -5141,6 +5162,18 @@ function ChecksTab({
|
|||
className: CHECK_COLOR.failure
|
||||
})
|
||||
}
|
||||
if (counts.needsAction > 0) {
|
||||
countChips.push({
|
||||
label: translate(
|
||||
'auto.components.GitHubItemDialog.checksNeedActionChip',
|
||||
'{{value0}} action required',
|
||||
{
|
||||
value0: counts.needsAction
|
||||
}
|
||||
),
|
||||
className: CHECK_COLOR.action_required
|
||||
})
|
||||
}
|
||||
if (counts.pending > 0) {
|
||||
countChips.push({
|
||||
label: translate('auto.components.GitHubItemDialog.18f80e1329', '{{value0}} pending', {
|
||||
|
|
|
|||
|
|
@ -4288,6 +4288,7 @@ function CommentReplyForm({
|
|||
const CHECK_SORT_ORDER: Record<string, number> = {
|
||||
failure: 0,
|
||||
timed_out: 0,
|
||||
action_required: 0,
|
||||
cancelled: 1,
|
||||
pending: 2,
|
||||
neutral: 3,
|
||||
|
|
@ -4313,6 +4314,9 @@ function getCheckStatusLabel(check: PRCheckDetail): string {
|
|||
if (conclusion === 'timed_out') {
|
||||
return 'Timed out'
|
||||
}
|
||||
if (conclusion === 'action_required') {
|
||||
return 'Action required'
|
||||
}
|
||||
if (conclusion === 'neutral') {
|
||||
return 'Neutral'
|
||||
}
|
||||
|
|
@ -4331,6 +4335,7 @@ function getCheckStatusLabel(check: PRCheckDetail): string {
|
|||
function getCheckCounts(checks: PRCheckDetail[]): {
|
||||
passing: number
|
||||
failing: number
|
||||
needsAction: number
|
||||
pending: number
|
||||
skipped: number
|
||||
neutral: number
|
||||
|
|
@ -4340,6 +4345,8 @@ function getCheckCounts(checks: PRCheckDetail[]): {
|
|||
const conclusion = getCheckConclusion(check)
|
||||
if (conclusion === 'success') {
|
||||
counts.passing += 1
|
||||
} else if (conclusion === 'action_required') {
|
||||
counts.needsAction += 1
|
||||
} else if (['failure', 'cancelled', 'timed_out'].includes(conclusion)) {
|
||||
counts.failing += 1
|
||||
} else if (conclusion === 'skipped') {
|
||||
|
|
@ -4351,7 +4358,7 @@ function getCheckCounts(checks: PRCheckDetail[]): {
|
|||
}
|
||||
return counts
|
||||
},
|
||||
{ passing: 0, failing: 0, pending: 0, skipped: 0, neutral: 0 }
|
||||
{ passing: 0, failing: 0, needsAction: 0, pending: 0, skipped: 0, neutral: 0 }
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -4363,6 +4370,11 @@ function getChecksSummaryLabel(checks: PRCheckDetail[]): string {
|
|||
if (counts.failing > 0) {
|
||||
return `${counts.failing} ${counts.failing === 1 ? 'check' : 'checks'} failing`
|
||||
}
|
||||
// Why: action_required (e.g. a workflow awaiting approval) blocks merge but is
|
||||
// not a failure; call it out distinctly so users know a manual step is needed.
|
||||
if (counts.needsAction > 0) {
|
||||
return `${counts.needsAction} ${counts.needsAction === 1 ? 'check needs' : 'checks need'} action`
|
||||
}
|
||||
if (counts.pending > 0) {
|
||||
return `${counts.pending} ${counts.pending === 1 ? 'check' : 'checks'} pending`
|
||||
}
|
||||
|
|
@ -4535,19 +4547,23 @@ function ChecksTab({
|
|||
const SummaryIcon =
|
||||
counts.failing > 0
|
||||
? CHECK_ICON.failure
|
||||
: counts.pending > 0
|
||||
? CHECK_ICON.pending
|
||||
: list.length > 0
|
||||
? CHECK_ICON.success
|
||||
: CircleDashed
|
||||
: counts.needsAction > 0
|
||||
? CHECK_ICON.action_required
|
||||
: counts.pending > 0
|
||||
? CHECK_ICON.pending
|
||||
: list.length > 0
|
||||
? CHECK_ICON.success
|
||||
: CircleDashed
|
||||
const summaryColor =
|
||||
counts.failing > 0
|
||||
? CHECK_COLOR.failure
|
||||
: counts.pending > 0
|
||||
? CHECK_COLOR.pending
|
||||
: list.length > 0
|
||||
? CHECK_COLOR.success
|
||||
: 'text-muted-foreground'
|
||||
: counts.needsAction > 0
|
||||
? CHECK_COLOR.action_required
|
||||
: counts.pending > 0
|
||||
? CHECK_COLOR.pending
|
||||
: list.length > 0
|
||||
? CHECK_COLOR.success
|
||||
: 'text-muted-foreground'
|
||||
const canFixBrokenChecks = Boolean((repoId ?? item.repoId) && failedChecks.length > 0)
|
||||
|
||||
const handleRefresh = useCallback(async (): Promise<PRCheckDetail[] | null> => {
|
||||
|
|
@ -5145,10 +5161,15 @@ function ChecksTab({
|
|||
|
||||
{!state?.error && !hasOutput && !hasAnnotations && !hasJobs && (
|
||||
<div className="text-[12px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.PullRequestPage.1550675e5f',
|
||||
'No inline output is available for this check.'
|
||||
)}
|
||||
{getCheckConclusion(check) === 'action_required'
|
||||
? translate(
|
||||
'auto.components.PullRequestPage.checkActionRequiredHint',
|
||||
'Needs a manual action on GitHub (e.g. approving the run) to unblock merging.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.PullRequestPage.1550675e5f',
|
||||
'No inline output is available for this check.'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -5270,6 +5291,18 @@ function ChecksTab({
|
|||
className: CHECK_COLOR.failure
|
||||
})
|
||||
}
|
||||
if (counts.needsAction > 0) {
|
||||
countChips.push({
|
||||
label: translate(
|
||||
'auto.components.PullRequestPage.checksNeedActionChip',
|
||||
'{{value0}} action required',
|
||||
{
|
||||
value0: counts.needsAction
|
||||
}
|
||||
),
|
||||
className: CHECK_COLOR.action_required
|
||||
})
|
||||
}
|
||||
if (counts.pending > 0) {
|
||||
countChips.push({
|
||||
label: translate('auto.components.PullRequestPage.88267924d5', '{{value0}} pending', {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,11 @@ function getCheckStatusLabel(check: CheckStatusLike): string {
|
|||
return translate('auto.components.editor.CheckRunDetailsPanel.91a4c7e2b0', 'Cancelled')
|
||||
case 'timed_out':
|
||||
return translate('auto.components.editor.CheckRunDetailsPanel.2f6d8a1c45', 'Timed out')
|
||||
case 'action_required':
|
||||
return translate(
|
||||
'auto.components.editor.CheckRunDetailsPanel.actionRequired',
|
||||
'Action required'
|
||||
)
|
||||
case 'skipped':
|
||||
return translate('auto.components.editor.CheckRunDetailsPanel.7b3e9d4f12', 'Skipped')
|
||||
case 'neutral':
|
||||
|
|
|
|||
|
|
@ -210,4 +210,22 @@ describe('ChecksList expanded check details', () => {
|
|||
expect(stickyBar?.textContent).toContain('View full details')
|
||||
expect(stickyBar?.textContent).not.toContain('View full logs')
|
||||
})
|
||||
|
||||
it('uses resolved details when showing the action-required fallback hint', async () => {
|
||||
renderChecksList({
|
||||
onLoadCheckDetails: async () => ({
|
||||
...checkDetails,
|
||||
conclusion: 'action_required',
|
||||
title: null,
|
||||
jobs: []
|
||||
})
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(container.textContent).toContain('manual action on GitHub')
|
||||
expect(container.textContent).not.toContain('No inline details are available')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -118,7 +118,8 @@ export const CHECK_ICON: Record<string, React.ComponentType<{ className?: string
|
|||
neutral: CircleDashed,
|
||||
skipped: CircleMinus,
|
||||
cancelled: CircleX,
|
||||
timed_out: CircleX
|
||||
timed_out: CircleX,
|
||||
action_required: AlertTriangle
|
||||
}
|
||||
|
||||
export const CHECK_COLOR: Record<string, string> = {
|
||||
|
|
@ -128,7 +129,8 @@ export const CHECK_COLOR: Record<string, string> = {
|
|||
neutral: 'text-muted-foreground',
|
||||
skipped: 'text-muted-foreground/60',
|
||||
cancelled: 'text-muted-foreground/60',
|
||||
timed_out: 'text-rose-500'
|
||||
timed_out: 'text-rose-500',
|
||||
action_required: 'text-amber-500'
|
||||
}
|
||||
|
||||
type ConflictReview = {
|
||||
|
|
@ -504,6 +506,7 @@ export function ConflictTriageStrip({
|
|||
const CHECK_SORT_ORDER: Record<string, number> = {
|
||||
failure: 0,
|
||||
timed_out: 0,
|
||||
action_required: 0,
|
||||
cancelled: 1,
|
||||
pending: 2,
|
||||
neutral: 3,
|
||||
|
|
@ -539,7 +542,12 @@ function getCheckConclusion(check: PRCheckDetail): NonNullable<PRCheckDetail['co
|
|||
}
|
||||
|
||||
function isFailedCheck(check: PRCheckDetail): boolean {
|
||||
return ['failure', 'cancelled', 'timed_out'].includes(getCheckConclusion(check))
|
||||
// Why: action_required blocks merge just like a failure, so it must count as
|
||||
// not-passing — otherwise the summary reads "all checks passing" while
|
||||
// auto-merge stays blocked.
|
||||
return ['failure', 'cancelled', 'timed_out', 'action_required'].includes(
|
||||
getCheckConclusion(check)
|
||||
)
|
||||
}
|
||||
|
||||
function isFailureState(state: string | null | undefined): boolean {
|
||||
|
|
@ -560,6 +568,9 @@ function getCheckStatusLabel(check: PRCheckDetail): string {
|
|||
if (conclusion === 'timed_out') {
|
||||
return 'Timed out'
|
||||
}
|
||||
if (conclusion === 'action_required') {
|
||||
return 'Action required'
|
||||
}
|
||||
if (conclusion === 'neutral') {
|
||||
return 'Neutral'
|
||||
}
|
||||
|
|
@ -905,10 +916,15 @@ function CheckRunDetails({
|
|||
|
||||
{!state?.error && !hasOutput && !hasAnnotations && !hasJobs && (
|
||||
<div className="text-[12px] text-muted-foreground">
|
||||
{translate(
|
||||
'auto.components.right.sidebar.checks.panel.content.e15a8b77ef',
|
||||
'No inline details are available for this check.'
|
||||
)}
|
||||
{getCheckConclusion(detailsStatusCheck) === 'action_required'
|
||||
? translate(
|
||||
'auto.components.right.sidebar.checks.panel.content.actionRequiredHint',
|
||||
'Needs a manual action on GitHub (e.g. approving the run) to unblock merging.'
|
||||
)
|
||||
: translate(
|
||||
'auto.components.right.sidebar.checks.panel.content.e15a8b77ef',
|
||||
'No inline details are available for this check.'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -971,10 +987,7 @@ export function ChecksList({
|
|||
[checkDetailsContextKey, sorted]
|
||||
)
|
||||
const passingCount = checks.filter((c) => c.conclusion === 'success').length
|
||||
const failingCount = checks.filter(
|
||||
(c) =>
|
||||
c.conclusion === 'failure' || c.conclusion === 'cancelled' || c.conclusion === 'timed_out'
|
||||
).length
|
||||
const failingCount = checks.filter((c) => isFailedCheck(c)).length
|
||||
const pendingCount = checks.filter(
|
||||
(c) => c.conclusion === 'pending' || c.conclusion === null
|
||||
).length
|
||||
|
|
|
|||
|
|
@ -273,4 +273,41 @@ describe('buildParentPrChecksProjection', () => {
|
|||
expect(projection.rows[0]?.detailNames).toEqual(['build'])
|
||||
expect(projection.rows[0]?.status).toBe('failing')
|
||||
})
|
||||
|
||||
it('prioritizes action-required check detail names before truncating the preview', () => {
|
||||
const repo = makeRepo()
|
||||
const worktree = makeWorktree({ id: 'repo-1::/feature' })
|
||||
const review = makeReview({ status: 'failure', headSha: 'abc123' })
|
||||
const hostedKey = getHostedReviewCacheKey(repo.path, 'feature', settings, repo.id)
|
||||
const checksKey = getGitHubRepoCacheKey(
|
||||
repo.path,
|
||||
repo.id,
|
||||
prChecksCacheSuffix(12, null, 'abc123'),
|
||||
settings
|
||||
)
|
||||
|
||||
const projection = makeProjection({
|
||||
worktree,
|
||||
repo,
|
||||
hostedReviewCache: { [hostedKey]: { data: review, fetchedAt: 1 } },
|
||||
checksCache: {
|
||||
[checksKey]: {
|
||||
data: [
|
||||
{ name: 'build', status: 'completed', conclusion: 'failure', url: null },
|
||||
{ name: 'lint', status: 'in_progress', conclusion: null, url: null },
|
||||
{
|
||||
name: 'GitHub Actions #1001',
|
||||
status: 'completed',
|
||||
conclusion: 'action_required',
|
||||
url: null
|
||||
}
|
||||
],
|
||||
fetchedAt: 1,
|
||||
headSha: 'abc123'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(projection.rows[0]?.detailNames).toEqual(['GitHub Actions #1001', 'build'])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -204,12 +204,17 @@ function getCheckDetailNames(checks: readonly PRCheckDetail[]): string[] {
|
|||
check.conclusion === 'failure' ||
|
||||
check.conclusion === 'timed_out' ||
|
||||
check.conclusion === 'cancelled' ||
|
||||
check.conclusion === 'action_required' ||
|
||||
check.conclusion === 'pending' ||
|
||||
check.conclusion === null ||
|
||||
check.status === 'queued' ||
|
||||
check.status === 'in_progress'
|
||||
)
|
||||
return interesting.slice(0, 2).map((check) => check.name)
|
||||
const ordered = [
|
||||
...interesting.filter((check) => check.conclusion === 'action_required'),
|
||||
...interesting.filter((check) => check.conclusion !== 'action_required')
|
||||
]
|
||||
return ordered.slice(0, 2).map((check) => check.name)
|
||||
}
|
||||
|
||||
function getGitHubChecksEntry(
|
||||
|
|
|
|||
|
|
@ -55,4 +55,19 @@ describe('deriveTaskPagePRCheckSummary', () => {
|
|||
pending: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('counts action_required as failed so a blocked PR never reads as passing', () => {
|
||||
expect(
|
||||
deriveTaskPagePRCheckSummary([
|
||||
check({ conclusion: 'success' }),
|
||||
check({ conclusion: 'action_required' })
|
||||
])
|
||||
).toEqual({
|
||||
state: 'failure',
|
||||
total: 2,
|
||||
passed: 1,
|
||||
failed: 1,
|
||||
pending: 0
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -28,7 +28,10 @@ export function deriveTaskPagePRCheckSummary(checks: PRCheckDetail[]): GitHubPRC
|
|||
} else if (
|
||||
conclusion === 'failure' ||
|
||||
conclusion === 'timed_out' ||
|
||||
conclusion === 'cancelled'
|
||||
conclusion === 'cancelled' ||
|
||||
// Why: action_required (e.g. a workflow awaiting approval) blocks merge; it
|
||||
// must count as failed so the summary never reads "passing" while blocked.
|
||||
conclusion === 'action_required'
|
||||
) {
|
||||
failed += 1
|
||||
} else if (isPendingCheck(check)) {
|
||||
|
|
|
|||
|
|
@ -710,6 +710,7 @@
|
|||
"d23bbb6416": "{{value0}} skipped",
|
||||
"18f80e1329": "{{value0}} pending",
|
||||
"b1ac991806": "{{value0}} failing",
|
||||
"checksNeedActionChip": "{{value0}} action required",
|
||||
"311d0cee55": "{{value0}} passing",
|
||||
"e52bed9264": "No checks reported yet",
|
||||
"90020cc1f3": "This pull request has no reported checks yet.",
|
||||
|
|
@ -858,7 +859,8 @@
|
|||
"to": "to",
|
||||
"activity": "Activity",
|
||||
"noActivity": "No activity yet."
|
||||
}
|
||||
},
|
||||
"checkActionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked."
|
||||
},
|
||||
"GitLabItemDialog": {
|
||||
"65e784c1f1": "Reopen",
|
||||
|
|
@ -1183,6 +1185,7 @@
|
|||
"e6ad0a8d06": "{{value0}} skipped",
|
||||
"88267924d5": "{{value0}} pending",
|
||||
"ae2a34c7b8": "{{value0}} failing",
|
||||
"checksNeedActionChip": "{{value0}} action required",
|
||||
"7c5035931a": "{{value0}} passing",
|
||||
"a18d01cda3": "No checks reported yet",
|
||||
"3912daf310": "This pull request has no reported checks yet.",
|
||||
|
|
@ -1317,7 +1320,8 @@
|
|||
"commentTooLarge": "Comment is too large to submit safely.",
|
||||
"8ff5ae8866": "Assignees",
|
||||
"82c87eceb9": "Edit assignees",
|
||||
"1ff5d979df": "No one assigned"
|
||||
"1ff5d979df": "No one assigned",
|
||||
"checkActionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked."
|
||||
},
|
||||
"QuickOpen": {
|
||||
"1dbd3f59ff": "Move",
|
||||
|
|
@ -9115,7 +9119,8 @@
|
|||
"8a621a2c4f": "Grouped",
|
||||
"b13f85d75c": "Timeline",
|
||||
"f5cf324efa": "Comment display options",
|
||||
"5e6e5a13fa": "View"
|
||||
"5e6e5a13fa": "View",
|
||||
"actionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked."
|
||||
},
|
||||
"empty": {
|
||||
"state": {
|
||||
|
|
@ -11209,7 +11214,8 @@
|
|||
"b3e7f9a1c2": "Check fix context unavailable",
|
||||
"d5a8c2f1b9": "Start the default AI agent to fix this check",
|
||||
"e2b4d7c8a1": "Choose an agent for this check",
|
||||
"f1c9e3a6d4": "Choose agent to fix check"
|
||||
"f1c9e3a6d4": "Choose agent to fix check",
|
||||
"actionRequired": "Action required"
|
||||
},
|
||||
"CheckRunJobs": {
|
||||
"1c0a4d7e02": "succeeded",
|
||||
|
|
|
|||
|
|
@ -710,6 +710,7 @@
|
|||
"d23bbb6416": "{{value0}} omitido",
|
||||
"18f80e1329": "{{value0}} pendiente",
|
||||
"b1ac991806": "{{value0}} fallando",
|
||||
"checksNeedActionChip": "{{value0}} requiere acción",
|
||||
"311d0cee55": "{{value0}} pasando",
|
||||
"e52bed9264": "Aún no se han reportado controles",
|
||||
"90020cc1f3": "Esta solicitud de extracción aún no tiene comprobaciones reportadas.",
|
||||
|
|
@ -858,7 +859,8 @@
|
|||
"to": "to",
|
||||
"activity": "Activity",
|
||||
"noActivity": "No activity yet."
|
||||
}
|
||||
},
|
||||
"checkActionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked."
|
||||
},
|
||||
"GitLabItemDialog": {
|
||||
"65e784c1f1": "Reabrir",
|
||||
|
|
@ -1183,6 +1185,7 @@
|
|||
"e6ad0a8d06": "{{value0}} omitido",
|
||||
"88267924d5": "{{value0}} pendiente",
|
||||
"ae2a34c7b8": "{{value0}} fallando",
|
||||
"checksNeedActionChip": "{{value0}} requiere acción",
|
||||
"7c5035931a": "{{value0}} pasando",
|
||||
"a18d01cda3": "Aún no se han reportado controles",
|
||||
"3912daf310": "Esta solicitud de extracción aún no tiene comprobaciones reportadas.",
|
||||
|
|
@ -1317,7 +1320,8 @@
|
|||
"commentTooLarge": "El comentario es demasiado grande para enviarlo de forma segura.",
|
||||
"8ff5ae8866": "Responsables",
|
||||
"82c87eceb9": "Editar responsables",
|
||||
"1ff5d979df": "Nadie asignado"
|
||||
"1ff5d979df": "Nadie asignado",
|
||||
"checkActionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked."
|
||||
},
|
||||
"QuickOpen": {
|
||||
"1dbd3f59ff": "Mover",
|
||||
|
|
@ -9115,7 +9119,8 @@
|
|||
"8a621a2c4f": "Grouped",
|
||||
"b13f85d75c": "Timeline",
|
||||
"f5cf324efa": "Comment display options",
|
||||
"5e6e5a13fa": "View"
|
||||
"5e6e5a13fa": "View",
|
||||
"actionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked."
|
||||
},
|
||||
"empty": {
|
||||
"state": {
|
||||
|
|
@ -11209,7 +11214,8 @@
|
|||
"d5a8c2f1b9": "Iniciar el agente de IA predeterminado para corregir esta comprobación",
|
||||
"e2b4d7c8a1": "Elige un agente para esta comprobación",
|
||||
"f1c9e3a6d4": "Elige un agente para corregir la comprobación",
|
||||
"5e2a9c3f88": "Open file at this line"
|
||||
"5e2a9c3f88": "Open file at this line",
|
||||
"actionRequired": "Action required"
|
||||
},
|
||||
"check": {
|
||||
"run": {
|
||||
|
|
|
|||
|
|
@ -710,6 +710,7 @@
|
|||
"d23bbb6416": "{{value0}} はスキップされました",
|
||||
"18f80e1329": "{{value0}} 保留中",
|
||||
"b1ac991806": "{{value0}} は失敗しました",
|
||||
"checksNeedActionChip": "{{value0}} 件の対応が必要",
|
||||
"311d0cee55": "{{value0}} 通過",
|
||||
"e52bed9264": "まだチェックは報告されていません",
|
||||
"90020cc1f3": "この PR にはまだチェックが報告されていません。",
|
||||
|
|
@ -858,7 +859,8 @@
|
|||
"to": "to",
|
||||
"activity": "Activity",
|
||||
"noActivity": "No activity yet."
|
||||
}
|
||||
},
|
||||
"checkActionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked."
|
||||
},
|
||||
"GitLabItemDialog": {
|
||||
"65e784c1f1": "再度開く",
|
||||
|
|
@ -1183,6 +1185,7 @@
|
|||
"e6ad0a8d06": "{{value0}} はスキップされました",
|
||||
"88267924d5": "{{value0}} 保留中",
|
||||
"ae2a34c7b8": "{{value0}} は失敗しました",
|
||||
"checksNeedActionChip": "{{value0}} 件の対応が必要",
|
||||
"7c5035931a": "{{value0}} 通過",
|
||||
"a18d01cda3": "まだチェックは報告されていません",
|
||||
"3912daf310": "この PR にはまだチェックが報告されていません。",
|
||||
|
|
@ -1317,7 +1320,8 @@
|
|||
"commentTooLarge": "コメントが大きすぎるため安全に送信できません。",
|
||||
"8ff5ae8866": "担当者",
|
||||
"82c87eceb9": "担当者を編集",
|
||||
"1ff5d979df": "担当者なし"
|
||||
"1ff5d979df": "担当者なし",
|
||||
"checkActionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked."
|
||||
},
|
||||
"QuickOpen": {
|
||||
"1dbd3f59ff": "移動",
|
||||
|
|
@ -9115,7 +9119,8 @@
|
|||
"8a621a2c4f": "Grouped",
|
||||
"b13f85d75c": "Timeline",
|
||||
"f5cf324efa": "Comment display options",
|
||||
"5e6e5a13fa": "View"
|
||||
"5e6e5a13fa": "View",
|
||||
"actionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked."
|
||||
},
|
||||
"empty": {
|
||||
"state": {
|
||||
|
|
@ -11209,7 +11214,8 @@
|
|||
"d5a8c2f1b9": "既定の AI エージェントでこのチェックを修正",
|
||||
"e2b4d7c8a1": "このチェックのエージェントを選択",
|
||||
"f1c9e3a6d4": "チェックを修正するエージェントを選択",
|
||||
"5e2a9c3f88": "Open file at this line"
|
||||
"5e2a9c3f88": "Open file at this line",
|
||||
"actionRequired": "Action required"
|
||||
},
|
||||
"check": {
|
||||
"run": {
|
||||
|
|
|
|||
|
|
@ -710,6 +710,7 @@
|
|||
"d23bbb6416": "{{value0}} 건너뜀",
|
||||
"18f80e1329": "{{value0}} 보류 중",
|
||||
"b1ac991806": "{{value0}} 실패",
|
||||
"checksNeedActionChip": "{{value0}}개 조치 필요",
|
||||
"311d0cee55": "{{value0}} 통과",
|
||||
"e52bed9264": "아직 보고된 체크가 없습니다.",
|
||||
"90020cc1f3": "이 PR에는 아직 보고된 체크가 없습니다.",
|
||||
|
|
@ -858,7 +859,8 @@
|
|||
"to": "to",
|
||||
"activity": "Activity",
|
||||
"noActivity": "No activity yet."
|
||||
}
|
||||
},
|
||||
"checkActionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked."
|
||||
},
|
||||
"GitLabItemDialog": {
|
||||
"65e784c1f1": "다시 열기",
|
||||
|
|
@ -1183,6 +1185,7 @@
|
|||
"e6ad0a8d06": "{{value0}} 건너뛰었습니다",
|
||||
"88267924d5": "{{value0}} 보류 중",
|
||||
"ae2a34c7b8": "{{value0}} 실패",
|
||||
"checksNeedActionChip": "{{value0}}개 조치 필요",
|
||||
"7c5035931a": "{{value0}} 통과",
|
||||
"a18d01cda3": "아직 보고된 체크가 없습니다.",
|
||||
"3912daf310": "이 PR에는 아직 보고된 체크가 없습니다.",
|
||||
|
|
@ -1317,7 +1320,8 @@
|
|||
"commentTooLarge": "댓글이 너무 커서 안전하게 제출할 수 없습니다.",
|
||||
"8ff5ae8866": "담당자",
|
||||
"82c87eceb9": "담당자 편집",
|
||||
"1ff5d979df": "할당된 사람이 없음"
|
||||
"1ff5d979df": "할당된 사람이 없음",
|
||||
"checkActionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked."
|
||||
},
|
||||
"QuickOpen": {
|
||||
"1dbd3f59ff": "이동",
|
||||
|
|
@ -9115,7 +9119,8 @@
|
|||
"8a621a2c4f": "Grouped",
|
||||
"b13f85d75c": "Timeline",
|
||||
"f5cf324efa": "Comment display options",
|
||||
"5e6e5a13fa": "View"
|
||||
"5e6e5a13fa": "View",
|
||||
"actionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked."
|
||||
},
|
||||
"empty": {
|
||||
"state": {
|
||||
|
|
@ -11209,7 +11214,8 @@
|
|||
"d5a8c2f1b9": "이 체크를 수정할 기본 AI agent 시작",
|
||||
"e2b4d7c8a1": "이 체크를 처리할 agent 선택",
|
||||
"f1c9e3a6d4": "체크를 수정할 agent 선택",
|
||||
"5e2a9c3f88": "Open file at this line"
|
||||
"5e2a9c3f88": "Open file at this line",
|
||||
"actionRequired": "Action required"
|
||||
},
|
||||
"check": {
|
||||
"run": {
|
||||
|
|
|
|||
|
|
@ -710,6 +710,7 @@
|
|||
"d23bbb6416": "{{value0}} 已跳过",
|
||||
"18f80e1329": "{{value0}} 待处理",
|
||||
"b1ac991806": "{{value0}} 失败",
|
||||
"checksNeedActionChip": "{{value0}} 个需要操作",
|
||||
"311d0cee55": "{{value0}} 通过",
|
||||
"e52bed9264": "尚未报告任何检查",
|
||||
"90020cc1f3": "此PR尚未报告检查。",
|
||||
|
|
@ -858,7 +859,8 @@
|
|||
"to": "to",
|
||||
"activity": "Activity",
|
||||
"noActivity": "No activity yet."
|
||||
}
|
||||
},
|
||||
"checkActionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked."
|
||||
},
|
||||
"GitLabItemDialog": {
|
||||
"65e784c1f1": "重新打开",
|
||||
|
|
@ -1183,6 +1185,7 @@
|
|||
"e6ad0a8d06": "{{value0}} 已跳过",
|
||||
"88267924d5": "{{value0}} 待处理",
|
||||
"ae2a34c7b8": "{{value0}} 失败",
|
||||
"checksNeedActionChip": "{{value0}} 个需要操作",
|
||||
"7c5035931a": "{{value0}} 通过",
|
||||
"a18d01cda3": "尚未报告任何检查",
|
||||
"3912daf310": "此PR尚未报告检查。",
|
||||
|
|
@ -1317,7 +1320,8 @@
|
|||
"commentTooLarge": "评论过长,无法安全提交。",
|
||||
"8ff5ae8866": "负责人",
|
||||
"82c87eceb9": "编辑负责人",
|
||||
"1ff5d979df": "未分配任何人"
|
||||
"1ff5d979df": "未分配任何人",
|
||||
"checkActionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked."
|
||||
},
|
||||
"QuickOpen": {
|
||||
"1dbd3f59ff": "手机",
|
||||
|
|
@ -9115,7 +9119,8 @@
|
|||
"8a621a2c4f": "已分组",
|
||||
"b13f85d75c": "时间线",
|
||||
"f5cf324efa": "评论显示选项",
|
||||
"5e6e5a13fa": "查看"
|
||||
"5e6e5a13fa": "查看",
|
||||
"actionRequiredHint": "This check needs a manual action on GitHub (for example, approving the workflow run) before merging is unblocked."
|
||||
},
|
||||
"empty": {
|
||||
"state": {
|
||||
|
|
@ -11209,7 +11214,8 @@
|
|||
"d5a8c2f1b9": "启动默认 AI 智能体来修复此检查",
|
||||
"e2b4d7c8a1": "为此检查选择智能体",
|
||||
"f1c9e3a6d4": "选择智能体修复检查",
|
||||
"5e2a9c3f88": "Open file at this line"
|
||||
"5e2a9c3f88": "Open file at this line",
|
||||
"actionRequired": "Action required"
|
||||
},
|
||||
"check": {
|
||||
"run": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,21 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { syncPRChecksStatus, normalizeBranchName } from './github-checks'
|
||||
import {
|
||||
deriveCheckStatusFromChecks,
|
||||
syncPRChecksStatus,
|
||||
normalizeBranchName
|
||||
} from './github-checks'
|
||||
import type { AppState } from '../types'
|
||||
import type { PRCheckDetail } from '../../../../shared/types'
|
||||
|
||||
describe('deriveCheckStatusFromChecks', () => {
|
||||
it('treats an action_required check as failure so it is not a silent pass', () => {
|
||||
const checks: PRCheckDetail[] = [
|
||||
{ name: 'build', status: 'completed', conclusion: 'success', url: null },
|
||||
{ name: 'approval', status: 'completed', conclusion: 'action_required', url: null }
|
||||
]
|
||||
expect(deriveCheckStatusFromChecks(checks)).toBe('failure')
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeBranchName', () => {
|
||||
it('strips refs/heads/ prefix', () => {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,10 @@ export function deriveCheckStatusFromChecks(checks: PRCheckDetail[]): CheckStatu
|
|||
if (
|
||||
check.conclusion === 'failure' ||
|
||||
check.conclusion === 'timed_out' ||
|
||||
check.conclusion === 'cancelled'
|
||||
check.conclusion === 'cancelled' ||
|
||||
// Why: action_required (e.g. an unapproved workflow run) blocks merge until
|
||||
// someone acts; treat it as needs-attention rather than a silent pass.
|
||||
check.conclusion === 'action_required'
|
||||
) {
|
||||
return 'failure'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,6 +91,17 @@ describe('hostedReviewSummaryFromGitHubPRInfo', () => {
|
|||
expect(summary.checksStatus).toBe('failure')
|
||||
})
|
||||
|
||||
it('treats action_required checks as failed so auto-merge sees the block', () => {
|
||||
const summary = hostedReviewSummaryFromGitHubPRInfo({
|
||||
pr: { ...pr, checksStatus: 'success' },
|
||||
owner: 'acme',
|
||||
repo: 'orca',
|
||||
checks: [{ name: 'approval', status: 'completed', conclusion: 'action_required', url: null }]
|
||||
})
|
||||
|
||||
expect(summary.checksStatus).toBe('failure')
|
||||
})
|
||||
|
||||
it('distinguishes loaded empty comments from unknown comments', () => {
|
||||
expect(
|
||||
hostedReviewSummaryFromGitHubPRInfo({
|
||||
|
|
|
|||
|
|
@ -39,7 +39,10 @@ function deriveChecksStatus(
|
|||
(check) =>
|
||||
check.conclusion === 'failure' ||
|
||||
check.conclusion === 'timed_out' ||
|
||||
check.conclusion === 'cancelled'
|
||||
check.conclusion === 'cancelled' ||
|
||||
// Why: action_required (e.g. a workflow awaiting approval) blocks merge;
|
||||
// treat it as failure so the review queue doesn't report a clean PR.
|
||||
check.conclusion === 'action_required'
|
||||
)
|
||||
if (hasFailure) {
|
||||
return 'failure'
|
||||
|
|
|
|||
|
|
@ -1231,6 +1231,10 @@ export type PRCheckDetail = {
|
|||
| 'neutral'
|
||||
| 'skipped'
|
||||
| 'pending'
|
||||
// Why: a check suite needing manual action (e.g. a workflow awaiting "Approve
|
||||
// and run") has no check run and is absent from statusCheckRollup, yet blocks
|
||||
// auto-merge (GitHub returns "unstable status"). Surface it as its own state.
|
||||
| 'action_required'
|
||||
| null
|
||||
url: string | null
|
||||
checkRunId?: number
|
||||
|
|
|
|||
Loading…
Reference in New Issue