Skip to content

Docs v2 Issue Indexer #125

Docs v2 Issue Indexer

Docs v2 Issue Indexer #125

name: Docs v2 Issue Indexer
on:
issues:
types: [opened, edited, labeled, unlabeled, reopened, closed]
workflow_dispatch:
schedule:
- cron: '0 */6 * * *'
permissions:
issues: write
contents: read
concurrency:
group: docs-v2-issue-indexer
cancel-in-progress: true
jobs:
build-index:
if: ${{ github.event_name != 'issues' || !github.event.issue.pull_request }}
runs-on: ubuntu-latest
steps:
- name: Create or update docs-v2 top-level issue index
uses: actions/github-script@v7
with:
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
// Maintainer knobs: keep marker stable unless you update issue-auto-label skip logic too.
const TARGET_LABEL = 'docs-v2';
const INDEX_TITLE = '[docs-v2-index] Open + Recently Closed Issue Index';
const INDEX_MARKER = '<!-- docs-v2-issue-indexer -->';
const RECENTLY_CLOSED_DAYS = 30;
if (context.eventName === 'issues' && (context.payload.issue?.body || '').includes(INDEX_MARKER)) {
console.log('Skipping self-triggered run for docs-v2 issue indexer issue');
return;
}
const normalizeLabelNames = (issue) =>
(issue.labels || []).map((label) => (typeof label === 'string' ? label : label.name)).filter(Boolean);
const decorateIssue = (issue) => {
const labels = normalizeLabelNames(issue);
const firstByPrefix = (prefix) => labels.find((name) => name.startsWith(prefix)) || '';
const allByPrefix = (prefix) => labels.filter((name) => name.startsWith(prefix)).sort();
return {
...issue,
_labels: labels,
_classification: firstByPrefix('classification:'),
_priority: firstByPrefix('priority:'),
_type: firstByPrefix('type:'),
_area: firstByPrefix('area:'),
_statuses: allByPrefix('status:'),
};
};
const searchIssues = async (q) => {
const results = [];
let page = 1;
while (true) {
const resp = await github.rest.search.issuesAndPullRequests({
q,
per_page: 100,
page,
});
const pageItems = (resp.data.items || []).filter((item) => !item.pull_request);
results.push(...pageItems);
if ((resp.data.items || []).length < 100) break;
page += 1;
if (page > 10) {
console.warn(`Search pagination hit 1000-result cap for query: ${q}`);
break;
}
}
return results;
};
// Marker lookup is the source of truth for the rolling index issue.
const markerQuery = `repo:${owner}/${repo} is:issue in:body "docs-v2-issue-indexer"`;
const markerCandidates = await searchIssues(markerQuery);
const sortedMarkerCandidates = markerCandidates
.filter((item) => Number.isInteger(item.number))
.sort((a, b) => a.number - b.number);
let indexIssue = sortedMarkerCandidates.find((item) => (item.body || '').includes(INDEX_MARKER));
if (!indexIssue && sortedMarkerCandidates.length > 0) {
for (const candidate of sortedMarkerCandidates) {
const candidateResp = await github.rest.issues.get({
owner,
repo,
issue_number: candidate.number,
});
if ((candidateResp.data.body || '').includes(INDEX_MARKER)) {
indexIssue = candidateResp.data;
break;
}
}
}
if (!indexIssue) {
const placeholderBody = [
INDEX_MARKER,
'',
'# docs-v2 Issue Index',
'',
'_Initializing index..._',
].join('\n');
const created = await github.rest.issues.create({
owner,
repo,
title: INDEX_TITLE,
body: placeholderBody,
labels: [TARGET_LABEL],
});
indexIssue = created.data;
console.log(`Created docs-v2 issue index #${indexIssue.number}`);
}
const indexIssueNumber = indexIssue.number;
const indexIssueResp = await github.rest.issues.get({
owner,
repo,
issue_number: indexIssueNumber,
});
indexIssue = indexIssueResp.data;
const cutoff = new Date();
cutoff.setUTCDate(cutoff.getUTCDate() - RECENTLY_CLOSED_DAYS);
const cutoffDate = cutoff.toISOString().slice(0, 10);
const nowIso = new Date().toISOString();
// Query by label so the index follows triage scope instead of title or issue source.
const openQuery = `repo:${owner}/${repo} is:issue is:open label:"${TARGET_LABEL}"`;
const recentClosedQuery = `repo:${owner}/${repo} is:issue is:closed label:"${TARGET_LABEL}" closed:>=${cutoffDate}`;
const openIssues = (await searchIssues(openQuery))
.filter((issue) => issue.number !== indexIssueNumber)
.map(decorateIssue);
const recentClosedIssues = (await searchIssues(recentClosedQuery))
.filter((issue) => issue.number !== indexIssueNumber)
.filter((issue) => issue.closed_at && new Date(issue.closed_at) >= cutoff)
.map(decorateIssue);
const classificationRank = {
'classification: urgent': 0,
'classification: high': 1,
'classification: moderate': 2,
'classification: minor': 3,
};
const priorityRank = {
'priority: critical': 0,
'priority: high': 1,
'priority: medium': 2,
'priority: low': 3,
};
openIssues.sort((a, b) => {
const aClass = classificationRank[a._classification] ?? 99;
const bClass = classificationRank[b._classification] ?? 99;
if (aClass !== bClass) return aClass - bClass;
const aPriority = priorityRank[a._priority] ?? 99;
const bPriority = priorityRank[b._priority] ?? 99;
if (aPriority !== bPriority) return aPriority - bPriority;
return new Date(b.updated_at) - new Date(a.updated_at);
});
recentClosedIssues.sort((a, b) => new Date(b.closed_at || 0) - new Date(a.closed_at || 0));
const escapeCell = (value) => {
const text = value === null || value === undefined ? '' : String(value);
const normalized = text.replace(/\r?\n+/g, ' ').replace(/\|/g, '\\|').trim();
return normalized || '-';
};
const markdownTable = (headers, rows) => {
if (!rows.length) return '_None_';
return [
`| ${headers.join(' | ')} |`,
`| ${headers.map(() => '---').join(' | ')} |`,
...rows.map((row) => `| ${row.map(escapeCell).join(' | ')} |`),
].join('\n');
};
const formatTimestamp = (value) => {
if (!value) return '-';
return new Date(value).toISOString().replace('.000Z', 'Z');
};
const issueLink = (issue) => `[#${issue.number}](${issue.html_url})`;
const statusCell = (issue) => (issue._statuses.length ? issue._statuses.join(', ') : '-');
const renderIssueRows = (issues, { includeClosedAt = false } = {}) =>
issues.map((issue) => {
const row = [
issueLink(issue),
issue.title || '(no title)',
issue._classification || '-',
issue._priority || '-',
issue._type || '-',
issue._area || '-',
statusCell(issue),
formatTimestamp(issue.updated_at),
];
if (includeClosedAt) row.push(formatTimestamp(issue.closed_at));
return row;
});
const increment = (map, key, amount = 1) => {
map.set(key, (map.get(key) || 0) + amount);
};
const countByPrefix = (issues, prefix, { includeUnlabeled = false, unlabeledKey = '(none)' } = {}) => {
const counts = new Map();
for (const issue of issues) {
const matching = issue._labels.filter((label) => label.startsWith(prefix));
if (matching.length === 0) {
if (includeUnlabeled) increment(counts, unlabeledKey);
continue;
}
for (const label of matching) increment(counts, label);
}
return counts;
};
const sortedCountEntries = (countMap, preferredOrder = []) => {
const preferredIndex = new Map(preferredOrder.map((key, idx) => [key, idx]));
return [...countMap.entries()].sort((a, b) => {
const aPref = preferredIndex.has(a[0]) ? preferredIndex.get(a[0]) : 999;
const bPref = preferredIndex.has(b[0]) ? preferredIndex.get(b[0]) : 999;
if (aPref !== bPref) return aPref - bPref;
if (b[1] !== a[1]) return b[1] - a[1];
return a[0].localeCompare(b[0]);
});
};
const renderCountTable = (countMap, preferredOrder = []) =>
markdownTable(
['Label', 'Count'],
sortedCountEntries(countMap, preferredOrder).map(([label, count]) => [label, String(count)])
);
const openClassificationCounts = countByPrefix(openIssues, 'classification:', {
includeUnlabeled: true,
unlabeledKey: 'classification: (unclassified)',
});
const openPriorityCounts = countByPrefix(openIssues, 'priority:', {
includeUnlabeled: true,
unlabeledKey: 'priority: (unlabeled)',
});
const openTypeCounts = countByPrefix(openIssues, 'type:', {
includeUnlabeled: true,
unlabeledKey: 'type: (unlabeled)',
});
const openAreaCounts = countByPrefix(openIssues, 'area:', {
includeUnlabeled: true,
unlabeledKey: 'area: (unlabeled)',
});
const openUnclassifiedIssues = openIssues.filter((issue) => !issue._classification);
const openUnprioritizedIssues = openIssues.filter((issue) => !issue._priority);
const needsTriageOpenCount = openIssues.filter((issue) => issue._statuses.includes('status: needs-triage')).length;
const summaryLines = [
`- Open docs-v2 issues: **${openIssues.length}**`,
`- Recently closed docs-v2 issues (${RECENTLY_CLOSED_DAYS}d): **${recentClosedIssues.length}**`,
`- Open docs-v2 issues missing classification: **${openUnclassifiedIssues.length}**`,
`- Open docs-v2 issues with \`status: needs-triage\`: **${needsTriageOpenCount}**`,
];
const openIssuesTable = markdownTable(
['Issue', 'Title', 'Classification', 'Priority', 'Type', 'Area', 'Status', 'Updated (UTC)'],
renderIssueRows(openIssues)
);
const recentClosedTable = markdownTable(
['Issue', 'Title', 'Classification', 'Priority', 'Type', 'Area', 'Status', 'Updated (UTC)', 'Closed (UTC)'],
renderIssueRows(recentClosedIssues, { includeClosedAt: true })
);
const missingClassificationTable = markdownTable(
['Issue', 'Title', 'Priority', 'Type', 'Area', 'Updated (UTC)'],
openUnclassifiedIssues.map((issue) => [
issueLink(issue),
issue.title || '(no title)',
issue._priority || '-',
issue._type || '-',
issue._area || '-',
formatTimestamp(issue.updated_at),
])
);
const missingPriorityTable = markdownTable(
['Issue', 'Title', 'Classification', 'Type', 'Area', 'Updated (UTC)'],
openUnprioritizedIssues.map((issue) => [
issueLink(issue),
issue.title || '(no title)',
issue._classification || '-',
issue._type || '-',
issue._area || '-',
formatTimestamp(issue.updated_at),
])
);
const body = [
INDEX_MARKER,
'',
'# docs-v2 Issue Index',
'',
'_Auto-generated by `.github/workflows/docs-v2-issue-indexer.yml`. Do not edit manually._',
'',
`Last updated (UTC): ${nowIso}`,
`Recently closed window: last ${RECENTLY_CLOSED_DAYS} days (since ${cutoffDate})`,
'',
'## Summary',
'',
...summaryLines,
'',
'## Open Issue Breakdown by Classification',
'',
renderCountTable(openClassificationCounts, [
'classification: urgent',
'classification: high',
'classification: moderate',
'classification: minor',
'classification: (unclassified)',
]),
'',
'## Open Issue Breakdown by Priority',
'',
renderCountTable(openPriorityCounts, [
'priority: critical',
'priority: high',
'priority: medium',
'priority: low',
'priority: (unlabeled)',
]),
'',
'## Open Issue Breakdown by Type',
'',
renderCountTable(openTypeCounts, [
'type: bug',
'type: enhancement',
'type: documentation',
'type: question',
'type: (unlabeled)',
]),
'',
'## Open Issue Breakdown by Area',
'',
renderCountTable(openAreaCounts, [
'area: home-about',
'area: community',
'area: developers',
'area: gateways',
'area: orchestrators',
'area: lpt-governance',
'area: resources',
'area: ci-cd',
'area: structure',
'area: multiple',
'area: (unlabeled)',
]),
'',
'## Open Issues',
'',
openIssuesTable,
'',
`## Recently Closed (${RECENTLY_CLOSED_DAYS}d)`,
'',
recentClosedTable,
'',
'## Open Issues Missing Classification',
'',
missingClassificationTable,
'',
'## Open Issues Missing Priority',
'',
missingPriorityTable,
'',
].join('\n');
if ((indexIssue.body || '') === body && indexIssue.title === INDEX_TITLE) {
console.log(`No docs-v2 issue index changes for #${indexIssueNumber}`);
return;
}
await github.rest.issues.update({
owner,
repo,
issue_number: indexIssueNumber,
title: INDEX_TITLE,
body,
});
console.log(`Updated docs-v2 issue index #${indexIssueNumber}`);