diff --git a/.github/workflows/update-contributor-metrics.yml b/.github/workflows/update-contributor-metrics.yml index 3c2d8da83..2b8c4a09d 100644 --- a/.github/workflows/update-contributor-metrics.yml +++ b/.github/workflows/update-contributor-metrics.yml @@ -6,18 +6,20 @@ on: workflow_dispatch: concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: ${{ github.workflow }} + cancel-in-progress: false permissions: contents: write pull-requests: write + issues: read + discussions: read jobs: update-contributor-metrics: name: Compute contributor metrics and create PR runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 30 steps: - name: Harden Runner @@ -33,10 +35,33 @@ jobs: with: node-version: "24" + - name: Verify collector offline + run: node --test scripts/fetch-contributor-metrics.test.js + + - name: Retain the newest verified refresh awaiting review + run: | + node <<'NODE' + const fs = require('node:fs'); + const { execFileSync, spawnSync } = require('node:child_process'); + const ref = 'refs/remotes/origin/chore/update-contributor-metrics'; + const found = spawnSync('git', ['show-ref', '--verify', '--quiet', ref]); + if (found.status === 1) process.exit(0); + if (found.status !== 0) throw new Error('Could not inspect the pending refresh branch'); + const file = 'static/data/contributor-metrics.json'; + const pending = JSON.parse(execFileSync('git', ['show', `${ref}:${file}`], { encoding: 'utf8' })); + const current = JSON.parse(fs.readFileSync(file, 'utf8')); + const stamp = Date.parse(pending.generatedAt); + if (pending.schemaVersion === 2 && pending.coverage?.complete === true && + Number.isFinite(stamp) && stamp <= Date.now() && + stamp > (Date.parse(current.generatedAt) || 0)) { + fs.writeFileSync(file, JSON.stringify(pending, null, 2) + '\n'); + } + NODE + - name: Compute contributor metrics env: GITHUB_TOKEN: ${{ github.token }} - run: node scripts/fetch-contributor-metrics.js + run: node scripts/fetch-contributor-metrics.js --force - name: Check for contributor metrics dataset changes id: check-changes @@ -51,16 +76,16 @@ jobs: fi - name: Create or update PR with fresh contributor data - if: steps.check-changes.outputs.changed == 'true' + if: steps.check-changes.outputs.changed == 'true' && github.ref == 'refs/heads/main' env: GH_TOKEN: ${{ github.token }} run: | - BRANCH="chore/update-contributor-metrics-$(date +%Y%m%d)" + BRANCH="chore/update-contributor-metrics" git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git checkout -b "$BRANCH" - git commit -m "chore(analytics): update contributor metrics cache" - git push -u origin "$BRANCH" --force + git commit -m "chore(analytics): refresh public contributor participation" + git push -u origin "$BRANCH" --force-with-lease # Check if PR already exists before opening duplicate EXISTING_PR=$(gh pr list --repo projectbluefin/documentation --head "$BRANCH" --state open --json number --jq '.[0].number') @@ -71,6 +96,6 @@ jobs: --repo projectbluefin/documentation \ --base main \ --head "$BRANCH" \ - --title "chore(analytics): update contributor metrics cache" \ - --body "Automated update of monthly contributor metrics, active cohorts, and activity streams. Requires maintainer approval before merge per repository policy." + --title "chore(analytics): refresh public contributor participation" \ + --body "Refreshes the complete public GitHub participation snapshot. Known bots and imported fork commit history are excluded; failed collection preserves last-good history and marks unavailability. Requires maintainer approval before merge." fi diff --git a/docs/analytics.mdx b/docs/analytics.mdx index 1dd5f1305..d5ff791b8 100644 --- a/docs/analytics.mdx +++ b/docs/analytics.mdx @@ -65,13 +65,34 @@ See the [Homebrew analytics documentation](https://docs.brew.sh/Analytics) for a ### Contributor Metrics Methodology -Contributor analytics adopt a CNCF DevStats and LFX-derived methodology: - -- **4 summary metrics and 3 trend charts:** Tracking monthly active human contributors (merged PR authors), first-time contributors, pull request merge rate, and human throughput alongside historical trends for contributor cohorts, activity breakdowns, and PR velocity. -- **Public GitHub dataset with automated bot and AI agent filtering:** Metrics are derived from public GitHub activity across the entire `projectbluefin` organization while strictly filtering automated bots, dependency managers, and autonomous AI agents to ensure counts represent real human open-source participation. -- **Organization-wide activity streams:** Activity streams track merged pull requests and issues across all public repositories in `projectbluefin` on a shared domain. -- **Community forum discussions:** Community discussions are hosted at [ublue-os/bluefin](https://github.com/ublue-os/bluefin/discussions). - -For real-time development and organizational velocity metrics across all 15 factory repositories, see the [Factory metrics view](/factory/metrics). - -Upstream repository metrics for `projectbluefin/bluefin` are also tracked on [Linux Foundation Insights](https://insights.linuxfoundation.org/project/ublue-os-bluefin). +These charts measure participation, not pull request throughput. A contributor is +one identifiable GitHub account with a recorded public contribution in a repository +currently owned by `projectbluefin`. Each account is counted once across the organization. + +- **Ways to contribute:** code and documentation changes, pull requests, issues, + comments, submitted reviews, and GitHub Discussions, including replies. Changes + do not have to be merged to recognize their authors. +- **Repository coverage:** all public organization repositories, including archived + repositories and forks. Commit authors are read from non-merge commits on non-fork + default branches; imported fork commit history is excluded. Other participation + in forks is included. Recorded history may predate a repository joining the org. +- **Community growth:** the cumulative number of accounts by their first recorded + contribution date. This is surviving public history, not a claim to recover deleted + contributions or activity in private repositories. +- **Recent participation:** distinct accounts active in the last 90 days. Monthly + counts use UTC calendar months; the current month is incomplete. The same person + may help in multiple ways, so the contribution-type counts must not be added together. +- **Identity limits:** known bots and bot account types are excluded. Accounts that + cannot be linked to a public GitHub identity are not counted. Public GitHub data + cannot establish whether an ordinary account used AI assistance, or whether two + accounts belong to the same person. +- **Freshness and gaps:** the scheduled collector follows every required page of + the original public GitHub APIs. A failed collection retains the last successful + snapshot and its timestamp, with a visible reason rather than a partial total. + +Sources: GitHub's [repository API](https://docs.github.com/en/rest/repos/repos#list-organization-repositories), +[GraphQL repository reference](https://docs.github.com/en/graphql/reference/objects#repository), +and [pagination documentation](https://docs.github.com/en/graphql/guides/using-pagination-in-the-graphql-api). +The precise local definition is implemented in `scripts/fetch-contributor-metrics.js`. +Community forum discussions at [ublue-os/bluefin](https://github.com/ublue-os/bluefin/discussions) +are outside this organization and are not mixed into its totals. diff --git a/docs/skills/factory-dashboard-content.md b/docs/skills/factory-dashboard-content.md index be71ac62f..8269f227c 100644 --- a/docs/skills/factory-dashboard-content.md +++ b/docs/skills/factory-dashboard-content.md @@ -164,29 +164,34 @@ authorization to it. ### Contributor analytics: methodology, scoping, and data integrity -The `/analytics` page presents contributor analytics alongside countme metrics. -Data is generated by `scripts/fetch-contributor-metrics.js` and rendered via -`src/components/analytics/ContributorMetricsCharts.tsx`. When maintaining or -updating contributor analytics content and charts, follow these conventions: - -- **In-flight month refetching window:** Contributor data pipelines must refetch - the current in-flight month AND the immediately preceding calendar month. - Month-end tails can have landing events delayed by timezone differences or - late pushes; freezing too early cuts off true activity. A calendar month is - only frozen after it has completely elapsed and no pagination truncation occurred. -- **Tier-1 scope for activity streams:** Activity streams (Code & Builds, Code Reviews, - Issues & Triage, and Documentation) track the 9 tier-1 core repositories on a - shared domain. Do not dilute activity stream metrics across low-velocity forks - or peripheral repos. -- **Bot and autonomous agent exclusion patterns:** Machine automation must not - be counted as human contribution. Pipelines strictly filter bot patterns - (`[bot]`, `kubestellar-hive`, `web-flow`, `github-actions`) and autonomous AI - agents. Where automation PRs or workflows are reported, keep them in distinct - channels separate from human contributor counts. -- **Truncation guards over zero-coercion:** If GitHub API pagination or rate - limits are tripped during metrics harvesting, the affected stream must record - `null` (rendering as unavailable / accumulating data) rather than coercing - to `0` or publishing a misleading partial undercount. +`/analytics` reads the tracked schema-2 snapshot generated by +`scripts/fetch-contributor-metrics.js`. The source is public GitHub resource +history, not a leaderboard, monthly report snapshots, or hosted Hive. + +- **Count account unions, not events or per-repo sums.** The total is the union + of stable GitHub account IDs across every discovered public repository. Include + archived repositories and native activity in forks; exclude imported fork git + history. Keep the identity records in the dataset so the total is auditable. +- **Recognize non-code work.** Issue authors, commenters, reviewers and discussion + participants count alongside change authors. Documentation uses the same change + pipeline as code; a repository name does not establish that every change is docs. +- **Use one source contract end to end.** Summary values, cumulative history, + monthly participation, offline render fixtures, tables and chart options must + come from the same schema. Do not synthesize missing fields, add a ratio fallback, + or advertise a series with no producer. +- **Make identity limits visible.** Exclude canonical known-bot patterns and bot + account types, not guessed employers or profile bios. An ordinary account may + use AI. Deleted or unlinked identities are not invented people. +- **Require complete pagination.** Follow all repository, resource and nested + comment/review/reply cursors. A failed page invalidates the refreshed snapshot; + keep the last good history and `generatedAt` with visible unavailability. +- **Do not sum overlapping participation.** Each contribution-type figure is a + distinct-account count; one account may appear in several types. Monthly active + counts are not cumulative growth. The cumulative curve uses first observed + dates from full surviving public history, including pre-org repository history. +- **Verify the actual surface.** Check both themes and narrow layouts, a verified + zero, missing data and too few points. `EChart` owns the accessible numbers table + and accumulating state; component tests should exercise it, not a mocked echo. ### CountMe: first-party stream counts only diff --git a/docs/skills/github-api-client.md b/docs/skills/github-api-client.md index daee3df90..9cef2222e 100644 --- a/docs/skills/github-api-client.md +++ b/docs/skills/github-api-client.md @@ -119,6 +119,45 @@ instead of restating a token read or a header object. If truncation or rate limiting is tripped, record `null` for the affected stream rather than coercing to 0 or publishing a partial undercount. +14. **Contributor totals require a complete account union, not search counts.** + `fetch-contributor-metrics.js` discovers every current public organization + repository, including archived repositories and forks. It traverses direct + GraphQL resource connections and every nested comment, review and discussion + reply cursor. Connection totals, unique resource IDs and cursor progress must + agree before a scan is complete. A failed or missing page invalidates the + whole new scan; a partial result cannot become an all-time total. + Compare discovered repository coverage with the org's REST `public_repos` + control total, not a curated list or token-filtered subset. A live connection + may append resources during pagination: traverse issues and PRs oldest-first, + require all collected raw IDs to match the final advertised total, and apply + the snapshot timestamp cutoff to participation. Do not reject a complete scan + solely because a total grew, or accept a missing final page. Log only safe + source operation names and fixed failure codes, never upstream error bodies. + Repository-wide REST inline review comments include replies on old PRs; + paginated REST default-branch commits count linked authors on non-merge + commits only, and skip fork git history while retaining fork-native resources. + Deduplicate accounts by public node ID, filter the canonical + `contributor-tracker.mjs` bot policy plus GitHub's Bot type, and count missing + or ghost identities separately instead of inventing account IDs. + This collector rescans recorded history: the incremental freeze rule above + does not apply, and source corrections may change earlier month counts. + Keep nested page sizes modest and exhaust their continuations instead of + multiplying large parent/comment/review connections. A live full scan hit + GitHub's secondary limit despite unused primary quota. Respect the server's + `Retry-After` through a shared worker cooldown; stop further requests after an + unrecoverable HTTP failure. Offline tests must cover both recovery and bounded + failure retaining the last-good snapshot. + + Run `node scripts/fetch-contributor-metrics.js` with `GITHUB_TOKEN` or + `GH_TOKEN`; `--force` bypasses its 24-hour `seed-cache.js` generatedAt TTL. + Successful scans print request counts and GraphQL cost for real budget + measurement. Only verified scans advance generatedAt. Identical serialized + output is not rewritten. Failures preserve schema2 history, coverage, + identity records and their old timestamp, with a fixed public reason; + schema1 counts are never a last-good fallback. The offline fixture in + `fetch-contributor-metrics.test.js` drives the actual injectable transport + through two-page nested connections and dropped-page failures. + ## Common Rationalizations - _"It's one line, inlining the token read is clearer than importing a helper."_ @@ -176,5 +215,7 @@ instead of restating a token read or a header object. - `AGENTS.md` → _Data pipelines_ — the never-fail-the-build and null-vs-zero rules restated in steps 6 and 8. - `eslint.config.mjs` — the `files` glob that excludes `.mjs`. +- GitHub GraphQL documentation (`/websites/github_en_graphql` in Context7): + direct node lookup, discussions pagination, submitted review fields and states. - `adr/0003-factory-two-level-navigation.md` — authorizes the factory layer's shared client. diff --git a/scripts/contributor-charts.test.js b/scripts/contributor-charts.test.js index 704351aa6..5c07772da 100644 --- a/scripts/contributor-charts.test.js +++ b/scripts/contributor-charts.test.js @@ -5,290 +5,199 @@ const React = require("react"); const { renderToStaticMarkup } = require("react-dom/server"); const { loadTsxModule } = require("./lib/load-tsx"); -const TSX_PATH = path.join( - __dirname, - "..", - "src", - "components", - "analytics", - "ContributorMetricsCharts.tsx", -); - -function loadComponent() { - return loadTsxModule(TSX_PATH, (id) => { +const root = path.join(__dirname, "..", "src", "components"); +const css = (id) => (id.endsWith(".css") ? {} : undefined); +const EChart = loadTsxModule( + path.join(root, "factory", "EChart.tsx"), + css, +).default; +const charts = []; +const ContributorMetricsCharts = loadTsxModule( + path.join(root, "analytics", "ContributorMetricsCharts.tsx"), + (id) => { if (id.endsWith(".css")) return {}; - if (id === "react") return React; - if (id === "@theme/Heading") { + if (id === "@theme/Heading") return { __esModule: true, - default: ({ as: Tag = "h3", children, ...rest }) => - React.createElement(Tag, rest, children), + default: ({ as: Tag = "h3", children, ...props }) => + React.createElement(Tag, props, children), }; - } - if (id.includes("EChart")) { + if (id === "@site/static/data/contributor-metrics.json") + return { unavailable: true }; + if (id === "../factory/EChart") return { __esModule: true, - default: (props) => - React.createElement( - "div", - { - "data-testid": "echart", - "data-title": props.title, - "data-summary": props.summary, - "data-points": String(props.points), - "data-min-points": String(props.minPoints), - "data-option": JSON.stringify(props.option), - }, - props.points < (props.minPoints ?? 2) - ? "accumulating data" - : props.summary, - props.tableCaption - ? React.createElement( - "details", - null, - React.createElement("summary", null, "Show the numbers"), - React.createElement( - "table", - null, - React.createElement("caption", null, props.tableCaption), - ), - ) - : null, - ), + default: (props) => { + charts.push(props); + return React.createElement(EChart, props); + }, }; - } - if (id.includes("Unavailable")) { - return { - __esModule: true, - default: (props) => - React.createElement( - "div", - { - "data-testid": "unavailable", - "data-what": props.what, - "data-reason": props.reason, - }, - `${props.what} unavailable: ${props.reason}`, - ), - }; - } return undefined; + }, +).default; + +let collectedFixture; +test.before(async () => { + const { aggregateContributions } = require("./fetch-contributor-metrics"); + const event = (id, kind, at) => ({ + repositoryId: "R1", + kind, + at, + author: { id, login: `account-${id}`, __typename: "User" }, }); -} - -const mod = loadComponent(); -const ContributorMetricsCharts = mod.default; - -const FULL_DATA_FIXTURE = { - schemaVersion: 1, - generatedAt: "2026-10-24T00:00:00.000Z", - source: "GitHub GraphQL & PR Events", - org: "projectbluefin", - unavailable: false, - stateReason: null, - months: [ - { - month: "2026-07", - activeContributors: 11, - firstTimeContributors: 3, - streams: { - humanPrs: 126, - automationPrs: 427, - codeBuilds: 126, - codeReviews: 45, - issuesTriage: 80, - documentation: 15, - prsOpened: 150, - prsMerged: 126, - medianDaysToMerge: 2.5, - medianResponseHours: 18, - mergeRatePercent: 84, - }, - cohorts: { - new: 3, - returning: 8, - }, - }, - { - month: "2026-08", - activeContributors: 15, - firstTimeContributors: 4, - streams: { - humanPrs: 258, - automationPrs: 283, - codeBuilds: 258, - codeReviews: 70, - issuesTriage: 110, - documentation: 25, - prsOpened: 270, - prsMerged: 258, - medianDaysToMerge: 1.8, - medianResponseHours: 12, - mergeRatePercent: 95, - }, - cohorts: { - new: 4, - returning: 11, - }, - }, - { - month: "2026-09", - activeContributors: 18, - firstTimeContributors: 5, - streams: { - humanPrs: 290, - automationPrs: 310, - codeBuilds: 290, - codeReviews: 85, - issuesTriage: 130, - documentation: 30, - prsOpened: 310, - prsMerged: 290, - medianDaysToMerge: 1.5, - medianResponseHours: 10, - mergeRatePercent: 93, - }, - cohorts: { - new: 5, - returning: 13, - }, - }, - ], -}; - -const ACCUMULATING_DATA_FIXTURE = { - schemaVersion: 1, - generatedAt: "2026-10-24T00:00:00.000Z", - source: "GitHub GraphQL & PR Events", - org: "projectbluefin", - unavailable: false, - stateReason: null, - months: [ - { - month: "2026-07", - activeContributors: 11, - firstTimeContributors: 3, - streams: { - humanPrs: 126, - automationPrs: 427, - issues: null, - reviews: null, - }, - cohorts: { - new: 3, - returning: 8, - }, - }, - { - month: "2026-08", - activeContributors: 15, - firstTimeContributors: 4, - streams: { - humanPrs: 258, - automationPrs: 283, - issues: null, - reviews: null, - }, - cohorts: { - new: 4, - returning: 11, - }, - }, - ], -}; + collectedFixture = await aggregateContributions({ + repositories: [ + { id: "R1", name: "documentation", fork: false, archived: false }, + ], + asOf: "2026-10-24T00:00:00.000Z", + complete: true, + events: [ + event("A", "changes", "2026-07-01T00:00:00Z"), + event("C", "changes", "2026-07-02T00:00:00Z"), + event("B", "issues", "2026-09-20T00:00:00Z"), + event("B", "comments", "2026-09-20T00:00:00Z"), + event("A", "changes", "2026-10-01T00:00:00Z"), + event("A", "comments", "2026-10-01T00:00:00Z"), + ], + }); +}); -const UNAVAILABLE_DATA_FIXTURE = { - schemaVersion: 1, - generatedAt: "2026-10-24T00:00:00.000Z", - source: "GitHub GraphQL & PR Events", - org: "projectbluefin", - unavailable: true, - stateReason: "Rate limit exceeded or GITHUB_TOKEN missing", - months: [], -}; +function fixture() { + return structuredClone(collectedFixture); +} -test("renders full contributor analytics metrics and charts", () => { - const html = renderToStaticMarkup( - React.createElement(ContributorMetricsCharts, { - data: FULL_DATA_FIXTURE, - }), +function render(data) { + charts.length = 0; + return renderToStaticMarkup( + React.createElement(ContributorMetricsCharts, { data }), ); +} - // 4 summary metric cards - assert.ok(html.includes("Monthly Active Humans")); - assert.ok(html.includes("First-Time Humans")); - assert.ok(html.includes("PR Merge Rate")); - assert.ok(html.includes("Human Merged PRs")); - - // Check numeric values rendered prominently - assert.ok(html.includes("18")); // Latest active humans (2026-09) - assert.ok(html.includes("5")); // Latest first-time humans (2026-09) - assert.ok(html.includes("93%")); // Latest merge rate (2026-09) - assert.ok(html.includes("290")); // Latest human merged PRs (2026-09) - assert.ok(html.includes("Active Human Contributors Trend")); - assert.ok(html.includes("Contribution Activity Streams")); - assert.ok( - html.includes("PR Velocity & Merge Rate") || - html.includes("PR Velocity & Merge Rate"), - ); +function assertChartCounts() { + assert.equal(charts.length, 2); + for (const chart of charts) { + assert.equal(chart.option.series.length, 1, "one real series per chart"); + const series = chart.option.series[0]; + assert.equal( + chart.points, + series.data.filter((v) => typeof v === "number" && Number.isFinite(v)) + .length, + ); + assert.equal(series.smooth, false); + assert.equal(series.connectNulls, false); + assert.equal(series.type, "line"); + assert.ok(series.areaStyle); + assert.equal(series.stack, undefined); + assert.equal(chart.option.yAxis.min, 0); + } +} - // Details summary tables - assert.ok(html.includes("
")); - assert.ok(html.includes("Show the numbers")); - assert.ok( - html.includes("Monthly breakdown of new vs returning human contributors"), - ); - assert.ok( - html.includes( - "Monthly activity breakdown by contribution stream across projectbluefin", - ), - ); - assert.ok( - html.includes( - "Monthly pull request velocity and merge efficiency across projectbluefin", - ), - ); +test("schema2 total is independently collected, not monthly activity summed; actual EChart tables expose the data", () => { + const data = fixture(); + const html = render(data); + assert.match(html, /Total contributors<\/span>]*>3<\/strong>/); + assert.match(html, /Active in last 90 days<\/span>]*>2<\/strong>/); + assert.equal((html.match(/
2026-08<\/th>0<\/td>/); + assert.deepEqual(charts[0].option.series[0].data, [2, 2, 3, 3]); + assert.deepEqual(charts[1].option.series[0].data, [2, 0, 1, 1]); + assertChartCounts(); }); -test("renders Unavailable when dataset is marked unavailable", () => { - const html = renderToStaticMarkup( - React.createElement(ContributorMetricsCharts, { - data: UNAVAILABLE_DATA_FIXTURE, - }), +test("null counts remain gaps, zero stays measured, and each chart counts only its own finite points", () => { + const data = fixture(); + data.summary.activeContributors90d = null; + data.summary.byType90d.reviews = null; + data.months[1].cumulativeContributors = null; + data.months[2].contributors = null; + data.months[3].contributors = null; + const html = render(data); + assert.match(html, /Active in last 90 days<\/span>]*>—<\/strong>/); + assert.match(html, /Count unavailable in this snapshot/); + assert.match( + html, + /Reviews<\/th>— · Count unavailable<\/td>/, ); - - assert.ok(html.includes('data-testid="unavailable"')); - assert.ok(html.includes("Contributor metrics unavailable")); - assert.ok(html.includes("Rate limit exceeded or GITHUB_TOKEN missing")); - assert.ok(!html.includes("Monthly Active Humans")); + assert.deepEqual(charts[0].option.series[0].data, [2, null, 3, 3]); + assert.deepEqual(charts[1].option.series[0].data, [2, 0, null, null]); + assert.equal(charts[0].points, 3); + assert.equal(charts[1].points, 2); + assert.match(charts[1].summary, /^— accounts · 2026-10/); + assertChartCounts(); }); -test("renders accumulating data when points count is under 3", () => { - const html = renderToStaticMarkup( - React.createElement(ContributorMetricsCharts, { - data: ACCUMULATING_DATA_FIXTURE, - }), - ); +test("zero and unknown datasets do not disappear or pretend to have chart points", () => { + const data = fixture(); + data.contributors = []; + data.summary.totalContributors = 0; + data.summary.activeContributors90d = 0; + data.summary.activeRepositories90d = 0; + data.months = [ + { month: "2026-10", contributors: 0, cumulativeContributors: null }, + ]; + const html = render(data); + assert.match(html, /Total contributors<\/span>]*>0<\/strong>/); + assert.match(html, /accumulating data/); + assert.equal(charts[0].points, 0); + assert.equal(charts[1].points, 1); + assertChartCounts(); + data.months = []; + const empty = render(data); + assert.equal((empty.match(/accumulating data/g) || []).length, 2); + assertChartCounts(); +}); - // Still renders summary metric cards - assert.ok(html.includes("Monthly Active Humans")); - // ECharts receive points=2 and minPoints=3, rendering accumulating data - assert.ok(html.includes("accumulating data")); - assert.ok(html.includes('data-min-points="3"')); - assert.ok(html.includes('data-points="2"')); +test("missing, deprecated, malformed, or non-finite data is visibly unavailable", () => { + const malformed = fixture(); + malformed.months[0].contributors = Infinity; + const omitted = fixture(); + delete omitted.summary.byType90d.comments; + const discontinuous = fixture(); + discontinuous.months.splice(1, 1); + for (const data of [ + undefined, + null, + {}, + { schemaVersion: 1, months: [] }, + malformed, + omitted, + discontinuous, + ]) { + const html = render(data); + assert.match(html, /role="status"/); + assert.match(html, /Contributor metrics unavailable/); + assert.match(html, /schema version 2/); + assert.equal(charts.length, 0); + } }); -test("deterministic SSR output across multiple renders", () => { - const render1 = renderToStaticMarkup( - React.createElement(ContributorMetricsCharts, { - data: FULL_DATA_FIXTURE, - }), - ); - const render2 = renderToStaticMarkup( - React.createElement(ContributorMetricsCharts, { - data: FULL_DATA_FIXTURE, - }), - ); +test("failed refresh, incomplete coverage, and future snapshots show a visible retained-snapshot warning", () => { + for (const patch of [ + { unavailable: true, stateReason: "GitHub collection could not complete." }, + { coverage: { ...fixture().coverage, complete: false } }, + { generatedAt: "2999-01-01T00:00:00Z" }, + { + months: [ + { month: "2999-01", contributors: 1, cumulativeContributors: 3 }, + ], + }, + ]) { + const html = render({ ...fixture(), ...patch }); + assert.match(html, /Current contributor metrics unavailable/); + assert.match(html, /retained snapshot, not a successful current refresh/); + assert.match(html, /role="status"/); + assertChartCounts(); + } +}); - assert.equal(render1, render2, "SSR output must be strictly deterministic"); +test("coverage and refresh are accessible and SSR output deterministic", () => { + const data = fixture(); + const html = render(data); + assert.match(html, /