RFC 016/018/024 wave 1 function catalog foundations #93
Workflow file for this run
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: Auto-label issues and PRs (sync) | |
| "on": | |
| issues: | |
| types: [opened, edited] | |
| pull_request_target: | |
| types: [opened, edited, reopened, synchronize, ready_for_review] | |
| permissions: | |
| issues: write | |
| pull-requests: write | |
| jobs: | |
| triage: | |
| runs-on: ubuntu-latest | |
| env: | |
| # Centralized mapping: keys must match the exact Area option strings in | |
| # `.github/ISSUE_TEMPLATE/*.yml` and `.github/pull_request_template.md` (Area(s)). | |
| AREA_OPTION_TO_LABEL_JSON: >- | |
| { | |
| "Package & tests": "package", | |
| "Specification (RFCs)": "specification", | |
| "Documentation": "documentation", | |
| "Automation & repo config": "automation", | |
| "Other": "other" | |
| } | |
| steps: | |
| - name: Mint GitHub App installation token | |
| id: app_token | |
| env: | |
| APP_ID: ${{ secrets.TRIAGE_APP_ID }} | |
| INSTALLATION_ID: ${{ secrets.TRIAGE_APP_INSTALLATION_ID }} | |
| PRIVATE_KEY: ${{ secrets.TRIAGE_APP_PRIVATE_KEY }} | |
| run: | | |
| set -euo pipefail | |
| b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; } | |
| now=$(date +%s) | |
| header='{"alg":"RS256","typ":"JWT"}' | |
| payload=$(printf '{"iat":%d,"exp":%d,"iss":"%s"}' "$((now-60))" "$((now+540))" "$APP_ID") | |
| header_b64=$(printf '%s' "$header" | b64url) | |
| payload_b64=$(printf '%s' "$payload" | b64url) | |
| unsigned="$header_b64.$payload_b64" | |
| keyfile=$(mktemp) | |
| printf '%s\n' "$PRIVATE_KEY" > "$keyfile" | |
| sig=$(printf '%s' "$unsigned" | openssl dgst -sha256 -sign "$keyfile" | b64url) | |
| rm -f "$keyfile" | |
| jwt="$unsigned.$sig" | |
| token=$(curl -sS -X POST \ | |
| -H "Authorization: Bearer $jwt" \ | |
| -H "Accept: application/vnd.github+json" \ | |
| "https://api.github.com/app/installations/$INSTALLATION_ID/access_tokens" \ | |
| | jq -r '.token') | |
| echo "token=$token" >> "$GITHUB_OUTPUT" | |
| - name: Sync PR labels (Area(s) -> labels) | |
| if: github.event_name == 'pull_request_target' | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ steps.app_token.outputs.token }} | |
| script: | | |
| const payload = context.payload; | |
| const body = payload.pull_request?.body ?? ""; | |
| const optionToLabelObj = JSON.parse(process.env.AREA_OPTION_TO_LABEL_JSON ?? "{}"); | |
| const managed = new Set(Object.values(optionToLabelObj)); | |
| const optionToLabel = new Map(Object.entries(optionToLabelObj)); | |
| function extractSection(markdown, heading) { | |
| const esc = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | |
| const re = new RegExp(`^\\s*#{2,6}\\s+${esc}\\s*$`, "mi"); | |
| const m = re.exec(markdown); | |
| if (!m) return ""; | |
| const start = m.index + m[0].length; | |
| const rest = markdown.slice(start); | |
| const next = rest.search(/^\s*#{2,6}\s+/m); | |
| return (next === -1 ? rest : rest.slice(0, next)).trim(); | |
| } | |
| const areaBlock = extractSection(body, "Area(s)") || extractSection(body, "Area"); | |
| const checkedLines = areaBlock | |
| .split("\n") | |
| .map((l) => l.trim()) | |
| .filter((l) => /^[-*]\s*\[[xX]\]\s+/.test(l)); | |
| const desired = new Set(); | |
| for (const [opt, label] of optionToLabel.entries()) { | |
| if (checkedLines.some((l) => l.includes(opt))) desired.add(label); | |
| } | |
| if (desired.size === 0) return; | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const issue_number = payload.pull_request.number; | |
| const current = new Set((payload.pull_request.labels ?? []).map((l) => l.name)); | |
| const toAdd = [...desired].filter((l) => !current.has(l)); | |
| const toRemove = [...current].filter((l) => managed.has(l) && !desired.has(l)); | |
| if (toAdd.length > 0) { | |
| await github.rest.issues.addLabels({ owner, repo, issue_number, labels: toAdd }); | |
| } | |
| for (const label of toRemove) { | |
| try { | |
| await github.rest.issues.removeLabel({ owner, repo, issue_number, name: label }); | |
| } catch (e) { | |
| if (e?.status !== 404) throw e; | |
| } | |
| } | |
| - name: Sync issue labels and Issue Type (Area -> labels, title prefix -> type) | |
| if: github.event_name == 'issues' | |
| uses: actions/github-script@v7 | |
| with: | |
| github-token: ${{ steps.app_token.outputs.token }} | |
| script: | | |
| const payload = context.payload; | |
| const body = payload.issue?.body ?? ""; | |
| const optionToLabelObj = JSON.parse(process.env.AREA_OPTION_TO_LABEL_JSON ?? "{}"); | |
| const managed = new Set(Object.values(optionToLabelObj)); | |
| const optionToLabel = new Map(Object.entries(optionToLabelObj)); | |
| function extractSection(markdown, heading) { | |
| const esc = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | |
| const re = new RegExp(`^\\s*#{2,6}\\s+${esc}\\s*$`, "mi"); | |
| const m = re.exec(markdown); | |
| if (!m) return ""; | |
| const start = m.index + m[0].length; | |
| const rest = markdown.slice(start); | |
| const next = rest.search(/^\s*#{2,6}\s+/m); | |
| return (next === -1 ? rest : rest.slice(0, next)).trim(); | |
| } | |
| const areaBlock = extractSection(body, "Area"); | |
| const desired = new Set(); | |
| for (const [opt, label] of optionToLabel.entries()) { | |
| if (areaBlock.includes(opt)) desired.add(label); | |
| } | |
| const owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const issue_number = context.issue.number; | |
| if (desired.size > 0) { | |
| const current = new Set((payload.issue.labels ?? []).map((l) => l.name)); | |
| const toAdd = [...desired].filter((l) => !current.has(l)); | |
| const toRemove = [...current].filter((l) => managed.has(l) && !desired.has(l)); | |
| if (toAdd.length > 0) { | |
| await github.rest.issues.addLabels({ owner, repo, issue_number, labels: toAdd }); | |
| } | |
| for (const label of toRemove) { | |
| try { | |
| await github.rest.issues.removeLabel({ owner, repo, issue_number, name: label }); | |
| } catch (e) { | |
| if (e?.status !== 404) throw e; | |
| } | |
| } | |
| } | |
| const title = payload.issue?.title ?? ""; | |
| const lower = title.toLowerCase(); | |
| const prefixToType = [ | |
| ["bug -", "Bug"], | |
| ["feature -", "Feature"], | |
| ["chore -", "Chore"], | |
| ]; | |
| const desiredTypeName = | |
| prefixToType.find(([prefix]) => lower.startsWith(prefix))?.[1] ?? null; | |
| if (!desiredTypeName) return; | |
| try { | |
| const query = ` | |
| query($owner: String!, $repo: String!, $number: Int!) { | |
| repository(owner: $owner, name: $repo) { | |
| issue(number: $number) { | |
| id | |
| issueType { id name } | |
| } | |
| issueTypes(first: 50) { | |
| nodes { id name } | |
| } | |
| } | |
| } | |
| `; | |
| const res = await github.graphql(query, { owner, repo, number: issue_number }); | |
| const repoInfo = res?.repository; | |
| const issue = repoInfo?.issue; | |
| const types = repoInfo?.issueTypes?.nodes ?? []; | |
| const currentTypeName = issue?.issueType?.name ?? null; | |
| if (currentTypeName === desiredTypeName) return; | |
| const desiredType = types.find((t) => t?.name === desiredTypeName); | |
| if (!issue?.id || !desiredType?.id) return; | |
| const mutation = ` | |
| mutation($issueId: ID!, $issueTypeId: ID!) { | |
| updateIssue(input: { id: $issueId, issueTypeId: $issueTypeId }) { | |
| issue { id issueType { name } } | |
| } | |
| } | |
| `; | |
| await github.graphql(mutation, { issueId: issue.id, issueTypeId: desiredType.id }); | |
| } catch (e) { | |
| console.log("Issue type sync failed (non-fatal):", e?.message ?? e); | |
| } |