mirror of
https://github.com/github/awesome-copilot.git
synced 2026-08-14 13:16:54 +00:00
258 lines
12 KiB
YAML
258 lines
12 KiB
YAML
name: PR Duplicate Check Writer
|
|
|
|
on:
|
|
workflow_run:
|
|
workflows: ["PR Duplicate Check"]
|
|
types: [completed]
|
|
|
|
permissions:
|
|
actions: read
|
|
issues: write
|
|
pull-requests: read
|
|
|
|
concurrency:
|
|
group: pdcw-${{ github.event.workflow_run.head_repository.id || 'unknown-repo' }}-${{ github.event.workflow_run.head_branch || 'unknown-branch' }}
|
|
cancel-in-progress: true
|
|
|
|
jobs:
|
|
process-safe-output:
|
|
runs-on: ubuntu-latest
|
|
if: github.event.workflow_run.event == 'pull_request'
|
|
steps:
|
|
- name: Download agent artifact
|
|
id: download-agent
|
|
continue-on-error: true
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: agent
|
|
path: ${{ runner.temp }}/pr-duplicate-agent
|
|
run-id: ${{ github.event.workflow_run.id }}
|
|
github-token: ${{ github.token }}
|
|
|
|
- name: Download PR context artifact
|
|
id: download-context
|
|
continue-on-error: true
|
|
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
|
with:
|
|
name: pr-duplicate-check-context
|
|
path: ${{ runner.temp }}/pr-duplicate-context
|
|
run-id: ${{ github.event.workflow_run.id }}
|
|
github-token: ${{ github.token }}
|
|
|
|
- name: Validate and publish safe comment output
|
|
if: steps.download-agent.outcome == 'success'
|
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
|
with:
|
|
script: |
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const workflowRun = context.payload.workflow_run;
|
|
const artifactRoot = path.join(process.env.RUNNER_TEMP, 'pr-duplicate-agent');
|
|
const contextRoot = path.join(process.env.RUNNER_TEMP, 'pr-duplicate-context');
|
|
|
|
function readJsonIfExists(filePath) {
|
|
if (!fs.existsSync(filePath)) return null;
|
|
const stat = fs.statSync(filePath);
|
|
if (stat.size > 1024 * 1024) throw new Error(`${filePath} exceeds 1 MiB`);
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
}
|
|
|
|
const prContext =
|
|
readJsonIfExists(path.join(contextRoot, 'pr-context.json')) ||
|
|
readJsonIfExists(path.join(artifactRoot, 'pr-context.json'));
|
|
if (!prContext || prContext.schema_version !== 'pr-duplicate-check-context/v1') {
|
|
throw new Error('Missing or invalid pr-context.json artifact.');
|
|
}
|
|
if (!Number.isInteger(prContext.pr_number) || prContext.pr_number < 1) {
|
|
throw new Error('Invalid PR context pr_number.');
|
|
}
|
|
if (!/^[0-9a-f]{40}$/i.test(String(prContext.head_sha || ''))) {
|
|
throw new Error('Invalid PR context head_sha.');
|
|
}
|
|
if (workflowRun.event !== 'pull_request') {
|
|
throw new Error('Invalid workflow_run event.');
|
|
}
|
|
if (String(prContext.run_id || '') !== String(workflowRun.id)) {
|
|
throw new Error('PR context run_id did not match workflow_run.');
|
|
}
|
|
if (prContext.head_sha !== workflowRun.head_sha) {
|
|
throw new Error('PR context head_sha did not match workflow_run.');
|
|
}
|
|
|
|
const prNumber = prContext.pr_number;
|
|
const { data: pr } = await github.rest.pulls.get({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
pull_number: prNumber,
|
|
});
|
|
if (pr.state !== 'open') {
|
|
core.info(`Skipping non-open PR #${prNumber}.`);
|
|
return;
|
|
}
|
|
const expectedBaseRepository = `${context.repo.owner}/${context.repo.repo}`.toLowerCase();
|
|
const runHeadRepository = String(workflowRun.head_repository?.full_name || '');
|
|
const runHeadRepositoryParts = runHeadRepository.split('/');
|
|
const runHeadRef = String(workflowRun.head_branch || '');
|
|
if (String(pr.base?.repo?.full_name || '').toLowerCase() !== expectedBaseRepository) {
|
|
throw new Error(`PR #${prNumber} base repository ${pr.base?.repo?.full_name || '<unknown>'} is not this repository.`);
|
|
}
|
|
if (pr.head.sha !== workflowRun.head_sha) {
|
|
core.warning(`Skipping stale PR duplicate output for PR #${prNumber}: artifact head ${prContext.head_sha}, current head ${pr.head.sha}`);
|
|
return;
|
|
}
|
|
if (
|
|
runHeadRepositoryParts.length !== 2 ||
|
|
!runHeadRepositoryParts[0] ||
|
|
!runHeadRepositoryParts[1] ||
|
|
!runHeadRef ||
|
|
String(pr.head?.repo?.full_name || '').toLowerCase() !== runHeadRepository.toLowerCase() ||
|
|
String(pr.head?.ref || '') !== runHeadRef
|
|
) {
|
|
throw new Error(`PR #${prNumber} head did not match workflow_run.`);
|
|
}
|
|
|
|
const workflowRunPullRequests = Array.isArray(workflowRun.pull_requests) ? workflowRun.pull_requests : [];
|
|
if (workflowRunPullRequests.length > 0) {
|
|
if (!workflowRunPullRequests.some((pullRequest) => pullRequest.number === prNumber)) {
|
|
throw new Error(`PR context number ${prNumber} was not present in workflow_run.pull_requests.`);
|
|
}
|
|
} else {
|
|
const candidatePullRequests = await github.paginate(github.rest.pulls.list, {
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
state: 'open',
|
|
head: `${runHeadRepositoryParts[0]}:${runHeadRef}`,
|
|
per_page: 100,
|
|
});
|
|
const trustedMatches = candidatePullRequests.filter((candidate) =>
|
|
candidate.head?.sha === workflowRun.head_sha &&
|
|
String(candidate.head?.ref || '') === runHeadRef &&
|
|
String(candidate.head?.repo?.full_name || '').toLowerCase() === runHeadRepository.toLowerCase() &&
|
|
String(candidate.base?.repo?.full_name || '').toLowerCase() === expectedBaseRepository
|
|
);
|
|
if (trustedMatches.length !== 1 || trustedMatches[0].number !== prNumber) {
|
|
throw new Error(`PR context number ${prNumber} could not be uniquely associated with workflow_run.`);
|
|
}
|
|
}
|
|
|
|
function collectCandidate(value, targetCandidates) {
|
|
if (!value || typeof value !== 'object') return;
|
|
const tool = value.tool || value.name || value.tool_name || value.type || value.action;
|
|
const args = value.arguments || value.args || value.input || value.params || value.data || value;
|
|
if (tool === 'add_comment') {
|
|
targetCandidates.push(args);
|
|
}
|
|
}
|
|
|
|
function collectCandidatesFromJson(value) {
|
|
const collected = [];
|
|
if (value) {
|
|
if (Array.isArray(value.items)) {
|
|
for (const item of value.items) collectCandidate(item, collected);
|
|
} else if (Array.isArray(value)) {
|
|
for (const item of value) collectCandidate(item, collected);
|
|
} else {
|
|
collectCandidate(value, collected);
|
|
}
|
|
}
|
|
return collected;
|
|
}
|
|
|
|
const agentOutput = readJsonIfExists(path.join(artifactRoot, 'agent_output.json'));
|
|
let candidates = collectCandidatesFromJson(agentOutput);
|
|
|
|
if (candidates.length === 0) {
|
|
const safeOutputsPath = path.join(artifactRoot, 'safeoutputs.jsonl');
|
|
if (fs.existsSync(safeOutputsPath)) {
|
|
const stat = fs.statSync(safeOutputsPath);
|
|
if (stat.size > 1024 * 1024) throw new Error('safeoutputs.jsonl exceeds 1 MiB');
|
|
const lines = fs.readFileSync(safeOutputsPath, 'utf8').split(/\r?\n/).filter(Boolean).slice(0, 100);
|
|
const safeOutputCandidates = [];
|
|
for (const line of lines) collectCandidate(JSON.parse(line), safeOutputCandidates);
|
|
candidates = safeOutputCandidates;
|
|
}
|
|
}
|
|
|
|
const stripControlCharacters = (value) =>
|
|
String(value).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '');
|
|
const neutralizeMentions = (value) =>
|
|
value.replace(/@([A-Za-z0-9][A-Za-z0-9-]{0,38})/g, '@\u200B$1');
|
|
const sanitizeCommentBody = (value, maxLength) => {
|
|
const sanitized = neutralizeMentions(stripControlCharacters(value));
|
|
return sanitized.length > maxLength ? sanitized.slice(0, maxLength) : sanitized;
|
|
};
|
|
|
|
const validComments = [];
|
|
for (const candidate of candidates) {
|
|
if (!candidate || typeof candidate !== 'object') continue;
|
|
const body = candidate.body;
|
|
if (typeof body !== 'string' || body.trim() === '' || body.length > 65000) continue;
|
|
if (candidate.item_number !== undefined && Number(candidate.item_number) !== prNumber) continue;
|
|
if (candidate.repo !== undefined && String(candidate.repo) !== `${context.repo.owner}/${context.repo.repo}`) continue;
|
|
validComments.push(body);
|
|
}
|
|
|
|
if (validComments.length === 0) {
|
|
core.info('No valid add_comment safe output was found.');
|
|
return;
|
|
}
|
|
if (validComments.length > 1) {
|
|
throw new Error(`Expected at most one add_comment safe output, found ${validComments.length}.`);
|
|
}
|
|
|
|
const marker = '<!-- pr-duplicate-check -->';
|
|
const runUrl = workflowRun.html_url;
|
|
const footer = `<!-- synchronized from read-only PR Duplicate Check run ${workflowRun.id}: ${runUrl} -->`;
|
|
const maxSafeOutputBodyLength = 65000 - marker.length - footer.length - 4;
|
|
const safeOutputBody = sanitizeCommentBody(validComments[0], Math.max(0, maxSafeOutputBodyLength));
|
|
if (safeOutputBody.trim() === '') {
|
|
core.info('No non-empty safe comment body remained after sanitization.');
|
|
return;
|
|
}
|
|
const body = [
|
|
marker,
|
|
safeOutputBody,
|
|
'',
|
|
footer,
|
|
].join('\n');
|
|
|
|
const comments = await github.paginate(github.rest.issues.listComments, {
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: prNumber,
|
|
per_page: 100,
|
|
});
|
|
const matchingComments = comments.filter((comment) =>
|
|
comment.user?.login === 'github-actions[bot]' && String(comment.body || '').includes(marker)
|
|
);
|
|
|
|
const [canonical, ...duplicates] = matchingComments;
|
|
if (canonical) {
|
|
await github.rest.issues.updateComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
comment_id: canonical.id,
|
|
body,
|
|
});
|
|
} else {
|
|
await github.rest.issues.createComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
issue_number: prNumber,
|
|
body,
|
|
});
|
|
}
|
|
|
|
for (const duplicate of duplicates) {
|
|
await github.rest.issues.deleteComment({
|
|
owner: context.repo.owner,
|
|
repo: context.repo.repo,
|
|
comment_id: duplicate.id,
|
|
}).catch(() => {});
|
|
}
|
|
|
|
- name: Note missing artifact
|
|
if: steps.download-agent.outcome != 'success'
|
|
run: echo "No PR Duplicate Check agent artifact was available; nothing to synchronize."
|