Explore PR Triage Commenter Writer #5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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, '\\|'); | |
| } |