diff --git a/.github/a11y-community.json b/.github/a11y-community.json new file mode 100644 index 00000000..4da540d6 --- /dev/null +++ b/.github/a11y-community.json @@ -0,0 +1,9 @@ +{ + "projectName": "GIT Going with GitHub", + "accessibilityLabel": "accessibility", + "installDate": "2026-10-29", + "owner": "accesswatch", + "accessibilityDoc": "https://github.com/Community-Access/git-going-with-github/blob/main/ACCESSIBILITY.md", + "responseBusinessDays": 5, + "creditReminderDays": 14 +} diff --git a/.github/scripts/__tests__/a11y-community.test.mjs b/.github/scripts/__tests__/a11y-community.test.mjs new file mode 100644 index 00000000..ae05ebf5 --- /dev/null +++ b/.github/scripts/__tests__/a11y-community.test.mjs @@ -0,0 +1,472 @@ +// Tests for a11y-community.mjs against a fake GitHub client. Run with +// node --test .github/scripts/__tests__/a11y-community.test.mjs +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + LABELS, MARKERS, addBusinessDays, closingReferences, collectCredits, formatCredits, + formatWarnings, handleEvent, makeClient, normalizeConfig, parseCreditReply, resolveSince, +} from '../a11y-community.mjs'; + +const config = normalizeConfig({ + projectName: 'Example', + installDate: '2026-10-29', + owner: 'accesswatch', + accessibilityDoc: 'https://github.com/example/example/blob/main/ACCESSIBILITY.md', +}); +const BOT = { login: 'github-actions[bot]', type: 'Bot' }; +const REPORTER = { login: 'reporter', type: 'User' }; +const MAINTAINER = { login: 'maintainer', type: 'User' }; +const STRANGER = { login: 'stranger', type: 'User' }; + +class FakeClient { + constructor() { + this.items = new Map(); + this.comments = new Map(); + this.timelines = new Map(); + this.calls = []; + this.nextId = 1000; + this.clock = '2026-11-02T12:00:00Z'; + } + addItem(item) { + const full = { + state: 'open', labels: [], author_association: 'NONE', created_at: '2026-11-02T10:00:00Z', user: REPORTER, title: `Item ${item.number}`, ...item, + }; + full.labels = full.labels.map((l) => (typeof l === 'string' ? { name: l } : l)); + this.items.set(full.number, full); + this.comments.set(full.number, []); + this.timelines.set(full.number, []); + return full; + } + say(number, user, body, association = 'NONE', created_at = this.clock) { + const c = { id: this.nextId++, user, body, author_association: association, created_at }; + this.comments.get(number).push(c); + return c; + } + bodies(number) { return this.comments.get(number).map((c) => c.body); } + labels(number) { return this.items.get(number).labels.map((l) => l.name).sort(); } + async getIssue(n) { + if (!this.items.has(n)) { const e = new Error('Not Found'); e.status = 404; throw e; } + return this.items.get(n); + } + async listComments(n) { return [...(this.comments.get(n) || [])]; } + async listTimeline(n) { return [...(this.timelines.get(n) || [])]; } + async listIssues({ labels, state }) { + return [...this.items.values()].filter((i) => (state === 'all' || i.state === state) + && i.labels.some((l) => l.name === labels)); + } + async createComment(n, body) { this.calls.push(['comment', n]); this.say(n, BOT, body, 'NONE'); } + async addLabels(n, labels) { + this.calls.push(['addLabels', n, labels]); + const item = this.items.get(n); + for (const name of labels) if (!item.labels.some((l) => l.name === name)) item.labels.push({ name }); + } + async removeLabel(n, name) { + this.calls.push(['removeLabel', n, name]); + const item = this.items.get(n); + item.labels = item.labels.filter((l) => l.name !== name); + } + async reopen(n) { this.calls.push(['reopen', n]); this.items.get(n).state = 'open'; } + async refDate(ref) { return ref === 'v1.0.0' ? '2026-11-01T00:00:00Z' : null; } +} + +const report = (client, extra = {}) => client.addItem({ number: 1, labels: ['accessibility'], ...extra }); +const issuesEvent = (client, action, number, extra = {}) => ({ + eventName: 'issues', payload: { action, issue: client.items.get(number), sender: REPORTER, ...extra }, +}); +const run = (client, rule, event, now) => handleEvent({ + rule, client, config, fullName: 'example/example', now, ...event, +}); +const comments = (client, number, marker) => client.bodies(number).filter((b) => b.includes(marker)); + +// ---------------------------------------------------------------- acknowledge + +test('a new report is acknowledged once and labelled awaiting response', async () => { + const client = new FakeClient(); + report(client); + await run(client, 'reports', issuesEvent(client, 'opened', 1)); + await run(client, 'reports', issuesEvent(client, 'labeled', 1, { label: { name: 'accessibility' } })); + const acks = comments(client, 1, MARKERS.ack); + assert.equal(acks.length, 1); + assert.match(acks[0], /within 5 business days with a status and a next step/); + assert.match(acks[0], /ACCESSIBILITY\.md#known-limitations/); + assert.match(acks[0], /assistive technology and its version, if you use one and choose to share it/); + assert.doesNotMatch(acks[0], /disabilit|diagnos/i); + assert.deepEqual(client.labels(1), ['a11y: awaiting response', 'accessibility']); +}); + +test('labelling an existing issue accessibility acknowledges it the first time', async () => { + const client = new FakeClient(); + client.addItem({ number: 1, labels: [] }); + await run(client, 'reports', issuesEvent(client, 'labeled', 1, { label: { name: 'bug' } })); + assert.equal(client.calls.length, 0); + client.items.get(1).labels.push({ name: 'accessibility' }); + await run(client, 'reports', issuesEvent(client, 'labeled', 1, { label: { name: 'accessibility' }, sender: MAINTAINER })); + assert.equal(comments(client, 1, MARKERS.ack).length, 1); +}); + +test('maintainers, bots, pull requests and issues from before the install date are not reports', async () => { + const client = new FakeClient(); + client.addItem({ number: 1, labels: ['accessibility'], author_association: 'MEMBER', user: MAINTAINER }); + client.addItem({ number: 2, labels: ['accessibility'], user: BOT }); + client.addItem({ number: 3, labels: ['accessibility'], pull_request: {} }); + client.addItem({ number: 4, labels: ['accessibility'], created_at: '2026-10-28T23:59:59Z' }); + for (const n of [1, 2, 3, 4]) await run(client, 'reports', issuesEvent(client, 'opened', n)); + assert.deepEqual(client.calls, []); +}); + +// ---------------------------------------------------------------- clear + +test('a maintainer comment clears awaiting response and response overdue', async () => { + const client = new FakeClient(); + report(client, { labels: ['accessibility', LABELS.awaiting, LABELS.overdue] }); + const event = (sender) => ({ eventName: 'issue_comment', payload: { action: 'created', issue: client.items.get(1), sender } }); + client.say(1, REPORTER, 'more detail'); + await run(client, 'reports', event(REPORTER)); + assert.deepEqual(client.calls, [], 'a reporter comment changes nothing'); + client.say(1, MAINTAINER, 'Thanks, triaging now. Severity: serious.', 'COLLABORATOR'); + await run(client, 'reports', event(MAINTAINER)); + assert.deepEqual(client.labels(1), ['accessibility']); +}); + +// ---------------------------------------------------------------- overdue + +test('business days skip weekends', () => { + // Monday 10:00 -> next Monday 10:00 + assert.equal(addBusinessDays('2026-11-02T10:00:00Z', 5).toISOString(), '2026-11-09T10:00:00.000Z'); + // Friday 16:00 -> next Friday 16:00 + assert.equal(addBusinessDays('2026-11-06T16:00:00Z', 5).toISOString(), '2026-11-13T16:00:00.000Z'); + // Saturday counts from Monday 00:00 -> the Monday after + assert.equal(addBusinessDays('2026-11-07T10:00:00Z', 5).toISOString(), '2026-11-16T00:00:00.000Z'); +}); + +test('the daily sweep marks a report overdue after 5 business days, once, mentioning the owner', async () => { + const client = new FakeClient(); + report(client, { labels: ['accessibility', LABELS.awaiting] }); + client.say(1, BOT, `${MARKERS.ack}\nThanks`, 'NONE', '2026-11-02T10:00:00Z'); + const sweep = (now) => run(client, 'reports', { eventName: 'schedule', payload: {} }, new Date(now)); + await sweep('2026-11-06T23:00:00Z'); // Friday: 4 business days + await sweep('2026-11-08T23:00:00Z'); // Sunday: still 4 + assert.deepEqual(client.calls, []); + await sweep('2026-11-09T10:00:00Z'); // Monday 10:00: 5 + await sweep('2026-11-10T10:00:00Z'); + const reminders = comments(client, 1, MARKERS.overdue); + assert.equal(reminders.length, 1); + assert.match(reminders[0], /@accesswatch/); + assert.ok(client.labels(1).includes(LABELS.overdue)); +}); + +test('the sweep leaves old issues alone and heals a missed maintainer reply', async () => { + const client = new FakeClient(); + client.addItem({ number: 1, labels: ['accessibility', LABELS.awaiting], created_at: '2026-09-01T00:00:00Z' }); + client.addItem({ number: 2, labels: ['accessibility', LABELS.awaiting], created_at: '2026-10-30T00:00:00Z' }); + client.say(2, MAINTAINER, 'On it', 'OWNER'); + await run(client, 'reports', { eventName: 'schedule', payload: {} }, new Date('2026-12-01T00:00:00Z')); + assert.deepEqual(client.labels(1), ['a11y: awaiting response', 'accessibility']); + assert.deepEqual(client.labels(2), ['accessibility']); + assert.equal(client.bodies(1).length, 0); +}); + +// ---------------------------------------------------------------- close gate + +const closed = (client, reason = 'completed', sender = MAINTAINER) => { + const issue = client.items.get(1); + issue.state = 'closed'; + issue.state_reason = reason; + client.timelines.get(1).push({ event: 'closed', commit_id: null }); + return { eventName: 'issues', payload: { action: 'closed', issue, sender } }; +}; + +test('closing a report without an outcome reopens it and explains once', async () => { + const client = new FakeClient(); + report(client, { labels: ['accessibility', LABELS.awaiting] }); + await run(client, 'reports', closed(client)); + await run(client, 'reports', closed(client)); + assert.equal(client.items.get(1).state, 'open'); + assert.equal(client.calls.filter((c) => c[0] === 'reopen').length, 2); + const gate = comments(client, 1, MARKERS.outcomeNeeded); + assert.equal(gate.length, 1); + assert.match(gate[0], /starts with `Outcome:`/); + assert.match(gate[0], /Fixes #1/); +}); + +test('an Outcome line before the reporter last spoke does not count', async () => { + const client = new FakeClient(); + report(client); + client.say(1, MAINTAINER, 'Outcome: fixed in 1.2', 'MEMBER', '2026-11-03T10:00:00Z'); + client.say(1, REPORTER, 'Still broken for me', 'NONE', '2026-11-04T10:00:00Z'); + await run(client, 'reports', closed(client)); + assert.equal(client.items.get(1).state, 'open'); + client.say(1, MAINTAINER, 'Thanks.\n\n**Outcome:** the real fix shipped in 1.3.', 'MEMBER', '2026-11-05T10:00:00Z'); + await run(client, 'reports', closed(client)); + assert.equal(client.items.get(1).state, 'closed'); +}); + +test('a non-maintainer Outcome line does not count', async () => { + const client = new FakeClient(); + report(client); + client.say(1, STRANGER, 'Outcome: works now', 'CONTRIBUTOR'); + await run(client, 'reports', closed(client)); + assert.equal(client.items.get(1).state, 'open'); +}); + +test('a close by commit counts as an outcome and invites validation once', async () => { + const client = new FakeClient(); + report(client, { labels: ['accessibility', LABELS.awaiting] }); + const event = closed(client); + client.timelines.get(1).at(-1).commit_id = 'abcdef1234567'; + await run(client, 'reports', event); + await run(client, 'reports', event); + assert.equal(client.items.get(1).state, 'closed'); + const invites = comments(client, 1, MARKERS.validation); + assert.equal(invites.length, 1); + assert.match(invites[0], /@reporter/); + assert.match(invites[0], /Replying here reopens the conversation/); + assert.deepEqual(client.labels(1), ['a11y: validation requested', 'accessibility']); +}); + +test('a cross-referenced merged pull request counts as an outcome', async () => { + const client = new FakeClient(); + report(client); + client.timelines.get(1).push({ event: 'cross-referenced', source: { issue: { number: 7, pull_request: { merged_at: '2026-11-03T00:00:00Z' } } } }); + await run(client, 'reports', closed(client)); + assert.equal(client.items.get(1).state, 'closed'); + assert.equal(comments(client, 1, MARKERS.validation).length, 1); +}); + +test('closed as not planned or duplicate needs the outcome but gets no validation invite', async () => { + for (const reason of ['not_planned', 'duplicate']) { + const client = new FakeClient(); + report(client); + await run(client, 'reports', closed(client, reason)); + assert.equal(client.items.get(1).state, 'open', reason); + client.say(1, MAINTAINER, 'Outcome: duplicate of #2, tracked there.', 'OWNER', '2026-11-03T00:00:00Z'); + await run(client, 'reports', closed(client, reason)); + assert.equal(client.items.get(1).state, 'closed', reason); + assert.equal(comments(client, 1, MARKERS.validation).length, 0, reason); + } +}); + +test('events from a bot are skipped, so the gate never loops', async () => { + const client = new FakeClient(); + report(client); + const log = await run(client, 'reports', closed(client, 'completed', BOT)); + assert.deepEqual(client.calls, []); + assert.match(log[0], /skipped/); + await run(client, 'reports', { eventName: 'issues', payload: { action: 'opened', issue: client.items.get(1), sender: BOT } }); + assert.deepEqual(client.calls, []); +}); + +// ---------------------------------------------------------------- credit ask + +const merged = (client, number, extra = {}) => { + const pull = client.addItem({ number, pull_request: {}, merged: true, user: STRANGER, author_association: 'CONTRIBUTOR', ...extra }); + return { eventName: 'pull_request_target', payload: { action: 'closed', pull_request: pull, sender: MAINTAINER } }; +}; + +test('a merged accessibility pull request from a contributor is asked once', async () => { + const client = new FakeClient(); + const event = merged(client, 5, { labels: ['accessibility'] }); + await run(client, 'credits', event); + await run(client, 'credits', event); + const asks = comments(client, 5, MARKERS.creditAsk); + assert.equal(asks.length, 1); + assert.match(asks[0], /`credit: @stranger`/); + assert.match(asks[0], /`credit: name `/); + assert.match(asks[0], /`credit: anonymous`/); + assert.match(asks[0], /`credit: no`/); + assert.match(asks[0], /nothing about you beyond the credit you choose/); + assert.doesNotMatch(asks[0], /disabilit|diagnos|screen reader/i); + assert.ok(client.labels(5).includes(LABELS.asked)); +}); + +test('a merged pull request that closes an accessibility issue is asked', async () => { + const client = new FakeClient(); + client.addItem({ number: 2, labels: ['accessibility'] }); + await run(client, 'credits', merged(client, 6, { body: 'This change\n\nFixes #2' })); + assert.equal(comments(client, 6, MARKERS.creditAsk).length, 1); + await run(client, 'credits', merged(client, 7, { body: 'Fixes other/repo#2' })); + await run(client, 'credits', merged(client, 8, { body: 'Mentions #2 only' })); + assert.equal(client.bodies(7).length + client.bodies(8).length, 0); +}); + +test('maintainer, bot and unmerged pull requests are not asked', async () => { + const client = new FakeClient(); + await run(client, 'credits', merged(client, 5, { labels: ['accessibility'], author_association: 'OWNER', user: MAINTAINER })); + await run(client, 'credits', merged(client, 6, { labels: ['accessibility'], user: { login: 'dependabot[bot]', type: 'Bot' } })); + await run(client, 'credits', merged(client, 7, { labels: ['accessibility'], merged: false })); + assert.deepEqual(client.calls, []); +}); + +test('a report closed as completed with an outcome asks the reporter; not planned does not', async () => { + const client = new FakeClient(); + report(client); + client.say(1, MAINTAINER, 'Outcome: fixed in 2.0', 'MEMBER', '2026-11-03T00:00:00Z'); + await run(client, 'credits', closed(client, 'not_planned')); + assert.equal(comments(client, 1, MARKERS.creditAsk).length, 0); + await run(client, 'credits', closed(client, 'completed')); + await run(client, 'credits', closed(client, 'completed')); + const asks = comments(client, 1, MARKERS.creditAsk); + assert.equal(asks.length, 1); + assert.match(asks[0], /@reporter/); +}); + +test('a report closed without an outcome is not asked yet', async () => { + const client = new FakeClient(); + report(client); + await run(client, 'credits', closed(client)); + assert.equal(client.bodies(1).length, 0); +}); + +// ---------------------------------------------------------------- credit replies + +async function askedReport() { + const client = new FakeClient(); + report(client, { state: 'closed', state_reason: 'completed', closed_at: '2026-11-03T00:00:00Z' }); + client.say(1, MAINTAINER, 'Outcome: fixed', 'MEMBER', '2026-11-03T00:00:00Z'); + client.timelines.get(1).push({ event: 'closed', commit_id: null }); + await run(client, 'credits', { eventName: 'issues', payload: { action: 'closed', issue: client.items.get(1), sender: MAINTAINER } }); + return client; +} +const reply = (client, user, body, association = 'NONE') => { + client.say(1, user, body, association); + return run(client, 'credits', { eventName: 'issue_comment', payload: { action: 'created', issue: client.items.get(1), sender: user } }); +}; +const confirmations = (client) => client.bodies(1).filter((b) => b.startsWith('/); + assert.match(confirmation, /credit you as Sam Lee/); +}); + +test('credit: anonymous and credit: no record their labels', async () => { + let client = await askedReport(); + await reply(client, REPORTER, 'credit: anonymous'); + assert.deepEqual(client.labels(1), ['accessibility', 'credit: anonymous']); + client = await askedReport(); + await reply(client, REPORTER, 'credit: no'); + assert.deepEqual(client.labels(1), ['accessibility', 'credit: no']); + assert.match(confirmations(client)[0], /will not name you/); +}); + +test('a reply from anyone but the contributor is ignored, even a maintainer', async () => { + const client = await askedReport(); + const before = client.calls.length; + await reply(client, STRANGER, 'credit: @stranger'); + await reply(client, MAINTAINER, 'credit: no', 'OWNER'); + assert.equal(client.calls.length, before); + assert.deepEqual(client.labels(1), ['accessibility', 'credit: asked']); +}); + +test('the same reply is confirmed once, and a later reply changes the choice', async () => { + const client = await askedReport(); + await reply(client, REPORTER, 'credit: @reporter'); + await reply(client, STRANGER, 'nice'); + assert.equal(confirmations(client).length, 1); + await reply(client, REPORTER, 'Actually, credit: no'); + assert.equal(confirmations(client).length, 1, 'credit: must start the line'); + await reply(client, REPORTER, 'Actually:\ncredit: no'); + assert.equal(confirmations(client).length, 2); + assert.deepEqual(client.labels(1), ['accessibility', 'credit: no']); +}); + +test('a forged confirmation line from a person is not trusted', async () => { + const client = await askedReport(); + client.say(1, STRANGER, ''); + await reply(client, REPORTER, 'credit: anonymous'); + assert.deepEqual(client.labels(1), ['accessibility', 'credit: anonymous']); +}); + +// ---------------------------------------------------------------- list + +test('closingReferences keeps same-repository closing keywords only', () => { + assert.deepEqual(closingReferences('Fixes #3, closes example/example#4, resolves other/x#5, see #6', 'example/example'), [3, 4]); +}); + +test('the credits list groups code and other work and warns about old questions', async () => { + const client = new FakeClient(); + const add = (number, label, extra = {}) => client.addItem({ number, labels: [label], state: 'closed', closed_at: '2026-11-10T00:00:00Z', title: `Work ${number}`, ...extra }); + add(10, LABELS.yes, { pull_request: {}, user: STRANGER, title: 'Label the *search* field' }); + client.say(10, BOT, '\nThanks'); + add(11, LABELS.yes); + client.say(11, BOT, '\nThanks'); + add(12, LABELS.anonymous, { pull_request: {} }); + add(13, LABELS.yes, { closed_at: '2026-10-01T00:00:00Z' }); + add(14, LABELS.no); + add(15, LABELS.asked, { title: 'Old question' }); + client.say(15, BOT, `${MARKERS.creditAsk}\nHow?`, 'NONE', '2026-11-01T00:00:00Z'); + add(16, LABELS.asked); + client.say(16, BOT, `${MARKERS.creditAsk}\nHow?`, 'NONE', '2026-11-20T00:00:00Z'); + + const since = await resolveSince(client, 'v1.0.0'); + assert.equal(since.toISOString(), '2026-11-01T00:00:00.000Z'); + assert.equal((await resolveSince(client, '2026-11-01')).toISOString(), '2026-11-01T00:00:00.000Z'); + const result = await collectCredits(client, config, since, new Date('2026-11-25T00:00:00Z')); + const block = formatCredits(result, config); + assert.equal(block, [ + '### Accessibility contributions', + '', + 'Thank you to everyone who helped make Example more accessible in this release.', + '', + 'Code contributions:', + '', + '- Label the \\*search\\* field (#10), by @stranger', + '- Work 12 (#12), by an anonymous contributor', + '', + 'Reports, testing, reviews and other contributions:', + '', + '- Work 11 (#11), by Sam Lee', + '', + ].join('\n')); + const warnings = formatWarnings(result, config); + assert.match(warnings, /1 credit question\(s\) unanswered for more than 14 days/); + assert.match(warnings, /#15 Old question \(asked 2026-11-01\)/); + assert.doesNotMatch(warnings, /#16/); + assert.equal(formatCredits({ credits: [] }, config), ''); +}); + +test('makeClient maps calls to REST routes and tolerates a missing label', async () => { + const seen = []; + const client = makeClient({ + request: async (route, params) => { + seen.push(route); + if (route.startsWith('DELETE')) { const e = new Error('gone'); e.status = 404; throw e; } + if (route.includes('/commits/')) return { commit: { committer: { date: '2026-11-01T00:00:00Z' } } }; + return {}; + }, + paginate: async (route) => { seen.push(route); return []; }, + }, { owner: 'o', repo: 'r' }); + await client.removeLabel(1, 'x'); + await client.reopen(1); + assert.equal(await client.refDate('v1'), '2026-11-01T00:00:00Z'); + await client.listTimeline(1); + assert.deepEqual(seen, [ + 'DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{name}', + 'PATCH /repos/{owner}/{repo}/issues/{issue_number}', + 'GET /repos/{owner}/{repo}/commits/{ref}', + 'GET /repos/{owner}/{repo}/issues/{issue_number}/timeline', + ]); +}); diff --git a/.github/scripts/a11y-community.mjs b/.github/scripts/a11y-community.mjs new file mode 100644 index 00000000..2ee8a956 --- /dev/null +++ b/.github/scripts/a11y-community.mjs @@ -0,0 +1,640 @@ +/** + * a11y-community.mjs - two enforced rules for Open Source Accessibility + * Framework, Community actions 2 and 4. + * + * Rule 1 (accessibility-reports.yml): every accessibility report gets an + * acknowledgement, a maintainer reply within 5 business days, and a documented + * outcome before it closes; a fixed report invites the reporter to confirm. + * + * Rule 2 (accessibility-credits.yml): every accessibility contribution from + * outside the maintainers gets one question about how, and whether, to credit + * it; the contributor's own answer is recorded; a release lists the credits. + * + * This file is identical in every repository that uses it. What differs per + * repository is in .github/a11y-community.json. It has no dependencies: the + * workflows run it through actions/github-script, the release script runs it + * with plain Node, and the tests run it against a fake client. + * + * Every handler re-reads the issue's comments rather than trusting one event, + * so an event dropped by the concurrency queue is repaired by the next one. + */ + +import { readFileSync } from 'node:fs'; + +export const LABELS = Object.freeze({ + awaiting: 'a11y: awaiting response', + overdue: 'a11y: response overdue', + validation: 'a11y: validation requested', + asked: 'credit: asked', + yes: 'credit: yes', + anonymous: 'credit: anonymous', + no: 'credit: no', +}); + +const CREDIT_LABELS = [LABELS.asked, LABELS.yes, LABELS.anonymous, LABELS.no]; + +/** Hidden markers that make every comment idempotent. */ +export const MARKERS = Object.freeze({ + ack: '', + overdue: '', + outcomeNeeded: '', + validation: '', + creditAsk: '', +}); + +const MAINTAINER_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); +const DEFAULT_BOT_LOGIN = 'github-actions[bot]'; +const DAY_MS = 24 * 60 * 60 * 1000; + +// ---------------------------------------------------------------- config + +export function loadConfig(path) { + return normalizeConfig(JSON.parse(readFileSync(path, 'utf8'))); +} + +export function normalizeConfig(raw) { + const config = { + accessibilityLabel: 'accessibility', + responseBusinessDays: 5, + creditReminderDays: 14, + botLogin: DEFAULT_BOT_LOGIN, + ...raw, + }; + for (const key of ['projectName', 'installDate', 'owner', 'accessibilityDoc']) { + if (!config[key]) throw new Error(`a11y-community.json is missing "${key}"`); + } + if (!/^\d{4}-\d{2}-\d{2}$/.test(config.installDate)) { + throw new Error('installDate must be YYYY-MM-DD'); + } + return config; +} + +// ---------------------------------------------------------------- helpers + +export function labelNames(item) { + return (item.labels || []).map((label) => (typeof label === 'string' ? label : label.name)); +} + +export function isBot(user) { + return !user || user.type === 'Bot' || /\[bot\]$/i.test(user.login || ''); +} + +export function isMaintainer(association) { + return MAINTAINER_ASSOCIATIONS.has(String(association || '').toUpperCase()); +} + +function isOurBot(user, config) { + return Boolean(user) && isBot(user) && user.login === config.botLogin; +} + +function hasMarker(comments, marker) { + return comments.some((comment) => (comment.body || '').includes(marker)); +} + +function maintainerComments(comments) { + return comments.filter((c) => !isBot(c.user) && isMaintainer(c.author_association)); +} + +/** + * An accessibility report: an issue (not a pull request) carrying the + * accessibility label, opened on or after the install date by someone who is + * neither a maintainer nor a bot. + */ +export function isReport(issue, config) { + if (!issue || issue.pull_request) return false; + if (!labelNames(issue).includes(config.accessibilityLabel)) return false; + if (isBot(issue.user) || isMaintainer(issue.author_association)) return false; + return new Date(issue.created_at) >= new Date(`${config.installDate}T00:00:00Z`); +} + +/** + * The moment `days` business days (Monday to Friday, UTC) have passed since + * `start`. A start on a weekend counts from the following Monday at 00:00. + */ +export function addBusinessDays(start, days) { + const d = new Date(start); + const weekend = (x) => x.getUTCDay() === 0 || x.getUTCDay() === 6; + if (weekend(d)) { + d.setUTCHours(0, 0, 0, 0); + while (weekend(d)) d.setUTCDate(d.getUTCDate() + 1); + } + let added = 0; + while (added < days) { + d.setUTCDate(d.getUTCDate() + 1); + if (!weekend(d)) added += 1; + } + return d; +} + +const OUTCOME_LINE = /^[ \t]*(?:[-*][ \t]+)?(?:\*\*|__)?outcome(?:\*\*|__)?[ \t]*:/im; + +/** + * Whether a closed report has a documented outcome: it was closed by a commit + * or a merged pull request, or a maintainer wrote an "Outcome:" line after the + * reporter's last comment. Returns a short description, or null. + */ +export async function findOutcome(client, issue, comments) { + const timeline = await client.listTimeline(issue.number); + const closes = timeline.filter((e) => e.event === 'closed'); + const lastClose = closes[closes.length - 1]; + if (lastClose && lastClose.commit_id) return `commit ${lastClose.commit_id.slice(0, 7)}`; + const merged = timeline.find( + (e) => e.event === 'cross-referenced' + && e.source && e.source.issue && e.source.issue.pull_request + && e.source.issue.pull_request.merged_at, + ); + if (merged) return `pull request #${merged.source.issue.number}`; + + const reporter = issue.user && issue.user.login; + const byReporter = comments.filter((c) => c.user && c.user.login === reporter); + const after = byReporter.length + ? new Date(byReporter[byReporter.length - 1].created_at) + : new Date(issue.created_at); + const outcome = maintainerComments(comments).find( + (c) => new Date(c.created_at) > after && OUTCOME_LINE.test(c.body || ''), + ); + return outcome ? 'an Outcome line' : null; +} + +// ---------------------------------------------------------------- wording + +function docLink(config, anchor) { + return `${config.accessibilityDoc}${anchor ? `#${anchor}` : ''}`; +} + +export function acknowledgementComment(issue, config) { + return [ + MARKERS.ack, + `Thank you for reporting this accessibility barrier in ${config.projectName}. Reports like this are expertise about the project, not complaints, and we are glad to have it.`, + '', + `A maintainer will reply within ${config.responseBusinessDays} business days with a status and a next step.`, + '', + `In the meantime, the [known limitations in our accessibility statement](${docLink(config, 'known-limitations')}) list workarounds for barriers we already know about.`, + '', + 'If you have not already, these details help us reproduce the barrier. All of them are optional:', + '', + '- the steps that lead to it;', + '- your operating system and its version;', + '- the browser or app you used, and its version;', + '- your assistive technology and its version, if you use one and choose to share it.', + '', + 'You never need to tell us anything about yourself beyond what helps reproduce the problem.', + ].join('\n'); +} + +export function overdueComment(issue, config) { + return [ + MARKERS.overdue, + `@${config.owner}, this accessibility report has had no reply from a maintainer for ${config.responseBusinessDays} business days. Please post a status and a next step, even if the next step is that it is not triaged yet.`, + '', + 'To the reporter: thank you for your patience. This reminder is for the maintainers, and you do not need to do anything.', + ].join('\n'); +} + +export function outcomeNeededComment(issue, config) { + return [ + MARKERS.outcomeNeeded, + 'This accessibility report was reopened automatically because it was closed without a documented outcome. An outcome lets the reporter, and anyone who meets the same barrier, see what happened.', + '', + 'Maintainers, to close it, do one of these:', + '', + `- close it from the pull request or commit that fixes it, for example with "Fixes #${issue.number}" in the pull request description; or`, + '- add a comment with a line that starts with `Outcome:` saying what changed and in which release, or why it cannot change and what to do instead, then close the issue again.', + '', + `The Outcome line must come after the reporter's most recent comment. How we respond to reports is in [our accessibility statement](${docLink(config, 'how-we-respond')}).`, + ].join('\n'); +} + +export function validationComment(issue) { + return [ + MARKERS.validation, + `@${issue.user.login}, thank you again for this report. It is closed as fixed, and the outcome is recorded above.`, + '', + 'When the fix is available to you, would you be willing to try it and tell us whether it removes the barrier for you? There is no obligation.', + '', + 'Replying here reopens the conversation: maintainers see replies on closed issues, and we will reopen this issue if the barrier is still there.', + ].join('\n'); +} + +export function creditAskComment(login) { + return [ + MARKERS.creditAsk, + `@${login}, thank you for this accessibility contribution. We credit accessibility work in our release notes, but only in the way you choose, or not at all.`, + '', + 'How would you like to be credited? Reply with one of these lines:', + '', + `- \`credit: @${login}\` to be credited by your GitHub handle;`, + '- `credit: name ` to be credited by a name you choose, for example `credit: name Sam Lee`;', + '- `credit: anonymous` to have your contribution listed without a name;', + '- `credit: no` to not be credited.', + '', + 'We will publish nothing about you beyond the credit you choose: release notes describe the work, never the person. Only your own reply counts, and you can change your answer later by replying with another credit line.', + ].join('\n'); +} + +function quoteAttr(value) { + return String(value).replace(/["<>]/g, '').replace(/-{2,}/g, '-'); +} + +export function creditConfirmationComment(login, choice, replyId) { + const attrs = [`choice="${choice.choice}"`]; + if (choice.handle) attrs.push(`handle="${quoteAttr(choice.handle)}"`); + if (choice.name) attrs.push(`name="${quoteAttr(choice.name)}"`); + attrs.push(`reply="${replyId}"`); + const line = ``; + const change = 'You can change this at any time by replying with another credit line.'; + const text = { + handle: `Thank you, @${login}. We will credit you as @${choice.handle} in the release notes.`, + name: `Thank you, @${login}. We will credit you as ${choice.name} in the release notes.`, + anonymous: `Thank you, @${login}. We will list this contribution in the release notes without your name or handle.`, + no: `Thank you, @${login}. We will not name you in the release notes. Your contribution is no less appreciated.`, + }[choice.choice]; + return [line, `${text} ${change}`].join('\n'); +} + +// ---------------------------------------------------------------- credit replies + +const HANDLE = /^@([A-Za-z0-9](?:[A-Za-z0-9-]{0,38}))$/; + +/** + * Read a contributor's reply. Returns {choice, handle?, name?} or null. + * Accepts `credit: @handle`, `credit: name ` (brackets optional), + * `credit: anonymous` and `credit: no`, on a line of its own, optionally in + * backticks. Quoted lines (starting with ">") are ignored. + */ +export function parseCreditReply(body) { + for (const raw of String(body || '').split(/\r?\n/)) { + const line = raw.trim().replace(/^`+|`+$/g, '').trim(); + const m = line.match(/^credit:\s*(.+)$/i); + if (!m) continue; + const value = m[1].trim().replace(/[.!]+$/, '').trim(); + const handle = value.match(HANDLE); + if (handle) return { choice: 'handle', handle: handle[1] }; + if (/^anonymous$/i.test(value)) return { choice: 'anonymous' }; + if (/^no$/i.test(value)) return { choice: 'no' }; + const name = value.match(/^name\s+(.+)$/i); + if (name) { + const clean = name[1] + .replace(/^<(.*)>$/, '$1') + .replace(/[<>"`]/g, '') + .replace(/-{2,}/g, '-') + .replace(/\s+/g, ' ') + .trim() + .slice(0, 80); + if (clean) return { choice: 'name', name: clean }; + } + } + return null; +} + +export function parseConfirmation(body) { + const m = String(body || '').match(//); + if (!m) return null; + const out = {}; + for (const [, key, value] of m[1].matchAll(/(\w+)="([^"]*)"/g)) out[key] = value; + return out; +} + +function creditLabelFor(choice) { + return { handle: LABELS.yes, name: LABELS.yes, anonymous: LABELS.anonymous, no: LABELS.no }[choice]; +} + +// ---------------------------------------------------------------- label edits + +async function setLabels(client, item, add, remove, log) { + const have = new Set(labelNames(item)); + for (const name of remove) { + if (have.has(name)) { + await client.removeLabel(item.number, name); + have.delete(name); + log.push(`removed label "${name}"`); + } + } + const missing = add.filter((name) => !have.has(name)); + if (missing.length) { + await client.addLabels(item.number, missing); + log.push(`added label ${missing.map((n) => `"${n}"`).join(', ')}`); + } + item.labels = [...have, ...missing].map((name) => ({ name })); +} + +async function comment(client, item, body, log, what) { + await client.createComment(item.number, body); + log.push(`commented: ${what}`); +} + +// ---------------------------------------------------------------- rule 1 + +export async function acknowledgeReport(client, issue, config, log = []) { + if (!isReport(issue, config) || issue.state === 'closed') return log; + const comments = await client.listComments(issue.number); + if (hasMarker(comments, MARKERS.ack)) return log; + await comment(client, issue, acknowledgementComment(issue, config), log, 'acknowledgement'); + if (maintainerComments(comments).length === 0) { + await setLabels(client, issue, [LABELS.awaiting], [], log); + } + return log; +} + +export async function clearIfAnswered(client, issue, config, log = []) { + if (!isReport(issue, config)) return log; + const names = labelNames(issue); + if (!names.includes(LABELS.awaiting) && !names.includes(LABELS.overdue)) return log; + const comments = await client.listComments(issue.number); + if (maintainerComments(comments).length === 0) return log; + await setLabels(client, issue, [], [LABELS.awaiting, LABELS.overdue], log); + return log; +} + +export async function sweepOverdue(client, config, now = new Date(), log = []) { + const open = await client.listIssues({ labels: LABELS.awaiting, state: 'open' }); + for (const issue of open) { + if (!isReport(issue, config)) continue; + if (labelNames(issue).includes(LABELS.overdue)) continue; + const comments = await client.listComments(issue.number); + const entry = []; + if (maintainerComments(comments).length) { + await setLabels(client, issue, [], [LABELS.awaiting], entry); + } else { + const ack = comments.find((c) => (c.body || '').includes(MARKERS.ack)); + const start = ack ? ack.created_at : issue.created_at; + if (now < addBusinessDays(start, config.responseBusinessDays)) continue; + await setLabels(client, issue, [LABELS.overdue], [], entry); + if (!hasMarker(comments, MARKERS.overdue)) { + await comment(client, issue, overdueComment(issue, config), entry, 'overdue reminder'); + } + } + log.push(...entry.map((e) => `#${issue.number}: ${e}`)); + } + return log; +} + +export async function gateClosedReport(client, issue, config, log = []) { + if (!isReport(issue, config)) return log; + const comments = await client.listComments(issue.number); + const outcome = await findOutcome(client, issue, comments); + if (!outcome) { + await client.reopen(issue.number); + issue.state = 'open'; + log.push('reopened: no documented outcome'); + if (!hasMarker(comments, MARKERS.outcomeNeeded)) { + await comment(client, issue, outcomeNeededComment(issue, config), log, 'outcome needed'); + } + return log; + } + log.push(`outcome found: ${outcome}`); + const completed = !issue.state_reason || issue.state_reason === 'completed'; + const add = []; + if (completed && !hasMarker(comments, MARKERS.validation)) { + await comment(client, issue, validationComment(issue), log, 'validation invite'); + add.push(LABELS.validation); + } + await setLabels(client, issue, add, [LABELS.awaiting, LABELS.overdue], log); + return log; +} + +// ---------------------------------------------------------------- rule 2 + +/** Same-repository issue numbers a pull request body says it closes. */ +export function closingReferences(body, fullName) { + const refs = new Set(); + const re = /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\b:?\s+(?:([\w.-]+\/[\w.-]+))?#(\d+)\b/gi; + for (const m of String(body || '').matchAll(re)) { + if (!m[1] || m[1].toLowerCase() === String(fullName).toLowerCase()) refs.add(Number(m[2])); + } + return [...refs]; +} + +async function alreadyAsked(client, item) { + if (labelNames(item).some((n) => CREDIT_LABELS.includes(n))) return true; + return hasMarker(await client.listComments(item.number), MARKERS.creditAsk); +} + +async function ask(client, item, log) { + if (await alreadyAsked(client, item)) return log; + await comment(client, item, creditAskComment(item.user.login), log, 'credit question'); + await setLabels(client, item, [LABELS.asked], [], log); + return log; +} + +export async function creditMergedPull(client, pull, config, fullName, log = []) { + if (!pull.merged) return log; + if (isBot(pull.user) || isMaintainer(pull.author_association)) return log; + let qualifies = labelNames(pull).includes(config.accessibilityLabel); + for (const number of qualifies ? [] : closingReferences(pull.body, fullName)) { + const linked = await client.getIssue(number).catch(() => null); + if (linked && !linked.pull_request && labelNames(linked).includes(config.accessibilityLabel)) { + qualifies = true; + break; + } + } + if (!qualifies) return log; + return ask(client, pull, log); +} + +export async function creditClosedReport(client, issue, config, log = []) { + if (!isReport(issue, config)) return log; + if (issue.state_reason && issue.state_reason !== 'completed') return log; + const comments = await client.listComments(issue.number); + if (!(await findOutcome(client, issue, comments))) return log; + return ask(client, issue, log); +} + +export async function recordCreditReply(client, item, config, log = []) { + if (!labelNames(item).some((n) => CREDIT_LABELS.includes(n))) return log; + const comments = await client.listComments(item.number); + const askAt = comments.findIndex( + (c) => isOurBot(c.user, config) && (c.body || '').includes(MARKERS.creditAsk), + ); + if (askAt === -1) return log; + const contributor = item.user.login; + let reply = null; + let lastConfirmed = null; + for (const c of comments.slice(askAt + 1)) { + if (isOurBot(c.user, config)) { + const confirmation = parseConfirmation(c.body); + if (confirmation) lastConfirmed = confirmation.reply; + } else if (c.user && c.user.login === contributor) { + const choice = parseCreditReply(c.body); + if (choice) reply = { id: String(c.id), choice }; + } + } + if (!reply || reply.id === lastConfirmed) return log; + const label = creditLabelFor(reply.choice.choice); + await setLabels(client, item, [label], CREDIT_LABELS.filter((n) => n !== label), log); + await comment(client, item, creditConfirmationComment(contributor, reply.choice, reply.id), log, `credit recorded (${reply.choice.choice})`); + return log; +} + +// ---------------------------------------------------------------- release list + +/** A YYYY-MM-DD date, or a tag or ref resolved to its commit date. */ +export async function resolveSince(client, since) { + if (/^\d{4}-\d{2}-\d{2}/.test(since)) return new Date(since.length === 10 ? `${since}T00:00:00Z` : since); + return new Date(await client.refDate(since)); +} + +export async function collectCredits(client, config, since, now = new Date()) { + const credits = []; + for (const label of [LABELS.yes, LABELS.anonymous]) { + const items = await client.listIssues({ labels: label, state: 'closed', since: since.toISOString() }); + for (const item of items) { + if (!item.closed_at || new Date(item.closed_at) < since) continue; + const comments = await client.listComments(item.number); + const confirmations = comments + .filter((c) => isOurBot(c.user, config)) + .map((c) => parseConfirmation(c.body)) + .filter(Boolean); + const last = confirmations[confirmations.length - 1]; + let who = 'an anonymous contributor'; + if (label === LABELS.yes) { + if (last && last.choice === 'name' && last.name) who = last.name; + else who = `@${(last && last.handle) || item.user.login}`; + } + credits.push({ + number: item.number, + title: item.title, + code: Boolean(item.pull_request), + who, + }); + } + } + credits.sort((a, b) => a.number - b.number); + + const unanswered = []; + const limit = config.creditReminderDays * DAY_MS; + for (const item of await client.listIssues({ labels: LABELS.asked, state: 'all' })) { + const comments = await client.listComments(item.number); + const askComment = comments.find( + (c) => isOurBot(c.user, config) && (c.body || '').includes(MARKERS.creditAsk), + ); + const askedAt = new Date(askComment ? askComment.created_at : item.closed_at || item.created_at); + if (now - askedAt > limit) { + unanswered.push({ number: item.number, title: item.title, askedAt, url: item.html_url }); + } + } + unanswered.sort((a, b) => a.number - b.number); + return { credits, unanswered }; +} + +function escapeMarkdown(text) { + return String(text).replace(/([\\`*_[\]<>#])/g, '\\$1'); +} + +export function formatCredits({ credits }, config) { + if (!credits.length) return ''; + const line = (c) => `- ${escapeMarkdown(c.title)} (#${c.number}), by ${c.who}`; + const code = credits.filter((c) => c.code); + const other = credits.filter((c) => !c.code); + const out = [ + '### Accessibility contributions', + '', + `Thank you to everyone who helped make ${config.projectName} more accessible in this release.`, + ]; + if (code.length) out.push('', 'Code contributions:', '', ...code.map(line)); + if (other.length) out.push('', 'Reports, testing, reviews and other contributions:', '', ...other.map(line)); + return `${out.join('\n')}\n`; +} + +export function formatWarnings({ unanswered }, config) { + if (!unanswered.length) return ''; + return [ + `Warning: ${unanswered.length} credit question(s) unanswered for more than ${config.creditReminderDays} days. Leave these contributors out, or ask them again in person:`, + ...unanswered.map((u) => `- #${u.number} ${u.title} (asked ${u.askedAt.toISOString().slice(0, 10)}) ${u.url || ''}`.trimEnd()), + ].join('\n'); +} + +// ---------------------------------------------------------------- GitHub client + +/** + * The client every handler uses, built on two functions: request(route, + * params) returning response data, and paginate(route, params) returning every + * page's items. actions/github-script supplies both; a11y-credits.mjs supplies + * a fetch-based pair. + */ +export function makeClient({ request, paginate }, { owner, repo }) { + const base = { owner, repo }; + return { + getIssue: (n) => request('GET /repos/{owner}/{repo}/issues/{issue_number}', { ...base, issue_number: n }), + listComments: (n) => paginate('GET /repos/{owner}/{repo}/issues/{issue_number}/comments', { ...base, issue_number: n, per_page: 100 }), + listTimeline: (n) => paginate('GET /repos/{owner}/{repo}/issues/{issue_number}/timeline', { ...base, issue_number: n, per_page: 100 }), + listIssues: (q) => paginate('GET /repos/{owner}/{repo}/issues', { ...base, per_page: 100, ...q }), + createComment: (n, body) => request('POST /repos/{owner}/{repo}/issues/{issue_number}/comments', { ...base, issue_number: n, body }), + addLabels: (n, labels) => request('POST /repos/{owner}/{repo}/issues/{issue_number}/labels', { ...base, issue_number: n, labels }), + removeLabel: async (n, name) => { + try { + await request('DELETE /repos/{owner}/{repo}/issues/{issue_number}/labels/{name}', { ...base, issue_number: n, name }); + } catch (error) { + if (error.status !== 404) throw error; + } + }, + reopen: (n) => request('PATCH /repos/{owner}/{repo}/issues/{issue_number}', { ...base, issue_number: n, state: 'open' }), + refDate: async (ref) => { + const data = await request('GET /repos/{owner}/{repo}/commits/{ref}', { ...base, ref }); + return data.commit.committer.date; + }, + }; +} + +// ---------------------------------------------------------------- dispatch + +/** + * Route one event to its handler. `rule` is "reports" or "credits", naming the + * workflow. Events whose actor is a bot are skipped, so the workflow's own + * reopen, comments and labels never trigger it again. + */ +export async function handleEvent({ rule, eventName, payload, client, config, fullName, now = new Date() }) { + const log = []; + if (eventName !== 'schedule' && eventName !== 'pull_request_target' && isBot(payload.sender)) { + log.push(`skipped: event from bot ${payload.sender && payload.sender.login}`); + return log; + } + const issue = payload.issue; + if (rule === 'reports') { + if (eventName === 'schedule') return sweepOverdue(client, config, now, log); + if (eventName === 'issues' && payload.action === 'opened') return acknowledgeReport(client, issue, config, log); + if (eventName === 'issues' && payload.action === 'labeled') { + if (payload.label && payload.label.name === config.accessibilityLabel) { + return acknowledgeReport(client, issue, config, log); + } + return log; + } + if (eventName === 'issues' && payload.action === 'closed') return gateClosedReport(client, issue, config, log); + if (eventName === 'issue_comment' && payload.action === 'created' && !issue.pull_request) { + return clearIfAnswered(client, issue, config, log); + } + return log; + } + if (rule === 'credits') { + if (eventName === 'pull_request_target' && payload.action === 'closed') { + return creditMergedPull(client, payload.pull_request, config, fullName, log); + } + if (eventName === 'issues' && payload.action === 'closed') return creditClosedReport(client, issue, config, log); + if (eventName === 'issue_comment' && payload.action === 'created') return recordCreditReply(client, issue, config, log); + return log; + } + throw new Error(`unknown rule "${rule}"`); +} + +/** Entry point for actions/github-script. */ +export async function runFromAction({ github, context, core }, rule) { + const workspace = process.env.GITHUB_WORKSPACE || '.'; + const config = loadConfig(`${workspace}/.github/a11y-community.json`); + const { owner, repo } = context.repo; + const client = makeClient( + { + request: async (route, params) => (await github.request(route, params)).data, + paginate: (route, params) => github.paginate(route, params), + }, + { owner, repo }, + ); + const log = await handleEvent({ + rule, + eventName: context.eventName, + payload: context.payload, + client, + config, + fullName: `${owner}/${repo}`, + }); + for (const line of log.length ? log : ['nothing to do']) core.info(line); +} diff --git a/.github/scripts/a11y-credits.mjs b/.github/scripts/a11y-credits.mjs new file mode 100644 index 00000000..6b3de41d --- /dev/null +++ b/.github/scripts/a11y-credits.mjs @@ -0,0 +1,121 @@ +#!/usr/bin/env node +/** + * a11y-credits.mjs - print the release-notes block of accessibility credits. + * + * node .github/scripts/a11y-credits.mjs --since [--repo owner/name] + * + * Lists items labelled "credit: yes" or "credit: anonymous" that closed since + * the tag or date, as the contributor chose to be credited, grouped as code + * (pull requests) and other contributions (reports and other issues). Each + * line describes the work by the item's title, never the person. + * + * The block goes to standard output, ready to paste. Warnings about credit + * questions unanswered for more than 14 days go to standard error. + * + * Needs a token with read access: GITHUB_TOKEN or GH_TOKEN, or a signed-in + * GitHub CLI (`gh auth token`). No dependencies. + */ + +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + collectCredits, formatCredits, formatWarnings, loadConfig, makeClient, resolveSince, +} from './a11y-community.mjs'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +function arg(name) { + const i = process.argv.indexOf(name); + return i === -1 ? null : process.argv[i + 1]; +} + +function token() { + if (process.env.GITHUB_TOKEN) return process.env.GITHUB_TOKEN; + if (process.env.GH_TOKEN) return process.env.GH_TOKEN; + try { + return execFileSync('gh', ['auth', 'token'], { encoding: 'utf8' }).trim(); + } catch { + return ''; + } +} + +function repoFromGit() { + const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: HERE, encoding: 'utf8' }).trim(); + const m = url.match(/github\.com[:/]([^/]+)\/(.+?)(?:\.git)?$/); + if (!m) throw new Error(`cannot tell the repository from ${url}; pass --repo owner/name`); + return `${m[1]}/${m[2]}`; +} + +/** request and paginate over fetch, matching what makeClient expects. */ +function fetchTransport(auth) { + const headers = { + accept: 'application/vnd.github+json', + 'x-github-api-version': '2022-11-28', + 'user-agent': 'a11y-credits', + ...(auth ? { authorization: `Bearer ${auth}` } : {}), + }; + const build = (route, params) => { + const [method, template] = route.split(' '); + const rest = { ...params }; + const pathname = template.replace(/\{(\w+)\}/g, (_, key) => { + const value = rest[key]; + delete rest[key]; + return encodeURIComponent(value); + }); + const url = new URL(`https://api.github.com${pathname}`); + for (const [key, value] of Object.entries(rest)) url.searchParams.set(key, value); + return { method, url }; + }; + const call = async (method, url) => { + const res = await fetch(url, { method, headers }); + if (!res.ok) { + const error = new Error(`${method} ${url.pathname}: ${res.status} ${await res.text()}`); + error.status = res.status; + throw error; + } + return res; + }; + return { + request: async (route, params) => { + const { method, url } = build(route, params); + return (await call(method, url)).json(); + }, + paginate: async (route, params) => { + let { method, url } = build(route, params); + const all = []; + while (url) { + const res = await call(method, url); + all.push(...(await res.json())); + const next = (res.headers.get('link') || '').match(/<([^>]+)>;\s*rel="next"/); + url = next ? new URL(next[1]) : null; + } + return all; + }, + }; +} + +async function main() { + const sinceArg = arg('--since'); + if (!sinceArg) { + console.error('Usage: node .github/scripts/a11y-credits.mjs --since [--repo owner/name]'); + process.exit(2); + } + const fullName = arg('--repo') || repoFromGit(); + const [owner, repo] = fullName.split('/'); + const config = loadConfig(path.join(HERE, '..', 'a11y-community.json')); + const client = makeClient(fetchTransport(token()), { owner, repo }); + + const since = await resolveSince(client, sinceArg); + const result = await collectCredits(client, config, since); + const block = formatCredits(result, config); + if (block) process.stdout.write(block); + else console.error(`No accessibility contributions to credit since ${since.toISOString().slice(0, 10)}.`); + const warnings = formatWarnings(result, config); + if (warnings) console.error(`\n${warnings}`); +} + +main().catch((error) => { + console.error(error.message); + process.exit(1); +}); diff --git a/.github/workflows/accessibility-credits.yml b/.github/workflows/accessibility-credits.yml new file mode 100644 index 00000000..ef95fdb1 --- /dev/null +++ b/.github/workflows/accessibility-credits.yml @@ -0,0 +1,65 @@ +name: Accessibility credits + +# Rule 2 of this project's accessibility practice (ACCESSIBILITY.md, +# recognition; Open Source Accessibility Framework, Community action 4). +# +# - A merged pull request with the accessibility label, or one that closes an +# accessibility issue, from someone who is not a maintainer or a bot; and an +# accessibility report closed as completed with a documented outcome: one +# question asking the contributor whether and how to be credited, label +# "credit: asked". Never asked twice on the same item. +# - The contributor, and only the contributor, replies "credit: @handle", +# "credit: name ", "credit: anonymous" or "credit: no": the label +# becomes "credit: yes", "credit: anonymous" or "credit: no", with a short +# confirmation. A chosen name is stored only in that confirmation. +# - At release time, node .github/scripts/a11y-credits.mjs --since +# prints the credits block for the release notes. +# +# pull_request_target is used so the question can be posted on pull requests +# from forks. It checks out only the base branch's .github folder and never +# runs code from the pull request. + +on: + pull_request_target: + types: [closed] + issues: + types: [closed] + issue_comment: + types: [created] + +# pull-requests: write is needed only because GitHub requires it to comment on, +# and label, a pull request; issues: write alone is enough for issues. +permissions: + contents: read + issues: write + pull-requests: write + +concurrency: + group: accessibility-credits-${{ github.event.issue.number || github.event.pull_request.number }} + cancel-in-progress: false + +jobs: + credit: + # The label name repeats accessibilityLabel in .github/a11y-community.json. + if: >- + github.repository_owner == 'Community-Access' && ( + (github.event_name == 'pull_request_target' && github.event.pull_request.merged) || + (github.event_name == 'issues' && github.event.sender.type != 'Bot' && + contains(github.event.issue.labels.*.name, 'accessibility')) || + (github.event_name == 'issue_comment' && github.event.sender.type != 'Bot' && + contains(join(github.event.issue.labels.*.name, ','), 'credit: '))) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out the rule + uses: actions/checkout@v4 + with: + persist-credentials: false + sparse-checkout: .github + + - name: Apply the rule + uses: actions/github-script@v8 + with: + script: | + const { runFromAction } = await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/a11y-community.mjs`); + await runFromAction({ github, context, core }, 'credits'); diff --git a/.github/workflows/accessibility-reports.yml b/.github/workflows/accessibility-reports.yml new file mode 100644 index 00000000..8a00a832 --- /dev/null +++ b/.github/workflows/accessibility-reports.yml @@ -0,0 +1,63 @@ +name: Accessibility reports + +# Rule 1 of this project's accessibility practice (ACCESSIBILITY.md, "How we +# respond"; Open Source Accessibility Framework, Community action 2). An +# accessibility report is an issue with the accessibility label, opened by +# someone who is not a maintainer or a bot, on or after the install date in +# .github/a11y-community.json. +# +# - Opened or first labelled: one acknowledgement, label "a11y: awaiting response". +# - A maintainer comments: that label and "a11y: response overdue" come off. +# - Daily: still awaiting after 5 business days gets "a11y: response overdue" +# and one reminder to the accessibility owner. +# - Closed: needs a merged pull request or commit, or a maintainer comment with +# an "Outcome:" line after the reporter's last comment; otherwise it is +# reopened. Closed as completed invites the reporter to confirm the fix. +# +# The logic, and its tests, are in .github/scripts/a11y-community.mjs. Events +# from bots are skipped, so the workflow's own reopen and comments never +# trigger it again. + +on: + issues: + types: [opened, labeled, closed] + issue_comment: + types: [created] + schedule: + # Daily at 13:41 UTC: 06:41 in Arizona, off the top of the hour. + - cron: "41 13 * * *" + +permissions: + contents: read + issues: write + pull-requests: read + +concurrency: + group: accessibility-reports-${{ github.event.issue.number || 'overdue-sweep' }} + cancel-in-progress: false + +jobs: + respond: + # The label name repeats accessibilityLabel in .github/a11y-community.json, + # so a comment on an unrelated issue does not start a job. + if: >- + github.repository_owner == 'Community-Access' && ( + github.event_name == 'schedule' || + (github.event.sender.type != 'Bot' && + github.event.issue.pull_request == null && + contains(github.event.issue.labels.*.name, 'accessibility'))) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Check out the rule + uses: actions/checkout@v4 + with: + persist-credentials: false + sparse-checkout: .github + + - name: Apply the rule + uses: actions/github-script@v8 + with: + script: | + const { runFromAction } = await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/a11y-community.mjs`); + await runFromAction({ github, context, core }, 'reports'); diff --git a/.github/workflows/accessibility-review-reminder.yml b/.github/workflows/accessibility-review-reminder.yml index 0007f8bc..eb04056c 100644 --- a/.github/workflows/accessibility-review-reminder.yml +++ b/.github/workflows/accessibility-review-reminder.yml @@ -67,7 +67,7 @@ jobs: '- [ ] Check that open reports have an acknowledgement, a severity, a status and a next step.', '- [ ] Keep a few `good first issue` and `help wanted` accessibility issues open, with context and acceptance criteria.', '- [ ] Review the dependency list in ACCESSIBILITY.md and any upstream blockers.', - '- [ ] Credit accessibility contributors in the update, only in the form each person agreed to.', + '- [ ] Credit accessibility contributors in the update, only in the form each person agreed to: run `node .github/scripts/a11y-credits.mjs --since ' + sinceDate + '` and paste its block; leave out anyone it lists as unanswered after 14 days, or ask them again.', '- [ ] Check the matching actions in the public Open Source Accessibility Framework tracking issue.', '', 'Close this issue with a link to the pull request that adds the update.' diff --git a/ACCESSIBILITY.md b/ACCESSIBILITY.md index 3868c9d1..e276e49b 100644 --- a/ACCESSIBILITY.md +++ b/ACCESSIBILITY.md @@ -92,6 +92,12 @@ We use four levels, based on how much the barrier stops a person completing a ta - Before we close a report, we write down the outcome on the issue, and we ask the reporter, or another person affected, to confirm the fix works for them when they are willing. - We credit reporters and testers only in the way they choose (see [Recognizing contributions](#recognizing-contributions)). +Two workflows hold us to this for reports opened from 29 October 2026 on. Their logic and its tests are in [.github/scripts/a11y-community.mjs](https://github.com/Community-Access/git-going-with-github/blob/main/.github/scripts/a11y-community.mjs). + +- **When you report a barrier,** a bot replies once: it thanks you, says a maintainer will reply with a status and a next step within 5 business days, points you to [Known limitations](#known-limitations), and invites, but never requires, details of your setup. The issue is labeled `a11y: awaiting response` until a maintainer comments. +- **If no maintainer has replied after 5 business days** (Monday to Friday), the issue is labeled `a11y: response overdue` and the accessibility owner is reminded on the issue. +- **A report cannot close without an outcome.** Maintainers close it from the pull request or commit that fixes it, or write a comment with a line that starts `Outcome:`, after the reporter's last comment, saying what changed or why it cannot. A report closed without either is reopened automatically, with a note saying how to add one. When a report closes as fixed, you are invited to confirm that the fix works for you, and the issue is labeled `a11y: validation requested`. + If we cannot meet a date we gave, we say why on the issue, share any workaround, and give a new date. ## Taking part @@ -193,6 +199,8 @@ We publish accessibility progress here, every quarter and after each cohort, and Accessibility work, including reports, testing, reviews and documentation, is project work, and we say so publicly. Before we name anyone in a progress update, release notes or a community post, we ask how they would like to be credited: by GitHub handle, by name, or not at all, and we record the answer on the issue or pull request. We describe the work, never the person's disability, diagnosis or assistive technology, even if they mentioned it in a report, and we never present one person as speaking for a disability community. +A workflow does the asking. When an accessibility pull request from outside the maintainers is merged, or your report closes as fixed, a bot asks you once how you would like to be credited, and you reply with one line: `credit: @handle`, `credit: name `, `credit: anonymous` or `credit: no`. Only your own reply counts, you can change it later, and nothing about you beyond the credit you choose is published. The answer is recorded as a `credit:` label. Before each progress update, the accessibility owner runs `node .github/scripts/a11y-credits.mjs --since ` and pastes the block it prints; it lists items labeled `credit: yes` or `credit: anonymous` closed since then, as code and other contributions, and warns about `credit: asked` items with no answer after 14 days, whose contributors are left out or asked again. + ## Reusing our accessibility materials Other projects are welcome to reuse what we built for this workshop, under the repository's [CC BY 4.0 license](https://github.com/Community-Access/git-going-with-github/blob/main/LICENSE), with attribution. Each item below says what problem it solves and where it falls short. diff --git a/html/ACCESSIBILITY.html b/html/ACCESSIBILITY.html index 2b3ba4d8..36929c8a 100644 --- a/html/ACCESSIBILITY.html +++ b/html/ACCESSIBILITY.html @@ -311,6 +311,12 @@

How we respond

  • Before we close a report, we write down the outcome on the issue, and we ask the reporter, or another person affected, to confirm the fix works for them when they are willing.
  • We credit reporters and testers only in the way they choose (see Recognizing contributions).
  • +

    Two workflows hold us to this for reports opened from 29 October 2026 on. Their logic and its tests are in .github/scripts/a11y-community.mjs.

    +
      +
    • When you report a barrier, a bot replies once: it thanks you, says a maintainer will reply with a status and a next step within 5 business days, points you to Known limitations, and invites, but never requires, details of your setup. The issue is labeled a11y: awaiting response until a maintainer comments.
    • +
    • If no maintainer has replied after 5 business days (Monday to Friday), the issue is labeled a11y: response overdue and the accessibility owner is reminded on the issue.
    • +
    • A report cannot close without an outcome. Maintainers close it from the pull request or commit that fixes it, or write a comment with a line that starts Outcome:, after the reporter's last comment, saying what changed or why it cannot. A report closed without either is reopened automatically, with a note saying how to add one. When a report closes as fixed, you are invited to confirm that the fix works for you, and the issue is labeled a11y: validation requested.
    • +

    If we cannot meet a date we gave, we say why on the issue, share any workaround, and give a new date.

    Taking part

    There are several ways to take part, and no single channel is required:

    @@ -400,6 +406,7 @@

    October 2026: adopting the framewor

    Recognizing contributions

    Accessibility work, including reports, testing, reviews and documentation, is project work, and we say so publicly. Before we name anyone in a progress update, release notes or a community post, we ask how they would like to be credited: by GitHub handle, by name, or not at all, and we record the answer on the issue or pull request. We describe the work, never the person's disability, diagnosis or assistive technology, even if they mentioned it in a report, and we never present one person as speaking for a disability community.

    +

    A workflow does the asking. When an accessibility pull request from outside the maintainers is merged, or your report closes as fixed, a bot asks you once how you would like to be credited, and you reply with one line: credit: @handle, credit: name <Name>, credit: anonymous or credit: no. Only your own reply counts, you can change it later, and nothing about you beyond the credit you choose is published. The answer is recorded as a credit: label. Before each progress update, the accessibility owner runs node .github/scripts/a11y-credits.mjs --since <date of the last update> and pastes the block it prints; it lists items labeled credit: yes or credit: anonymous closed since then, as code and other contributions, and warns about credit: asked items with no answer after 14 days, whose contributors are left out or asked again.

    Reusing our accessibility materials

    Other projects are welcome to reuse what we built for this workshop, under the repository's CC BY 4.0 license, with attribution. Each item below says what problem it solves and where it falls short.

      diff --git a/package.json b/package.json index a7c2f3da..f0964a09 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,7 @@ "build": "npm run build:podcasts && npm run build:html", "watch:html": "node scripts/build-html.js --watch", "clean": "node -e \"require('fs').rmSync('html', {recursive: true, force: true})\"", - "test:automation": "node --test .github/scripts/__tests__/*.test.js", + "test:automation": "node --test .github/scripts/__tests__/*.test.js .github/scripts/__tests__/*.test.mjs", "test:provisioning": "node --test .github/scripts/provisioning/__tests__/*.test.js" }, "keywords": [