mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 22:40:09 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85d9710a8b | ||
|
|
887d228a1b | ||
|
|
9af475c16a | ||
|
|
68269ce2bd | ||
|
|
0d6df02bf6 | ||
|
|
6b1c5632e3 | ||
|
|
a603a0e1f0 |
No files matched your search
@@ -254,6 +254,24 @@ jobs:
|
||||
context,
|
||||
issueNumber: issue.number,
|
||||
});
|
||||
const relatedPull = pull || await auto.findOpenPullForIssue({
|
||||
github,
|
||||
context,
|
||||
issueNumber: issue.number,
|
||||
includeRelated: true,
|
||||
});
|
||||
const refined = auto.refineIssueCommentRoute(decision, {
|
||||
hasOpenBotPull: Boolean(pull),
|
||||
hasOpenRelatedPull: Boolean(relatedPull),
|
||||
body: comment.body,
|
||||
});
|
||||
if (refined.kind === 'issue_classify') {
|
||||
return set('issue_classify', {
|
||||
issue_number: String(issue.number),
|
||||
trigger_comment_id: String(comment.id),
|
||||
reason: refined.reason,
|
||||
});
|
||||
}
|
||||
return set('issue_followup', {
|
||||
issue_number: String(issue.number),
|
||||
pull_number: pull ? String(pull.number) : '',
|
||||
@@ -274,9 +292,8 @@ jobs:
|
||||
repository: `${context.repo.owner}/${context.repo.repo}`,
|
||||
});
|
||||
if (
|
||||
sameRepo &&
|
||||
context.payload.action === 'closed' &&
|
||||
auto.shouldGatePullOnSourceIssueFollowups(pr, {
|
||||
auto.shouldCleanupSourceIssueAfterPull(pr, {
|
||||
ownActors,
|
||||
repository: `${context.repo.owner}/${context.repo.repo}`,
|
||||
})
|
||||
@@ -284,7 +301,7 @@ jobs:
|
||||
return set('source_issue_cleanup', {
|
||||
issue_number: String(auto.extractSourceIssueNumber(pr)),
|
||||
pull_number: String(pr.number),
|
||||
reason: pr.merged ? 'automation PR merged' : 'automation PR closed',
|
||||
reason: pr.merged ? 'merged PR closed source issue' : 'automation PR closed',
|
||||
});
|
||||
}
|
||||
if (sameRepo) {
|
||||
@@ -370,16 +387,16 @@ jobs:
|
||||
core.warning(`PR #${pullNumber} is no longer closed; skipping source cleanup.`);
|
||||
return;
|
||||
}
|
||||
if (!auto.shouldGatePullOnSourceIssueFollowups(pull, {
|
||||
if (!auto.shouldCleanupSourceIssueAfterPull(pull, {
|
||||
ownActors: process.env.OWN_ACTORS,
|
||||
repository: `${context.repo.owner}/${context.repo.repo}`,
|
||||
})) {
|
||||
core.warning(`PR #${pullNumber} is not a trusted automation pull request; skipping source cleanup.`);
|
||||
core.warning(`PR #${pullNumber} is not eligible for source cleanup; skipping.`);
|
||||
return;
|
||||
}
|
||||
const issueNumber = auto.extractSourceIssueNumber(pull);
|
||||
const issueNumbers = auto.extractSourceIssueNumbers(pull);
|
||||
const expectedIssueNumber = Number(process.env.SOURCE_ISSUE_NUMBER);
|
||||
if (!issueNumber || issueNumber !== expectedIssueNumber) {
|
||||
if (!issueNumbers.includes(expectedIssueNumber)) {
|
||||
core.warning(`PR #${pullNumber} source issue changed while queued; skipping cleanup.`);
|
||||
return;
|
||||
}
|
||||
@@ -394,20 +411,161 @@ jobs:
|
||||
if (error.status !== 404) throw error;
|
||||
}
|
||||
};
|
||||
for (const name of ['ready-for-agent', 'needs-info', 'ready-for-human']) {
|
||||
await removeLabel(issueNumber, name);
|
||||
}
|
||||
if (!pull.merged) {
|
||||
await github.rest.issues.addLabels({
|
||||
for (const issueNumber of issueNumbers) {
|
||||
const { data: sourceIssue } = await github.rest.issues.get({
|
||||
...context.repo,
|
||||
issue_number: issueNumber,
|
||||
labels: ['ready-for-human'],
|
||||
});
|
||||
if (sourceIssue.pull_request) {
|
||||
core.warning(`#${issueNumber} is a pull request, not a source issue; skipping cleanup.`);
|
||||
continue;
|
||||
}
|
||||
if (pull.merged && sourceIssue.state !== 'closed') {
|
||||
core.warning(`Merged PR #${pullNumber} does not currently close issue #${issueNumber}; skipping cleanup.`);
|
||||
continue;
|
||||
}
|
||||
for (const name of ['ready-for-agent', 'needs-info', 'ready-for-human']) {
|
||||
await removeLabel(issueNumber, name);
|
||||
}
|
||||
if (!pull.merged) {
|
||||
await github.rest.issues.addLabels({
|
||||
...context.repo,
|
||||
issue_number: issueNumber,
|
||||
labels: ['ready-for-human'],
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const name of ['automation:codex-loop', 'ready-for-human']) {
|
||||
await removeLabel(pullNumber, name);
|
||||
}
|
||||
|
||||
reconcile_closed_handoffs:
|
||||
name: Reconcile handoffs
|
||||
if: github.event_name == 'schedule' && github.event.schedule == '17 3 * * *'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout automation helpers
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
|
||||
- name: Remove stale ready-for-human labels
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.TRIAGE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const auto = require(`${process.env.GITHUB_WORKSPACE}/scripts/cursor-automation.cjs`);
|
||||
const queries = [
|
||||
`repo:${context.repo.owner}/${context.repo.repo} is:issue is:closed label:"ready-for-human" label:triage`,
|
||||
`repo:${context.repo.owner}/${context.repo.repo} is:pr is:merged label:"ready-for-human" label:"automation:bot-pr"`,
|
||||
];
|
||||
let cleaned = 0;
|
||||
for (const q of queries) {
|
||||
const items = await github.paginate(
|
||||
github.rest.search.issuesAndPullRequests,
|
||||
{ q, per_page: 100 },
|
||||
(response) => auto.extractPaginatedItems(response),
|
||||
);
|
||||
for (const item of items) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
...context.repo,
|
||||
issue_number: item.number,
|
||||
name: 'ready-for-human',
|
||||
});
|
||||
cleaned += 1;
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
core.summary.addHeading('Closed handoff reconciliation');
|
||||
core.summary.addRaw(`Removed stale ready-for-human from ${cleaned} closed items.`);
|
||||
await core.summary.write();
|
||||
|
||||
- name: Retry handoffs fixed by this hardening pass
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
ISSUE_BOT_LOGINS: ${{ vars.AUTOMATION_ISSUE_BOT_LOGINS || '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`);
|
||||
const recoveryVersion = 'protected-tests-and-media-v1';
|
||||
const auditedIssueNumbers = new Set([2679, 2697, 2704, 2705, 2708, 2709]);
|
||||
const repository = `${context.repo.owner}/${context.repo.repo}`;
|
||||
const issues = await github.paginate(
|
||||
github.rest.search.issuesAndPullRequests,
|
||||
{
|
||||
q: `repo:${repository} is:issue is:open label:"ready-for-human" label:triage`,
|
||||
per_page: 100,
|
||||
},
|
||||
(response) => auto.extractPaginatedItems(response),
|
||||
);
|
||||
const pulls = await github.paginate(github.rest.pulls.list, {
|
||||
...context.repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
sort: 'updated',
|
||||
direction: 'desc',
|
||||
});
|
||||
let dispatched = 0;
|
||||
for (const issue of issues) {
|
||||
if (!auditedIssueNumbers.has(Number(issue.number))) continue;
|
||||
const comments = await github.paginate(
|
||||
github.rest.issues.listComments,
|
||||
{ ...context.repo, issue_number: issue.number, per_page: 100 },
|
||||
);
|
||||
if (!auto.shouldRetryIssueHandoff(comments, {
|
||||
trustedActors: process.env.ISSUE_BOT_LOGINS,
|
||||
recoveryVersion,
|
||||
notBefore: '2026-07-31T12:54:37Z',
|
||||
notAfter: '2026-08-04T08:27:14Z',
|
||||
})) continue;
|
||||
if (pulls.some((pull) => auto.isTrustedOpenPullForIssue(pull, issue.number, {
|
||||
repository,
|
||||
includeRelated: true,
|
||||
}))) continue;
|
||||
|
||||
const { data: marker } = await github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number: issue.number,
|
||||
body: [
|
||||
'<!-- cursor-automation -->',
|
||||
`<!-- cursor-handoff-recovery:version=${recoveryVersion} -->`,
|
||||
'',
|
||||
'此前导致自动处理停止的问题已经修复,现重新进入自动检查。',
|
||||
].join('\n'),
|
||||
});
|
||||
try {
|
||||
await github.rest.actions.createWorkflowDispatch({
|
||||
...context.repo,
|
||||
workflow_id: 'cursor-automation.yml',
|
||||
ref: context.payload.repository.default_branch,
|
||||
inputs: { issue_number: String(issue.number) },
|
||||
});
|
||||
dispatched += 1;
|
||||
} catch (error) {
|
||||
try {
|
||||
await github.rest.issues.deleteComment({
|
||||
...context.repo,
|
||||
comment_id: marker.id,
|
||||
});
|
||||
} catch (cleanupError) {
|
||||
core.warning(`Issue #${issue.number}: could not remove retry marker (${cleanupError.message}).`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
core.summary.addHeading('Retry recovered handoffs');
|
||||
core.summary.addRaw(`Re-dispatched ${dispatched} retryable issues without open related PRs.`);
|
||||
await core.summary.write();
|
||||
|
||||
classify:
|
||||
name: Classify issue
|
||||
needs: route
|
||||
@@ -693,6 +851,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Research external context for classification
|
||||
id: research
|
||||
if: steps.prepare.outputs.should_run == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ''
|
||||
@@ -802,6 +961,7 @@ jobs:
|
||||
run: *stage_cursor_api_key
|
||||
|
||||
- name: Classify with Cursor CLI
|
||||
id: classify_agent
|
||||
if: steps.prepare.outputs.should_run == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ''
|
||||
@@ -857,6 +1017,15 @@ jobs:
|
||||
);
|
||||
'
|
||||
|
||||
- name: Validate classification result
|
||||
id: validate_classification
|
||||
if: steps.prepare.outputs.should_run == 'true'
|
||||
run: |
|
||||
node -e '
|
||||
const auto = require(process.env.RUNNER_TEMP + "/cursor-automation.cjs");
|
||||
auto.parseClassificationFile(".cursor-runtime/classification.json");
|
||||
'
|
||||
|
||||
- name: Apply classification
|
||||
id: apply
|
||||
if: steps.prepare.outputs.should_run == 'true'
|
||||
@@ -890,6 +1059,20 @@ jobs:
|
||||
with:
|
||||
github-token: ${{ secrets.TRIAGE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const fs = require('node:fs');
|
||||
let auto = null;
|
||||
for (const helper of [
|
||||
`${process.env.RUNNER_TEMP}/cursor-automation.cjs`,
|
||||
`${process.env.GITHUB_WORKSPACE}/scripts/cursor-automation.cjs`,
|
||||
]) {
|
||||
if (!fs.existsSync(helper)) continue;
|
||||
try {
|
||||
auto = require(helper);
|
||||
break;
|
||||
} catch (error) {
|
||||
core.warning(`Could not load automation helper ${helper}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
const issueNumber = Number(process.env.ISSUE_NUMBER);
|
||||
const commentId = String(process.env.TRIGGER_COMMENT_ID || '');
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
@@ -933,22 +1116,42 @@ jobs:
|
||||
if (issue.state === 'closed') update.state = 'open';
|
||||
await github.rest.issues.update(update);
|
||||
if (!processed) {
|
||||
const isChinese = /[\u3400-\u9fff]/u.test(
|
||||
`${issue.title || ''}\n${issue.body || ''}`,
|
||||
);
|
||||
const marker = /^[A-Za-z0-9_-]+$/.test(commentId)
|
||||
? `<!-- cursor-followup:comment-id=${commentId};result=blocked -->`
|
||||
: '';
|
||||
const kind = '${{ steps.research.outcome }}' === 'failure'
|
||||
? 'research_failed'
|
||||
: '${{ steps.classify_agent.outcome }}' === 'failure'
|
||||
? 'classification_failed'
|
||||
: '${{ steps.validate_classification.outcome }}' === 'failure'
|
||||
? 'classification_failed'
|
||||
: '${{ steps.apply.outcome }}' === 'failure'
|
||||
? 'apply_failed'
|
||||
: 'processing_failed';
|
||||
const chinese = /[\u3400-\u9fff]/u.test(
|
||||
`${issue.title || ''}\n${issue.body || ''}`,
|
||||
);
|
||||
const failureMessage = auto
|
||||
? auto.buildClassificationFailureMessage(issue, {
|
||||
kind,
|
||||
isFollowup: Boolean(commentId),
|
||||
workflowUrl: `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
|
||||
})
|
||||
: [
|
||||
chinese
|
||||
? '自动分类流程没有正常完成,Issue 已保留并转给维护者继续处理。'
|
||||
: 'The automatic classification process did not finish normally. The issue was preserved for maintainer review.',
|
||||
`${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
|
||||
].join('\n\n');
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number: issueNumber,
|
||||
body: [
|
||||
'<!-- cursor-automation -->',
|
||||
`<!-- cursor-classification-failure:kind=${kind};run=${context.runId} -->`,
|
||||
marker,
|
||||
'',
|
||||
isChinese
|
||||
? '收到这条补充了,但自动复核没有安全完成,已经转给维护者继续处理。'
|
||||
: 'We received the additional information, but the automatic review could not finish safely. A maintainer has been notified.',
|
||||
failureMessage,
|
||||
].filter(Boolean).join('\n'),
|
||||
});
|
||||
}
|
||||
@@ -1760,6 +1963,7 @@ jobs:
|
||||
changedFiles: process.argv[2].split("\n").filter(Boolean),
|
||||
nameStatusText: process.argv[3],
|
||||
});
|
||||
auto.writeProtectedPathReport(process.env.RUNNER_TEMP + "/protected-paths.json", hits);
|
||||
if (hits.length) {
|
||||
console.error("Protected paths modified:", hits.join(", "));
|
||||
process.exit(1);
|
||||
@@ -1855,6 +2059,7 @@ jobs:
|
||||
const hits = auto.hasProtectedChangesInSources({
|
||||
nameStatusText: process.argv[1],
|
||||
});
|
||||
auto.writeProtectedPathReport(process.env.RUNNER_TEMP + "/protected-paths.json", hits);
|
||||
if (hits.length) {
|
||||
console.error("Protected paths modified after verification:", hits.join(", "));
|
||||
process.exit(1);
|
||||
@@ -2084,10 +2289,19 @@ jobs:
|
||||
...context.repo,
|
||||
issue_number: issueNumber,
|
||||
});
|
||||
const fallback = auto.buildIssueFollowupFallbackReply(
|
||||
issue,
|
||||
'processing_failed',
|
||||
const protectedPaths = auto.readProtectedPathReport(
|
||||
process.env.RUNNER_TEMP + '/protected-paths.json',
|
||||
);
|
||||
const fallback = protectedPaths.length
|
||||
? auto.buildImplementationFailureMessage(issue, {
|
||||
kind: 'protected_path',
|
||||
protectedPaths,
|
||||
workflowUrl: `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
|
||||
})
|
||||
: auto.buildIssueFollowupFallbackReply(
|
||||
issue,
|
||||
'processing_failed',
|
||||
);
|
||||
const comments = await github.paginate(
|
||||
github.rest.issues.listComments,
|
||||
{ ...context.repo, issue_number: issueNumber, per_page: 100 },
|
||||
@@ -2813,29 +3027,32 @@ jobs:
|
||||
cp scripts/cursor-automation.cjs "$RUNNER_TEMP/cursor-automation.cjs"
|
||||
cp scripts/compare-ci-test-baseline.cjs "$RUNNER_TEMP/compare-ci-test-baseline.cjs"
|
||||
|
||||
- name: Skip if bot PR already open
|
||||
- name: Skip if trusted related PR already open
|
||||
id: existing
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const auto = require(`${process.env.RUNNER_TEMP}/cursor-automation.cjs`);
|
||||
const existing = await auto.findOpenBotPrForIssue({
|
||||
const botPull = await auto.findOpenBotPrForIssue({
|
||||
github,
|
||||
context,
|
||||
issueNumber: process.env.ISSUE_NUMBER,
|
||||
});
|
||||
const existing = botPull || await auto.findOpenPullForIssue({
|
||||
github,
|
||||
context,
|
||||
issueNumber: process.env.ISSUE_NUMBER,
|
||||
includeRelated: true,
|
||||
});
|
||||
core.setOutput('exists', existing ? 'true' : 'false');
|
||||
core.setOutput('url', existing ? existing.html_url : '');
|
||||
if (existing) {
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number: Number(process.env.ISSUE_NUMBER),
|
||||
body: [
|
||||
auto.TRIAGE_MARKER,
|
||||
auto.DISCLAIMER,
|
||||
'',
|
||||
`An automation pull request is already open or was already filed: ${existing.html_url}`,
|
||||
].join('\n'),
|
||||
await auto.markNeedsHuman({
|
||||
github,
|
||||
context,
|
||||
issueNumber: Number(process.env.ISSUE_NUMBER),
|
||||
message: `A trusted related pull request is already open: ${existing.html_url}`,
|
||||
dedupeMarker: `<!-- cursor-existing-related-pr:${existing.number} -->`,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2992,6 +3209,7 @@ jobs:
|
||||
changedFiles: process.argv[2].split("\n").filter(Boolean),
|
||||
nameStatusText: process.argv[3],
|
||||
});
|
||||
auto.writeProtectedPathReport(process.env.RUNNER_TEMP + "/protected-paths.json", hits);
|
||||
if (hits.length) {
|
||||
console.error("Protected paths modified:", hits.join(", "));
|
||||
process.exit(1);
|
||||
@@ -3114,6 +3332,7 @@ jobs:
|
||||
node -e '
|
||||
const auto = require(process.env.RUNNER_TEMP + "/cursor-automation.cjs");
|
||||
const hits = auto.hasProtectedChangesInSources({ nameStatusText: process.argv[1] });
|
||||
auto.writeProtectedPathReport(process.env.RUNNER_TEMP + "/protected-paths.json", hits);
|
||||
if (hits.length) {
|
||||
console.error("Protected paths modified:", hits.join(", "));
|
||||
process.exit(1);
|
||||
@@ -3170,6 +3389,7 @@ jobs:
|
||||
'
|
||||
|
||||
- name: Upload implement patch
|
||||
id: upload_patch
|
||||
if: steps.patch.outputs.artifact_ready == 'true'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -3203,14 +3423,17 @@ jobs:
|
||||
: `${process.env.GITHUB_WORKSPACE}/scripts/cursor-automation.cjs`;
|
||||
const auto = require(helper);
|
||||
const issueNumber = Number(process.env.ISSUE_NUMBER);
|
||||
const protectedPaths = auto.readProtectedPathReport(
|
||||
process.env.RUNNER_TEMP + '/protected-paths.json',
|
||||
);
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
...context.repo,
|
||||
issue_number: issueNumber,
|
||||
});
|
||||
const protectedFailure = '${{ steps.check_protected.outcome }}' === 'failure';
|
||||
const artifactReady = '${{ steps.patch.outputs.artifact_ready }}' === 'true';
|
||||
const artifactUploaded = '${{ steps.upload_patch.outcome }}' === 'success';
|
||||
const verifyFailed = '${{ steps.verify.outputs.passed }}' === 'false';
|
||||
const kind = protectedFailure
|
||||
const kind = protectedPaths.length
|
||||
? 'protected_path'
|
||||
: artifactReady && verifyFailed
|
||||
? 'verification_failed'
|
||||
@@ -3222,7 +3445,10 @@ jobs:
|
||||
message: auto.buildImplementationFailureMessage(issue, {
|
||||
kind,
|
||||
workflowUrl: `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
|
||||
artifactName: artifactReady ? `implement-patch-${context.runId}` : '',
|
||||
artifactName: artifactReady && artifactUploaded
|
||||
? `implement-patch-${context.runId}`
|
||||
: '',
|
||||
protectedPaths,
|
||||
}),
|
||||
dedupeMarker: `<!-- cursor-implement-failure:base=${{ steps.branch.outputs.base_sha }};kind=${kind} -->`,
|
||||
});
|
||||
@@ -3258,7 +3484,60 @@ jobs:
|
||||
name: implement-patch-${{ github.run_id }}
|
||||
path: patch-in
|
||||
|
||||
- name: Checkout trusted helper for publish guard
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
path: helpers
|
||||
|
||||
- name: Ensure automation helper present
|
||||
working-directory: helpers
|
||||
env:
|
||||
BOOTSTRAP_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -f scripts/cursor-automation.cjs ]]; then
|
||||
cp scripts/cursor-automation.cjs "$RUNNER_TEMP/cursor-automation.cjs"
|
||||
exit 0
|
||||
fi
|
||||
git fetch --depth=1 origin "${BOOTSTRAP_SHA}"
|
||||
git show "FETCH_HEAD:scripts/cursor-automation.cjs" > "$RUNNER_TEMP/cursor-automation.cjs"
|
||||
test -s "$RUNNER_TEMP/cursor-automation.cjs"
|
||||
|
||||
- name: Skip publish if trusted related PR opened during implementation
|
||||
id: existing
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.TRIAGE_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const auto = require(`${process.env.RUNNER_TEMP}/cursor-automation.cjs`);
|
||||
const issueNumber = Number(process.env.ISSUE_NUMBER);
|
||||
const botPull = await auto.findOpenBotPrForIssue({
|
||||
github,
|
||||
context,
|
||||
issueNumber,
|
||||
});
|
||||
const existing = botPull || await auto.findOpenPullForIssue({
|
||||
github,
|
||||
context,
|
||||
issueNumber,
|
||||
includeRelated: true,
|
||||
});
|
||||
core.setOutput('exists', existing ? 'true' : 'false');
|
||||
if (existing) {
|
||||
await auto.markNeedsHuman({
|
||||
github,
|
||||
context,
|
||||
issueNumber,
|
||||
message: `A trusted related pull request is already open: ${existing.html_url}`,
|
||||
dedupeMarker: `<!-- cursor-existing-related-pr:${existing.number} -->`,
|
||||
});
|
||||
core.notice(`Trusted related pull request ${existing.html_url} opened during implementation; skip publishing a duplicate branch.`);
|
||||
}
|
||||
|
||||
- name: Publish branch from fresh runner
|
||||
if: steps.existing.outputs.exists != 'true'
|
||||
id: publish
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -3301,7 +3580,7 @@ jobs:
|
||||
exit 1
|
||||
|
||||
- name: Mark needs human on publish failure
|
||||
if: failure() && steps.publish.outputs.handoff == 'true'
|
||||
if: failure() && steps.existing.outputs.exists != 'true' && steps.publish.outputs.handoff == 'true'
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ needs.implement.outputs.issue_number }}
|
||||
@@ -3329,32 +3608,6 @@ jobs:
|
||||
});
|
||||
}
|
||||
|
||||
- name: Checkout helper for PR body helpers
|
||||
if: steps.publish.outputs.published == 'true'
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
path: helpers
|
||||
|
||||
- name: Ensure automation helper present
|
||||
if: steps.publish.outputs.published == 'true'
|
||||
working-directory: helpers
|
||||
env:
|
||||
BOOTSTRAP_SHA: ${{ github.sha }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
HEAD_REPO: ''
|
||||
BASE_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -f scripts/cursor-automation.cjs ]]; then
|
||||
cp scripts/cursor-automation.cjs "$RUNNER_TEMP/cursor-automation.cjs"
|
||||
exit 0
|
||||
fi
|
||||
git fetch --depth=1 origin "${BOOTSTRAP_SHA}"
|
||||
git show "FETCH_HEAD:scripts/cursor-automation.cjs" > "$RUNNER_TEMP/cursor-automation.cjs"
|
||||
test -s "$RUNNER_TEMP/cursor-automation.cjs"
|
||||
|
||||
- name: Open draft PR
|
||||
if: steps.publish.outputs.published == 'true'
|
||||
id: openpr
|
||||
@@ -3373,6 +3626,29 @@ jobs:
|
||||
script: |
|
||||
const auto = require(`${process.env.RUNNER_TEMP}/cursor-automation.cjs`);
|
||||
const fs = require('node:fs');
|
||||
const issueNumber = Number(process.env.ISSUE_NUMBER);
|
||||
const botPull = await auto.findOpenBotPrForIssue({
|
||||
github,
|
||||
context,
|
||||
issueNumber,
|
||||
});
|
||||
const existing = botPull || await auto.findOpenPullForIssue({
|
||||
github,
|
||||
context,
|
||||
issueNumber,
|
||||
includeRelated: true,
|
||||
});
|
||||
if (existing) {
|
||||
await auto.markNeedsHuman({
|
||||
github,
|
||||
context,
|
||||
issueNumber,
|
||||
message: `A trusted related pull request is already open: ${existing.html_url}`,
|
||||
dedupeMarker: `<!-- cursor-existing-related-pr:${existing.number} -->`,
|
||||
});
|
||||
core.notice(`Trusted related pull request ${existing.html_url} opened before PR creation; skip the duplicate.`);
|
||||
return;
|
||||
}
|
||||
let statusText = '';
|
||||
let agentBody = '';
|
||||
try {
|
||||
@@ -4400,6 +4676,7 @@ jobs:
|
||||
changedFiles: process.argv[2].split("\n").filter(Boolean),
|
||||
nameStatusText: process.argv[3],
|
||||
});
|
||||
auto.writeProtectedPathReport(process.env.RUNNER_TEMP + "/protected-paths.json", hits);
|
||||
if (hits.length) {
|
||||
console.error("Protected paths modified:", hits.join(", "));
|
||||
process.exit(1);
|
||||
@@ -4498,6 +4775,7 @@ jobs:
|
||||
node -e '
|
||||
const auto = require(process.env.RUNNER_TEMP + "/cursor-automation.cjs");
|
||||
const hits = auto.hasProtectedChangesInSources({ nameStatusText: process.argv[1] });
|
||||
auto.writeProtectedPathReport(process.env.RUNNER_TEMP + "/protected-paths.json", hits);
|
||||
if (hits.length) {
|
||||
console.error("Protected paths modified:", hits.join(", "));
|
||||
process.exit(1);
|
||||
@@ -4544,6 +4822,7 @@ jobs:
|
||||
'
|
||||
|
||||
- name: Upload fix patch
|
||||
id: upload_fixpatch
|
||||
if: steps.codex.outputs.action == 'fix' && steps.fixpatch.outputs.artifact_ready == 'true'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -4578,9 +4857,12 @@ jobs:
|
||||
issue_number: pull_number,
|
||||
body: [
|
||||
auto.TRIAGE_MARKER,
|
||||
auto.DISCLAIMER,
|
||||
`<!-- cursor-codex-fix-failure:kind=no_changes;run=${context.runId} -->`,
|
||||
'',
|
||||
'Automatic fix did not produce additional code changes for the latest Codex findings. Marking for human review.',
|
||||
auto.buildCodexFixFailureMessage({
|
||||
kind: 'no_changes',
|
||||
workflowUrl: `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
|
||||
}),
|
||||
].join('\n'),
|
||||
});
|
||||
await auto.applyCodexTerminalLabels({
|
||||
@@ -4604,14 +4886,32 @@ jobs:
|
||||
: `${process.env.GITHUB_WORKSPACE}/scripts/cursor-automation.cjs`;
|
||||
const auto = require(helper);
|
||||
const pull_number = Number(process.env.PULL_NUMBER);
|
||||
const protectedPaths = auto.readProtectedPathReport(
|
||||
process.env.RUNNER_TEMP + '/protected-paths.json',
|
||||
);
|
||||
const artifactReady = '${{ steps.fixpatch.outputs.artifact_ready }}' === 'true';
|
||||
const artifactUploaded = '${{ steps.upload_fixpatch.outcome }}' === 'success';
|
||||
const verifyFailed = '${{ steps.fixverify.outputs.passed }}' === 'false';
|
||||
const kind = protectedPaths.length
|
||||
? 'protected_path'
|
||||
: artifactReady && verifyFailed
|
||||
? 'verification_failed'
|
||||
: 'processing_failed';
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number: pull_number,
|
||||
body: [
|
||||
auto.TRIAGE_MARKER,
|
||||
auto.DISCLAIMER,
|
||||
`<!-- cursor-codex-fix-failure:kind=${kind};run=${context.runId} -->`,
|
||||
'',
|
||||
'Automatic Codex fix did not finish cleanly (agent, protected paths, or verification failed). Marking for human review.',
|
||||
auto.buildCodexFixFailureMessage({
|
||||
kind,
|
||||
protectedPaths,
|
||||
artifactName: artifactReady && artifactUploaded
|
||||
? `codex-fix-patch-${context.runId}`
|
||||
: '',
|
||||
workflowUrl: `${process.env.GITHUB_SERVER_URL}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
|
||||
}),
|
||||
].join('\n'),
|
||||
});
|
||||
await auto.applyCodexTerminalLabels({
|
||||
|
||||
+361
-54
@@ -169,6 +169,8 @@ function parseExternalResearchEnvelope(value) {
|
||||
const USER_AUTHORED_URL_TOKEN_PATTERN = /https?:\/\/[^\s<>()"'`,;]+/gi;
|
||||
const GITHUB_USER_ATTACHMENT_ASSET_PATH_PATTERN =
|
||||
/^\/user-attachments\/assets\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const GITHUB_USER_ATTACHMENT_REDIRECT_HOST_PATTERN =
|
||||
/^github-production-user-asset-[a-z0-9-]+\.s3\.amazonaws\.com$/i;
|
||||
|
||||
function normalizeGithubUserAttachmentAssetUrl(value) {
|
||||
const candidate = String(value || '').replace(/[.!:\]}]+$/g, '');
|
||||
@@ -196,6 +198,38 @@ function extractGithubUserAttachmentAssetUrls(input = {}) {
|
||||
return [...new Set(tokens.map(normalizeGithubUserAttachmentAssetUrl).filter(Boolean))];
|
||||
}
|
||||
|
||||
/** Classify GitHub's signed attachment redirect without fetching untrusted media. */
|
||||
function classifyGithubUserAttachmentRedirect(value) {
|
||||
const locations = String(value || '')
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => /^location\s*:/i.test(line))
|
||||
.map((line) => line.replace(/^location\s*:\s*/i, '').trim())
|
||||
.filter(Boolean);
|
||||
if (!locations.length) return '';
|
||||
try {
|
||||
const parsed = new URL(locations.at(-1));
|
||||
if (
|
||||
parsed.protocol !== 'https:'
|
||||
|| parsed.username
|
||||
|| parsed.password
|
||||
|| !GITHUB_USER_ATTACHMENT_REDIRECT_HOST_PATTERN.test(parsed.hostname)
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
const mediaType = String(parsed.searchParams.get('response-content-type') || '')
|
||||
.split(';', 1)[0]
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (mediaType.startsWith('image/')) return 'image';
|
||||
if (mediaType.startsWith('video/') || mediaType.startsWith('audio/')) {
|
||||
return 'unsupported_media';
|
||||
}
|
||||
return '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function rewriteExternalResearchInputAttachments(input, attachments = []) {
|
||||
const replacements = new Map();
|
||||
for (const attachment of attachments) {
|
||||
@@ -205,8 +239,16 @@ function rewriteExternalResearchInputAttachments(input, attachments = []) {
|
||||
if (exactSource !== sourceUrl) {
|
||||
throw new Error('Research attachment source must be a GitHub user attachment asset URL.');
|
||||
}
|
||||
if (!/^attachments\/[A-Za-z0-9._/-]+$/.test(relativePath) || relativePath.includes('..')) {
|
||||
throw new Error('Research attachment path must stay under attachments/.');
|
||||
if (attachment?.kind === 'unsupported_media') {
|
||||
replacements.set(sourceUrl, '[video or audio attachment omitted from automated research]');
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
attachment?.kind != null && attachment.kind !== 'image'
|
||||
|| !/^attachments\/[A-Za-z0-9._/-]+$/.test(relativePath)
|
||||
|| relativePath.includes('..')
|
||||
) {
|
||||
throw new Error('Research image attachment path must stay under attachments/.');
|
||||
}
|
||||
replacements.set(sourceUrl, `[proxied image: ${relativePath}]`);
|
||||
}
|
||||
@@ -263,6 +305,14 @@ function normalizeResearchSourceUrl(value) {
|
||||
if (parsed.protocol !== 'https:') return '';
|
||||
parsed.hash = '';
|
||||
if (parsed.pathname.length > 1) parsed.pathname = parsed.pathname.replace(/\/$/, '');
|
||||
if (parsed.hostname === 'github.com') {
|
||||
const segments = parsed.pathname.split('/');
|
||||
if (segments.length >= 3) {
|
||||
segments[1] = segments[1].toLowerCase();
|
||||
segments[2] = segments[2].toLowerCase();
|
||||
parsed.pathname = segments.join('/');
|
||||
}
|
||||
}
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return '';
|
||||
@@ -1122,17 +1172,40 @@ function extractIssueCommentWatermark(body) {
|
||||
return String(body || '').match(ISSUE_WATERMARK_RE)?.[1] || '';
|
||||
}
|
||||
|
||||
function extractSourceIssueNumber(pull) {
|
||||
function extractKeywordIssueNumbers(body, { includeRelated = false } = {}) {
|
||||
const keywords = includeRelated
|
||||
? 'close[sd]?|fix(?:e[sd])?|resolve[sd]?|related\\s+to|refs?|references?'
|
||||
: 'close[sd]?|fix(?:e[sd])?|resolve[sd]?';
|
||||
const issueReference = '#\\d+(?![A-Za-z0-9_])';
|
||||
const clause = new RegExp(
|
||||
`(?:^|\\W)(?:${keywords})\\s+(`
|
||||
+ `${issueReference}(?:\\s*(?:,\\s*(?:and\\s+)?|and\\s+|&\\s*)${issueReference})*`
|
||||
+ ')',
|
||||
'gi',
|
||||
);
|
||||
const issueNumbers = [];
|
||||
for (const match of String(body || '').matchAll(clause)) {
|
||||
for (const reference of match[1].matchAll(/#(\d+)/g)) {
|
||||
const issueNumber = Number(reference[1]);
|
||||
if (Number.isFinite(issueNumber) && issueNumber > 0) issueNumbers.push(issueNumber);
|
||||
}
|
||||
}
|
||||
return [...new Set(issueNumbers)];
|
||||
}
|
||||
|
||||
function extractSourceIssueNumbers(pull) {
|
||||
const body = String(pull?.body || '');
|
||||
const marker = body.match(SOURCE_ISSUE_RE);
|
||||
if (marker) return Number(marker[1]);
|
||||
const closing = body.match(
|
||||
/(?:^|\W)(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#(\d+)\b/i,
|
||||
);
|
||||
if (closing) return Number(closing[1]);
|
||||
if (marker) return [Number(marker[1])];
|
||||
const closing = extractKeywordIssueNumbers(body);
|
||||
if (closing.length) return [...new Set(closing)];
|
||||
const headRef = String(pull?.head?.ref || pull?.headRefName || '');
|
||||
const branch = headRef.match(/^cursor\/issue-(\d+)-/i);
|
||||
return branch ? Number(branch[1]) : null;
|
||||
return branch ? [Number(branch[1])] : [];
|
||||
}
|
||||
|
||||
function extractSourceIssueNumber(pull) {
|
||||
return extractSourceIssueNumbers(pull)[0] || null;
|
||||
}
|
||||
|
||||
function extractProcessedIssueFollowupIds(
|
||||
@@ -1416,16 +1489,47 @@ function nextSourceIssueLabelsAfterPull(existing = [], merged = false) {
|
||||
return [...new Set(labels)];
|
||||
}
|
||||
|
||||
function shouldCleanupSourceIssueAfterPull(pull, options = {}) {
|
||||
if (!pull || String(pull.state || '').toLowerCase() !== 'closed') return false;
|
||||
const issueNumbers = extractSourceIssueNumbers(pull);
|
||||
if (!issueNumbers.length) return false;
|
||||
|
||||
const repository = String(options.repository || '').toLowerCase();
|
||||
const baseRepo = String(
|
||||
pull.base?.repo?.full_name || pull.base?.repo?.nameWithOwner || repository,
|
||||
).toLowerCase();
|
||||
if (repository && baseRepo && baseRepo !== repository) return false;
|
||||
|
||||
const merged = Boolean(pull.merged || pull.merged_at || pull.mergedAt);
|
||||
if (merged) {
|
||||
const body = String(pull.body || '');
|
||||
if (SOURCE_ISSUE_RE.test(body)) {
|
||||
const issueNumber = issueNumbers[0];
|
||||
const closing = new RegExp(
|
||||
`(?:^|\\W)(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s+#${issueNumber}\\b`,
|
||||
'i',
|
||||
);
|
||||
if (closing.test(body)) return true;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return shouldGatePullOnSourceIssueFollowups(pull, options);
|
||||
}
|
||||
|
||||
function buildImplementationFailureMessage(issue = {}, {
|
||||
kind = 'processing_failed',
|
||||
workflowUrl = '',
|
||||
artifactName = '',
|
||||
protectedPaths = [],
|
||||
} = {}) {
|
||||
const chinese = /[\u3400-\u9fff]/u.test(`${issue.title || ''}\n${issue.body || ''}`);
|
||||
const link = workflowUrl ? (chinese ? `查看本次运行:${workflowUrl}` : `View this run: ${workflowUrl}`) : '';
|
||||
const artifact = artifactName
|
||||
? (chinese ? `候选补丁和验证报告已保存为 ${artifactName}。` : `The candidate patch and verification report were preserved as ${artifactName}.`)
|
||||
: '';
|
||||
const protectedDetails = formatProtectedPathDetails(protectedPaths, { chinese });
|
||||
const messages = chinese
|
||||
? {
|
||||
verification_failed: '自动修改已经完成,但本次改动新增了验证失败,因此没有创建 PR。',
|
||||
@@ -1439,7 +1543,68 @@ function buildImplementationFailureMessage(issue = {}, {
|
||||
no_changes: 'Cursor did not produce a safe focused change. A maintainer needs to continue the investigation.',
|
||||
processing_failed: 'The automation process itself did not finish normally. A maintainer needs to continue.',
|
||||
};
|
||||
return [messages[kind] || messages.processing_failed, artifact, link].filter(Boolean).join('\n\n');
|
||||
return [messages[kind] || messages.processing_failed, protectedDetails, artifact, link]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
function buildCodexFixFailureMessage({
|
||||
kind = 'processing_failed',
|
||||
workflowUrl = '',
|
||||
artifactName = '',
|
||||
protectedPaths = [],
|
||||
} = {}) {
|
||||
const messages = {
|
||||
verification_failed: 'The automatic Codex fix was preserved, but it introduced verification failures. The PR remains draft for maintainer review.',
|
||||
protected_path: 'The automatic Codex fix touched protected release or automation files, so the safety gate stopped publication.',
|
||||
no_changes: 'The automatic Codex fix completed, but it did not change any files for the latest review findings. The findings may already be addressed, stale, or require maintainer judgment, so the PR remains draft for human review.',
|
||||
processing_failed: 'The automatic Codex fix process itself did not finish normally. The PR remains draft for maintainer review.',
|
||||
};
|
||||
const protectedDetails = formatProtectedPathDetails(protectedPaths);
|
||||
const artifact = artifactName
|
||||
? `The candidate patch and verification report were preserved as ${artifactName}.`
|
||||
: '';
|
||||
const link = workflowUrl ? `View this run: ${workflowUrl}` : '';
|
||||
return [messages[kind] || messages.processing_failed, protectedDetails, artifact, link]
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
function buildClassificationFailureMessage(issue = {}, {
|
||||
kind = 'processing_failed',
|
||||
workflowUrl = '',
|
||||
isFollowup = false,
|
||||
} = {}) {
|
||||
const chinese = /[\u3400-\u9fff]/u.test(`${issue.title || ''}\n${issue.body || ''}`);
|
||||
const messages = chinese
|
||||
? {
|
||||
research_failed: isFollowup
|
||||
? '自动复核在读取这次补充的附件或外部资料时失败,Issue 已保留并转给维护者继续处理。'
|
||||
: '自动分类在读取附件或外部资料时失败,Issue 已保留并转给维护者继续处理。',
|
||||
classification_failed: isFollowup
|
||||
? '自动复核没有得到可安全使用的判断结果,Issue 已保留并转给维护者继续处理。'
|
||||
: '自动分类没有得到可安全使用的判断结果,Issue 已保留并转给维护者继续处理。',
|
||||
apply_failed: '自动分类已经得到结果,但更新 Issue 状态时没有安全完成,已转给维护者继续处理。',
|
||||
processing_failed: isFollowup
|
||||
? '这次补充的自动复核流程没有正常完成,Issue 已保留并转给维护者继续处理。'
|
||||
: '自动分类流程没有正常完成,Issue 已保留并转给维护者继续处理。',
|
||||
}
|
||||
: {
|
||||
research_failed: isFollowup
|
||||
? 'The automatic review could not read the new attachments or external context. The issue was preserved for maintainer review.'
|
||||
: 'Automatic classification could not read the attachments or external context. The issue was preserved for maintainer review.',
|
||||
classification_failed: isFollowup
|
||||
? 'The automatic review did not produce a safe usable decision. The issue was preserved for maintainer review.'
|
||||
: 'Automatic classification did not produce a safe usable decision. The issue was preserved for maintainer review.',
|
||||
apply_failed: 'Automatic classification produced a result, but the issue state could not be updated safely. A maintainer needs to continue.',
|
||||
processing_failed: isFollowup
|
||||
? 'The automatic review of the new information did not finish normally. The issue was preserved for maintainer review.'
|
||||
: 'The automatic classification process did not finish normally. The issue was preserved for maintainer review.',
|
||||
};
|
||||
const link = workflowUrl
|
||||
? (chinese ? `查看本次运行:${workflowUrl}` : `View this run: ${workflowUrl}`)
|
||||
: '';
|
||||
return [messages[kind] || messages.processing_failed, link].filter(Boolean).join('\n\n');
|
||||
}
|
||||
|
||||
function buildIssueFollowupReply({
|
||||
@@ -2162,6 +2327,22 @@ function decideIssueCommentRoute({
|
||||
return { kind: 'skip', reason: 'issue comment no follow-up signal' };
|
||||
}
|
||||
|
||||
function refineIssueCommentRoute(decision, {
|
||||
hasOpenBotPull = false,
|
||||
hasOpenRelatedPull = false,
|
||||
body = '',
|
||||
} = {}) {
|
||||
if (decision?.kind !== 'issue_followup' || hasOpenBotPull) return decision;
|
||||
const simpleKind = classifySimpleIssueFollowup([{ body }]);
|
||||
if (simpleKind) return decision;
|
||||
return {
|
||||
kind: 'issue_classify',
|
||||
reason: hasOpenRelatedPull
|
||||
? 'actionable author follow-up with trusted related PR'
|
||||
: 'actionable author follow-up without open automation PR',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a path as printed by Git when it contains special characters
|
||||
* (leading quote + C-style escapes). Unquoted paths are returned as-is.
|
||||
@@ -2303,41 +2484,38 @@ function listProtectedPathHits(filePaths) {
|
||||
return hits;
|
||||
}
|
||||
|
||||
function isTestSourcePath(filePath) {
|
||||
const normalized = String(filePath || '').replace(/\\/g, '/');
|
||||
return (
|
||||
/(?:^|\/)[^/]+\.(?:test|spec)\.[cm]?[jt]sx?$/.test(normalized) ||
|
||||
/(?:^|\/)__tests__\//.test(normalized)
|
||||
);
|
||||
function normalizeProtectedPathDetails(filePaths = []) {
|
||||
const unique = [...new Set((filePaths || []).map((value) =>
|
||||
String(value || '').replace(/\\/g, '/').trim(),
|
||||
))];
|
||||
return listProtectedPathHits(unique)
|
||||
.filter((value) => value.length <= 300 && !/[\r\n\0]/.test(value))
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
function listModifiedExistingTestPaths({
|
||||
gitStatusPorcelain = '',
|
||||
nameStatusText = '',
|
||||
} = {}) {
|
||||
const hits = [];
|
||||
for (const line of String(gitStatusPorcelain || '').split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
const status = line.slice(0, 2);
|
||||
if (status === '??' || status.includes('A')) continue;
|
||||
const rest = line.slice(3).trim();
|
||||
const paths = rest.includes(' -> ')
|
||||
? rest.split(' -> ').map((value) => unquoteGitPath(value.trim()))
|
||||
: [unquoteGitPath(rest)];
|
||||
hits.push(...paths.filter(isTestSourcePath));
|
||||
function formatProtectedPathDetails(filePaths = [], { chinese = false } = {}) {
|
||||
const paths = normalizeProtectedPathDetails(filePaths);
|
||||
if (!paths.length) return '';
|
||||
const heading = chinese ? '触发安全规则的位置:' : 'Protected paths:';
|
||||
return [heading, ...paths.map((value) => `- ${value}`)].join('\n');
|
||||
}
|
||||
|
||||
function writeProtectedPathReport(filePath, filePaths = []) {
|
||||
const reportPath = String(filePath || '');
|
||||
if (!reportPath) throw new Error('Protected path report requires a file path.');
|
||||
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||
fs.rmSync(reportPath, { force: true });
|
||||
const paths = normalizeProtectedPathDetails(filePaths);
|
||||
if (paths.length) writeJson(reportPath, paths);
|
||||
return paths;
|
||||
}
|
||||
|
||||
function readProtectedPathReport(filePath) {
|
||||
try {
|
||||
return normalizeProtectedPathDetails(JSON.parse(fs.readFileSync(filePath, 'utf8')));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
for (const line of String(nameStatusText || '').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const parts = trimmed.split(/\t/);
|
||||
const status = parts[0] || '';
|
||||
if (/^A\d*$/.test(status)) continue;
|
||||
const paths = /^R\d*/.test(status) && parts.length >= 3
|
||||
? [parts[1], parts[2]]
|
||||
: parts.slice(1, 2);
|
||||
hits.push(...paths.map(unquoteGitPath).filter(isTestSourcePath));
|
||||
}
|
||||
return [...new Set(hits)];
|
||||
}
|
||||
|
||||
function hasProtectedChanges(gitStatusPorcelain) {
|
||||
@@ -2358,10 +2536,7 @@ function hasProtectedChangesInSources({
|
||||
...fromCommits,
|
||||
...fromNameStatus,
|
||||
]);
|
||||
return [...new Set([
|
||||
...protectedHits,
|
||||
...listModifiedExistingTestPaths({ gitStatusPorcelain, nameStatusText }),
|
||||
])];
|
||||
return [...new Set(protectedHits)];
|
||||
}
|
||||
|
||||
function getCodexRoundFromComments(comments = [], options = {}) {
|
||||
@@ -2632,20 +2807,16 @@ async function prepareIssueContext({
|
||||
(comment) => String(comment?.id || '') === previousWatermark,
|
||||
)
|
||||
: -1;
|
||||
const needsInfoReply = Boolean(
|
||||
triggerId &&
|
||||
(labelNames.includes('needs-info') ||
|
||||
labelNames.includes('triage:bug-needs-info')),
|
||||
);
|
||||
const commentTriggeredReclassification = Boolean(triggerId);
|
||||
const processed = extractProcessedIssueFollowupIds(commentList, botLogins);
|
||||
if ((needsInfoReply || automaticBacklogDrain) && !manual) {
|
||||
if ((commentTriggeredReclassification || automaticBacklogDrain) && !manual) {
|
||||
if (
|
||||
triggerId &&
|
||||
(processed.has(triggerId) ||
|
||||
commentIdAtOrBefore(triggerId, previousWatermark))
|
||||
) {
|
||||
setOutput(core, 'should_run', false);
|
||||
setOutput(core, 'reason', 'This needs-info reply was already processed.');
|
||||
setOutput(core, 'reason', 'This issue reply was already processed.');
|
||||
return { shouldRun: false, issue, comments: commentList };
|
||||
}
|
||||
const startOfDay = new Date(nowMs);
|
||||
@@ -2661,7 +2832,7 @@ async function prepareIssueContext({
|
||||
setOutput(core, 'should_run', false);
|
||||
setOutput(core, 'rate_limited', true);
|
||||
setOutput(core, 'pending_ids', triggerId);
|
||||
setOutput(core, 'reason', 'Daily needs-info follow-up limit reached.');
|
||||
setOutput(core, 'reason', 'Daily issue follow-up limit reached.');
|
||||
return {
|
||||
shouldRun: false,
|
||||
rateLimited: true,
|
||||
@@ -2925,6 +3096,129 @@ function isBotPrForIssue(pull, issueNumber) {
|
||||
);
|
||||
}
|
||||
|
||||
function pullReferencesIssue(pull, issueNumber, { includeRelated = false } = {}) {
|
||||
if (!pull) return false;
|
||||
const n = String(issueNumber);
|
||||
const body = String(pull.body || '');
|
||||
const marker = body.match(SOURCE_ISSUE_RE);
|
||||
if (marker && marker[1] === n) return true;
|
||||
return extractKeywordIssueNumbers(body, { includeRelated }).includes(Number(n));
|
||||
}
|
||||
|
||||
function isTrustedOpenPullForIssue(pull, issueNumber, {
|
||||
repository = '',
|
||||
includeRelated = false,
|
||||
} = {}) {
|
||||
if (!pull || (pull.state && String(pull.state).toLowerCase() !== 'open')) return false;
|
||||
const normalizedRepository = String(repository || '').toLowerCase();
|
||||
const headRepository = String(
|
||||
pull.head?.repo?.full_name || pull.headRepository?.nameWithOwner || '',
|
||||
).toLowerCase();
|
||||
const trustedAssociations = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
|
||||
const trustedAuthor = trustedAssociations.has(
|
||||
String(pull.author_association || pull.authorAssociation || '').toUpperCase(),
|
||||
);
|
||||
const body = String(pull.body || '');
|
||||
const sourceMarker = body.match(SOURCE_ISSUE_RE);
|
||||
const labels = (pull.labels || []).map((label) => (
|
||||
typeof label === 'string' ? label : label?.name
|
||||
));
|
||||
const author = String(pull.user?.login || pull.author?.login || '').toLowerCase();
|
||||
const headRef = String(pull.head?.ref || pull.headRefName || '');
|
||||
const trustedBotAuthor = ['netcatty-bot', 'github-actions[bot]', 'github-actions']
|
||||
.includes(author);
|
||||
const automationManaged =
|
||||
Boolean(sourceMarker)
|
||||
|| (
|
||||
trustedBotAuthor
|
||||
&& (
|
||||
isBotPrMarker(body)
|
||||
|| labels.includes('automation:bot-pr')
|
||||
|| /^cursor\/issue-\d+-/.test(headRef)
|
||||
)
|
||||
);
|
||||
const referencesIssue = automationManaged
|
||||
? Boolean(sourceMarker && sourceMarker[1] === String(issueNumber))
|
||||
: pullReferencesIssue(pull, issueNumber, { includeRelated });
|
||||
return (
|
||||
(Boolean(normalizedRepository) && headRepository === normalizedRepository || trustedAuthor)
|
||||
&& referencesIssue
|
||||
);
|
||||
}
|
||||
|
||||
async function findOpenPullForIssue({
|
||||
github,
|
||||
context,
|
||||
issueNumber,
|
||||
includeRelated = false,
|
||||
}) {
|
||||
const pulls = await github.paginate(github.rest.pulls.list, {
|
||||
...context.repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
sort: 'updated',
|
||||
direction: 'desc',
|
||||
});
|
||||
const repository = `${context.repo.owner}/${context.repo.repo}`.toLowerCase();
|
||||
return (pulls || []).find((pull) => isTrustedOpenPullForIssue(pull, issueNumber, {
|
||||
repository,
|
||||
includeRelated,
|
||||
})) || null;
|
||||
}
|
||||
|
||||
function shouldRetryIssueHandoff(comments = [], {
|
||||
trustedActors = '',
|
||||
recoveryVersion = '',
|
||||
notBefore = '',
|
||||
notAfter = '',
|
||||
} = {}) {
|
||||
const actors = new Set(
|
||||
String(trustedActors || '')
|
||||
.split(',')
|
||||
.map((value) => value.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
);
|
||||
const notBeforeMs = Date.parse(String(notBefore || ''));
|
||||
const notAfterMs = Date.parse(String(notAfter || ''));
|
||||
const trustedComments = (comments || []).filter((comment) => (
|
||||
actors.has(String(comment.user?.login || '').toLowerCase())
|
||||
));
|
||||
if (
|
||||
recoveryVersion
|
||||
&& trustedComments.some((comment) => (
|
||||
String(comment.body || '').includes(`cursor-handoff-recovery:version=${recoveryVersion}`)
|
||||
))
|
||||
) return false;
|
||||
const boundedComments = trustedComments
|
||||
.filter((comment) => {
|
||||
if (!Number.isFinite(notBeforeMs) && !Number.isFinite(notAfterMs)) return true;
|
||||
const createdAt = Date.parse(comment.created_at || comment.createdAt || '');
|
||||
if (!Number.isFinite(createdAt)) return false;
|
||||
if (Number.isFinite(notBeforeMs) && createdAt < notBeforeMs) return false;
|
||||
if (Number.isFinite(notAfterMs) && createdAt > notAfterMs) return false;
|
||||
return true;
|
||||
})
|
||||
.sort((left, right) => (
|
||||
Date.parse(left.created_at || left.createdAt || '')
|
||||
- Date.parse(right.created_at || right.createdAt || '')
|
||||
));
|
||||
let latestTerminal = '';
|
||||
for (const comment of boundedComments) {
|
||||
const body = String(comment.body || '');
|
||||
const retryableFailure =
|
||||
/cursor-implement-failure:[^>]*kind=protected_path/i.test(body)
|
||||
|| /cursor-classification-failure:kind=research_failed/i.test(body)
|
||||
|| body.includes('收到这条补充了,但自动复核没有安全完成')
|
||||
|| body.includes('The automatic follow-up did not finish safely');
|
||||
if (retryableFailure) {
|
||||
latestTerminal = 'retryable_failure';
|
||||
} else if (/cursor-followup:comment-id=[^;>]+;result=(?:no_change|updated)/i.test(body)) {
|
||||
latestTerminal = 'success';
|
||||
}
|
||||
}
|
||||
return latestTerminal === 'retryable_failure';
|
||||
}
|
||||
|
||||
async function findOpenBotPrForIssue({ github, context, issueNumber }) {
|
||||
const n = String(issueNumber);
|
||||
// Only open PRs count — a closed/unmerged automation PR must not block retries.
|
||||
@@ -3340,6 +3634,7 @@ module.exports = {
|
||||
CODEX_TERMINALS,
|
||||
sanitizeUntrustedText,
|
||||
extractGithubUserAttachmentAssetUrls,
|
||||
classifyGithubUserAttachmentRedirect,
|
||||
rewriteExternalResearchInputAttachments,
|
||||
normalizeExternalResearchText,
|
||||
parseExternalResearchStream,
|
||||
@@ -3370,6 +3665,7 @@ module.exports = {
|
||||
buildTriageComment,
|
||||
buildPullRequestBody,
|
||||
extractIssueCommentWatermark,
|
||||
extractSourceIssueNumbers,
|
||||
extractSourceIssueNumber,
|
||||
extractProcessedIssueFollowupIds,
|
||||
countIssueFollowupRepliesSince,
|
||||
@@ -3383,7 +3679,10 @@ module.exports = {
|
||||
classifySimpleIssueFollowup,
|
||||
buildSimpleIssueFollowupReply,
|
||||
nextSourceIssueLabelsAfterPull,
|
||||
shouldCleanupSourceIssueAfterPull,
|
||||
buildImplementationFailureMessage,
|
||||
buildCodexFixFailureMessage,
|
||||
buildClassificationFailureMessage,
|
||||
buildIssueFollowupReply,
|
||||
buildIssueFollowupFallbackReply,
|
||||
buildPullRequestComment,
|
||||
@@ -3412,8 +3711,12 @@ module.exports = {
|
||||
shouldReTriageIssueComment,
|
||||
mentionsIssueBot,
|
||||
decideIssueCommentRoute,
|
||||
refineIssueCommentRoute,
|
||||
formatCodexFindingsMarkdown,
|
||||
listProtectedPathHits,
|
||||
formatProtectedPathDetails,
|
||||
writeProtectedPathReport,
|
||||
readProtectedPathReport,
|
||||
unquoteGitPath,
|
||||
pathsFromGitStatusPorcelain,
|
||||
pathsFromGitDiffNameStatus,
|
||||
@@ -3427,7 +3730,11 @@ module.exports = {
|
||||
applyClassification,
|
||||
markNeedsHuman,
|
||||
isBotPrForIssue,
|
||||
pullReferencesIssue,
|
||||
isTrustedOpenPullForIssue,
|
||||
findOpenBotPrForIssue,
|
||||
findOpenPullForIssue,
|
||||
shouldRetryIssueHandoff,
|
||||
shouldGatePullOnSourceIssueFollowups,
|
||||
getPendingIssueFollowupsForPull,
|
||||
ensurePullRequestDraft,
|
||||
|
||||
@@ -591,6 +591,44 @@ test('decideIssueCommentRoute sends author additions on managed issues to follow
|
||||
);
|
||||
});
|
||||
|
||||
test('actionable author follow-ups without an open bot PR are reclassified', () => {
|
||||
const decision = { kind: 'issue_followup', reason: 'author follow-up on managed issue' };
|
||||
assert.deepEqual(
|
||||
auto.refineIssueCommentRoute(decision, {
|
||||
hasOpenBotPull: false,
|
||||
body: '默认开启 X11 就可以,内置服务暂时不需要。',
|
||||
}),
|
||||
{
|
||||
kind: 'issue_classify',
|
||||
reason: 'actionable author follow-up without open automation PR',
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
auto.refineIssueCommentRoute(decision, {
|
||||
hasOpenBotPull: true,
|
||||
body: 'Please also cover this case.',
|
||||
}),
|
||||
decision,
|
||||
);
|
||||
assert.deepEqual(
|
||||
auto.refineIssueCommentRoute(decision, {
|
||||
hasOpenRelatedPull: true,
|
||||
body: 'Please also cover this case.',
|
||||
}),
|
||||
{
|
||||
kind: 'issue_classify',
|
||||
reason: 'actionable author follow-up with trusted related PR',
|
||||
},
|
||||
);
|
||||
assert.equal(
|
||||
auto.refineIssueCommentRoute(decision, {
|
||||
hasOpenBotPull: false,
|
||||
body: '收到,谢谢',
|
||||
}),
|
||||
decision,
|
||||
);
|
||||
});
|
||||
|
||||
test('decideIssueCommentRoute accepts maintainer @bot and ignores untrusted bystanders', () => {
|
||||
assert.equal(
|
||||
auto.decideIssueCommentRoute({
|
||||
@@ -791,6 +829,51 @@ test('source issue labels clear stale agent state after PR completion', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('source cleanup includes merged maintainer fixes but not unmerged handoffs', () => {
|
||||
const maintainerPull = {
|
||||
state: 'closed',
|
||||
merged: true,
|
||||
body: 'Focused maintainer fix.\n\nFixes #42',
|
||||
user: { login: 'binaricat' },
|
||||
head: { repo: { full_name: 'binaricat/Netcatty' } },
|
||||
base: { repo: { full_name: 'binaricat/Netcatty' } },
|
||||
};
|
||||
const options = {
|
||||
ownActors: 'binaricat,netcatty-bot,github-actions[bot]',
|
||||
repository: 'binaricat/Netcatty',
|
||||
};
|
||||
assert.equal(auto.shouldCleanupSourceIssueAfterPull(maintainerPull, options), true);
|
||||
assert.deepEqual(
|
||||
auto.extractSourceIssueNumbers({
|
||||
...maintainerPull,
|
||||
body: 'Fixes #42, #43, and #44\nCloses #42',
|
||||
}),
|
||||
[42, 43, 44],
|
||||
);
|
||||
assert.equal(
|
||||
auto.shouldCleanupSourceIssueAfterPull({
|
||||
...maintainerPull,
|
||||
merged: false,
|
||||
merged_at: null,
|
||||
}, options),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
auto.shouldCleanupSourceIssueAfterPull({
|
||||
...maintainerPull,
|
||||
body: 'Related to #42',
|
||||
}, options),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
auto.shouldCleanupSourceIssueAfterPull({
|
||||
...maintainerPull,
|
||||
base: { repo: { full_name: 'another/repo' } },
|
||||
}, options),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('implementation failure messages report the real category and preserved artifact', () => {
|
||||
const message = auto.buildImplementationFailureMessage(
|
||||
{ title: '[Bug] 上传失败' },
|
||||
@@ -803,6 +886,63 @@ test('implementation failure messages report the real category and preserved art
|
||||
assert.match(message, /新增了验证失败/);
|
||||
assert.match(message, /implement-patch-1/);
|
||||
assert.match(message, /github\.example/);
|
||||
|
||||
const protectedMessage = auto.buildImplementationFailureMessage(
|
||||
{ title: '[Bug] 自动修改失败' },
|
||||
{
|
||||
kind: 'protected_path',
|
||||
protectedPaths: [
|
||||
'.github/workflows/release.yml',
|
||||
'components/ordinary.test.ts',
|
||||
],
|
||||
},
|
||||
);
|
||||
assert.match(protectedMessage, /\.github\/workflows\/release\.yml/);
|
||||
assert.doesNotMatch(protectedMessage, /ordinary\.test\.ts/);
|
||||
|
||||
const codexMessage = auto.buildCodexFixFailureMessage({
|
||||
kind: 'verification_failed',
|
||||
workflowUrl: 'https://github.example/run/2',
|
||||
artifactName: 'codex-fix-patch-2',
|
||||
});
|
||||
assert.match(codexMessage, /verification failures/);
|
||||
assert.match(codexMessage, /codex-fix-patch-2/);
|
||||
assert.match(codexMessage, /github\.example/);
|
||||
|
||||
const noChangesMessage = auto.buildCodexFixFailureMessage({
|
||||
kind: 'no_changes',
|
||||
workflowUrl: 'https://github.example/run/3',
|
||||
});
|
||||
assert.match(noChangesMessage, /did not change any files/);
|
||||
assert.match(noChangesMessage, /already be addressed, stale/);
|
||||
assert.match(noChangesMessage, /github\.example\/run\/3/);
|
||||
|
||||
const classificationMessage = auto.buildClassificationFailureMessage(
|
||||
{ title: '[Bug] 附件无法读取' },
|
||||
{
|
||||
kind: 'research_failed',
|
||||
workflowUrl: 'https://github.example/run/4',
|
||||
},
|
||||
);
|
||||
assert.match(classificationMessage, /读取附件或外部资料时失败/);
|
||||
assert.match(classificationMessage, /github\.example\/run\/4/);
|
||||
});
|
||||
|
||||
test('protected path reports replace stale data and ignore ordinary source files', (t) => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cursor-protected-report-'));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
const report = path.join(dir, 'protected-paths.json');
|
||||
|
||||
assert.deepEqual(auto.writeProtectedPathReport(report, [
|
||||
'package-lock.json',
|
||||
'components/App.tsx',
|
||||
'package-lock.json',
|
||||
]), ['package-lock.json']);
|
||||
assert.deepEqual(auto.readProtectedPathReport(report), ['package-lock.json']);
|
||||
|
||||
assert.deepEqual(auto.writeProtectedPathReport(report, []), []);
|
||||
assert.equal(fs.existsSync(report), false);
|
||||
assert.deepEqual(auto.readProtectedPathReport(report), []);
|
||||
});
|
||||
|
||||
test('markNeedsHuman ignores forged dedupe markers from untrusted commenters', async () => {
|
||||
@@ -842,19 +982,42 @@ test('markNeedsHuman ignores forged dedupe markers from untrusted commenters', a
|
||||
assert.equal(created, 1);
|
||||
});
|
||||
|
||||
test('workflow cleans source labels on automation PR close and dedupes clean notices', () => {
|
||||
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'),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(workflow, /types: \[opened, synchronize, reopened, ready_for_review, closed\]/);
|
||||
assert.match(workflow, /kind == 'source_issue_cleanup'/);
|
||||
assert.match(workflow, /shouldGatePullOnSourceIssueFollowups\(pull/);
|
||||
assert.match(workflow, /shouldCleanupSourceIssueAfterPull\(pull/);
|
||||
assert.match(workflow, /const issueNumbers = auto\.extractSourceIssueNumbers\(pull\)/);
|
||||
assert.match(workflow, /for \(const issueNumber of issueNumbers\)/);
|
||||
assert.match(workflow, /github\.rest\.issues\.removeLabel/);
|
||||
assert.match(workflow, /github\.rest\.issues\.addLabels/);
|
||||
assert.match(workflow, /not a trusted automation pull request; skipping source cleanup/);
|
||||
assert.match(workflow, /not eligible for source cleanup; skipping/);
|
||||
assert.match(workflow, /is no longer closed; skipping source cleanup/);
|
||||
assert.match(workflow, /source issue changed while queued; skipping cleanup/);
|
||||
assert.match(workflow, /does not currently close issue/);
|
||||
assert.match(workflow, /findOpenPullForIssue/);
|
||||
assert.match(workflow, /refineIssueCommentRoute/);
|
||||
assert.match(workflow, /issueNumber: issue\.number,\n\s+includeRelated: true/);
|
||||
assert.match(workflow, /reconcile_closed_handoffs:/);
|
||||
assert.match(workflow, /shouldRetryIssueHandoff/);
|
||||
assert.match(workflow, /isTrustedOpenPullForIssue/);
|
||||
assert.match(workflow, /cursor-handoff-recovery:version=\$\{recoveryVersion\}/);
|
||||
assert.match(workflow, /workflow_id: 'cursor-automation\.yml'/);
|
||||
assert.match(workflow, /Reconcile handoffs/);
|
||||
assert.ok((workflow.match(/auto\.extractPaginatedItems\(response\)/g) || []).length >= 2);
|
||||
assert.match(workflow, /notBefore: '2026-07-31T12:54:37Z'/);
|
||||
assert.match(workflow, /notAfter: '2026-08-04T08:27:14Z'/);
|
||||
assert.match(workflow, /auditedIssueNumbers = new Set\(\[2679, 2697, 2704, 2705, 2708, 2709\]\)/);
|
||||
assert.match(workflow, /is:issue is:closed label:"ready-for-human" label:triage/);
|
||||
assert.match(workflow, /is:pr is:merged label:"ready-for-human" label:"automation:bot-pr"/);
|
||||
const route = workflow.match(/\n route:\n[\s\S]*?(?=\n cleanup_source_issue:)/)?.[0] || '';
|
||||
assert.doesNotMatch(
|
||||
route,
|
||||
/sameRepo\s*&&\s*\n\s*context\.payload\.action === 'closed'/,
|
||||
);
|
||||
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/);
|
||||
@@ -984,6 +1147,10 @@ test('buildPullRequestBody records the issue comment snapshot', () => {
|
||||
assert.match(body, /<!-- cursor-issue-watermark:comment-id=987 -->/);
|
||||
assert.equal(auto.extractIssueCommentWatermark(body), '987');
|
||||
assert.equal(auto.extractSourceIssueNumber({ body }), 42);
|
||||
assert.deepEqual(
|
||||
auto.extractSourceIssueNumbers({ body: `${body}\nFixes #99` }),
|
||||
[42],
|
||||
);
|
||||
});
|
||||
|
||||
test('parseIssueFollowupStatus is fail-closed and builds durable reply markers', () => {
|
||||
@@ -1120,6 +1287,218 @@ test('shouldGatePullOnSourceIssueFollowups is limited to automation bot PRs', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('pullReferencesIssue matches exact closing references without prefix collisions', () => {
|
||||
assert.equal(auto.pullReferencesIssue({ body: 'Fixes #42' }, 42), true);
|
||||
assert.equal(auto.pullReferencesIssue({ body: 'Fixes #420' }, 42), false);
|
||||
assert.equal(auto.pullReferencesIssue({ body: 'Related to #42' }, 42), false);
|
||||
assert.equal(
|
||||
auto.pullReferencesIssue({ body: 'Related to #42' }, 42, { includeRelated: true }),
|
||||
true,
|
||||
);
|
||||
const relatedList = { body: 'Related to #41, #42, and #43.' };
|
||||
assert.equal(auto.pullReferencesIssue(relatedList, 41, { includeRelated: true }), true);
|
||||
assert.equal(auto.pullReferencesIssue(relatedList, 42, { includeRelated: true }), true);
|
||||
assert.equal(auto.pullReferencesIssue(relatedList, 43, { includeRelated: true }), true);
|
||||
assert.equal(auto.pullReferencesIssue(relatedList, 44, { includeRelated: true }), false);
|
||||
assert.equal(
|
||||
auto.pullReferencesIssue({ body: 'Related to #42abc' }, 42, { includeRelated: true }),
|
||||
false,
|
||||
);
|
||||
assert.equal(auto.pullReferencesIssue({ body: 'Fixes #42_foo' }, 42), false);
|
||||
assert.equal(auto.pullReferencesIssue({ body: 'Fixes #42-detail' }, 42), true);
|
||||
assert.equal(auto.pullReferencesIssue({ body: 'Fixes #42, #43oops' }, 42), true);
|
||||
assert.equal(auto.pullReferencesIssue({ body: 'Fixes #42, #43oops' }, 43), false);
|
||||
assert.equal(
|
||||
auto.pullReferencesIssue({ head: { ref: 'cursor/issue-42-123' } }, 42),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('findOpenPullForIssue keeps maintainer work from spawning a duplicate bot PR', async () => {
|
||||
const pulls = [
|
||||
{
|
||||
number: 7,
|
||||
body: 'Fixes #42',
|
||||
author_association: 'NONE',
|
||||
head: { repo: { full_name: 'attacker/fork' } },
|
||||
},
|
||||
{
|
||||
number: 8,
|
||||
body: 'Maintainer implementation\n\nFixes #42',
|
||||
author_association: 'MEMBER',
|
||||
head: { repo: { full_name: 'maintainer/fork' } },
|
||||
},
|
||||
];
|
||||
const found = await auto.findOpenPullForIssue({
|
||||
github: {
|
||||
paginate: async () => pulls,
|
||||
rest: { pulls: { list: async () => ({ data: pulls }) } },
|
||||
},
|
||||
context: { repo: { owner: 'binaricat', repo: 'Netcatty' } },
|
||||
issueNumber: 42,
|
||||
});
|
||||
assert.equal(found.number, 8);
|
||||
});
|
||||
|
||||
test('legacy retry only accepts trusted fixed failure categories once', () => {
|
||||
const options = {
|
||||
trustedActors: 'netcatty-bot,github-actions[bot]',
|
||||
recoveryVersion: 'handoff-v1',
|
||||
};
|
||||
assert.equal(auto.shouldRetryIssueHandoff([{
|
||||
user: { login: 'netcatty-bot' },
|
||||
body: '<!-- cursor-implement-failure:base=abc;kind=protected_path -->',
|
||||
}], options), true);
|
||||
assert.equal(auto.shouldRetryIssueHandoff([{
|
||||
user: { login: 'netcatty-bot' },
|
||||
body: '收到这条补充了,但自动复核没有安全完成,已经转给维护者继续处理。',
|
||||
}], options), true);
|
||||
assert.equal(auto.shouldRetryIssueHandoff([{
|
||||
user: { login: 'attacker' },
|
||||
body: '<!-- cursor-implement-failure:base=abc;kind=protected_path -->',
|
||||
}], options), false);
|
||||
assert.equal(auto.shouldRetryIssueHandoff([{
|
||||
user: { login: 'netcatty-bot' },
|
||||
body: '<!-- cursor-implement-failure:base=abc;kind=verification_failed -->',
|
||||
}], options), false);
|
||||
assert.equal(auto.shouldRetryIssueHandoff([
|
||||
{
|
||||
user: { login: 'netcatty-bot' },
|
||||
body: '<!-- cursor-implement-failure:base=abc;kind=protected_path -->',
|
||||
},
|
||||
{
|
||||
user: { login: 'netcatty-bot' },
|
||||
body: '<!-- cursor-handoff-recovery:version=handoff-v1 -->',
|
||||
},
|
||||
], options), false);
|
||||
|
||||
const boundedOptions = {
|
||||
...options,
|
||||
recoveryVersion: 'handoff-v2',
|
||||
notBefore: '2026-07-31T12:54:37Z',
|
||||
notAfter: '2026-08-04T08:27:14Z',
|
||||
};
|
||||
assert.equal(auto.shouldRetryIssueHandoff([{
|
||||
user: { login: 'netcatty-bot' },
|
||||
body: '收到这条补充了,但自动复核没有安全完成,已经转给维护者继续处理。',
|
||||
created_at: '2026-07-30T12:00:00Z',
|
||||
}], boundedOptions), false);
|
||||
assert.equal(auto.shouldRetryIssueHandoff([{
|
||||
user: { login: 'netcatty-bot' },
|
||||
body: '收到这条补充了,但自动复核没有安全完成,已经转给维护者继续处理。',
|
||||
created_at: '2026-08-01T12:00:00Z',
|
||||
}], boundedOptions), true);
|
||||
assert.equal(auto.shouldRetryIssueHandoff([
|
||||
{
|
||||
user: { login: 'netcatty-bot' },
|
||||
body: '<!-- cursor-implement-failure:base=abc;kind=protected_path -->',
|
||||
created_at: '2026-08-01T12:00:00Z',
|
||||
},
|
||||
{
|
||||
user: { login: 'netcatty-bot' },
|
||||
body: '<!-- cursor-followup:comment-id=123;result=no_change -->',
|
||||
created_at: '2026-08-02T12:00:00Z',
|
||||
},
|
||||
], boundedOptions), false);
|
||||
assert.equal(auto.shouldRetryIssueHandoff([
|
||||
{
|
||||
user: { login: 'netcatty-bot' },
|
||||
body: '<!-- cursor-followup:comment-id=123;result=updated -->',
|
||||
created_at: '2026-08-01T12:00:00Z',
|
||||
},
|
||||
{
|
||||
user: { login: 'netcatty-bot' },
|
||||
body: '<!-- cursor-classification-failure:kind=research_failed;run=2 -->',
|
||||
created_at: '2026-08-02T12:00:00Z',
|
||||
},
|
||||
], boundedOptions), true);
|
||||
assert.equal(auto.shouldRetryIssueHandoff([{
|
||||
user: { login: 'netcatty-bot' },
|
||||
body: '<!-- cursor-implement-failure:base=future;kind=protected_path -->',
|
||||
created_at: '2026-08-05T12:00:00Z',
|
||||
}], boundedOptions), false);
|
||||
assert.equal(auto.shouldRetryIssueHandoff([
|
||||
{
|
||||
user: { login: 'netcatty-bot' },
|
||||
body: '<!-- cursor-implement-failure:base=abc;kind=protected_path -->',
|
||||
created_at: '2026-08-01T12:00:00Z',
|
||||
},
|
||||
{
|
||||
user: { login: 'netcatty-bot' },
|
||||
body: '<!-- cursor-handoff-recovery:version=handoff-v2 -->',
|
||||
created_at: '2026-08-05T12:00:00Z',
|
||||
},
|
||||
], boundedOptions), false);
|
||||
});
|
||||
|
||||
test('findOpenPullForIssue accepts same-repo work but ignores untrusted fork claims', async () => {
|
||||
const pulls = [
|
||||
{
|
||||
number: 7,
|
||||
body: 'Fixes #42',
|
||||
author_association: 'NONE',
|
||||
head: { repo: { full_name: 'attacker/fork' } },
|
||||
},
|
||||
{
|
||||
number: 8,
|
||||
body: 'Fixes #42',
|
||||
author_association: 'NONE',
|
||||
head: { repo: { full_name: 'binaricat/Netcatty' } },
|
||||
},
|
||||
];
|
||||
const found = await auto.findOpenPullForIssue({
|
||||
github: {
|
||||
paginate: async () => pulls,
|
||||
rest: { pulls: { list: async () => ({ data: pulls }) } },
|
||||
},
|
||||
context: { repo: { owner: 'binaricat', repo: 'Netcatty' } },
|
||||
issueNumber: 42,
|
||||
});
|
||||
assert.equal(found.number, 8);
|
||||
});
|
||||
|
||||
test('automation pull references only control the marked source issue', () => {
|
||||
const pull = {
|
||||
number: 8,
|
||||
state: 'open',
|
||||
body: [
|
||||
'<!-- cursor-bot-pr -->',
|
||||
'<!-- cursor-source-issue:41 -->',
|
||||
'Related to #42',
|
||||
'Fixes #43',
|
||||
].join('\n'),
|
||||
labels: [{ name: 'automation:bot-pr' }],
|
||||
user: { login: 'netcatty-bot' },
|
||||
head: {
|
||||
ref: 'cursor/issue-41-123',
|
||||
repo: { full_name: 'binaricat/Netcatty' },
|
||||
},
|
||||
};
|
||||
const options = { repository: 'binaricat/Netcatty', includeRelated: true };
|
||||
assert.equal(auto.isTrustedOpenPullForIssue(pull, 41, options), true);
|
||||
assert.equal(auto.isTrustedOpenPullForIssue(pull, 42, options), false);
|
||||
assert.equal(auto.isTrustedOpenPullForIssue(pull, 43, options), false);
|
||||
});
|
||||
|
||||
test('automation label does not hide a trusted maintainer pull reference', () => {
|
||||
const pull = {
|
||||
number: 2703,
|
||||
state: 'open',
|
||||
body: 'Maintainer implementation\n\nRelated to #2699',
|
||||
labels: [{ name: 'automation:bot-pr' }],
|
||||
user: { login: 'binaricat' },
|
||||
author_association: 'OWNER',
|
||||
head: {
|
||||
ref: 'worktree/quiet-cloud-b74d',
|
||||
repo: { full_name: 'binaricat/Netcatty' },
|
||||
},
|
||||
};
|
||||
assert.equal(auto.isTrustedOpenPullForIssue(pull, 2699, {
|
||||
repository: 'binaricat/Netcatty',
|
||||
includeRelated: true,
|
||||
}), true);
|
||||
});
|
||||
|
||||
test('getPendingIssueFollowupsForPull does not block maintainer Fixes-only PRs', async () => {
|
||||
let issuesFetched = 0;
|
||||
const pull = {
|
||||
@@ -1734,7 +2113,7 @@ test('hasProtectedChangesInSources checks commit names', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test('generated changes may add tests but cannot alter existing test sources', () => {
|
||||
test('generated changes may add or update regression tests', () => {
|
||||
const committed = auto.hasProtectedChangesInSources({
|
||||
nameStatusText: [
|
||||
'M\tcomponents/existing.test.ts',
|
||||
@@ -1742,7 +2121,7 @@ test('generated changes may add tests but cannot alter existing test sources', (
|
||||
'M\tcomponents/App.tsx',
|
||||
].join('\n'),
|
||||
});
|
||||
assert.deepEqual(committed, ['components/existing.test.ts']);
|
||||
assert.deepEqual(committed, []);
|
||||
|
||||
const workingTree = auto.hasProtectedChangesInSources({
|
||||
gitStatusPorcelain: [
|
||||
@@ -1750,7 +2129,7 @@ test('generated changes may add tests but cannot alter existing test sources', (
|
||||
'?? components/new-regression-2.test.ts',
|
||||
].join('\n'),
|
||||
});
|
||||
assert.deepEqual(workingTree, ['components/other.test.ts']);
|
||||
assert.deepEqual(workingTree, []);
|
||||
});
|
||||
|
||||
test('hasProtectedChangesInSources blocks electron-builder configs', () => {
|
||||
@@ -1924,7 +2303,24 @@ test('every code-writing Cursor path compares exact-base failures and preserves
|
||||
assert.ok(restoreDependencies >= 0);
|
||||
assert.ok(lintCandidate > restoreDependencies);
|
||||
assert.match(section, /restoring locked dependencies failed/);
|
||||
assert.equal((section.match(/writeProtectedPathReport/g) || []).length, 2);
|
||||
assert.match(section, /readProtectedPathReport/);
|
||||
}
|
||||
assert.match(implement, /Skip if trusted related PR already open/);
|
||||
assert.match(implement, /findOpenPullForIssue/);
|
||||
assert.match(implement, /includeRelated: true/);
|
||||
assert.match(implement, /id: upload_patch/);
|
||||
assert.match(implement, /steps\.upload_patch\.outcome/);
|
||||
assert.match(codex, /buildCodexFixFailureMessage/);
|
||||
assert.match(codex, /id: upload_fixpatch/);
|
||||
assert.match(codex, /steps\.upload_fixpatch\.outcome/);
|
||||
assert.match(codex, /cursor-codex-fix-failure:kind=/);
|
||||
assert.match(codex, /cursor-codex-fix-failure:kind=no_changes/);
|
||||
assert.doesNotMatch(
|
||||
codex,
|
||||
/agent, protected paths, or verification failed/,
|
||||
);
|
||||
assert.doesNotMatch(codex, /did not produce additional code changes/);
|
||||
});
|
||||
|
||||
test('classification failure handoff receives its issue number', () => {
|
||||
@@ -1937,6 +2333,50 @@ test('classification failure handoff receives its issue number', () => {
|
||||
)?.[0] || '';
|
||||
assert.match(handoff, /ISSUE_NUMBER: \$\{\{ needs\.route\.outputs\.issue_number \}\}/);
|
||||
assert.match(handoff, /const issueNumber = Number\(process\.env\.ISSUE_NUMBER\)/);
|
||||
assert.match(handoff, /cursor-classification-failure:kind=\$\{kind\};run=\$\{context\.runId\}/);
|
||||
assert.match(handoff, /buildClassificationFailureMessage/);
|
||||
assert.match(handoff, /steps\.research\.outcome/);
|
||||
assert.match(handoff, /steps\.classify_agent\.outcome/);
|
||||
assert.match(handoff, /steps\.validate_classification\.outcome/);
|
||||
assert.match(handoff, /actions\/runs\/\$\{context\.runId\}/);
|
||||
assert.match(handoff, /fs\.existsSync\(helper\)/);
|
||||
assert.match(handoff, /const failureMessage = auto/);
|
||||
assert.doesNotMatch(handoff, /自动复核没有安全完成/);
|
||||
assert.match(workflow, /name: Validate classification result/);
|
||||
assert.match(workflow, /auto\.parseClassificationFile\("\.cursor-runtime\/classification\.json"\)/);
|
||||
});
|
||||
|
||||
test('implementation publish rechecks related work and records a deduplicated handoff', () => {
|
||||
const workflow = fs.readFileSync(
|
||||
path.join(__dirname, '..', '.github', 'workflows', 'cursor-automation.yml'),
|
||||
'utf8',
|
||||
);
|
||||
const implement = workflow.match(
|
||||
/\n implement:\n[\s\S]*?(?=\n publish_implement:\n)/,
|
||||
)?.[0] || '';
|
||||
const publish = workflow.match(
|
||||
/\n publish_implement:\n[\s\S]*?(?=\n codex_loop:\n)/,
|
||||
)?.[0] || '';
|
||||
assert.match(implement, /cursor-existing-related-pr:\$\{existing\.number\}/);
|
||||
assert.match(implement, /auto\.markNeedsHuman/);
|
||||
assert.match(publish, /name: Skip publish if trusted related PR opened during implementation/);
|
||||
assert.match(publish, /includeRelated: true/);
|
||||
assert.match(publish, /auto\.markNeedsHuman/);
|
||||
assert.match(publish, /cursor-existing-related-pr:\$\{existing\.number\}/);
|
||||
assert.ok(
|
||||
publish.indexOf('name: Skip publish if trusted related PR opened during implementation')
|
||||
< publish.indexOf('name: Publish branch from fresh runner'),
|
||||
);
|
||||
assert.match(
|
||||
publish,
|
||||
/name: Publish branch from fresh runner\n\s+if: steps\.existing\.outputs\.exists != 'true'/,
|
||||
);
|
||||
const openPr = publish.match(
|
||||
/- name: Open draft PR[\s\S]*?(?=\n\s{6}- name:)/,
|
||||
)?.[0] || '';
|
||||
assert.match(openPr, /findOpenPullForIssue/);
|
||||
assert.match(openPr, /includeRelated: true/);
|
||||
assert.match(openPr, /skip the duplicate/);
|
||||
});
|
||||
|
||||
test('classification follow-up rate-limit handoff receives its issue number', () => {
|
||||
@@ -2386,6 +2826,43 @@ test('research input replaces only successfully proxied GitHub image attachments
|
||||
auto.normalizeExternalResearchText(noResearchNeeded, { input: imageOnly }),
|
||||
noResearchNeeded,
|
||||
);
|
||||
|
||||
const mediaOnly = auto.rewriteExternalResearchInputAttachments(
|
||||
{ body: attachmentUrl },
|
||||
[{ sourceUrl: attachmentUrl, kind: 'unsupported_media' }],
|
||||
);
|
||||
assert.match(mediaOnly.body, /video or audio attachment omitted/);
|
||||
assert.doesNotMatch(mediaOnly.body, /https?:\/\//);
|
||||
assert.equal(
|
||||
auto.normalizeExternalResearchText(noResearchNeeded, { input: mediaOnly }),
|
||||
noResearchNeeded,
|
||||
);
|
||||
});
|
||||
|
||||
test('GitHub attachment redirects distinguish images from video and reject other hosts', () => {
|
||||
const redirect = (host, mediaType) => [
|
||||
'HTTP/2 302',
|
||||
`location: https://${host}/asset?response-content-type=${encodeURIComponent(mediaType)}`,
|
||||
'',
|
||||
].join('\r\n');
|
||||
assert.equal(
|
||||
auto.classifyGithubUserAttachmentRedirect(
|
||||
redirect('github-production-user-asset-6210df.s3.amazonaws.com', 'image/png'),
|
||||
),
|
||||
'image',
|
||||
);
|
||||
assert.equal(
|
||||
auto.classifyGithubUserAttachmentRedirect(
|
||||
redirect('github-production-user-asset-6210df.s3.amazonaws.com', 'video/mp4'),
|
||||
),
|
||||
'unsupported_media',
|
||||
);
|
||||
assert.equal(
|
||||
auto.classifyGithubUserAttachmentRedirect(
|
||||
redirect('example.com', 'image/png'),
|
||||
),
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeExternalResearchText fails closed on blocked or unsourced research', () => {
|
||||
@@ -2462,6 +2939,36 @@ test('parseExternalResearchStream requires a recorded web tool call and sources'
|
||||
}),
|
||||
/WebSearch\/WebFetch tool call/i,
|
||||
);
|
||||
|
||||
const githubCaseStream = [
|
||||
JSON.stringify({
|
||||
type: 'tool_call',
|
||||
subtype: 'completed',
|
||||
tool_call: {
|
||||
webSearchToolCall: {
|
||||
result: {
|
||||
success: {
|
||||
content: 'Repository: https://github.com/ByteDance/trae-agent',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'result',
|
||||
subtype: 'success',
|
||||
is_error: false,
|
||||
result: [
|
||||
'RESEARCH_COMPLETE: Trae Agent is a CLI.',
|
||||
'Sources:',
|
||||
'- https://github.com/bytedance/trae-agent — official repository',
|
||||
].join('\n'),
|
||||
}),
|
||||
].join('\n');
|
||||
assert.match(
|
||||
auto.parseExternalResearchStream(githubCaseStream, {}),
|
||||
/^RESEARCH_COMPLETE:/,
|
||||
);
|
||||
});
|
||||
|
||||
test('parseExternalResearchStream supports standard deltas and terminal result', () => {
|
||||
@@ -3125,8 +3632,10 @@ test('workflow sanitizes GitHub screenshots through pinned imgproxy before resea
|
||||
/git show "FETCH_HEAD:scripts\/prepare-cursor-research-input\.sh" > "\$RUNNER_TEMP\/prepare-cursor-research-input\.sh"/,
|
||||
);
|
||||
assert.match(attachmentProxy, /extractGithubUserAttachmentAssetUrls/);
|
||||
assert.match(attachmentProxy, /classifyGithubUserAttachmentRedirect/);
|
||||
assert.match(attachmentProxy, /rewriteExternalResearchInputAttachments/);
|
||||
assert.match(attachmentProxy, /attachment_count > 4/);
|
||||
assert.match(attachmentProxy, /unsupported_media/);
|
||||
assert.match(
|
||||
attachmentProxy,
|
||||
/IMGPROXY_ALLOWED_SOURCES=https:\/\/github\.com\/user-attachments\/assets\//,
|
||||
@@ -3866,7 +4375,7 @@ const SAMPLE_BUG_BODY = [
|
||||
'Windows 11',
|
||||
].join('\n');
|
||||
|
||||
test('prepareIssueContext dedupes and limits needs-info author replies', async () => {
|
||||
test('prepareIssueContext dedupes and limits all managed issue author replies', async () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cursor-needs-info-'));
|
||||
const issue = {
|
||||
number: 99,
|
||||
@@ -3878,12 +4387,17 @@ test('prepareIssueContext dedupes and limits needs-info author replies', async (
|
||||
author_association: 'NONE',
|
||||
labels: [{ name: 'needs-info' }],
|
||||
};
|
||||
const run = async (comments, triggerCommentId, followupDailyLimit = 20) => {
|
||||
const run = async (
|
||||
comments,
|
||||
triggerCommentId,
|
||||
followupDailyLimit = 20,
|
||||
labels = [{ name: 'needs-info' }],
|
||||
) => {
|
||||
const outputs = {};
|
||||
const github = {
|
||||
rest: {
|
||||
issues: {
|
||||
get: async () => ({ data: issue }),
|
||||
get: async () => ({ data: { ...issue, labels } }),
|
||||
listComments: Symbol('listComments'),
|
||||
},
|
||||
},
|
||||
@@ -3932,6 +4446,34 @@ test('prepareIssueContext dedupes and limits needs-info author replies', async (
|
||||
assert.equal(rateLimited.outputs.rate_limited, 'true');
|
||||
assert.equal(rateLimited.outputs.pending_ids, '11');
|
||||
|
||||
const managedRateLimited = await run([
|
||||
{
|
||||
id: 10,
|
||||
user: { login: 'netcatty-bot', type: 'User' },
|
||||
body: '<!-- cursor-triage-watermark:comment-id=8 -->',
|
||||
created_at: '2026-07-24T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
user: { login: 'alice', type: 'User' },
|
||||
body: '请重新检查这个聚焦方案',
|
||||
created_at: '2026-07-24T11:00:00Z',
|
||||
},
|
||||
], 11, 1, [{ name: 'ready-for-human' }, { name: 'triage:feature-defer' }]);
|
||||
assert.equal(managedRateLimited.result.shouldRun, false);
|
||||
assert.equal(managedRateLimited.outputs.rate_limited, 'true');
|
||||
|
||||
const managedProcessed = await run([
|
||||
{
|
||||
id: 10,
|
||||
user: { login: 'netcatty-bot', type: 'User' },
|
||||
body: '<!-- cursor-triage-watermark:comment-id=11 -->',
|
||||
created_at: '2026-07-24T10:00:00Z',
|
||||
},
|
||||
], 11, 20, [{ name: 'ready-for-agent' }]);
|
||||
assert.equal(managedProcessed.result.shouldRun, false);
|
||||
assert.match(managedProcessed.outputs.reason, /already processed/i);
|
||||
|
||||
const burstComments = [
|
||||
{
|
||||
id: 1,
|
||||
|
||||
@@ -300,7 +300,7 @@ test("issue implementation publishing tolerates competing automation runs", () =
|
||||
assert.match(publishJob[0], /echo "handoff=true" >> "\$GITHUB_OUTPUT"/);
|
||||
assert.match(
|
||||
publishJob[0],
|
||||
/if: failure\(\) && steps\.publish\.outputs\.handoff == 'true'/,
|
||||
/if: failure\(\) && steps\.existing\.outputs\.exists != 'true' && steps\.publish\.outputs\.handoff == 'true'/,
|
||||
);
|
||||
assert.doesNotMatch(publishJob[0], /steps\.publish\.outcome == 'failure'/);
|
||||
assert.match(publishJob[0], /labels: \['ready-for-human'\]/);
|
||||
|
||||
@@ -25,32 +25,69 @@ if (( attachment_count == 0 )); then
|
||||
fi
|
||||
|
||||
mkdir -p "$research_dir/attachments"
|
||||
attachment_kinds_path="$research_dir/attachment-kinds.txt"
|
||||
: > "$attachment_kinds_path"
|
||||
image_count=0
|
||||
for (( index=0; index<attachment_count; index++ )); do
|
||||
source_url="$(node -e '
|
||||
process.stdout.write(require(process.argv[1])[Number(process.argv[2])]);
|
||||
' "$attachment_urls_path" "$index")"
|
||||
headers_path="$(mktemp "${RUNNER_TEMP}/cursor-attachment-headers.XXXXXX")"
|
||||
curl --proto '=https' --tlsv1.2 --retry 3 --retry-all-errors \
|
||||
--connect-timeout 3 --max-time 20 --max-redirs 0 -fsS \
|
||||
-D "$headers_path" -o /dev/null "$source_url"
|
||||
kind="$(node -e '
|
||||
const fs = require("node:fs");
|
||||
const auto = require(process.env.RUNNER_TEMP + "/cursor-automation.cjs");
|
||||
process.stdout.write(auto.classifyGithubUserAttachmentRedirect(
|
||||
fs.readFileSync(process.argv[1], "utf8"),
|
||||
));
|
||||
' "$headers_path")"
|
||||
rm -f "$headers_path"
|
||||
if [[ "$kind" != "image" && "$kind" != "unsupported_media" ]]; then
|
||||
echo "GitHub attachment did not provide a trusted image/video/audio redirect." >&2
|
||||
exit 1
|
||||
fi
|
||||
printf '%s\n' "$kind" >> "$attachment_kinds_path"
|
||||
if [[ "$kind" == "image" ]]; then
|
||||
image_count=$((image_count + 1))
|
||||
fi
|
||||
done
|
||||
|
||||
container_name="cursor-research-imgproxy-${GITHUB_RUN_ID}-${GITHUB_JOB}"
|
||||
docker run -d --rm --name "$container_name" \
|
||||
--cap-drop=ALL --security-opt=no-new-privileges --read-only \
|
||||
--memory=512m --cpus=1 --pids-limit=128 \
|
||||
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
|
||||
-p 127.0.0.1:18081:8080 \
|
||||
-e IMGPROXY_ALLOWED_SOURCES=https://github.com/user-attachments/assets/ \
|
||||
-e IMGPROXY_ALLOW_LOOPBACK_SOURCE_ADDRESSES=false \
|
||||
-e IMGPROXY_ALLOW_LINK_LOCAL_SOURCE_ADDRESSES=false \
|
||||
-e IMGPROXY_ALLOW_PRIVATE_SOURCE_ADDRESSES=false \
|
||||
-e IMGPROXY_MAX_SRC_FILE_SIZE=10485760 \
|
||||
-e IMGPROXY_MAX_SRC_RESOLUTION=50 \
|
||||
-e IMGPROXY_MAX_RESULT_DIMENSION=4096 \
|
||||
-e IMGPROXY_MAX_REDIRECTS=2 \
|
||||
-e IMGPROXY_MAX_ANIMATION_FRAMES=1 \
|
||||
-e IMGPROXY_ALWAYS_RASTERIZE_SVG=true \
|
||||
-e IMGPROXY_ALLOW_SECURITY_OPTIONS=false \
|
||||
-e IMGPROXY_COOKIE_PASSTHROUGH=false \
|
||||
-e IMGPROXY_COOKIE_PASSTHROUGH_ALL=false \
|
||||
"$CURSOR_RESEARCH_IMGPROXY_IMAGE" >/dev/null
|
||||
if (( image_count > 0 )); then
|
||||
docker run -d --rm --name "$container_name" \
|
||||
--cap-drop=ALL --security-opt=no-new-privileges --read-only \
|
||||
--memory=512m --cpus=1 --pids-limit=128 \
|
||||
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
|
||||
-p 127.0.0.1:18081:8080 \
|
||||
-e IMGPROXY_ALLOWED_SOURCES=https://github.com/user-attachments/assets/ \
|
||||
-e IMGPROXY_ALLOW_LOOPBACK_SOURCE_ADDRESSES=false \
|
||||
-e IMGPROXY_ALLOW_LINK_LOCAL_SOURCE_ADDRESSES=false \
|
||||
-e IMGPROXY_ALLOW_PRIVATE_SOURCE_ADDRESSES=false \
|
||||
-e IMGPROXY_MAX_SRC_FILE_SIZE=10485760 \
|
||||
-e IMGPROXY_MAX_SRC_RESOLUTION=50 \
|
||||
-e IMGPROXY_MAX_RESULT_DIMENSION=4096 \
|
||||
-e IMGPROXY_MAX_REDIRECTS=2 \
|
||||
-e IMGPROXY_MAX_ANIMATION_FRAMES=1 \
|
||||
-e IMGPROXY_ALWAYS_RASTERIZE_SVG=true \
|
||||
-e IMGPROXY_ALLOW_SECURITY_OPTIONS=false \
|
||||
-e IMGPROXY_COOKIE_PASSTHROUGH=false \
|
||||
-e IMGPROXY_COOKIE_PASSTHROUGH_ALL=false \
|
||||
"$CURSOR_RESEARCH_IMGPROXY_IMAGE" >/dev/null
|
||||
fi
|
||||
stop_imgproxy() {
|
||||
docker stop "$container_name" >/dev/null 2>&1 || true
|
||||
if (( image_count > 0 )); then
|
||||
docker stop "$container_name" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
trap stop_imgproxy EXIT
|
||||
|
||||
for (( index=0; index<attachment_count; index++ )); do
|
||||
kind="$(sed -n "$((index + 1))p" "$attachment_kinds_path")"
|
||||
if [[ "$kind" != "image" ]]; then
|
||||
continue
|
||||
fi
|
||||
source_url="$(node -e '
|
||||
process.stdout.write(require(process.argv[1])[Number(process.argv[2])]);
|
||||
' "$attachment_urls_path" "$index")"
|
||||
@@ -79,12 +116,17 @@ node -e '
|
||||
const auto = require(process.env.RUNNER_TEMP + "/cursor-automation.cjs");
|
||||
const input = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
||||
const urls = require(process.argv[2]);
|
||||
const kinds = fs.readFileSync(process.argv[3], "utf8").trim().split("\n");
|
||||
const attachments = urls.map((sourceUrl, index) => ({
|
||||
sourceUrl,
|
||||
relativePath: `attachments/issue-image-${index + 1}.png`,
|
||||
kind: kinds[index],
|
||||
...(kinds[index] === "image"
|
||||
? { relativePath: `attachments/issue-image-${index + 1}.png` }
|
||||
: {}),
|
||||
}));
|
||||
fs.writeFileSync(
|
||||
process.argv[3],
|
||||
process.argv[4],
|
||||
JSON.stringify(auto.rewriteExternalResearchInputAttachments(input, attachments), null, 2) + "\n",
|
||||
);
|
||||
' "$input_path" "$attachment_urls_path" "$research_dir/input.json"
|
||||
' "$input_path" "$attachment_urls_path" "$attachment_kinds_path" "$research_dir/input.json"
|
||||
rm -f "$attachment_urls_path" "$attachment_kinds_path"
|
||||
Reference in new issue
Block a user