Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 c4ebe0af42 fix(ci): reclassify non-auto-close issue reopens
After a merged automation fix, cleanup leaves triage:bug-ready/admitted on
the source issue. Human reopen of those labels must re-enter classify so a
failed fix can spawn another implementation; only bot reopen stays skipped.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 12:38:45 +00:00
Cursor Agentand陈大猫 c8e30cf20d fix(ci): do not reopen issues already closed before handoff
Queued ready_for_human_handoff must respect a maintainer who re-closed the
issue while the job was waiting. Skip when live state is closed instead of
forcing ensureOpen.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 12:24:22 +00:00
Cursor Agentand陈大猫 526248ebdd test(ci): cover skipped reopen handoff without auto-close labels
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 12:11:16 +00:00
Cursor Agentand陈大猫 2f51c98ed0 fix(ci): revalidate auto-close labels before handoff
Queued ready_for_human_handoff must not overwrite a maintainer who already
cleared triage:already-available/unclear and moved the issue (for example
to ready-for-agent). Skip the handoff when those outcome labels are gone.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 12:11:04 +00:00
Cursor Agentand陈大猫 558a31947b fix(ci): keep unclear reopen disputes off classify
Match the already-available dispute guard for triage:unclear / unclear so
human reopen handoffs of auto-closed unclear issues cannot re-enter
classify and close again on the next author follow-up.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 12:00:51 +00:00
Cursor Agentand陈大猫 ebc18e7397 fix(ci): preserve already-available dispute label on handoff
Keep triage:already-available when handing reopened auto-closed issues
to humans, and only use that label (not ready-for-human) to block
reclassify. This stops the close loop without blocking feature_defer or
other ready-for-human follow-ups from re-entering classify.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:55:01 +00:00
Cursor Agentand陈大猫 607b53b6f8 fix(ci): keep ready-for-human follow-ups off classify
After reopen handoff drops triage:already-available, actionable author
replies could still be refined into classify and auto-close again. Treat
ready-for-human like the dispute signal and keep those comments on
follow-up.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:47:31 +00:00
Cursor Agentand陈大猫 e5168240a4 fix(ci): stop reopen from replaying issue triage
Already-admitted issue reopen events were routed back into full classify,
so follow-up handoffs and maintainer reopens could close again or overwrite
ready-for-human with needs-info. Skip bot reopen, hand auto-closed human
reopens to maintainers, and keep already-available disputes on follow-up.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:21:11 +00:00
3 changed files with 502 additions and 14 deletions

No files matched your search

+46 -3
View File
@@ -196,9 +196,16 @@ jobs:
}
if (event === 'issues') {
return set('issue_classify', {
issue_number: String(context.payload.issue.number),
reason: `issues:${context.payload.action}`,
const issue = context.payload.issue;
const decision = auto.decideIssuesEventRoute({
action: context.payload.action,
labels: issue?.labels || [],
actorLogin: context.payload.sender?.login,
botLogins: process.env.ISSUE_BOT_LOGINS,
});
return set(decision.kind, {
issue_number: String(issue.number),
reason: decision.reason,
});
}
@@ -264,6 +271,7 @@ jobs:
hasOpenBotPull: Boolean(pull),
hasOpenRelatedPull: Boolean(relatedPull),
body: comment.body,
labels,
});
if (refined.kind === 'issue_classify') {
return set('issue_classify', {
@@ -349,6 +357,41 @@ jobs:
return set('skip', { reason: `unhandled ${event}` });
ready_for_human_handoff:
name: Hand reopened issue to maintainers
needs: route
if: needs.route.outputs.kind == 'ready_for_human_handoff'
runs-on: ubuntu-latest
concurrency:
group: cursor-source-issue-${{ needs.route.outputs.issue_number || github.run_id }}
cancel-in-progress: false
permissions:
contents: read
issues: write
steps:
- name: Checkout helpers
uses: actions/checkout@v7
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Apply ready-for-human handoff
uses: actions/github-script@v9
env:
ISSUE_NUMBER: ${{ needs.route.outputs.issue_number }}
TRUSTED_COMMENT_AUTHORS: ${{ vars.AUTOMATION_OWN_ACTORS || 'binaricat,netcatty-bot,github-actions[bot]' }}
with:
github-token: ${{ secrets.TRIAGE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
script: |
const auto = require(`${process.env.GITHUB_WORKSPACE}/scripts/cursor-automation.cjs`);
await auto.applyReadyForHumanHandoff({
github,
context,
issueNumber: process.env.ISSUE_NUMBER,
dedupeMarker: auto.REOPEN_HANDOFF_MARKER,
trustedCommentAuthors: process.env.TRUSTED_COMMENT_AUTHORS,
});
cleanup_source_issue:
name: Clean source issue state
needs: route
+191 -9
View File
@@ -2253,6 +2253,121 @@ const ISSUE_FOLLOWUP_LABELS = new Set([
'triage:unclear',
]);
/** Outcome labels that mean automatic triage already finished once. */
const ISSUE_ADMITTED_LABELS = new Set([
'triage:admitted',
'needs-info',
'ready-for-agent',
'ready-for-human',
'triage:bug-ready',
'triage:bug-needs-info',
'triage:feature-quick-win',
'triage:feature-defer',
'triage:already-available',
'triage:other',
'triage:unclear',
'unclear',
]);
/** Auto-closed triage outcomes that a human reopen should hand to maintainers. */
const ISSUE_AUTO_CLOSE_HANDOFF_LABELS = new Set([
'triage:already-available',
'triage:unclear',
'unclear',
]);
const REOPEN_HANDOFF_MARKER = '<!-- cursor-reopen-handoff -->';
function normalizeIssueLabelNames(labels = []) {
return (labels || [])
.map((label) => (typeof label === 'string' ? label : label?.name))
.filter(Boolean);
}
function isIssueAlreadyAdmitted(labels = []) {
return normalizeIssueLabelNames(labels).some((name) =>
ISSUE_ADMITTED_LABELS.has(name),
);
}
function labelsForReadyForHumanHandoff(existingLabels = []) {
const existing = normalizeIssueLabelNames(existingLabels);
// Keep triage:already-available / unclear as dispute signals so later author
// follow-ups stay on issue_followup instead of re-entering classify.
const drop = new Set(['ready-for-agent']);
const preserved = existing.includes('triage:admitted')
? ['triage:admitted']
: [];
return [
...new Set([
...existing.filter((name) => !drop.has(name) && name !== 'ready-for-human'),
'triage',
'ready-for-human',
...preserved,
]),
];
}
/**
* Route issues opened/reopened without always re-running agent classify.
* Reopen of an already-triaged issue must not replay the close/reopen loop.
*/
function decideIssuesEventRoute({
action,
labels = [],
actorLogin,
botLogins = ['netcatty-bot', 'github-actions[bot]'],
} = {}) {
const normalizedAction = String(action || '').toLowerCase();
if (normalizedAction === 'opened') {
return { kind: 'issue_classify', reason: 'issues:opened' };
}
if (normalizedAction !== 'reopened') {
return {
kind: 'skip',
reason: `issues:${normalizedAction || 'unknown'}`,
};
}
const names = normalizeIssueLabelNames(labels);
const bots = normalizeLoginList(botLogins, [
'netcatty-bot',
'github-actions[bot]',
]);
const actor = String(actorLogin || '').trim().toLowerCase();
if (actor && bots.has(actor)) {
return {
kind: 'skip',
reason: 'bot reopen of managed issue',
};
}
if (!isIssueAlreadyAdmitted(names)) {
return { kind: 'issue_classify', reason: 'issues:reopened' };
}
if (names.some((name) => ISSUE_AUTO_CLOSE_HANDOFF_LABELS.has(name))) {
return {
kind: 'ready_for_human_handoff',
reason: 'human reopen of auto-closed triage',
};
}
// Merged-fix / bug-ready / feature reopens must re-enter classify so a
// failed fix can spawn another implementation. Bot reopen is already skipped
// above, which covers follow-up reopen side effects.
return {
kind: 'issue_classify',
reason: 'issues:reopened admitted non-auto-close',
};
}
function buildReopenHandoffReply(issue = {}) {
const chinese = /[\u3400-\u9fff]/u.test(
`${issue.title || ''}\n${issue.body || ''}`,
);
return chinese
? '这条 Issue 已重新打开,并交给维护者继续处理。自动分流不会再次把它标成“已有能力/可关闭”。'
: 'This issue was reopened and handed to a maintainer. Automatic triage will not close it again as already available.';
}
function normalizeLoginList(value, fallback = []) {
const values = Array.isArray(value) ? value : String(value || '').split(',');
const normalized = values
@@ -2331,8 +2446,17 @@ function refineIssueCommentRoute(decision, {
hasOpenBotPull = false,
hasOpenRelatedPull = false,
body = '',
labels = [],
} = {}) {
if (decision?.kind !== 'issue_followup' || hasOpenBotPull) return decision;
const names = normalizeIssueLabelNames(labels);
// Disputed auto-close outcomes (already_available / unclear) must stay on
// follow-up after reopen handoff. Do not gate on ready-for-human alone —
// that label also covers feature_defer / other / implement failures that
// should still be allowed to re-enter classify.
if (names.some((name) => ISSUE_AUTO_CLOSE_HANDOFF_LABELS.has(name))) {
return decision;
}
const simpleKind = classifySimpleIssueFollowup([{ body }]);
if (simpleKind) return decision;
return {
@@ -3010,6 +3134,8 @@ async function markNeedsHuman({
message,
dedupeMarker = '',
trustedCommentAuthors = 'binaricat,netcatty-bot,github-actions[bot]',
labels,
ensureOpen = false,
}) {
const owner = context.repo.owner;
const repo = context.repo.repo;
@@ -3019,19 +3145,19 @@ async function markNeedsHuman({
issue_number: Number(issueNumber),
});
const existing = issue.labels.map((l) => (typeof l === 'string' ? l : l.name));
const next = [
...new Set([
...existing.filter((l) => l !== 'ready-for-agent'),
'triage',
'ready-for-human',
]),
];
await github.rest.issues.update({
const next = Array.isArray(labels)
? [...new Set(labels.filter(Boolean))]
: labelsForReadyForHumanHandoff(existing);
const update = {
owner,
repo,
issue_number: issue.number,
labels: next,
});
};
if (ensureOpen && String(issue.state || '').toLowerCase() === 'closed') {
update.state = 'open';
}
await github.rest.issues.update(update);
const marker = String(dedupeMarker || '').trim();
if (marker) {
const trusted = normalizeLoginList(trustedCommentAuthors, [
@@ -3065,6 +3191,53 @@ async function markNeedsHuman({
return { issue, labels: next, commented: true };
}
async function applyReadyForHumanHandoff({
github,
context,
issueNumber,
message,
dedupeMarker = REOPEN_HANDOFF_MARKER,
trustedCommentAuthors = 'binaricat,netcatty-bot,github-actions[bot]',
} = {}) {
const { data: issue } = await github.rest.issues.get({
...context.repo,
issue_number: Number(issueNumber),
});
const names = normalizeIssueLabelNames(issue.labels);
// Reopen already left the issue open. If a maintainer re-closed it while this
// handoff was queued, do not force it open again.
if (String(issue.state || '').toLowerCase() === 'closed') {
return {
issue,
labels: names,
commented: false,
skipped: true,
reason: 'issue already closed',
};
}
// Revalidate at apply time: a queued handoff must not undo a maintainer who
// already cleared the auto-close outcome and moved the issue elsewhere.
if (!names.some((name) => ISSUE_AUTO_CLOSE_HANDOFF_LABELS.has(name))) {
return {
issue,
labels: names,
commented: false,
skipped: true,
reason: 'auto-close handoff labels no longer present',
};
}
return markNeedsHuman({
github,
context,
issueNumber,
message: message || buildReopenHandoffReply(issue),
dedupeMarker,
trustedCommentAuthors,
labels: labelsForReadyForHumanHandoff(issue.labels),
ensureOpen: false,
});
}
function isBotPrForIssue(pull, issueNumber) {
if (!pull) return false;
const n = String(issueNumber);
@@ -3709,9 +3882,18 @@ module.exports = {
CODEX_REQUEST_RETRY_MS,
decideCodexLoopAction,
shouldReTriageIssueComment,
ISSUE_ADMITTED_LABELS,
ISSUE_AUTO_CLOSE_HANDOFF_LABELS,
REOPEN_HANDOFF_MARKER,
normalizeIssueLabelNames,
isIssueAlreadyAdmitted,
labelsForReadyForHumanHandoff,
decideIssuesEventRoute,
buildReopenHandoffReply,
mentionsIssueBot,
decideIssueCommentRoute,
refineIssueCommentRoute,
applyReadyForHumanHandoff,
formatCodexFindingsMarkdown,
listProtectedPathHits,
formatProtectedPathDetails,
+265 -2
View File
@@ -627,6 +627,119 @@ test('actionable author follow-ups without an open bot PR are reclassified', ()
}),
decision,
);
assert.equal(
auto.refineIssueCommentRoute(decision, {
hasOpenBotPull: false,
labels: ['triage:already-available', 'triage:admitted'],
body: '我从 main build 了,还是一样,本地网络权限没有弹窗。',
}),
decision,
);
assert.equal(
auto.refineIssueCommentRoute(decision, {
hasOpenBotPull: false,
labels: ['triage:unclear', 'unclear', 'ready-for-human'],
body: '补充:复现步骤是打开 Vault 再连局域网主机。',
}),
decision,
);
// ready-for-human alone must NOT block reclassify (feature_defer / other /
// implement-failure handoffs still need actionable follow-ups to classify).
assert.deepEqual(
auto.refineIssueCommentRoute(decision, {
hasOpenBotPull: false,
labels: ['triage', 'triage:feature-defer', 'ready-for-human'],
body: '默认开启 X11 就可以,内置服务暂时不需要。',
}),
{
kind: 'issue_classify',
reason: 'actionable author follow-up without open automation PR',
},
);
// After reopen handoff, triage:already-available is preserved with
// ready-for-human so disputes cannot re-close via classify.
assert.equal(
auto.refineIssueCommentRoute(decision, {
hasOpenBotPull: false,
labels: [
'triage',
'triage:admitted',
'triage:already-available',
'ready-for-human',
],
body: '我从 main build 了,还是一样,本地网络权限没有弹窗。',
}),
decision,
);
});
test('decideIssuesEventRoute skips bot reopen and hands auto-closed reopen to humans', () => {
assert.deepEqual(
auto.decideIssuesEventRoute({ action: 'opened', labels: [] }),
{ kind: 'issue_classify', reason: 'issues:opened' },
);
assert.deepEqual(
auto.decideIssuesEventRoute({
action: 'reopened',
labels: ['bug', 'triage'],
actorLogin: 'alice',
}),
{ kind: 'issue_classify', reason: 'issues:reopened' },
);
assert.deepEqual(
auto.decideIssuesEventRoute({
action: 'reopened',
labels: ['triage:admitted', 'triage:already-available'],
actorLogin: 'netcatty-bot',
}),
{ kind: 'skip', reason: 'bot reopen of managed issue' },
);
assert.deepEqual(
auto.decideIssuesEventRoute({
action: 'reopened',
labels: ['triage:admitted', 'triage:already-available'],
actorLogin: 'binaricat',
}),
{
kind: 'ready_for_human_handoff',
reason: 'human reopen of auto-closed triage',
},
);
assert.deepEqual(
auto.decideIssuesEventRoute({
action: 'reopened',
labels: ['triage:admitted', 'ready-for-human', 'triage'],
actorLogin: 'binaricat',
}),
{
kind: 'issue_classify',
reason: 'issues:reopened admitted non-auto-close',
},
);
assert.deepEqual(
auto.decideIssuesEventRoute({
action: 'reopened',
labels: ['triage:admitted', 'triage:bug-ready', 'bug'],
actorLogin: 'alice',
}),
{
kind: 'issue_classify',
reason: 'issues:reopened admitted non-auto-close',
},
);
const handoff = auto.labelsForReadyForHumanHandoff([
'bug',
'triage',
'triage:admitted',
'triage:already-available',
'ready-for-agent',
]);
assert.ok(handoff.includes('ready-for-human'));
assert.ok(handoff.includes('triage:admitted'));
assert.ok(handoff.includes('triage:already-available'));
assert.ok(!handoff.includes('ready-for-agent'));
assert.equal(auto.isIssueAlreadyAdmitted(['triage:admitted']), true);
assert.equal(auto.isIssueAlreadyAdmitted(['bug', 'triage']), false);
});
test('decideIssueCommentRoute accepts maintainer @bot and ignores untrusted bystanders', () => {
@@ -947,6 +1060,7 @@ test('protected path reports replace stale data and ignore ordinary source files
test('markNeedsHuman ignores forged dedupe markers from untrusted commenters', async () => {
let created = 0;
let lastUpdate = null;
let comments = [{
user: { login: 'mallory' },
body: '<!-- cursor-implement-failure:base=abc;kind=no_changes -->',
@@ -954,8 +1068,21 @@ test('markNeedsHuman ignores forged dedupe markers from untrusted commenters', a
const github = {
rest: {
issues: {
get: async () => ({ data: { number: 42, labels: [] } }),
update: async () => ({ data: {} }),
get: async () => ({
data: {
number: 42,
state: 'open',
labels: [
{ name: 'triage:already-available' },
{ name: 'triage:admitted' },
{ name: 'ready-for-agent' },
],
},
}),
update: async (args) => {
lastUpdate = args;
return { data: {} };
},
listComments: Symbol('listComments'),
createComment: async () => { created += 1; return { data: {} }; },
},
@@ -972,6 +1099,10 @@ test('markNeedsHuman ignores forged dedupe markers from untrusted commenters', a
const first = await auto.markNeedsHuman(args);
assert.equal(first.commented, true);
assert.equal(created, 1);
assert.ok(lastUpdate.labels.includes('ready-for-human'));
assert.ok(lastUpdate.labels.includes('triage:admitted'));
assert.ok(lastUpdate.labels.includes('triage:already-available'));
assert.ok(!lastUpdate.labels.includes('ready-for-agent'));
comments = [{
user: { login: 'netcatty-bot' },
@@ -982,6 +1113,128 @@ test('markNeedsHuman ignores forged dedupe markers from untrusted commenters', a
assert.equal(created, 1);
});
test('applyReadyForHumanHandoff hands open auto-closed issues to humans', async () => {
let update = null;
let commentBody = '';
const github = {
rest: {
issues: {
get: async () => ({
data: {
number: 2673,
state: 'open',
title: '[Bug] 不能连接本地网络',
body: '本地网络权限',
labels: [
{ name: 'triage' },
{ name: 'triage:admitted' },
{ name: 'triage:already-available' },
],
},
}),
update: async (args) => {
update = args;
return { data: {} };
},
listComments: Symbol('listComments'),
createComment: async (args) => {
commentBody = args.body;
return { data: {} };
},
},
},
paginate: async () => [],
};
const result = await auto.applyReadyForHumanHandoff({
github,
context: { repo: { owner: 'binaricat', repo: 'Netcatty' } },
issueNumber: 2673,
});
assert.equal(result.commented, true);
assert.equal(update.state, undefined);
assert.ok(update.labels.includes('ready-for-human'));
assert.ok(update.labels.includes('triage:already-available'));
assert.match(commentBody, /cursor-reopen-handoff/);
assert.match(commentBody, /重新打开/);
});
test('applyReadyForHumanHandoff skips when auto-close labels were cleared', async () => {
let updated = false;
const github = {
rest: {
issues: {
get: async () => ({
data: {
number: 2673,
state: 'open',
title: '[Bug] 不能连接本地网络',
body: '本地网络权限',
labels: [
{ name: 'triage' },
{ name: 'triage:admitted' },
{ name: 'ready-for-agent' },
],
},
}),
update: async () => {
updated = true;
return { data: {} };
},
listComments: Symbol('listComments'),
createComment: async () => ({ data: {} }),
},
},
paginate: async () => [],
};
const result = await auto.applyReadyForHumanHandoff({
github,
context: { repo: { owner: 'binaricat', repo: 'Netcatty' } },
issueNumber: 2673,
});
assert.equal(result.skipped, true);
assert.equal(result.commented, false);
assert.equal(updated, false);
});
test('applyReadyForHumanHandoff skips when maintainer already re-closed', async () => {
let updated = false;
const github = {
rest: {
issues: {
get: async () => ({
data: {
number: 2673,
state: 'closed',
title: '[Bug] 不能连接本地网络',
body: '本地网络权限',
labels: [
{ name: 'triage' },
{ name: 'triage:admitted' },
{ name: 'triage:already-available' },
],
},
}),
update: async () => {
updated = true;
return { data: {} };
},
listComments: Symbol('listComments'),
createComment: async () => ({ data: {} }),
},
},
paginate: async () => [],
};
const result = await auto.applyReadyForHumanHandoff({
github,
context: { repo: { owner: 'binaricat', repo: 'Netcatty' } },
issueNumber: 2673,
});
assert.equal(result.skipped, true);
assert.equal(result.reason, 'issue already closed');
assert.equal(result.commented, false);
assert.equal(updated, false);
});
test('workflow cleans source labels after eligible PR close and dedupes clean notices', () => {
const workflow = fs.readFileSync(
path.join(__dirname, '..', '.github', 'workflows', 'cursor-automation.yml'),
@@ -1000,6 +1253,14 @@ test('workflow cleans source labels after eligible PR close and dedupes clean no
assert.match(workflow, /does not currently close issue/);
assert.match(workflow, /findOpenPullForIssue/);
assert.match(workflow, /refineIssueCommentRoute/);
assert.match(
workflow,
/refineIssueCommentRoute\(decision, \{\n\s+hasOpenBotPull: Boolean\(pull\),\n\s+hasOpenRelatedPull: Boolean\(relatedPull\),\n\s+body: comment\.body,\n\s+labels,/,
);
assert.match(workflow, /decideIssuesEventRoute/);
assert.match(workflow, /kind == 'ready_for_human_handoff'/);
assert.match(workflow, /applyReadyForHumanHandoff/);
assert.match(workflow, /REOPEN_HANDOFF_MARKER/);
assert.match(workflow, /issueNumber: issue\.number,\n\s+includeRelated: true/);
assert.match(workflow, /reconcile_closed_handoffs:/);
assert.match(workflow, /shouldRetryIssueHandoff/);
@@ -1018,6 +1279,8 @@ test('workflow cleans source labels after eligible PR close and dedupes clean no
route,
/sameRepo\s*&&\s*\n\s*context\.payload\.action === 'closed'/,
);
assert.match(route, /decideIssuesEventRoute/);
assert.match(route, /ready_for_human_handoff:/);
assert.match(workflow, /Follow-up changed before the simple reply; dispatched a fresh review/);
assert.match(workflow, /is merged; skipped stale follow-up handoff state changes/);
assert.match(workflow, /trusted_comment_bodies/);