Skip to content

RFC 016/018/024 wave 1 function catalog foundations #93

RFC 016/018/024 wave 1 function catalog foundations

RFC 016/018/024 wave 1 function catalog foundations #93

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);
}