diff --git a/.github/workflows/metrics-dashboard.yml b/.github/workflows/metrics-dashboard.yml index 4ebd88ae4..3150ac2ea 100644 --- a/.github/workflows/metrics-dashboard.yml +++ b/.github/workflows/metrics-dashboard.yml @@ -219,15 +219,6 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.VADS_WORKFLOWS }} - - name: Collect bug report metrics - continue-on-error: true - run: | - echo "Starting bug report metrics collection..." - node scripts/collect-bug-report-metrics.js - echo "Bug report metrics collection completed" - env: - GITHUB_TOKEN: ${{ secrets.VADS_WORKFLOWS }} - - name: Verify generated data files run: | echo "Checking for generated files..." @@ -346,21 +337,6 @@ jobs: echo "ℹ️ component-bug-metrics.json not found for Jekyll (collection may have been skipped)" fi - # Check bug report metrics in both locations (non-blocking since collection has continue-on-error) - if [ -f "src/assets/data/metrics/bug-report-metrics.json" ]; then - echo "✅ bug-report-metrics.json generated successfully in assets" - echo "File size: $(du -h src/assets/data/metrics/bug-report-metrics.json)" - else - echo "ℹ️ bug-report-metrics.json not found in assets (collection may have been skipped)" - fi - - if [ -f "src/_data/metrics/bug-report-metrics.json" ]; then - echo "✅ bug-report-metrics.json also generated for Jekyll" - echo "File size: $(du -h src/_data/metrics/bug-report-metrics.json)" - else - echo "ℹ️ bug-report-metrics.json not found for Jekyll (collection may have been skipped)" - fi - - name: Check for changes id: git-check run: | @@ -375,14 +351,12 @@ jobs: src/assets/data/metrics/governance-index.json \ src/assets/data/metrics/imposter-metrics.json \ src/assets/data/metrics/component-bug-metrics.json \ - src/assets/data/metrics/bug-report-metrics.json \ src/_data/metrics/issue-metrics.json \ src/_data/metrics/experimental-metrics.json \ src/_data/metrics/component-usage.json \ src/_data/metrics/governance-index.json \ src/_data/metrics/imposter-metrics.json \ - src/_data/metrics/component-bug-metrics.json \ - src/_data/metrics/bug-report-metrics.json; do + src/_data/metrics/component-bug-metrics.json; do if [ -f "$file" ]; then git add "$file" fi diff --git a/__tests__/collect-bug-report-metrics.test.js b/__tests__/collect-bug-report-metrics.test.js deleted file mode 100644 index 1048c06bf..000000000 --- a/__tests__/collect-bug-report-metrics.test.js +++ /dev/null @@ -1,368 +0,0 @@ -/** - * Unit Tests for Bug Report Metrics Collection Script - * - * Tests the core logic for collecting bug issues, - * parsing template fields, computing weekly buckets, - * grouping by team/product area/label, and computing time-to-completion stats. - */ - -describe('Bug Report Metrics Collector', () => { - let collector; - - beforeAll(() => { - collector = require('../scripts/collect-bug-report-metrics'); - }); - - describe('parseBugBody', () => { - test('extracts team name from bug template body', () => { - const body = '### Team Name\n\nDesign System Team\n\n### Select your product area\n\nDigital Experience'; - const result = collector.parseBugBody(body); - expect(result.team_name).toBe('Design System Team'); - }); - - test('extracts product area from bug template body', () => { - const body = '### Select your product area\n\nBenefits Portfolio\n\n### Team Name\n\nVBA Team'; - const result = collector.parseBugBody(body); - expect(result.product_area).toBe('Benefits Portfolio'); - }); - - test('extracts component name from bug template body', () => { - const body = '### Component or Pattern name\n\nva-text-input\n\n### Team Name\n\nTest Team'; - const result = collector.parseBugBody(body); - expect(result.component_name).toBe('va-text-input'); - }); - - test('returns null for missing fields', () => { - const body = '### Some Other Heading\n\nSome content'; - const result = collector.parseBugBody(body); - expect(result.team_name).toBeNull(); - expect(result.product_area).toBeNull(); - expect(result.component_name).toBeNull(); - }); - - test('returns null for empty body', () => { - const result = collector.parseBugBody(''); - expect(result.team_name).toBeNull(); - expect(result.product_area).toBeNull(); - expect(result.component_name).toBeNull(); - }); - - test('handles _No response_ placeholder', () => { - const body = '### Team Name\n\n_No response_\n\n### Component or Pattern name\n\nva-alert'; - const result = collector.parseBugBody(body); - expect(result.team_name).toBeNull(); - expect(result.component_name).toBe('va-alert'); - }); - - test('handles body with all fields populated', () => { - const body = [ - '### Team Name', - '', - 'Claims Team', - '', - '### Select your product area', - '', - 'Health Portfolio', - '', - '### Component or Pattern name', - '', - 'va-modal', - ].join('\n'); - - const result = collector.parseBugBody(body); - expect(result.team_name).toBe('Claims Team'); - expect(result.product_area).toBe('Health Portfolio'); - expect(result.component_name).toBe('va-modal'); - }); - }); - - describe('computeWeeklyBuckets', () => { - test('returns an array of weekly buckets', () => { - const buckets = collector.computeWeeklyBuckets([]); - expect(Array.isArray(buckets)).toBe(true); - expect(buckets.length).toBeGreaterThan(0); - expect(buckets.length).toBeLessThanOrEqual(12); - }); - - test('each bucket has required fields', () => { - const buckets = collector.computeWeeklyBuckets([]); - buckets.forEach(bucket => { - expect(bucket).toHaveProperty('week'); - expect(bucket).toHaveProperty('week_start'); - expect(bucket).toHaveProperty('bugs_opened'); - expect(bucket).toHaveProperty('bugs_closed'); - expect(bucket).toHaveProperty('net_change'); - }); - }); - - test('counts issues opened in the current week', () => { - const now = new Date(); - const issues = [ - { created_at: now.toISOString(), closed_at: null, state: 'open', labels: [] }, - ]; - const buckets = collector.computeWeeklyBuckets(issues); - const lastBucket = buckets[buckets.length - 1]; - expect(lastBucket.bugs_opened).toBeGreaterThanOrEqual(1); - }); - - test('counts issues closed in the current week', () => { - const now = new Date(); - const yesterday = new Date(now); - yesterday.setDate(yesterday.getDate() - 1); - const issues = [ - { created_at: yesterday.toISOString(), closed_at: now.toISOString(), state: 'closed', labels: [] }, - ]; - const buckets = collector.computeWeeklyBuckets(issues); - const lastBucket = buckets[buckets.length - 1]; - expect(lastBucket.bugs_closed).toBeGreaterThanOrEqual(1); - }); - - test('net_change equals bugs_opened minus bugs_closed', () => { - const buckets = collector.computeWeeklyBuckets([]); - buckets.forEach(bucket => { - expect(bucket.net_change).toBe(bucket.bugs_opened - bucket.bugs_closed); - }); - }); - }); - - describe('groupByTeam', () => { - const mockIssues = [ - { number: 1, state: 'open', body: '### Team Name\n\nAlpha Team', labels: ['bug'], created_at: '2025-01-01', closed_at: null }, - { number: 2, state: 'closed', body: '### Team Name\n\nAlpha Team', labels: ['bug'], created_at: '2025-01-01', closed_at: '2025-01-10' }, - { number: 3, state: 'open', body: '### Team Name\n\nBeta Team', labels: ['bug'], created_at: '2025-01-01', closed_at: null }, - ]; - - test('groups issues by team name', () => { - const result = collector.groupByTeam(mockIssues); - expect(result.length).toBe(2); - }); - - test('counts open and closed per team', () => { - const result = collector.groupByTeam(mockIssues); - const alpha = result.find(t => t.team === 'Alpha Team'); - expect(alpha.open).toBe(1); - expect(alpha.closed).toBe(1); - expect(alpha.total).toBe(2); - }); - - test('sorts by total descending', () => { - const result = collector.groupByTeam(mockIssues); - expect(result[0].team).toBe('Alpha Team'); - expect(result[0].total).toBe(2); - }); - - test('uses Unknown for issues without team name', () => { - const issues = [ - { number: 1, state: 'open', body: '### Some Other Field\n\nValue', labels: ['bug'], created_at: '2025-01-01', closed_at: null }, - ]; - const result = collector.groupByTeam(issues); - expect(result[0].team).toBe('Unknown'); - }); - - test('handles empty issues array', () => { - const result = collector.groupByTeam([]); - expect(result).toEqual([]); - }); - }); - - describe('groupByProductArea', () => { - test('groups issues by product area', () => { - const issues = [ - { number: 1, state: 'open', body: '### Select your product area\n\nBenefits Portfolio', labels: ['bug'], created_at: '2025-01-01', closed_at: null }, - { number: 2, state: 'closed', body: '### Select your product area\n\nHealth Portfolio', labels: ['bug'], created_at: '2025-01-01', closed_at: '2025-01-10' }, - { number: 3, state: 'open', body: '### Select your product area\n\nBenefits Portfolio', labels: ['bug'], created_at: '2025-01-01', closed_at: null }, - ]; - - const result = collector.groupByProductArea(issues); - expect(result.length).toBe(2); - - const benefits = result.find(a => a.product_area === 'Benefits Portfolio'); - expect(benefits.total).toBe(2); - expect(benefits.open).toBe(2); - }); - - test('handles empty issues', () => { - const result = collector.groupByProductArea([]); - expect(result).toEqual([]); - }); - }); - - describe('groupByLabel', () => { - test('groups issues by label excluding bug label', () => { - const issues = [ - { number: 1, state: 'open', labels: ['bug', 'va-alert'], created_at: '2025-01-01', closed_at: null, body: '' }, - { number: 2, state: 'closed', labels: ['bug', 'va-alert', 'va-modal'], created_at: '2025-01-01', closed_at: '2025-01-10', body: '' }, - ]; - - const result = collector.groupByLabel(issues); - - const alertLabel = result.find(l => l.label === 'va-alert'); - expect(alertLabel).toBeDefined(); - expect(alertLabel.total).toBe(2); - expect(alertLabel.open).toBe(1); - expect(alertLabel.closed).toBe(1); - - const modalLabel = result.find(l => l.label === 'va-modal'); - expect(modalLabel).toBeDefined(); - expect(modalLabel.total).toBe(1); - }); - - test('does not include bug label itself', () => { - const issues = [ - { number: 1, state: 'open', labels: ['bug', 'va-alert'], created_at: '2025-01-01', closed_at: null, body: '' }, - ]; - const result = collector.groupByLabel(issues); - const bugLabel = result.find(l => l.label === 'bug'); - expect(bugLabel).toBeUndefined(); - }); - - test('computes avg and median days for labels with closed issues', () => { - const issues = [ - { number: 1, state: 'closed', labels: ['bug', 'va-alert'], created_at: '2025-01-01T00:00:00Z', closed_at: '2025-01-04T00:00:00Z', body: '' }, - { number: 2, state: 'closed', labels: ['bug', 'va-alert'], created_at: '2025-01-01T00:00:00Z', closed_at: '2025-01-11T00:00:00Z', body: '' }, - ]; - - const result = collector.groupByLabel(issues); - const alertLabel = result.find(l => l.label === 'va-alert'); - - expect(alertLabel.avg_days).toBeDefined(); - expect(typeof alertLabel.avg_days).toBe('number'); - expect(alertLabel.median_days).toBeDefined(); - expect(typeof alertLabel.median_days).toBe('number'); - }); - - test('includes github_url per label', () => { - const issues = [ - { number: 1, state: 'open', labels: ['bug', 'va-button'], created_at: '2025-01-01', closed_at: null, body: '' }, - ]; - - const result = collector.groupByLabel(issues); - const buttonLabel = result.find(l => l.label === 'va-button'); - expect(buttonLabel.github_url).toContain('github.com'); - expect(buttonLabel.github_url).toContain('va-button'); - }); - - test('handles empty issues', () => { - const result = collector.groupByLabel([]); - expect(result).toEqual([]); - }); - }); - - describe('computeTimeToCompletion', () => { - test('computes stats for closed issues', () => { - const issues = [ - { state: 'closed', created_at: '2025-01-01T00:00:00Z', closed_at: '2025-01-04T00:00:00Z', labels: [] }, - { state: 'closed', created_at: '2025-01-01T00:00:00Z', closed_at: '2025-02-01T00:00:00Z', labels: [] }, - { state: 'closed', created_at: '2025-01-01T00:00:00Z', closed_at: '2025-06-01T00:00:00Z', labels: [] }, - ]; - - const result = collector.computeTimeToCompletion(issues); - - expect(result.count).toBe(3); - expect(result.avg_days).toBeGreaterThan(0); - expect(result.median_days).toBeGreaterThan(0); - expect(result.p90_days).toBeGreaterThan(0); - }); - - test('computes correct distribution buckets', () => { - const issues = [ - { state: 'closed', created_at: '2025-01-01T00:00:00Z', closed_at: '2025-01-04T00:00:00Z', labels: [] }, // 3 days -> under_7 - { state: 'closed', created_at: '2025-01-01T00:00:00Z', closed_at: '2025-01-15T00:00:00Z', labels: [] }, // 14 days -> days_7_to_30 - { state: 'closed', created_at: '2025-01-01T00:00:00Z', closed_at: '2025-03-01T00:00:00Z', labels: [] }, // ~59 days -> days_30_to_90 - { state: 'closed', created_at: '2025-01-01T00:00:00Z', closed_at: '2025-06-01T00:00:00Z', labels: [] }, // ~151 days -> over_90 - ]; - - const result = collector.computeTimeToCompletion(issues); - - expect(result.distribution.under_7).toBe(1); - expect(result.distribution.days_7_to_30).toBe(1); - expect(result.distribution.days_30_to_90).toBe(1); - expect(result.distribution.over_90).toBe(1); - }); - - test('ignores open issues', () => { - const issues = [ - { state: 'open', created_at: '2025-01-01T00:00:00Z', closed_at: null, labels: [] }, - ]; - - const result = collector.computeTimeToCompletion(issues); - expect(result.count).toBe(0); - }); - - test('handles empty issues', () => { - const result = collector.computeTimeToCompletion([]); - expect(result.count).toBe(0); - expect(result.avg_days).toBe(0); - expect(result.median_days).toBe(0); - expect(result.p90_days).toBe(0); - }); - }); - - describe('computeSummary', () => { - test('counts open bugs', () => { - const issues = [ - { state: 'open', created_at: '2025-01-01T00:00:00Z', closed_at: null, labels: [] }, - { state: 'open', created_at: '2025-01-01T00:00:00Z', closed_at: null, labels: [] }, - { state: 'closed', created_at: '2025-01-01T00:00:00Z', closed_at: '2025-01-10T00:00:00Z', labels: [] }, - ]; - - const result = collector.computeSummary(issues, []); - expect(result.open_bugs).toBe(2); - expect(result.total_bugs).toBe(3); - }); - - test('includes trend information', () => { - const result = collector.computeSummary([], []); - expect(result.open_bugs_trend).toBeDefined(); - expect(result.open_bugs_trend).toHaveProperty('direction'); - expect(result.open_bugs_trend).toHaveProperty('percentage'); - }); - - test('includes last_updated timestamp', () => { - const result = collector.computeSummary([], []); - expect(result.last_updated).toBeDefined(); - expect(() => new Date(result.last_updated)).not.toThrow(); - }); - - test('counts new and closed this week for recent issues', () => { - const now = new Date(); - const yesterday = new Date(now); - yesterday.setDate(yesterday.getDate() - 1); - - const issues = [ - { state: 'open', created_at: yesterday.toISOString(), closed_at: null, labels: [] }, - { state: 'closed', created_at: yesterday.toISOString(), closed_at: now.toISOString(), labels: [] }, - ]; - - const result = collector.computeSummary(issues, []); - expect(result.new_this_week).toBe(2); - expect(result.closed_this_week).toBe(1); - }); - - test('handles empty issues array', () => { - const result = collector.computeSummary([], []); - expect(result.open_bugs).toBe(0); - expect(result.total_bugs).toBe(0); - expect(result.new_this_week).toBe(0); - expect(result.closed_this_week).toBe(0); - expect(result.avg_resolution_days).toBe(0); - }); - }); - - describe('exported functions', () => { - test('all expected functions are exported', () => { - expect(typeof collector.fetchAllBugIssues).toBe('function'); - expect(typeof collector.parseBugBody).toBe('function'); - expect(typeof collector.computeWeeklyBuckets).toBe('function'); - expect(typeof collector.groupByTeam).toBe('function'); - expect(typeof collector.groupByProductArea).toBe('function'); - expect(typeof collector.groupByLabel).toBe('function'); - expect(typeof collector.computeTimeToCompletion).toBe('function'); - expect(typeof collector.computeSummary).toBe('function'); - }); - - test('SINCE_DATE is exported and set to April 1, 2025', () => { - expect(collector.SINCE_DATE).toBe('2025-04-01T00:00:00Z'); - }); - }); -}); diff --git a/scripts/collect-bug-report-metrics.js b/scripts/collect-bug-report-metrics.js deleted file mode 100644 index ac0464cee..000000000 --- a/scripts/collect-bug-report-metrics.js +++ /dev/null @@ -1,640 +0,0 @@ -#!/usr/bin/env node - -/** - * Bug Report Metrics Collection Script - * - * Collects bug issue data from the vets-design-system-documentation repository - * and generates a weekly bug report with: - * - 12-week rolling opened/closed/net-change buckets - * - Bugs grouped by reporting team (with open/closed breakdown) - * - Bugs grouped by label (component, severity, product area) - * - Time-to-completion statistics (avg, median, P90, distribution) - * - * Usage: node scripts/collect-bug-report-metrics.js - * Requires: GITHUB_TOKEN environment variable and GitHub CLI (gh) - */ - -const fs = require('fs').promises; -const path = require('path'); -const { execFileSync } = require('child_process'); - -const REPO = 'department-of-veterans-affairs/vets-design-system-documentation'; -const DATA_DIR = path.join(__dirname, '../src/_data/metrics'); -const ASSETS_DIR = path.join(__dirname, '../src/assets/data/metrics'); -const OUTPUT_FILENAME = 'bug-report-metrics.json'; - -// Number of weeks for the rolling window -const ROLLING_WEEKS = 12; - -// Only include issues created on or after this date -const SINCE_DATE = '2025-04-01T00:00:00Z'; - -// GitHub CLI execution limits -const GH_MAX_BUFFER = 50 * 1024 * 1024; // 50 MB -const GH_TIMEOUT_MS = 180000; // 3 minutes - -// Product areas from the bug template -const PRODUCT_AREAS = [ - 'Digital Experience', - 'Content and Information Architecture', - 'Benefits Portfolio', - 'Health Portfolio', - 'BAM Portfolio', -]; - -// --------------------------------------------------------------------------- -// Data fetching -// --------------------------------------------------------------------------- - -/** - * Fetch all issues labeled 'bug' (open and closed) including body text. - * Uses `gh api` with REST pagination so results are not capped at 1000. - */ -function fetchBugIssues(state) { - console.log(`Fetching ${state} bug issues...`); - - const apiPath = `repos/${REPO}/issues`; - const apiArgs = [ - 'api', - apiPath, - '--method', 'GET', - '--paginate', - '--slurp', - '-f', `state=${state}`, - '-f', 'labels=bug', - '-f', 'per_page=100', - '-f', 'direction=asc', - '-f', `since=${SINCE_DATE}`, - ]; - - console.log(` Running: gh ${apiArgs.join(' ')}`); - - const output = execFileSync('gh', apiArgs, { - encoding: 'utf8', - maxBuffer: GH_MAX_BUFFER, - timeout: GH_TIMEOUT_MS, - }); - - if (output.trim()) { - const pages = JSON.parse(output); - const issues = pages - .flat() - .filter((issue) => !issue.pull_request) - .map((issue) => ({ - number: issue.number, - title: issue.title, - state: issue.state, - createdAt: issue.created_at, - closedAt: issue.closed_at, - labels: issue.labels, - body: issue.body, - })); - console.log(` Found ${issues.length} ${state} bug issues`); - return issues.map(normalizeIssue); - } - - return []; -} - -/** - * Normalize a raw issue object from `gh search issues` into a consistent shape. - */ -function normalizeIssue(raw) { - return { - number: raw.number, - title: raw.title, - state: raw.state === 'open' ? 'open' : 'closed', - created_at: raw.createdAt, - closed_at: raw.closedAt || null, - labels: (raw.labels || []).map(l => (typeof l === 'string' ? l : l.name)), - body: raw.body || '', - }; -} - -/** - * Fetch all bug issues (open + closed), deduplicate, and filter to only - * those created on or after SINCE_DATE. - */ -function fetchAllBugIssues() { - const openIssues = fetchBugIssues('open'); - const closedIssues = fetchBugIssues('closed'); - - // Deduplicate (gh search can return overlap) - const seen = new Set(); - const all = []; - for (const issue of [...openIssues, ...closedIssues]) { - if (!seen.has(issue.number)) { - seen.add(issue.number); - all.push(issue); - } - } - - // The API `since` param filters by updated_at, so also filter by created_at - const sinceDate = new Date(SINCE_DATE); - const filtered = all.filter(i => new Date(i.created_at) >= sinceDate); - - console.log(`Total unique bug issues (since ${SINCE_DATE}): ${filtered.length}`); - return filtered; -} - -// --------------------------------------------------------------------------- -// Bug template body parsing -// --------------------------------------------------------------------------- - -/** - * Extract a form field value from the issue body. - * GitHub YAML issue forms render as: - * ### Field Label - * - * Value text - * - */ -function extractField(body, heading) { - if (!body) return null; - - // Match the heading and capture content until the next heading or end - const regex = new RegExp( - `###\\s+${escapeRegExp(heading)}\\s*\\n+([\\s\\S]*?)(?=\\n###|$)`, - 'i', - ); - const match = body.match(regex); - if (!match) return null; - - const value = match[1].trim(); - // Skip placeholder values like "_No response_" - if (!value || value === '_No response_' || value === 'None') return null; - return value; -} - -function escapeRegExp(string) { - return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); -} - -/** - * Parse all relevant fields from a bug report body. - */ -function parseBugBody(body) { - return { - team_name: extractField(body, 'Team Name'), - product_area: extractField(body, 'Select your product area'), - component_name: extractField(body, 'Component or Pattern name'), - }; -} - -// --------------------------------------------------------------------------- -// Weekly buckets (rolling 12-week window) -// --------------------------------------------------------------------------- - -/** - * Get the ISO week string for a date (YYYY-Www). - */ -function getWeekKey(date) { - const d = new Date(date); - // Set to nearest Thursday: current date + 4 - current day number (Mon=1..Sun=7) - d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7)); - const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); - const weekNo = Math.ceil(((d - yearStart) / 86400000 + 1) / 7); - return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`; -} - -/** - * Get the Monday date (YYYY-MM-DD) for an ISO week key. - */ -function weekKeyToMonday(weekKey) { - const [yearStr, weekStr] = weekKey.split('-W'); - const year = parseInt(yearStr, 10); - const week = parseInt(weekStr, 10); - // Jan 4 is always in ISO week 1 - const jan4 = new Date(Date.UTC(year, 0, 4)); - const dayOfWeek = jan4.getUTCDay() || 7; // Mon=1 - const monday = new Date(jan4); - monday.setUTCDate(jan4.getUTCDate() - dayOfWeek + 1 + (week - 1) * 7); - return monday.toISOString().split('T')[0]; -} - -/** - * Compute 12-week rolling buckets of opened / closed / net change. - */ -function computeWeeklyBuckets(issues) { - const now = new Date(); - const currentWeek = getWeekKey(now); - - // Build an ordered list of the last ROLLING_WEEKS week keys - const weekKeys = []; - for (let i = ROLLING_WEEKS - 1; i >= 0; i--) { - const d = new Date(now); - d.setUTCDate(d.getUTCDate() - i * 7); - const key = getWeekKey(d); - if (!weekKeys.includes(key)) weekKeys.push(key); - } - - // Initialize buckets - const buckets = new Map(); - for (const key of weekKeys) { - buckets.set(key, { - week: key, - week_start: weekKeyToMonday(key), - bugs_opened: 0, - bugs_closed: 0, - net_change: 0, - }); - } - - // Fill buckets - for (const issue of issues) { - const openedWeek = getWeekKey(issue.created_at); - if (buckets.has(openedWeek)) { - buckets.get(openedWeek).bugs_opened++; - } - - if (issue.closed_at) { - const closedWeek = getWeekKey(issue.closed_at); - if (buckets.has(closedWeek)) { - buckets.get(closedWeek).bugs_closed++; - } - } - } - - // Calculate net change - const result = []; - for (const key of weekKeys) { - const b = buckets.get(key); - b.net_change = b.bugs_opened - b.bugs_closed; - result.push(b); - } - - return result; -} - -// --------------------------------------------------------------------------- -// Group by reporting team -// --------------------------------------------------------------------------- - -/** - * Group issues by the team that reported them (parsed from body). - * Returns an array sorted by total descending. - */ -function groupByTeam(issues) { - const teamMap = new Map(); - - for (const issue of issues) { - const parsed = parseBugBody(issue.body); - const team = parsed.team_name || 'Unknown'; - - if (!teamMap.has(team)) { - teamMap.set(team, { team, open: 0, closed: 0, total: 0 }); - } - - const entry = teamMap.get(team); - entry.total++; - if (issue.state === 'open') { - entry.open++; - } else { - entry.closed++; - } - } - - return Array.from(teamMap.values()).sort((a, b) => b.total - a.total); -} - -// --------------------------------------------------------------------------- -// Group by product area -// --------------------------------------------------------------------------- - -/** - * Group issues by the product area field from the bug template. - */ -function groupByProductArea(issues) { - const areaMap = new Map(); - - for (const issue of issues) { - const parsed = parseBugBody(issue.body); - const area = parsed.product_area || 'Unknown'; - - if (!areaMap.has(area)) { - areaMap.set(area, { product_area: area, open: 0, closed: 0, total: 0 }); - } - - const entry = areaMap.get(area); - entry.total++; - if (issue.state === 'open') { - entry.open++; - } else { - entry.closed++; - } - } - - return Array.from(areaMap.values()).sort((a, b) => b.total - a.total); -} - -// --------------------------------------------------------------------------- -// Group by label -// --------------------------------------------------------------------------- - -/** - * Group issues by their labels, producing a count for each label. - * Only includes labels that appear on at least 1 issue. - */ -function groupByLabel(issues) { - const labelMap = new Map(); - - for (const issue of issues) { - for (const label of issue.labels) { - if (label === 'bug') continue; // skip the filter label itself - - if (!labelMap.has(label)) { - labelMap.set(label, { - label, - open: 0, - closed: 0, - total: 0, - github_url: `https://github.com/${REPO}/issues?q=is:issue+label:bug+label:${encodeURIComponent(label)}`, - }); - } - - const entry = labelMap.get(label); - entry.total++; - if (issue.state === 'open') { - entry.open++; - } else { - entry.closed++; - } - } - } - - // Calculate avg resolution time per label for closed issues - for (const issue of issues) { - if (!issue.closed_at) continue; - const days = resolutionDays(issue); - for (const label of issue.labels) { - if (label === 'bug') continue; - const entry = labelMap.get(label); - if (entry) { - if (!entry._days) entry._days = []; - entry._days.push(days); - } - } - } - - // Compute stats per label and clean up internal field - const result = []; - for (const entry of labelMap.values()) { - const daysList = entry._days || []; - delete entry._days; - - if (daysList.length > 0) { - entry.avg_days = Math.round(average(daysList)); - entry.median_days = Math.round(median(daysList)); - } else { - entry.avg_days = null; - entry.median_days = null; - } - - result.push(entry); - } - - return result.sort((a, b) => b.total - a.total); -} - -// --------------------------------------------------------------------------- -// Time-to-completion statistics -// --------------------------------------------------------------------------- - -function resolutionDays(issue) { - const created = new Date(issue.created_at); - const closed = new Date(issue.closed_at); - return Math.max(0, Math.ceil((closed - created) / (1000 * 60 * 60 * 24))); -} - -function average(arr) { - if (arr.length === 0) return 0; - return arr.reduce((a, b) => a + b, 0) / arr.length; -} - -function median(arr) { - if (arr.length === 0) return 0; - const sorted = [...arr].sort((a, b) => a - b); - const mid = Math.floor(sorted.length / 2); - return sorted.length % 2 !== 0 - ? sorted[mid] - : (sorted[mid - 1] + sorted[mid]) / 2; -} - -function percentile(arr, p) { - if (arr.length === 0) return 0; - const sorted = [...arr].sort((a, b) => a - b); - const index = Math.ceil((p / 100) * sorted.length) - 1; - return sorted[Math.max(0, index)]; -} - -/** - * Calculate overall time-to-completion stats for closed bug issues. - */ -function computeTimeToCompletion(issues) { - const closedIssues = issues.filter(i => i.closed_at); - if (closedIssues.length === 0) { - return { - count: 0, - avg_days: 0, - median_days: 0, - p90_days: 0, - distribution: { - under_7: 0, - days_7_to_30: 0, - days_30_to_90: 0, - over_90: 0, - }, - }; - } - - const daysList = closedIssues.map(resolutionDays); - - // Distribution buckets - const distribution = { - under_7: 0, - days_7_to_30: 0, - days_30_to_90: 0, - over_90: 0, - }; - - // Distribution buckets: - // under_7: 0–6 days (label: "Under 7 days") - // days_7_to_30: 7–30 days (label: "7–30 days") - // days_30_to_90: 31–90 days (label: "30–90 days") - // over_90: 91+ days (label: "Over 90 days") - for (const d of daysList) { - if (d < 7) distribution.under_7++; - else if (d <= 30) distribution.days_7_to_30++; - else if (d <= 90) distribution.days_30_to_90++; - else distribution.over_90++; - } - - return { - count: closedIssues.length, - avg_days: Math.round(average(daysList)), - median_days: Math.round(median(daysList)), - p90_days: Math.round(percentile(daysList, 90)), - distribution, - }; -} - -// --------------------------------------------------------------------------- -// Summary statistics -// --------------------------------------------------------------------------- - -function computeSummary(issues) { - const now = new Date(); - const oneWeekAgo = new Date(now); - oneWeekAgo.setUTCDate(oneWeekAgo.getUTCDate() - 7); - - const thisMonthStart = new Date( - Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1), - ); - - const openBugs = issues.filter(i => i.state === 'open').length; - - const newThisWeek = issues.filter( - i => new Date(i.created_at) >= oneWeekAgo, - ).length; - const closedThisWeek = issues.filter( - i => i.closed_at && new Date(i.closed_at) >= oneWeekAgo, - ).length; - - const newThisMonth = issues.filter( - i => new Date(i.created_at) >= thisMonthStart, - ).length; - const closedThisMonth = issues.filter( - i => i.closed_at && new Date(i.closed_at) >= thisMonthStart, - ).length; - - // Trend: compare current open count to 4 weeks ago - const fourWeeksAgo = new Date(now); - fourWeeksAgo.setUTCDate(fourWeeksAgo.getUTCDate() - 28); - const openFourWeeksAgo = issues.filter(i => { - const created = new Date(i.created_at); - return ( - created <= fourWeeksAgo && - (i.state === 'open' || - (i.closed_at && new Date(i.closed_at) > fourWeeksAgo)) - ); - }).length; - - let trendDirection = 'neutral'; - let trendPercentage = 0; - if (openFourWeeksAgo > 0) { - const change = openBugs - openFourWeeksAgo; - trendPercentage = Math.round((Math.abs(change) / openFourWeeksAgo) * 100); - trendDirection = change > 0 ? 'up' : change < 0 ? 'down' : 'neutral'; - } else if (openBugs > 0) { - trendDirection = 'up'; - trendPercentage = 100; - } - - const closedIssues = issues.filter(i => i.closed_at); - const avgResolutionDays = - closedIssues.length > 0 - ? Math.round(average(closedIssues.map(resolutionDays))) - : 0; - - return { - open_bugs: openBugs, - new_this_week: newThisWeek, - closed_this_week: closedThisWeek, - new_this_month: newThisMonth, - closed_this_month: closedThisMonth, - avg_resolution_days: avgResolutionDays, - total_bugs: issues.length, - open_bugs_trend: { - direction: trendDirection, - percentage: trendPercentage, - }, - last_updated: now.toISOString(), - }; -} - -// --------------------------------------------------------------------------- -// Main -// --------------------------------------------------------------------------- - -async function main() { - try { - console.log('Starting bug report metrics collection...'); - - if (!process.env.GITHUB_TOKEN) { - console.warn('Warning: GITHUB_TOKEN not set. Rate limiting may occur.'); - } - - // Ensure output directories exist - await fs.mkdir(DATA_DIR, { recursive: true }); - await fs.mkdir(ASSETS_DIR, { recursive: true }); - - // Fetch all bug issues - const issues = fetchAllBugIssues(); - - // Build all metric sections - const weeklyBuckets = computeWeeklyBuckets(issues); - const byTeam = groupByTeam(issues); - const byProductArea = groupByProductArea(issues); - const byLabel = groupByLabel(issues); - const timeToCompletion = computeTimeToCompletion(issues); - const summary = computeSummary(issues, weeklyBuckets); - - // Assemble output - const metricsData = { - summary, - weekly: weeklyBuckets, - by_team: byTeam, - by_product_area: byProductArea, - by_label: byLabel, - time_to_completion: timeToCompletion, - data_source: 'github-issues', - report_date: new Date().toISOString().split('T')[0], - }; - - // Write to both locations - const jsonOutput = JSON.stringify(metricsData, null, 2); - - const dataPath = path.join(DATA_DIR, OUTPUT_FILENAME); - const assetsPath = path.join(ASSETS_DIR, OUTPUT_FILENAME); - - await fs.writeFile(dataPath, jsonOutput); - await fs.writeFile(assetsPath, jsonOutput); - - console.log(`\n✅ Bug report metrics written to ${dataPath}`); - console.log(`✅ Bug report metrics also written to ${assetsPath}`); - - console.log(`\n📊 Summary:`); - console.log(` - Open bugs: ${summary.open_bugs}`); - console.log(` - New this week: ${summary.new_this_week}`); - console.log(` - Closed this week: ${summary.closed_this_week}`); - console.log(` - New this month: ${summary.new_this_month}`); - console.log(` - Closed this month: ${summary.closed_this_month}`); - console.log(` - Avg resolution: ${summary.avg_resolution_days} days`); - console.log(` - Total bug issues: ${summary.total_bugs}`); - console.log(` - Teams reporting bugs: ${byTeam.length}`); - console.log(` - Weekly data points: ${weeklyBuckets.length}`); - console.log( - ` - Time-to-completion (median): ${timeToCompletion.median_days} days`, - ); - } catch (error) { - console.error('❌ Error collecting bug report metrics:', error.message); - process.exit(1); - } -} - -// Run if called directly -if (require.main === module) { - main(); -} - -module.exports = { - SINCE_DATE, - fetchAllBugIssues, - parseBugBody, - computeWeeklyBuckets, - groupByTeam, - groupByProductArea, - groupByLabel, - computeTimeToCompletion, - computeSummary, -}; diff --git a/src/_about/metrics/bug-report.html b/src/_about/metrics/bug-report.html deleted file mode 100644 index 97e457a1a..000000000 --- a/src/_about/metrics/bug-report.html +++ /dev/null @@ -1,893 +0,0 @@ ---- -layout: documentation -permalink: /about/metrics/bug-report/ -has-parent: /about/metrics/ -title: Bug Report ---- - -
-

Bug Report

- -

This dashboard tracks bug issues reported against the VA Design System. Data is updated weekly and can be shared with other teams to provide visibility into bug volume, resolution times, and which teams are reporting issues.

- - -
-

Summary

- -
- -
- -

Open Bugs

-
- {% if site.data.metrics.bug-report-metrics.summary.open_bugs != nil %} - {{ site.data.metrics.bug-report-metrics.summary.open_bugs }} - {% else %} - -- - {% endif %} -
-
- {% if site.data.metrics.bug-report-metrics.summary.open_bugs_trend %} - {% assign trend = site.data.metrics.bug-report-metrics.summary.open_bugs_trend %} - {% if trend.direction == 'up' %} - - - {{ trend.percentage }}% from 4 weeks ago - - {% elsif trend.direction == 'down' %} - - - {{ trend.percentage }}% from 4 weeks ago - - {% else %} - - - No change from 4 weeks ago - - {% endif %} - {% endif %} -
-
- {% if site.data.metrics.bug-report-metrics.summary.last_updated %} - {% assign last_updated = site.data.metrics.bug-report-metrics.summary.last_updated | date: "%B %d, %Y" %} - Updated {{ last_updated }} - {% else %} - Data collection in progress - {% endif %} -
-
-
- - -
- -

New This Week

-
- {% if site.data.metrics.bug-report-metrics.summary.new_this_week != nil %} - {{ site.data.metrics.bug-report-metrics.summary.new_this_week }} - {% else %} - -- - {% endif %} -
-
- - {% if site.data.metrics.bug-report-metrics.summary.new_this_month != nil %} - {{ site.data.metrics.bug-report-metrics.summary.new_this_month }} new this month - {% endif %} - -
-
-
- - -
- -

Closed This Week

-
- {% if site.data.metrics.bug-report-metrics.summary.closed_this_week != nil %} - {{ site.data.metrics.bug-report-metrics.summary.closed_this_week }} - {% else %} - -- - {% endif %} -
-
- - {% if site.data.metrics.bug-report-metrics.summary.closed_this_month != nil %} - {{ site.data.metrics.bug-report-metrics.summary.closed_this_month }} closed this month - {% endif %} - -
-
-
- - -
- -

Avg Resolution

-
- {% if site.data.metrics.bug-report-metrics.summary.avg_resolution_days != nil %} - {{ site.data.metrics.bug-report-metrics.summary.avg_resolution_days }} - {% else %} - -- - {% endif %} - days -
-
- - {% if site.data.metrics.bug-report-metrics.time_to_completion.median_days != nil %} - Median: {{ site.data.metrics.bug-report-metrics.time_to_completion.median_days }} days - {% endif %} - -
-
-
-
-
- - -
-

Weekly Bug Activity

- -
-

Bugs Opened vs. Closed (Last 12 Weeks)

-

Rolling 12-week view of new bugs reported and bugs resolved each week

-
- {% if site.data.metrics.bug-report-metrics.summary.last_updated %} - {% assign last_updated = site.data.metrics.bug-report-metrics.summary.last_updated | date: "%B %d, %Y" %} - Data updated {{ last_updated }} - {% else %} - Data collection in progress - {% endif %} -
- - - - - - - - - - - - - Week - Opened - Closed - Net Change - - {% if site.data.metrics.bug-report-metrics.weekly %} - {% assign sorted_weekly = site.data.metrics.bug-report-metrics.weekly | reverse %} - {% for week in sorted_weekly %} - - {{ week.week_start }} - {{ week.bugs_opened }} - {{ week.bugs_closed }} - {% if week.net_change > 0 %}+{% endif %}{{ week.net_change }} - - {% endfor %} - {% else %} - - No weekly data currently available - - {% endif %} - - - -
-
- - -
-

Bugs by Reporting Team

- -
-

Which teams report the most bugs?

-

Team names are extracted from the bug report form. Teams reporting more bugs may need closer collaboration on component quality.

- - - - - - - - - - - - - Team - Open - Closed - Total - - {% if site.data.metrics.bug-report-metrics.by_team %} - {% for team in site.data.metrics.bug-report-metrics.by_team %} - - {{ team.team }} - {{ team.open }} - {{ team.closed }} - {{ team.total }} - - {% endfor %} - {% else %} - - No team data currently available - - {% endif %} - - - -
-
- - -
-

Bugs by Product Area

- -
-

Bug distribution across VA product areas

-

Product areas are captured in the bug report form and represent the broad portfolio that reported the issue.

- - - - - - - - - - - - - Product Area - Open - Closed - Total - - {% if site.data.metrics.bug-report-metrics.by_product_area %} - {% for area in site.data.metrics.bug-report-metrics.by_product_area %} - - {{ area.product_area }} - {{ area.open }} - {{ area.closed }} - {{ area.total }} - - {% endfor %} - {% else %} - - No product area data currently available - - {% endif %} - - - -
-
- - -
-

Bugs by Label

- -
-

Bug counts grouped by issue label

-

Includes component labels (like va-alert), severity labels, and other tags. Click a label name to view those issues on GitHub.

- - - - - - - - Label - Open - Closed - Total - Avg Days - Median Days - - {% if site.data.metrics.bug-report-metrics.by_label %} - {% for item in site.data.metrics.bug-report-metrics.by_label %} - - {{ item.label }} - {{ item.open }} - {{ item.closed }} - {{ item.total }} - {% if item.avg_days != nil %}{{ item.avg_days }}{% else %}N/A{% endif %} - {% if item.median_days != nil %}{{ item.median_days }}{% else %}N/A{% endif %} - - {% endfor %} - {% else %} - - No label data currently available - - {% endif %} - - - -
-
- - -
-

Time to Completion

- -
-

How long does it take to resolve bugs?

-

Distribution of resolution times for all closed bug issues. Faster resolution indicates healthier bug triage and fix processes.

- -
-
- -

Average

-
- {% if site.data.metrics.bug-report-metrics.time_to_completion.avg_days != nil %} - {{ site.data.metrics.bug-report-metrics.time_to_completion.avg_days }} - {% else %} - -- - {% endif %} - days -
-
-
-
- -

Median

-
- {% if site.data.metrics.bug-report-metrics.time_to_completion.median_days != nil %} - {{ site.data.metrics.bug-report-metrics.time_to_completion.median_days }} - {% else %} - -- - {% endif %} - days -
-
-
-
- -

P90

-
- {% if site.data.metrics.bug-report-metrics.time_to_completion.p90_days != nil %} - {{ site.data.metrics.bug-report-metrics.time_to_completion.p90_days }} - {% else %} - -- - {% endif %} - days -
-
-
-
- -

Closed Bugs

-
- {% if site.data.metrics.bug-report-metrics.time_to_completion.count != nil %} - {{ site.data.metrics.bug-report-metrics.time_to_completion.count }} - {% else %} - -- - {% endif %} -
-
-
-
- - - - - - - - - - - - - Resolution Time - Bug Count - - {% if site.data.metrics.bug-report-metrics.time_to_completion.distribution %} - {% assign dist = site.data.metrics.bug-report-metrics.time_to_completion.distribution %} - - Under 7 days - {{ dist.under_7 }} - - - 7–30 days - {{ dist.days_7_to_30 }} - - - 30–90 days - {{ dist.days_30_to_90 }} - - - Over 90 days - {{ dist.over_90 }} - - {% else %} - - No time to completion data currently available - - {% endif %} - - - -
-
- - -
-

Data source information

-

- - Data source: GitHub Issues labeled "bug". - {% if site.data.metrics.bug-report-metrics.summary.last_updated %} - Last updated {{ site.data.metrics.bug-report-metrics.summary.last_updated | date: "%B %d, %Y at %l:%M %p UTC" }}. - {% endif %} - Updated weekly via automated workflow. - -

-
-
- - - diff --git a/src/_about/metrics/index.html b/src/_about/metrics/index.html index e88489eae..00b20ade6 100644 --- a/src/_about/metrics/index.html +++ b/src/_about/metrics/index.html @@ -5,7 +5,6 @@ permalink: /about/metrics/ sub-pages: - sub-page: Governance -- sub-page: Bug Report mime_type: text/html ---