Skip to content

Explore PR Triage Commenter Writer #5

Explore PR Triage Commenter Writer

Explore PR Triage Commenter Writer #5

name: Explore PR Triage Commenter Writer
on:
workflow_run:
workflows: [Explore PR Triage Commenter]
types: [completed]
permissions:
actions: read
issues: write
pull-requests: read
concurrency:
group: explore-triage-commenter-writer-${{ github.event.workflow_run.head_repository.full_name || github.event.workflow_run.head_sha }}-${{ github.event.workflow_run.head_branch || github.event.workflow_run.head_sha }}
cancel-in-progress: false
jobs:
upsert-comment:
if: >-
${{ github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'pull_request' }}
runs-on: ubuntu-latest
steps:
- name: Download triage comment data
id: artifact
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
RUN_ID: ${{ github.event.workflow_run.id }}
MAX_ARTIFACT_BYTES: 1048576
MAX_JSON_BYTES: 131072
run: |
set -euo pipefail
mkdir -p "$RUNNER_TEMP/explore-triage"
artifact_rows="$(
gh api --paginate "repos/$REPOSITORY/actions/runs/$RUN_ID/artifacts" \
--jq '.artifacts[] | select(.name == "explore-triage-comment" and .expired == false) | [.id, .size_in_bytes] | @tsv'
)"
artifacts=()
if [ -n "$artifact_rows" ]; then
mapfile -t artifacts <<< "$artifact_rows"
fi
if (( ${#artifacts[@]} == 0 )); then
echo "No explore triage artifact found for run $RUN_ID"
echo "found=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if (( ${#artifacts[@]} != 1 )); then
echo "Expected exactly one explore triage artifact, found ${#artifacts[@]}" >&2
exit 1
fi
IFS=$'\t' read -r artifact_id artifact_size <<< "${artifacts[0]}"
if ! [[ "$artifact_id" =~ ^[0-9]+$ && "$artifact_size" =~ ^[0-9]+$ ]]; then
echo "Artifact metadata is invalid" >&2
exit 1
fi
if (( artifact_size == 0 || artifact_size > MAX_ARTIFACT_BYTES )); then
echo "Artifact archive size $artifact_size is outside the allowed range" >&2
exit 1
fi
artifact_zip="$RUNNER_TEMP/explore-triage/artifact.zip"
comment_json="$RUNNER_TEMP/explore-triage/comment.json"
gh api "repos/$REPOSITORY/actions/artifacts/$artifact_id/zip" > "$artifact_zip"
actual_artifact_size="$(stat -c '%s' "$artifact_zip")"
if ! [[ "$actual_artifact_size" =~ ^[0-9]+$ ]] ||
(( actual_artifact_size == 0 || actual_artifact_size > MAX_ARTIFACT_BYTES )); then
echo "Downloaded artifact archive size $actual_artifact_size is outside the allowed range" >&2
exit 1
fi
ARTIFACT_ZIP="$artifact_zip" COMMENT_JSON="$comment_json" python3 - <<'PY'
import os
import zipfile
archive_path = os.environ["ARTIFACT_ZIP"]
output_path = os.environ["COMMENT_JSON"]
max_json_bytes = int(os.environ["MAX_JSON_BYTES"])
expected_name = "explore-triage-comment.json"
with zipfile.ZipFile(archive_path) as archive:
matches = [entry for entry in archive.infolist() if entry.filename == expected_name]
if len(matches) != 1:
raise ValueError(f"Expected exactly one {expected_name} entry, found {len(matches)}")
entry = matches[0]
if entry.is_dir() or entry.flag_bits & 0x1:
raise ValueError("Artifact JSON entry must be an unencrypted regular file")
if entry.file_size == 0 or entry.file_size > max_json_bytes:
raise ValueError(
f"Artifact JSON entry size {entry.file_size} is outside the allowed range"
)
total = 0
with archive.open(entry) as source, open(output_path, "xb") as destination:
while chunk := source.read(65536):
total += len(chunk)
if total > max_json_bytes:
raise ValueError("Artifact JSON exceeded the allowed size while extracting")
destination.write(chunk)
if total != entry.file_size:
raise ValueError(
f"Extracted JSON size {total} did not match declared size {entry.file_size}"
)
PY
echo "found=true" >> "$GITHUB_OUTPUT"
- name: Upsert sticky comment
if: steps.artifact.outputs.found == 'true'
uses: actions/github-script@v9
env:
COMMENT_DATA_PATH: ${{ runner.temp }}/explore-triage/comment.json
MARKER: '<!-- explore-triage-comment -->'
with:
script: |
const fs = require('fs');
const marker = process.env.MARKER;
const owner = context.repo.owner;
const repo = context.repo.repo;
const expectedRepo = `${owner}/${repo}`;
const run = context.payload.workflow_run;
const data = JSON.parse(fs.readFileSync(process.env.COMMENT_DATA_PATH, 'utf8'));
validateIdentity(data);
validatePayload(data);
const runHeadSha = await getWorkflowRunHeadSha(run);
if (!/^[0-9a-f]{40}$/i.test(runHeadSha)) {
throw new Error(`Workflow run head SHA is invalid: ${runHeadSha}`);
}
const associatedPrNumber = await getAssociatedPullRequestNumber(run, runHeadSha);
if (associatedPrNumber === null) return;
if (data.prNumber !== associatedPrNumber) {
core.info(`Artifact PR #${data.prNumber} is not the PR associated with workflow run ${run.id}; skipping.`);
return;
}
const { data: pr } = await github.rest.pulls.get({
owner,
repo,
pull_number: associatedPrNumber,
});
if (pr.head.sha !== runHeadSha) {
core.info(`PR #${pr.number} head ${pr.head.sha} does not match workflow run head ${runHeadSha}; skipping.`);
return;
}
if (run.head_repository && pr.head.repo && pr.head.repo.full_name !== run.head_repository.full_name) {
core.info(`PR #${pr.number} head repository does not match the workflow run; skipping.`);
return;
}
if (run.head_branch && pr.head.ref !== run.head_branch) {
core.info(`PR #${pr.number} head branch does not match the workflow run; skipping.`);
return;
}
if (pr.base.repo.full_name !== expectedRepo) {
throw new Error(`Unexpected base repo: ${pr.base.repo.full_name}`);
}
if (pr.state !== 'open') {
core.info(`PR #${pr.number} is ${pr.state}; skipping.`);
return;
}
if (data.headSha !== pr.head.sha) {
core.info(`Stale triage data for ${data.headSha}; current PR head is ${pr.head.sha}.`);
return;
}
if (!data.hasChanges) {
core.info('No topic or collection changes were reported; skipping.');
return;
}
const body = renderComment(data);
if (Buffer.byteLength(body, 'utf8') > 60000) {
throw new Error('Rendered triage comment exceeds the allowed size.');
}
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: pr.number,
per_page: 100,
});
const existing = comments.find(c => c.body && c.body.startsWith(marker));
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
core.info(`Updated comment ${existing.id}`);
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number: pr.number,
body,
});
core.info('Created new comment');
}
async function getWorkflowRunHeadSha(run) {
if (typeof run.head_sha === 'string' && run.head_sha.length > 0) {
return run.head_sha;
}
if (!run.id) {
throw new Error('Workflow run id is missing.');
}
const { data: workflowRun } = await github.rest.actions.getWorkflowRun({
owner,
repo,
run_id: run.id,
});
return workflowRun.head_sha;
}
async function getAssociatedPullRequestNumber(run, runHeadSha) {
const runPullRequests = Array.isArray(run.pull_requests) ? run.pull_requests : [];
if (runPullRequests.length > 0) {
const associatedNumbers = runPullRequests
.map(pull => pull && pull.number)
.filter(number => Number.isSafeInteger(number) && number > 0);
if (!associatedNumbers.includes(data.prNumber)) {
core.info(`Artifact PR #${data.prNumber} is not in the workflow run pull request association; skipping.`);
return null;
}
return data.prNumber;
}
const headRepo = run.head_repository;
const headBranch = run.head_branch;
const headOwner = headRepo && headRepo.owner && headRepo.owner.login;
if (!headRepo || typeof headRepo.full_name !== 'string' ||
typeof headOwner !== 'string' || typeof headBranch !== 'string' ||
headOwner.length === 0 || headBranch.length === 0) {
throw new Error('Workflow run is missing the trusted head repository or branch association.');
}
const candidates = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'all',
head: `${headOwner}:${headBranch}`,
per_page: 100,
});
const matches = candidates.filter(pull =>
Number.isSafeInteger(pull.number) &&
pull.head && pull.head.sha === runHeadSha &&
pull.head.ref === headBranch &&
pull.head.repo && pull.head.repo.full_name === headRepo.full_name &&
pull.base && pull.base.repo && pull.base.repo.full_name === expectedRepo
);
if (matches.length !== 1) {
core.warning(`Could not uniquely associate workflow run ${run.id} with a pull request; found ${matches.length} matches.`);
return null;
}
return matches[0].number;
}
function validateIdentity(data) {
if (!data || data.schema !== 'explore-triage-comment/v1') {
throw new Error('Unexpected artifact schema.');
}
if (data.owner !== owner || data.repo !== repo) {
throw new Error(`Artifact repo mismatch: ${data.owner}/${data.repo}`);
}
if (!Number.isSafeInteger(data.prNumber) || data.prNumber <= 0) {
throw new Error(`Artifact PR number is invalid: ${data.prNumber}`);
}
if (data.baseRepoFullName !== `${owner}/${repo}`) {
throw new Error(`Artifact base repo mismatch: ${data.baseRepoFullName}`);
}
if (!/^[0-9a-f]{40}$/i.test(data.headSha)) {
throw new Error('Artifact head SHA is invalid.');
}
if (typeof data.hasChanges !== 'boolean') {
throw new Error('Artifact hasChanges flag is invalid.');
}
}
function validatePayload(data) {
if (!Array.isArray(data.topics) || !Array.isArray(data.collections)) {
throw new Error('Artifact topics/collections must be arrays.');
}
if (data.topics.length > 100 || data.collections.length > 100) {
throw new Error('Artifact contains too many topic or collection entries.');
}
for (const topic of data.topics) {
if (!topic || typeof topic !== 'object') {
throw new Error('Invalid topic entry.');
}
validateSlug(topic.slug);
if (topic.count !== null && (!Number.isSafeInteger(topic.count) || topic.count < 0)) {
throw new Error(`Invalid topic count for ${topic.slug}.`);
}
}
for (const collection of data.collections) {
if (!collection || typeof collection !== 'object') {
throw new Error('Invalid collection entry.');
}
validateSlug(collection.slug);
if (!['ok', 'not-found', 'error'].includes(collection.readStatus)) {
throw new Error(`Invalid read status for ${collection.slug}.`);
}
validateOptionalStatusToken(
collection.errorStatus,
`collection ${collection.slug}`,
collection.readStatus !== 'ok'
);
if (!Array.isArray(collection.items) || collection.items.length > 500) {
throw new Error(`Invalid item list for ${collection.slug}.`);
}
for (const item of collection.items) validateItem(item);
}
}
function validateSlug(slug) {
if (typeof slug !== 'string' || !/^[a-z0-9](?:[a-z0-9-]{0,80}[a-z0-9])?$/i.test(slug)) {
throw new Error(`Invalid slug: ${slug}`);
}
}
function validateItem(item) {
if (!item || typeof item.name !== 'string' || item.name.length === 0 || item.name.length > 140) {
throw new Error('Invalid item name.');
}
if (item.valid === false) {
if (/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/u.test(item.name)) {
throw new Error(`Unsafe invalid item token: ${item.name}`);
}
return;
}
if (item.valid !== true || !/^[\w.-]+\/[\w.-]+$/.test(item.name)) {
throw new Error(`Invalid repository item: ${item.name}`);
}
if (!['ok', 'not-found', 'error'].includes(item.lookupStatus)) {
throw new Error(`Invalid lookup status for ${item.name}.`);
}
validateOptionalStatusToken(
item.errorStatus,
`item ${item.name}`,
item.lookupStatus !== 'ok'
);
if (item.lookupStatus === 'ok') {
if (!Number.isSafeInteger(item.stars) || item.stars < 0) throw new Error(`Invalid stars for ${item.name}.`);
if (item.pushed !== null && !/^\d{4}-\d{2}-\d{2}$/.test(item.pushed)) throw new Error(`Invalid pushed date for ${item.name}.`);
if (typeof item.ownerType !== 'string' || !/^[A-Za-z]{1,32}$/.test(item.ownerType)) throw new Error(`Invalid owner type for ${item.name}.`);
if (!Array.isArray(item.notes) || item.notes.length > 3) throw new Error(`Invalid notes for ${item.name}.`);
for (const note of item.notes) {
if (!['possible-self-submission', 'archived', 'disabled'].includes(note)) {
throw new Error(`Invalid note for ${item.name}: ${note}`);
}
}
}
}
function validateOptionalStatusToken(value, label, required) {
if (value === null || value === undefined) {
if (required) throw new Error(`Missing error status for ${label}.`);
return;
}
if (typeof value !== 'string' || !/^[A-Za-z0-9_-]{1,32}$/.test(value)) {
throw new Error(`Invalid error status for ${label}.`);
}
}
function renderComment(data) {
const sections = [];
if (data.topics.length > 0) {
const lines = ['### Topics', ''];
for (const topic of data.topics) {
const url = `https://github.com/topics/${encodeURIComponent(topic.slug)}`;
if (topic.count === null) {
lines.push(`- **${topic.slug}** — [topic page](${url}) _(repo count lookup failed)_`);
} else {
lines.push(`- **${topic.slug}** — ${topic.count.toLocaleString()} repositories — [topic page](${url})`);
}
}
sections.push(lines.join('\n'));
}
for (const collection of data.collections) {
const lines = [`### Collection \`${collection.slug}\``, ''];
if (collection.readStatus !== 'ok') {
lines.push(`_Could not read \`collections/${collection.slug}/index.md\` at PR head (\`${collection.errorStatus || collection.readStatus}\`)._`);
sections.push(lines.join('\n'));
continue;
}
if (collection.items.length === 0) {
lines.push('_No `items:` list found in frontmatter._');
sections.push(lines.join('\n'));
continue;
}
lines.push('| Item | Stars | Last push | Owner type | Notes |');
lines.push('| --- | ---: | --- | --- | --- |');
for (const item of collection.items) {
if (item.valid === false) {
lines.push(`| \`${escapeTableToken(item.name)}\` | – | – | – | invalid format |`);
continue;
}
if (item.lookupStatus === 'ok') {
const notes = item.notes.map(noteText).join(', ') || '–';
lines.push(`| [\`${item.name}\`](https://github.com/${item.name}) | ${item.stars.toLocaleString()} | ${item.pushed || '–'} | ${item.ownerType} | ${notes} |`);
} else {
const note = item.lookupStatus === 'not-found' ? 'not found' : `error (${item.errorStatus || '?'})`;
lines.push(`| \`${item.name}\` | – | – | – | ${note} |`);
}
}
lines.push('');
sections.push(lines.join('\n'));
}
return [
marker,
'<!-- Maintained by .github/workflows/explore-triage-commenter.yml. Edits will be overwritten. -->',
'',
'## Maintainer triage',
'',
...sections,
].join('\n');
}
function noteText(note) {
return {
'possible-self-submission': '⚠️ possible self-submission',
archived: 'archived',
disabled: 'disabled',
}[note];
}
function escapeTableToken(value) {
return value.replace(/`/g, "'").replace(/\\/g, '\\\\').replace(/\|/g, '\\|');
}