From fcaf19a9366f29821d2f8d261a2f741490894650 Mon Sep 17 00:00:00 2001 From: Wilco Fiers Date: Mon, 6 Apr 2026 14:26:11 +0200 Subject: [PATCH 1/2] feat: new ACT approval report creation script --- .gitignore | 1 + package.json | 3 + .../build-rule-approval-rows.test.ts | 217 +++++++++ .../__tests__/generate-report.test.ts | 426 ++++++++++++++++++ .../__tests__/git-changes.test.ts | 180 ++++++++ .../__tests__/github-issues.test.ts | 168 +++++++ .../__tests__/load-data.test.ts | 134 ++++++ .../__tests__/rule-type-summary.test.ts | 94 ++++ src/approval-report/__tests__/run.test.ts | 80 ++++ .../build-rule-approval-rows.ts | 156 +++++++ src/approval-report/generate-report.ts | 416 +++++++++++++++++ src/approval-report/git-changes.ts | 150 ++++++ src/approval-report/github-issues.ts | 76 ++++ src/approval-report/load-data.ts | 81 ++++ src/approval-report/rule-type-summary.ts | 29 ++ src/approval-report/run.ts | 19 + src/approval-report/types.ts | 67 +++ src/cli/approval-report.ts | 74 +++ yarn.lock | 173 +++++++ 19 files changed, 2544 insertions(+) create mode 100644 src/approval-report/__tests__/build-rule-approval-rows.test.ts create mode 100644 src/approval-report/__tests__/generate-report.test.ts create mode 100644 src/approval-report/__tests__/git-changes.test.ts create mode 100644 src/approval-report/__tests__/github-issues.test.ts create mode 100644 src/approval-report/__tests__/load-data.test.ts create mode 100644 src/approval-report/__tests__/rule-type-summary.test.ts create mode 100644 src/approval-report/__tests__/run.test.ts create mode 100644 src/approval-report/build-rule-approval-rows.ts create mode 100644 src/approval-report/generate-report.ts create mode 100644 src/approval-report/git-changes.ts create mode 100644 src/approval-report/github-issues.ts create mode 100644 src/approval-report/load-data.ts create mode 100644 src/approval-report/rule-type-summary.ts create mode 100644 src/approval-report/run.ts create mode 100644 src/approval-report/types.ts create mode 100644 src/cli/approval-report.ts diff --git a/.gitignore b/.gitignore index 5963a55..426cfc2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules/ dist/ +approval-report.md logs/ *.log .env diff --git a/package.json b/package.json index 1c0dbf8..803ad65 100644 --- a/package.json +++ b/package.json @@ -17,15 +17,18 @@ "build-examples": "ts-node src/cli/build-examples.ts", "map-implementation": "ts-node src/cli/map-implementation.ts", "implementations-update": "ts-node src/cli/implementations-update.ts", + "approval-report": "ts-node src/cli/approval-report.ts", "test": "jest", "prepare": "husky install" }, "dependencies": { + "@octokit/rest": "^22.0.0", "axios": "^1.13.5", "commander": "^14.0.3", "debug": "^4.4.3", "fastmatter": "^2.1.1", "globby": "^11.0.4", + "js-yaml": "^4.1.0", "jsonld": "^9.0.0", "markdown-table": "2.0.0", "moment": "^2.30.1", diff --git a/src/approval-report/__tests__/build-rule-approval-rows.test.ts b/src/approval-report/__tests__/build-rule-approval-rows.test.ts new file mode 100644 index 0000000..04faac7 --- /dev/null +++ b/src/approval-report/__tests__/build-rule-approval-rows.test.ts @@ -0,0 +1,217 @@ +jest.mock("@octokit/rest", () => ({ + Octokit: jest.fn(), +})); + +import type { Parent } from "unist"; +import { + buildRuleApprovalRows, + type ApprovalReportDeps, +} from "../build-rule-approval-rows"; +import type { ApprovalReportOptions } from "../types"; +import type { RulePage } from "../../types"; + +const emptyMdAst = { type: "root", children: [] } as Parent; + +function atomicPage( + id: string, + extra: Partial = {}, +): RulePage { + return { + body: "", + markdownAST: emptyMdAst, + filename: `${id}.md`, + assets: {}, + frontmatter: { + id, + name: `Name ${id}`, + rule_type: "atomic", + description: "", + input_aspects: [], + ...extra, + } as RulePage["frontmatter"], + }; +} + +const baseOpts: ApprovalReportOptions = { + rulesDir: "/rules", + glossaryDir: "/glossary", + testAssetsDir: "/assets", + actRulesRepo: "/repo", + wcagActRulesDir: "/wcag", + outFile: "/out/report.md", + githubOwner: "o", + githubRepo: "r", +}; + +function mockDeps( + overrides: Partial, +): Partial { + return { + getDefinitionPages: () => [], + loadApprovalByRuleId: () => ({}), + fetchOpenIssues: async () => [], + getRuleDefinitions: () => [], + getChangesSinceApproval: () => [], + getLatestCommitDateOnPaths: () => "2024-01-01", + ...overrides, + }; +} + +/** One non-deprecated atomic rule with a default complete implementation for `ruleId`. */ +function oneAtomic( + ruleId: string, + extra: Partial = {}, +): Partial { + return mockDeps({ + getRulePages: () => [atomicPage(ruleId)], + pathRelativeToRepo: () => `_rules/${ruleId}.md`, + loadCompleteImplementationsByRuleId: () => ({ [ruleId]: ["axe"] }), + ...extra, + }); +} + +describe("buildRuleApprovalRows", () => { + it("skips deprecated rules", async () => { + const rows = await buildRuleApprovalRows( + baseOpts, + mockDeps({ + getRulePages: () => [ + atomicPage("gone", { deprecated: "true" }), + atomicPage("keep"), + ], + loadCompleteImplementationsByRuleId: () => ({ keep: ["axe"] }), + pathRelativeToRepo: () => "_rules/keep.md", + }), + ); + expect(rows).toHaveLength(1); + expect(rows[0].ruleId).toBe("keep"); + }); + + it("strips issue body from row issues", async () => { + const rows = await buildRuleApprovalRows( + baseOpts, + oneAtomic("rid", { + fetchOpenIssues: async () => [ + { + number: 7, + title: "rid bug", + html_url: "https://github.com/o/r/issues/7", + body: "do not leak", + labelNames: [], + }, + ], + getLatestCommitDateOnPaths: () => null, + }), + ); + expect(rows[0].issues).toEqual([ + { + number: 7, + title: "rid bug", + html_url: "https://github.com/o/r/issues/7", + }, + ]); + }); + + it("buckets notReady without complete implementation", async () => { + const rows = await buildRuleApprovalRows( + baseOpts, + oneAtomic("n", { loadCompleteImplementationsByRuleId: () => ({}) }), + ); + expect(rows[0].reportBucket).toBe("notReady"); + }); + + it("buckets notReady when a matched issue has Blocker label", async () => { + const rows = await buildRuleApprovalRows( + baseOpts, + oneAtomic("b", { + fetchOpenIssues: async () => [ + { + number: 1, + title: "b", + html_url: "u", + body: "", + labelNames: ["Blocker"], + }, + ], + }), + ); + expect(rows[0].reportBucket).toBe("notReady"); + expect(rows[0].blockersCount).toBe(1); + }); + + it("buckets proposedReadyForUpdate when not WAI-approved but has implementation", async () => { + const rows = await buildRuleApprovalRows(baseOpts, oneAtomic("p")); + expect(rows[0].reportBucket).toBe("proposedReadyForUpdate"); + expect(rows[0].waiApproved).toBe(false); + }); + + it("buckets approvedUpToDate when approved with no commits after approval", async () => { + const rows = await buildRuleApprovalRows( + baseOpts, + oneAtomic("a", { + loadApprovalByRuleId: () => ({ + a: { approved: true, approvalIsoDate: "2023-01-01" }, + }), + }), + ); + expect(rows[0].reportBucket).toBe("approvedUpToDate"); + expect(rows[0].commitsBehindSummary).toBe("0"); + }); + + it("buckets approvedReadyForUpdate when there are changes after approval", async () => { + const change = { + hash: "e".repeat(40), + subject: "edit", + dateIso: "2024-01-01T00:00:00Z", + touchedRule: true, + touchedDefinitionKeys: [] as string[], + }; + const rows = await buildRuleApprovalRows( + baseOpts, + oneAtomic("u", { + loadApprovalByRuleId: () => ({ + u: { approved: true, approvalIsoDate: "2023-01-01" }, + }), + getChangesSinceApproval: () => [change], + getLatestCommitDateOnPaths: () => "2024-06-01", + }), + ); + expect(rows[0].reportBucket).toBe("approvedReadyForUpdate"); + expect(rows[0].commitsBehindSummary).toBe("1"); + expect(rows[0].changes).toEqual([change]); + }); + + it("does not call getChangesSinceApproval when rule is not WAI-approved", async () => { + const getChangesSinceApproval = jest.fn(() => []); + await buildRuleApprovalRows( + baseOpts, + oneAtomic("x", { getChangesSinceApproval }), + ); + expect(getChangesSinceApproval).not.toHaveBeenCalled(); + }); + + it("sets compositeInputs for composite rules", async () => { + const composite: RulePage = { + body: "", + markdownAST: emptyMdAst, + filename: "comp.md", + assets: {}, + frontmatter: { + id: "comp", + name: "Composite", + rule_type: "composite", + description: "", + input_rules: ["in1", "in2"], + } as RulePage["frontmatter"], + }; + const rows = await buildRuleApprovalRows( + baseOpts, + mockDeps({ + getRulePages: () => [composite], + loadCompleteImplementationsByRuleId: () => ({ comp: ["axe"] }), + pathRelativeToRepo: () => "_rules/comp.md", + }), + ); + expect(rows[0].compositeInputs).toEqual(["in1", "in2"]); + }); +}); diff --git a/src/approval-report/__tests__/generate-report.test.ts b/src/approval-report/__tests__/generate-report.test.ts new file mode 100644 index 0000000..6e30c8c --- /dev/null +++ b/src/approval-report/__tests__/generate-report.test.ts @@ -0,0 +1,426 @@ +import { generateApprovalReportMarkdown } from "../generate-report"; +import type { ChangeEntry, ReportBucket, RuleApprovalRow } from "../types"; + +const github = { owner: "act-rules", repo: "act-rules.github.io" }; + +function baseRow( + ruleId: string, + overrides: Partial = {}, +): RuleApprovalRow { + return { + ruleId, + name: ruleId, + ruleTypeSummary: "atomic", + waiApproved: false, + reportBucket: "approvedUpToDate", + implementations: ["axe"], + issues: [], + changes: [], + lastApprovedSummary: "2023-01-01", + lastUpdatedSummary: "2024-01-01", + commitsBehindSummary: "0", + blockersCount: 0, + ...overrides, + }; +} + +function sectionAfterHeading(md: string, headingLine: string): string { + const idx = md.indexOf(headingLine); + if (idx < 0) return ""; + const rest = md.slice(idx + headingLine.length); + const next = rest.search(/\n## /); + return next < 0 ? rest : rest.slice(0, next); +} + +describe("generateApprovalReportMarkdown", () => { + beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2024-06-15T12:00:00.000Z")); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + it("includes fixed generated date from system time", () => { + const md = generateApprovalReportMarkdown( + [baseRow("r1", { reportBucket: "approvedUpToDate" })], + github, + ); + expect(md).toContain("Generated: 2024-06-15"); + }); + + it("renders _None._ for empty bucket tables", () => { + const md = generateApprovalReportMarkdown([], github); + expect(md).toContain("## Approved ready for update (0)"); + expect(md).toContain("## Proposed ready for update (0)"); + expect(md).toContain("## Approved, up to date (0)"); + expect(md).toContain("## Not ready (0)"); + const noneCount = (md.match(/_None\._/g) ?? []).length; + expect(noneCount).toBeGreaterThanOrEqual(4); + }); + + it("places each rule only in the matching bucket section", () => { + const rows: RuleApprovalRow[] = [ + baseRow("approved-upd", { + reportBucket: "approvedReadyForUpdate", + waiApproved: true, + commitsBehindSummary: "2", + changes: [ + { + hash: "a".repeat(40), + subject: "s", + dateIso: "", + touchedRule: true, + touchedDefinitionKeys: [], + }, + ], + }), + baseRow("proposed-upd", { reportBucket: "proposedReadyForUpdate" }), + baseRow("approved-ok", { + reportBucket: "approvedUpToDate", + waiApproved: true, + commitsBehindSummary: "0", + changes: [], + }), + baseRow("not-ready", { + reportBucket: "notReady", + implementations: [], + waiApproved: false, + commitsBehindSummary: "-", + lastApprovedSummary: "-", + }), + ]; + const md = generateApprovalReportMarkdown(rows, github); + expect( + sectionAfterHeading(md, "## Approved ready for update (1)"), + ).toContain("[approved-upd](#approved-upd)"); + expect( + sectionAfterHeading(md, "## Proposed ready for update (1)"), + ).toContain("[proposed-upd](#proposed-upd)"); + expect(sectionAfterHeading(md, "## Approved, up to date (1)")).toContain( + "[approved-ok](#approved-ok)", + ); + expect(sectionAfterHeading(md, "## Not ready (1)")).toContain( + "[not-ready](#not-ready)", + ); + }); + + it("includes WAI and GitHub search links with rule id", () => { + const md = generateApprovalReportMarkdown( + [ + baseRow("my-rule-id", { + reportBucket: "proposedReadyForUpdate", + implementations: ["t"], + issues: [], + }), + ], + github, + ); + expect(md).toContain( + "https://www.w3.org/WAI/standards-guidelines/act/rules/my-rule-id/proposed/#implementations", + ); + expect(md).toContain("my-rule-id+"); + }); + + it("escapes pipe and newlines in table name cells", () => { + const md = generateApprovalReportMarkdown( + [ + baseRow("rid", { + name: "a|b\nc", + reportBucket: "proposedReadyForUpdate", + }), + ], + github, + ); + expect(md).toContain("a\\|b c"); + }); + + it("interleaves composite groups with other rules using global sort keys", () => { + const inputId = "composed-input"; + const rows: RuleApprovalRow[] = [ + baseRow("composite-low", { + ruleTypeSummary: "composite", + compositeInputs: [inputId], + reportBucket: "approvedReadyForUpdate", + waiApproved: true, + commitsBehindSummary: "1", + changes: [ + { + hash: "1".repeat(40), + subject: "s", + dateIso: "", + touchedRule: true, + touchedDefinitionKeys: [], + }, + ], + }), + baseRow(inputId, { + ruleTypeSummary: "composed", + reportBucket: "approvedReadyForUpdate", + waiApproved: true, + commitsBehindSummary: "1", + changes: [ + { + hash: "2".repeat(40), + subject: "s", + dateIso: "", + touchedRule: true, + touchedDefinitionKeys: [], + }, + ], + }), + baseRow("atomic-high", { + reportBucket: "approvedReadyForUpdate", + waiApproved: true, + commitsBehindSummary: "5", + changes: Array(5) + .fill(null) + .map((_, i) => ({ + hash: String(i).padStart(40, "h"), + subject: "s", + dateIso: "", + touchedRule: true, + touchedDefinitionKeys: [], + })), + }), + ]; + const md = generateApprovalReportMarkdown(rows, github); + const sec = sectionAfterHeading(md, "## Approved ready for update (3)"); + const iHigh = sec.indexOf("[atomic-high](#atomic-high)"); + const iComp = sec.indexOf("[composite-low](#composite-low)"); + const iInput = sec.indexOf(`[${inputId}](#${inputId})`); + expect(iHigh).not.toEqual(-1); + expect(iComp).not.toEqual(-1); + expect(iInput).not.toEqual(-1); + expect(iHigh).toBeLessThan(iComp); + expect(iComp).toBeLessThan(iInput); + }); + + it("sorts approved-needs-update by commits behind descending", () => { + const rows: RuleApprovalRow[] = [ + baseRow("low", { + reportBucket: "approvedReadyForUpdate", + waiApproved: true, + commitsBehindSummary: "1", + changes: [ + { + hash: "1".repeat(40), + subject: "s", + dateIso: "", + touchedRule: true, + touchedDefinitionKeys: [], + }, + ], + }), + baseRow("high", { + reportBucket: "approvedReadyForUpdate", + waiApproved: true, + commitsBehindSummary: "5", + changes: Array(5) + .fill(null) + .map((_, i) => ({ + hash: String(i).padStart(40, "h"), + subject: "s", + dateIso: "", + touchedRule: true, + touchedDefinitionKeys: [], + })), + }), + ]; + const md = generateApprovalReportMarkdown(rows, github); + const sec = sectionAfterHeading(md, "## Approved ready for update (2)"); + const iHigh = sec.indexOf("[high](#high)"); + const iLow = sec.indexOf("[low](#low)"); + expect(iHigh).toBeGreaterThan(-1); + expect(iLow).toBeGreaterThan(-1); + expect(iHigh).toBeLessThan(iLow); + }); + + it("groups composed rule under lexicographically greatest composite parent", () => { + const shared = "atomic-shared"; + const rows: RuleApprovalRow[] = [ + baseRow("c-a", { + ruleTypeSummary: "composite", + compositeInputs: [shared], + reportBucket: "notReady", + implementations: [], + commitsBehindSummary: "-", + lastApprovedSummary: "-", + blockersCount: 0, + issues: [], + }), + baseRow("c-b", { + ruleTypeSummary: "composite", + compositeInputs: [shared], + reportBucket: "notReady", + implementations: [], + commitsBehindSummary: "-", + lastApprovedSummary: "-", + blockersCount: 0, + issues: [], + }), + baseRow(shared, { + ruleTypeSummary: "composed", + reportBucket: "notReady", + implementations: [], + commitsBehindSummary: "-", + lastApprovedSummary: "-", + blockersCount: 0, + issues: [], + }), + ]; + const md = generateApprovalReportMarkdown(rows, github); + const sec = sectionAfterHeading(md, "## Not ready (3)"); + const iCA = sec.indexOf("[c-a](#c-a)"); + const iCB = sec.indexOf("[c-b](#c-b)"); + const iShared = sec.indexOf(`[${shared}](#${shared})`); + expect(iCA).toBeLessThan(iCB); + expect(iCB).toBeLessThan(iShared); + }); + + it("shows no-commits line in approved details when changes array is empty", () => { + const md = generateApprovalReportMarkdown( + [ + baseRow("stale", { + reportBucket: "approvedReadyForUpdate", + waiApproved: true, + approvalIsoDate: "2023-01-01", + commitsBehindSummary: "0", + changes: [], + }), + ], + github, + ); + expect(md).toContain("_No commits after approval date._"); + }); + + it("omits detail sections when update buckets are empty", () => { + const md = generateApprovalReportMarkdown( + [ + baseRow("x", { + reportBucket: "approvedUpToDate", + waiApproved: true, + commitsBehindSummary: "0", + changes: [], + }), + ], + github, + ); + expect(md).not.toContain("Approved ready for update — details"); + expect(md).not.toContain("Proposed ready for update — details"); + }); + + it("includes approved and proposed detail sections when buckets are non-empty", () => { + const change: ChangeEntry = { + hash: "ab".repeat(20), + subject: 'Fix & "quotes"', + dateIso: "2024-01-01T00:00:00Z", + touchedRule: true, + touchedDefinitionKeys: ["def-a", "def-b"], + }; + const rows: RuleApprovalRow[] = [ + baseRow("appr", { + reportBucket: "approvedReadyForUpdate", + waiApproved: true, + approvalIsoDate: "2023-06-01", + commitsBehindSummary: "1", + changes: [change], + issues: [ + { + number: 99, + title: "Issue & amp", + html_url: "https://github.com/a/b/issues/99?x=1&y=2", + }, + ], + }), + baseRow("prop", { + reportBucket: "proposedReadyForUpdate", + waiApproved: false, + commitsBehindSummary: "-", + lastApprovedSummary: "-", + changes: [], + issues: [], + }), + ]; + const md = generateApprovalReportMarkdown(rows, github); + expect(md).toContain("## Approved ready for update — details"); + expect(md).toContain("## Proposed ready for update — details"); + expect(md).toContain('

'); + expect(md).toContain("Fix & <bad> "quotes""); + expect(md).toContain("definition(s): def-a, def-b"); + expect(md).toContain("&"); + expect(md).toContain("#### Changes since approval"); + expect(md).toContain("abababa"); // 7-char hash prefix in detail output + const propDetail = + md.split("## Proposed ready for update — details")[1] ?? ""; + expect(propDetail).toContain('

'); + expect(propDetail).not.toContain("#### Changes since approval"); + }); + + it("renders not-ready sort: fewer blockers before more when commits tie", () => { + const mk = ( + id: string, + blockers: number, + issues: number, + ): RuleApprovalRow => + baseRow(id, { + reportBucket: "notReady", + implementations: [], + waiApproved: false, + commitsBehindSummary: "2", + lastApprovedSummary: "-", + blockersCount: blockers, + issues: Array.from({ length: issues }, (_, i) => ({ + number: i, + title: "t", + html_url: "u", + })), + changes: [], + }); + const rows = [mk("z-many-blockers", 2, 0), mk("a-few-blockers", 1, 0)]; + const md = generateApprovalReportMarkdown(rows, github); + const sec = sectionAfterHeading(md, "## Not ready (2)"); + const iFew = sec.indexOf("[a-few-blockers](#a-few-blockers)"); + const iMany = sec.indexOf("[z-many-blockers](#z-many-blockers)"); + expect(iFew).toBeLessThan(iMany); + }); + + it("matches reportBucket type exhaustively in tests", () => { + const buckets: ReportBucket[] = [ + "approvedReadyForUpdate", + "proposedReadyForUpdate", + "approvedUpToDate", + "notReady", + ]; + for (const reportBucket of buckets) { + const r = baseRow(`id-${reportBucket}`, { reportBucket }); + if (reportBucket === "notReady") { + r.implementations = []; + r.commitsBehindSummary = "-"; + r.lastApprovedSummary = "-"; + } + if ( + reportBucket === "approvedUpToDate" || + reportBucket === "approvedReadyForUpdate" + ) { + r.waiApproved = true; + r.commitsBehindSummary = + reportBucket === "approvedUpToDate" ? "0" : "1"; + if (reportBucket === "approvedReadyForUpdate") { + r.changes = [ + { + hash: "c".repeat(40), + subject: "s", + dateIso: "", + touchedRule: true, + touchedDefinitionKeys: [], + }, + ]; + } + } + const md = generateApprovalReportMarkdown([r], github); + expect(md.length).toBeGreaterThan(100); + } + }); +}); diff --git a/src/approval-report/__tests__/git-changes.test.ts b/src/approval-report/__tests__/git-changes.test.ts new file mode 100644 index 0000000..11b36c2 --- /dev/null +++ b/src/approval-report/__tests__/git-changes.test.ts @@ -0,0 +1,180 @@ +jest.mock("node:child_process", () => ({ + execFileSync: jest.fn(), +})); + +import { execFileSync } from "node:child_process"; +import * as path from "node:path"; +import { + getChangesSinceApproval, + getLatestCommitDateOnPaths, + pathRelativeToRepo, +} from "../git-changes"; + +const execMock = execFileSync as jest.MockedFunction; + +describe("getChangesSinceApproval", () => { + afterEach(() => { + execMock.mockReset(); + }); + + it("returns empty array when paths list is empty", () => { + expect(getChangesSinceApproval("/repo", "2023-01-01", "", [])).toEqual([]); + expect(execMock).not.toHaveBeenCalled(); + }); + + it("parses git log and diff-tree into ChangeEntry with rule and glossary keys", () => { + const hashNew = "a".repeat(40); + const hashOld = "b".repeat(40); + execMock.mockImplementation((file, args) => { + if (file !== "git") throw new Error("expected git"); + const argv = args as string[]; + if (argv[0] === "log" && argv.includes("--after=2023-01-01")) { + return ( + `${hashNew}\tsubject one\t2024-02-01T10:00:00+01:00\n` + + `${hashOld}\tsecond commit\t2024-01-15T08:00:00Z\n` + ); + } + if (argv[0] === "diff-tree") { + const hash = argv[argv.length - 1]; + if (hash === hashNew) { + return "_rules/rule.md\npages/glossary/term-a.md\n"; + } + if (hash === hashOld) { + return "pages/glossary/term-z.md\n_other/ignore.txt\n"; + } + } + throw new Error(`unexpected git args: ${argv.join(" ")}`); + }); + + const out = getChangesSinceApproval( + "/repo", + "2023-01-01", + "_rules/rule.md", + ["pages/glossary/term-a.md", "pages/glossary/term-z.md"], + ); + + expect(out).toHaveLength(2); + expect(out[0]).toMatchObject({ + hash: hashNew, + subject: "subject one", + touchedRule: true, + touchedDefinitionKeys: ["term-a"], + }); + expect(out[1]).toMatchObject({ + hash: hashOld, + subject: "second commit", + touchedRule: false, + touchedDefinitionKeys: ["term-z"], + }); + expect(out[0].dateIso.localeCompare(out[1].dateIso)).toBeGreaterThan(0); + }); + + it("dedupes duplicate commit hashes in log output", () => { + const hash = "c".repeat(40); + execMock.mockImplementation((file, args) => { + const argv = args as string[]; + if (argv[0] === "log") { + return `${hash}\tone\t2024-01-01T00:00:00Z\n${hash}\tone\t2024-01-01T00:00:00Z\n`; + } + if (argv[0] === "diff-tree") { + return "_rules/r.md\n"; + } + throw new Error("unexpected"); + }); + + expect( + getChangesSinceApproval("/r", "2020-01-01", "_rules/r.md", []), + ).toHaveLength(1); + }); + + it("returns empty array when git log throws with status 0", () => { + execMock.mockImplementation(() => { + const err = new Error("no commits") as Error & { status?: number }; + err.status = 0; + throw err; + }); + expect( + getChangesSinceApproval("/r", "2020-01-01", "_rules/x.md", []), + ).toEqual([]); + }); + + it("returns empty when diff-tree fails for a commit", () => { + execMock.mockImplementation((file, args) => { + const argv = args as string[]; + if (argv[0] === "log") { + return `${"d".repeat(40)}\ts\t2024-01-01T00:00:00Z\n`; + } + if (argv[0] === "diff-tree") { + throw new Error("diff-tree failed"); + } + throw new Error("unexpected"); + }); + const out = getChangesSinceApproval("/r", "2020-01-01", "_rules/x.md", []); + expect(out).toHaveLength(1); + expect(out[0].touchedRule).toBe(false); + expect(out[0].touchedDefinitionKeys).toEqual([]); + }); +}); + +describe("getLatestCommitDateOnPaths", () => { + afterEach(() => { + execMock.mockReset(); + }); + + it("returns YYYY-MM-DD from latest commit timestamp", () => { + execMock.mockReturnValue("2024-03-20T14:30:00+00:00\n"); + expect(getLatestCommitDateOnPaths("/repo", "_rules/r.md", [])).toBe( + "2024-03-20", + ); + }); + + it("passes rule path and every glossary path to git log", () => { + execMock.mockReturnValue("2024-03-20T14:30:00+00:00\n"); + getLatestCommitDateOnPaths("/repo", "_rules/rule.md", [ + "pages/glossary/term-a.md", + "pages/glossary/term-b.md", + ]); + expect(execMock).toHaveBeenCalledTimes(1); + expect(execMock).toHaveBeenCalledWith( + "git", + [ + "log", + "-1", + "--format=%cI", + "--", + "_rules/rule.md", + "pages/glossary/term-a.md", + "pages/glossary/term-b.md", + ], + expect.objectContaining({ + cwd: "/repo", + encoding: "utf8", + }), + ); + }); + + it("returns null when paths list is empty", () => { + expect(getLatestCommitDateOnPaths("/repo", "", [])).toBeNull(); + expect(execMock).not.toHaveBeenCalled(); + }); + + it("returns null when git log fails", () => { + execMock.mockImplementation(() => { + throw new Error("git error"); + }); + expect(getLatestCommitDateOnPaths("/repo", "x.md", [])).toBeNull(); + }); + + it("returns null when git returns empty stdout", () => { + execMock.mockReturnValue(" \n"); + expect(getLatestCommitDateOnPaths("/repo", "x.md", [])).toBeNull(); + }); +}); + +describe("pathRelativeToRepo", () => { + it("returns POSIX-style relative path from repo root", () => { + const repo = path.resolve("/tmp/repo"); + const file = path.join(repo, "_rules", "x.md"); + expect(pathRelativeToRepo(repo, file)).toBe("_rules/x.md"); + }); +}); diff --git a/src/approval-report/__tests__/github-issues.test.ts b/src/approval-report/__tests__/github-issues.test.ts new file mode 100644 index 0000000..c34d161 --- /dev/null +++ b/src/approval-report/__tests__/github-issues.test.ts @@ -0,0 +1,168 @@ +jest.mock("@octokit/rest", () => ({ + Octokit: jest.fn(), +})); + +import { Octokit } from "@octokit/rest"; +import { + BLOCKER_LABEL_NAME, + fetchOpenIssues, + issueHasBlockerLabel, + issuesForRuleId, +} from "../github-issues"; +import type { GitHubIssueRef } from "../types"; + +describe("fetchOpenIssues", () => { + const listForRepo = jest.fn(); + + beforeEach(() => { + listForRepo.mockReset(); + (Octokit as unknown as jest.Mock).mockImplementation(() => ({ + paginate: { + iterator: jest.fn().mockImplementation(() => { + async function* gen() { + yield { + data: [ + { + number: 10, + title: "Issue A", + html_url: "https://github.com/o/r/issues/10", + body: "body", + labels: [{ name: "bug" }, "enhancement"], + }, + { + number: 11, + title: "A PR disguised", + html_url: "https://github.com/o/r/issues/11", + pull_request: {}, + }, + ], + }; + yield { + data: [ + { + number: 12, + title: "Second page", + html_url: "https://github.com/o/r/issues/12", + body: null, + labels: ["triage"], + }, + ], + }; + } + return gen(); + }), + }, + rest: { + issues: { + listForRepo, + }, + }, + })); + }); + + it("maps issues and excludes pull requests", async () => { + const out = await fetchOpenIssues("act-rules", "act-rules.github.io"); + expect(out).toHaveLength(2); + expect(out[0]).toMatchObject({ + number: 10, + title: "Issue A", + html_url: "https://github.com/o/r/issues/10", + body: "body", + labelNames: ["bug", "enhancement"], + }); + expect(out[1]).toMatchObject({ + number: 12, + title: "Second page", + body: null, + labelNames: ["triage"], + }); + const octokitInstance = (Octokit as unknown as jest.Mock).mock.results[0] + .value; + expect(octokitInstance.paginate.iterator).toHaveBeenCalledWith( + listForRepo, + expect.objectContaining({ + owner: "act-rules", + repo: "act-rules.github.io", + state: "open", + per_page: 100, + }), + ); + }); +}); + +describe("issuesForRuleId", () => { + const issues: GitHubIssueRef[] = [ + { + number: 1, + title: "Fix abc-123 rule", + html_url: "https://github.com/o/r/issues/1", + body: "details", + }, + { + number: 2, + title: "Unrelated", + html_url: "https://github.com/o/r/issues/2", + body: "mention xyz-999 in body", + }, + { + number: 3, + title: "No body issue", + html_url: "https://github.com/o/r/issues/3", + }, + ]; + + it("matches rule id in title case-insensitively", () => { + const matched = issuesForRuleId("ABC-123", issues); + expect(matched.map((i) => i.number)).toEqual([1]); + }); + + it("matches rule id in body case-insensitively", () => { + const matched = issuesForRuleId("xyz-999", issues); + expect(matched.map((i) => i.number)).toEqual([2]); + }); + + it("treats missing body as empty string", () => { + const matched = issuesForRuleId("no-body-issue", [ + { number: 1, title: "x", html_url: "u", body: undefined }, + ]); + expect(matched).toHaveLength(0); + }); + + it("returns empty when no match", () => { + expect(issuesForRuleId("not-found", issues)).toEqual([]); + }); +}); + +describe("issueHasBlockerLabel", () => { + it(`returns true when ${BLOCKER_LABEL_NAME} is present`, () => { + expect( + issueHasBlockerLabel({ + number: 1, + title: "t", + html_url: "u", + labelNames: ["Bug", BLOCKER_LABEL_NAME], + }), + ).toBe(true); + }); + + it("returns false when labelNames is missing", () => { + expect( + issueHasBlockerLabel({ + number: 1, + title: "t", + html_url: "u", + }), + ).toBe(false); + }); + + it("returns false when labelNames is empty", () => { + expect( + issueHasBlockerLabel({ + number: 1, + title: "t", + html_url: "u", + labelNames: [], + }), + ).toBe(false); + }); +}); diff --git a/src/approval-report/__tests__/load-data.test.ts b/src/approval-report/__tests__/load-data.test.ts new file mode 100644 index 0000000..a3d120a --- /dev/null +++ b/src/approval-report/__tests__/load-data.test.ts @@ -0,0 +1,134 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { + loadApprovalByRuleId, + loadCompleteImplementationsByRuleId, +} from "../load-data"; + +function writeTree(root: string, files: Record): void { + for (const [rel, content] of Object.entries(files)) { + const abs = path.join(root, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, content, "utf8"); + } +} + +describe("loadApprovalByRuleId", () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "act-approval-")); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it("marks approved when index.md entry has isoDate string", () => { + writeTree(dir, { + "_data/wcag-act-rules/rule-versions.yml": ` +rule-a: + - file: other.md + - file: index.md + isoDate: "2023-05-10" +`, + }); + const out = loadApprovalByRuleId(dir); + expect(out["rule-a"]).toEqual({ + approved: true, + approvalIsoDate: "2023-05-10", + }); + }); + + it("normalizes Date parsed by YAML to ISO date string", () => { + writeTree(dir, { + "_data/wcag-act-rules/rule-versions.yml": ` +rule-b: + - file: index.md + isoDate: 2023-06-15 +`, + }); + const out = loadApprovalByRuleId(dir); + expect(out["rule-b"]?.approved).toBe(true); + expect(out["rule-b"]?.approvalIsoDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + + it("marks not approved when index.md has no isoDate", () => { + writeTree(dir, { + "_data/wcag-act-rules/rule-versions.yml": ` +rule-c: + - file: index.md +`, + }); + expect(loadApprovalByRuleId(dir)["rule-c"]).toEqual({ approved: false }); + }); + + it("omits rule keys when entries value is not an array", () => { + writeTree(dir, { + "_data/wcag-act-rules/rule-versions.yml": ` +rule-d: "broken" +`, + }); + expect(loadApprovalByRuleId(dir)["rule-d"]).toBeUndefined(); + }); + + it("skips empty isoDate on index.md", () => { + writeTree(dir, { + "_data/wcag-act-rules/rule-versions.yml": ` +rule-e: + - file: index.md + isoDate: "" +`, + }); + expect(loadApprovalByRuleId(dir)["rule-e"]).toEqual({ approved: false }); + }); +}); + +describe("loadCompleteImplementationsByRuleId", () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), "act-impl-")); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it("aggregates complete mappings from multiple JSON files and sorts names", () => { + writeTree(dir, { + "_data/wcag-act-rules/implementations/zebra.json": JSON.stringify({ + name: "Zebra", + actRuleMapping: [ + { ruleId: "r1", consistency: "complete" }, + { ruleId: "r2", consistency: "partial" }, + ], + }), + "_data/wcag-act-rules/implementations/apple.json": JSON.stringify({ + name: "Apple", + actRuleMapping: [{ ruleId: "r1", consistency: "complete" }], + }), + }); + const out = loadCompleteImplementationsByRuleId(dir); + expect(out["r1"]).toEqual(["Apple", "Zebra"]); + expect(out["r2"]).toBeUndefined(); + }); + + it("uses basename when name is missing", () => { + writeTree(dir, { + "_data/wcag-act-rules/implementations/custom.json": JSON.stringify({ + actRuleMapping: [{ ruleId: "rx", consistency: "complete" }], + }), + }); + expect(loadCompleteImplementationsByRuleId(dir)["rx"]).toEqual(["custom"]); + }); + + it("returns empty object when implementations dir has no json", () => { + fs.mkdirSync(path.join(dir, "_data/wcag-act-rules/implementations"), { + recursive: true, + }); + expect(loadCompleteImplementationsByRuleId(dir)).toEqual({}); + }); +}); diff --git a/src/approval-report/__tests__/rule-type-summary.test.ts b/src/approval-report/__tests__/rule-type-summary.test.ts new file mode 100644 index 0000000..15971ae --- /dev/null +++ b/src/approval-report/__tests__/rule-type-summary.test.ts @@ -0,0 +1,94 @@ +import type { RulePage } from "../../types"; +import { + buildAtomicIdsReferencedByComposites, + getRuleTypeSummary, +} from "../rule-type-summary"; + +function atomicRule( + id: string, + overrides: Partial = {}, +): RulePage { + return { + body: "", + markdownAST: { type: "root", children: [] } as RulePage["markdownAST"], + filename: `${id}.md`, + assets: {}, + frontmatter: { + id, + name: id, + rule_type: "atomic", + description: "", + input_aspects: [], + ...overrides, + } as RulePage["frontmatter"], + }; +} + +function compositeRule( + id: string, + input_rules: string[], + overrides: Partial = {}, +): RulePage { + return { + body: "", + markdownAST: { type: "root", children: [] } as RulePage["markdownAST"], + filename: `${id}.md`, + assets: {}, + frontmatter: { + id, + name: id, + rule_type: "composite", + description: "", + input_rules, + ...overrides, + } as RulePage["frontmatter"], + }; +} + +describe("buildAtomicIdsReferencedByComposites", () => { + it("collects input_rules from all composite rules", () => { + const rules: RulePage[] = [ + compositeRule("c1", ["a1", "a2"]), + compositeRule("c2", ["a2", "a3"]), + ]; + const set = buildAtomicIdsReferencedByComposites(rules); + expect([...set].sort()).toEqual(["a1", "a2", "a3"]); + }); + + it("dedupes ids referenced by multiple composites", () => { + const rules: RulePage[] = [ + compositeRule("c1", ["x"]), + compositeRule("c2", ["x"]), + ]; + expect(buildAtomicIdsReferencedByComposites(rules)).toEqual(new Set(["x"])); + }); + + it("ignores atomic rules", () => { + const rules: RulePage[] = [atomicRule("a1"), compositeRule("c1", ["a1"])]; + expect(buildAtomicIdsReferencedByComposites(rules)).toEqual( + new Set(["a1"]), + ); + }); +}); + +describe("getRuleTypeSummary", () => { + const referenced = new Set(["used-in-composite"]); + + it('returns "composite" for composite rules', () => { + expect(getRuleTypeSummary(compositeRule("c1", ["a"]), referenced)).toBe( + "composite", + ); + }); + + it('returns "composed" when atomic id is referenced by a composite', () => { + expect( + getRuleTypeSummary(atomicRule("used-in-composite"), referenced), + ).toBe("composed"); + }); + + it('returns "atomic" for standalone atomics', () => { + expect(getRuleTypeSummary(atomicRule("standalone"), referenced)).toBe( + "atomic", + ); + }); +}); diff --git a/src/approval-report/__tests__/run.test.ts b/src/approval-report/__tests__/run.test.ts new file mode 100644 index 0000000..a17dd0f --- /dev/null +++ b/src/approval-report/__tests__/run.test.ts @@ -0,0 +1,80 @@ +jest.mock("@octokit/rest", () => ({ + Octokit: jest.fn(), +})); + +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { runApprovalReport } from "../run"; +import type { ApprovalReportOptions } from "../types"; + +jest.mock("../build-rule-approval-rows", () => { + const actual = jest.requireActual("../build-rule-approval-rows") as { + buildRuleApprovalRows: typeof import("../build-rule-approval-rows").buildRuleApprovalRows; + }; + return { + ...actual, + buildRuleApprovalRows: jest.fn(actual.buildRuleApprovalRows), + }; +}); + +import { buildRuleApprovalRows } from "../build-rule-approval-rows"; + +const buildRowsMock = buildRuleApprovalRows as jest.MockedFunction< + typeof buildRuleApprovalRows +>; + +const baseOpts: ApprovalReportOptions = { + rulesDir: "/rules", + glossaryDir: "/glossary", + testAssetsDir: "/assets", + actRulesRepo: "/repo", + wcagActRulesDir: "/wcag", + outFile: "/out/report.md", + githubOwner: "o", + githubRepo: "r", +}; + +describe("runApprovalReport", () => { + let tmp: string; + let outFile: string; + + beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), "act-run-")); + outFile = path.join(tmp, "nested", "out.md"); + buildRowsMock.mockResolvedValue([ + { + ruleId: "only", + name: "Only rule", + ruleTypeSummary: "atomic", + waiApproved: true, + reportBucket: "approvedUpToDate", + implementations: ["axe"], + issues: [], + changes: [], + approvalIsoDate: "2023-01-01", + lastApprovedSummary: "2023-01-01", + lastUpdatedSummary: "2024-01-01", + commitsBehindSummary: "0", + blockersCount: 0, + }, + ]); + }); + + afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it("writes markdown from rows and logs path", async () => { + const log = jest.spyOn(console, "log").mockImplementation(() => undefined); + await runApprovalReport({ ...baseOpts, outFile }); + const written = fs.readFileSync(outFile, "utf8"); + expect(written).toContain("# ACT rules ready for approval"); + expect(written).toContain("[only](#only)"); + expect(log).toHaveBeenCalledWith( + expect.stringContaining(path.resolve(outFile)), + ); + expect(log).toHaveBeenCalledWith(expect.stringContaining("(1 rules)")); + log.mockRestore(); + }); +}); diff --git a/src/approval-report/build-rule-approval-rows.ts b/src/approval-report/build-rule-approval-rows.ts new file mode 100644 index 0000000..37c8fc8 --- /dev/null +++ b/src/approval-report/build-rule-approval-rows.ts @@ -0,0 +1,156 @@ +import * as path from "node:path"; +import { getRuleDefinitions } from "../act/get-rule-definitions"; +import { getDefinitionPages, getRulePages } from "../utils/get-page-data"; +import { + getChangesSinceApproval, + getLatestCommitDateOnPaths, + pathRelativeToRepo, +} from "./git-changes"; +import { + fetchOpenIssues, + issueHasBlockerLabel, + issuesForRuleId, +} from "./github-issues"; +import { + loadApprovalByRuleId, + loadCompleteImplementationsByRuleId, +} from "./load-data"; +import { + buildAtomicIdsReferencedByComposites, + getRuleTypeSummary, +} from "./rule-type-summary"; +import { + ApprovalReportOptions, + ChangeEntry, + GitHubIssueRef, + ReportBucket, + RuleApprovalRow, +} from "./types"; + +function stripIssueBody(issues: GitHubIssueRef[]): GitHubIssueRef[] { + return issues.map(({ number, title, html_url }) => ({ + number, + title, + html_url, + })); +} + +export type ApprovalReportDeps = { + getRulePages: typeof getRulePages; + getDefinitionPages: typeof getDefinitionPages; + loadApprovalByRuleId: typeof loadApprovalByRuleId; + loadCompleteImplementationsByRuleId: typeof loadCompleteImplementationsByRuleId; + fetchOpenIssues: typeof fetchOpenIssues; + getRuleDefinitions: typeof getRuleDefinitions; + getChangesSinceApproval: typeof getChangesSinceApproval; + getLatestCommitDateOnPaths: typeof getLatestCommitDateOnPaths; + pathRelativeToRepo: typeof pathRelativeToRepo; +}; + +const defaultDeps: ApprovalReportDeps = { + getRulePages, + getDefinitionPages, + loadApprovalByRuleId, + loadCompleteImplementationsByRuleId, + fetchOpenIssues, + getRuleDefinitions, + getChangesSinceApproval, + getLatestCommitDateOnPaths, + pathRelativeToRepo, +}; + +export async function buildRuleApprovalRows( + opts: ApprovalReportOptions, + deps: Partial = {}, +): Promise { + const d: ApprovalReportDeps = { ...defaultDeps, ...deps }; + const rules = d.getRulePages(opts.rulesDir, opts.testAssetsDir); + const glossary = d.getDefinitionPages(opts.glossaryDir); + const approvalById = d.loadApprovalByRuleId(opts.wcagActRulesDir); + const implById = d.loadCompleteImplementationsByRuleId(opts.wcagActRulesDir); + const openIssues = await d.fetchOpenIssues(opts.githubOwner, opts.githubRepo); + const referencedAtomicIds = buildAtomicIdsReferencedByComposites(rules); + + const rows: RuleApprovalRow[] = []; + for (const rule of rules) { + const ruleId = rule.frontmatter.id; + if (rule.frontmatter.deprecated) continue; + + const approval = approvalById[ruleId] ?? { approved: false }; + const implementations = implById[ruleId] ?? []; + const matched = issuesForRuleId(ruleId, openIssues); + const blockersCount = matched.filter(issueHasBlockerLabel).length; + const issues = stripIssueBody(matched); + + const ruleRel = d.pathRelativeToRepo( + opts.actRulesRepo, + path.join(opts.rulesDir, rule.filename), + ); + const defs = d.getRuleDefinitions( + { markdownAST: rule.markdownAST }, + glossary, + ); + const glossaryPaths = defs.map((def) => + d.pathRelativeToRepo( + opts.actRulesRepo, + path.join(opts.glossaryDir, def.filename), + ), + ); + + let changes: ChangeEntry[] = []; + if (approval.approved && approval.approvalIsoDate) { + changes = d.getChangesSinceApproval( + opts.actRulesRepo, + approval.approvalIsoDate, + ruleRel, + glossaryPaths, + ); + } + + const lastUpdatedRaw = d.getLatestCommitDateOnPaths( + opts.actRulesRepo, + ruleRel, + glossaryPaths, + ); + const lastUpdatedSummary = lastUpdatedRaw ?? "-"; + const ruleTypeSummary = getRuleTypeSummary(rule, referencedAtomicIds); + const waiApproved = Boolean(approval.approved && approval.approvalIsoDate); + const lastApprovedSummary = approval.approvalIsoDate ?? "-"; + const commitsBehindSummary = waiApproved ? String(changes.length) : "-"; + + const hasCompleteImpl = implementations.length > 0; + const hasBlockers = blockersCount > 0; + + let reportBucket: ReportBucket; + if (!hasCompleteImpl || hasBlockers) { + reportBucket = "notReady"; + } else if (waiApproved) { + reportBucket = + changes.length > 0 ? "approvedReadyForUpdate" : "approvedUpToDate"; + } else { + reportBucket = "proposedReadyForUpdate"; + } + + rows.push({ + ruleId, + name: rule.frontmatter.name, + ruleTypeSummary, + compositeInputs: + rule.frontmatter.rule_type === "composite" + ? [...rule.frontmatter.input_rules] + : undefined, + waiApproved, + reportBucket, + implementations, + issues, + changes, + approvalIsoDate: approval.approvalIsoDate, + lastApprovedSummary, + lastUpdatedSummary, + commitsBehindSummary, + blockersCount, + }); + } + + return rows; +} diff --git a/src/approval-report/generate-report.ts b/src/approval-report/generate-report.ts new file mode 100644 index 0000000..b4b9483 --- /dev/null +++ b/src/approval-report/generate-report.ts @@ -0,0 +1,416 @@ +import markdownTable from "markdown-table"; + +import { ChangeEntry, ReportBucket, RuleApprovalRow } from "./types"; + +type GithubRepoRef = { owner: string; repo: string }; + +type SectionTableKind = + | "approvedNeedsUpdate" + | "proposedNeedsUpdate" + | "approvedCurrent" + | "notReady"; + +const WAI_IMPLEMENTATIONS_BASE = + "https://www.w3.org/WAI/standards-guidelines/act/rules"; + +const GITHUB_OPEN_ISSUES_SEARCH = + "https://github.com/act-rules/act-rules.github.io/issues?utf8=%E2%9C%93&q=is:issue+is:open+"; + +const GITHUB_BLOCKER_ISSUES_SEARCH = + "https://github.com/act-rules/act-rules.github.io/issues?utf8=%E2%9C%93&q=is:issue+is:open+label:Blocker+"; + +export function generateApprovalReportMarkdown( + rows: RuleApprovalRow[], + github: GithubRepoRef, +): string { + const generated = new Date().toISOString().slice(0, 10); + const approvedUpdate = sortBucketRows( + byBucket(rows, "approvedReadyForUpdate"), + "approvedNeedsUpdate", + ); + const proposedUpdate = sortBucketRows( + byBucket(rows, "proposedReadyForUpdate"), + "proposedNeedsUpdate", + ); + const approvedOk = sortBucketRows( + byBucket(rows, "approvedUpToDate"), + "approvedCurrent", + ); + const notReady = sortBucketRows(byBucket(rows, "notReady"), "notReady"); + + const sections: string[] = [ + "# ACT rules ready for approval", + "", + `Generated: ${generated}`, + "", + formatSection( + "Approved ready for update", + approvedUpdate, + "approvedNeedsUpdate", + ), + formatSection( + "Proposed ready for update", + proposedUpdate, + "proposedNeedsUpdate", + ), + formatSection("Approved, up to date", approvedOk, "approvedCurrent"), + formatSection("Not ready", notReady, "notReady"), + ]; + + const approvedDetail = approvedUpdate; + const proposedDetail = proposedUpdate; + + if (approvedDetail.length > 0) { + sections.push("## Approved ready for update — details", ""); + for (const r of approvedDetail) { + sections.push(...renderRuleSection(r, true, github)); + } + } + + if (proposedDetail.length > 0) { + sections.push("## Proposed ready for update — details", ""); + for (const r of proposedDetail) { + sections.push(...renderRuleSection(r, false, github)); + } + } + + return sections.join("\n").trimEnd() + "\n"; +} + +/** + * Order rows for a summary table: commits behind (desc, when column exists), blockers (asc, when + * column exists), issues (asc). Each composite and its in-bucket composed inputs form one unit + * ordered by the composite's keys; composed rows sit directly under that composite. If several + * composites list the same input, the lexicographically greatest composite id is the parent. + */ +function sortBucketRows( + bucket: RuleApprovalRow[], + kind: SectionTableKind, +): RuleApprovalRow[] { + const cmp = (a: RuleApprovalRow, b: RuleApprovalRow) => + compareRowsForTable(a, b, kind); + + const compositeRows = bucket.filter((r) => r.ruleTypeSummary === "composite"); + const compositeIds = new Set(compositeRows.map((r) => r.ruleId)); + + function parentCompositeIdForRow(row: RuleApprovalRow): string | null { + if (row.ruleTypeSummary !== "composed") return null; + const parentIds = compositeRows + .filter((c) => c.compositeInputs?.includes(row.ruleId)) + .map((c) => c.ruleId); + if (parentIds.length === 0) return null; + return parentIds.sort((x, y) => y.localeCompare(x))[0]; + } + + const childRuleIds = new Set(); + for (const r of bucket) { + if (r.ruleTypeSummary !== "composed") continue; + const p = parentCompositeIdForRow(r); + if (p !== null && compositeIds.has(p)) childRuleIds.add(r.ruleId); + } + + type Unit = + | { kind: "group"; head: RuleApprovalRow; children: RuleApprovalRow[] } + | { kind: "singleton"; row: RuleApprovalRow }; + + const units: Unit[] = []; + + for (const comp of compositeRows) { + const children = bucket.filter( + (r) => + r.ruleTypeSummary === "composed" && + parentCompositeIdForRow(r) === comp.ruleId, + ); + children.sort(cmp); + units.push({ kind: "group", head: comp, children }); + } + + for (const r of bucket) { + if (r.ruleTypeSummary === "composite") continue; + if (childRuleIds.has(r.ruleId)) continue; + units.push({ kind: "singleton", row: r }); + } + + units.sort((u, v) => { + const a = u.kind === "group" ? u.head : u.row; + const b = v.kind === "group" ? v.head : v.row; + return cmp(a, b); + }); + + const result: RuleApprovalRow[] = []; + for (const u of units) { + if (u.kind === "group") { + result.push(u.head); + result.push(...u.children); + } else { + result.push(u.row); + } + } + return result; +} + +function compareRowsForTable( + a: RuleApprovalRow, + b: RuleApprovalRow, + kind: SectionTableKind, +): number { + const useCommits = kind === "approvedNeedsUpdate" || kind === "notReady"; + const useBlockers = kind === "notReady"; + + if (useCommits) { + const ca = commitsSortKey(a); + const cb = commitsSortKey(b); + if (cb !== ca) return cb - ca; + } + if (useBlockers && a.blockersCount !== b.blockersCount) { + return a.blockersCount - b.blockersCount; + } + if (a.issues.length !== b.issues.length) { + return a.issues.length - b.issues.length; + } + return a.ruleId.localeCompare(b.ruleId); +} + +/** Escape `|` and newlines for pipe-table cells (Markdown). */ +function escMdTableCell(s: string): string { + return s.replace(/\n/g, " ").replace(/\|/g, "\\|"); +} + +/** For use inside double-quoted HTML attributes (e.g. href). */ +function escAttrHref(url: string): string { + return url.replace(/&/g, "&").replace(/"/g, """); +} + +/** Escape text inside HTML elements. */ +function escHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function waiImplementationsUrl(ruleId: string): string { + return `${WAI_IMPLEMENTATIONS_BASE}/${ruleId}/proposed/#implementations`; +} + +function githubOpenIssuesSearchUrl(ruleId: string): string { + return `${GITHUB_OPEN_ISSUES_SEARCH}${ruleId}+`; +} + +function githubBlockerIssuesSearchUrl(ruleId: string): string { + return `${GITHUB_BLOCKER_ISSUES_SEARCH}${ruleId}+`; +} + +function waiStatusLabel(r: RuleApprovalRow): string { + return r.waiApproved ? "approved" : "proposed"; +} + +function baseRowCells(r: RuleApprovalRow, includeStatus: boolean): string[] { + const cells = [ + `[${r.ruleId}](#${r.ruleId})`, + escMdTableCell(r.name), + escMdTableCell(r.ruleTypeSummary), + ]; + if (includeStatus) { + cells.push(escMdTableCell(waiStatusLabel(r))); + } + return cells; +} + +function implIssuesCells(r: RuleApprovalRow): string[] { + const nImpl = r.implementations.length; + const nIssues = r.issues.length; + return [ + `[${nImpl}](${waiImplementationsUrl(r.ruleId)})`, + `[${nIssues}](${githubOpenIssuesSearchUrl(r.ruleId)})`, + ]; +} + +function implIssuesBlockersCells(r: RuleApprovalRow): string[] { + const nBlockers = r.blockersCount; + return [ + ...implIssuesCells(r), + `[${nBlockers}](${githubBlockerIssuesSearchUrl(r.ruleId)})`, + ]; +} + +function markdownTableOrNone(headers: string[], dataRows: string[][]): string { + if (dataRows.length === 0) { + return "_None._\n\n"; + } + return markdownTable([headers, ...dataRows]) + "\n\n"; +} + +function commitsSortKey(r: RuleApprovalRow): number { + const s = r.commitsBehindSummary; + if (s === "-") return -1; + const n = Number(s); + return Number.isFinite(n) ? n : 0; +} + +function formatSection( + title: string, + bucketRows: RuleApprovalRow[], + kind: SectionTableKind, +): string { + const n = bucketRows.length; + const lines: string[] = [`## ${title} (${n})`, ""]; + + let headers: string[]; + let dataRows: string[][]; + + switch (kind) { + case "approvedNeedsUpdate": + headers = [ + "Rule ID", + "Name", + "Type", + "Last approved", + "Last updated", + "Commits behind", + "Implementations", + "Issues", + ]; + dataRows = bucketRows.map((r) => [ + ...baseRowCells(r, false), + escMdTableCell(r.lastApprovedSummary), + escMdTableCell(r.lastUpdatedSummary), + escMdTableCell(r.commitsBehindSummary), + ...implIssuesCells(r), + ]); + break; + case "proposedNeedsUpdate": + headers = [ + "Rule ID", + "Name", + "Type", + "Last updated", + "Implementations", + "Issues", + ]; + dataRows = bucketRows.map((r) => [ + ...baseRowCells(r, false), + escMdTableCell(r.lastUpdatedSummary), + ...implIssuesCells(r), + ]); + break; + case "approvedCurrent": + headers = [ + "Rule ID", + "Name", + "Type", + "Last approved", + "Implementations", + "Issues", + ]; + dataRows = bucketRows.map((r) => [ + ...baseRowCells(r, false), + escMdTableCell(r.lastApprovedSummary), + ...implIssuesCells(r), + ]); + break; + case "notReady": + headers = [ + "Rule ID", + "Name", + "Type", + "Status", + "Last approved", + "Last updated", + "Commits behind", + "Implementations", + "Issues", + "Blockers", + ]; + dataRows = bucketRows.map((r) => [ + ...baseRowCells(r, true), + escMdTableCell(r.lastApprovedSummary), + escMdTableCell(r.lastUpdatedSummary), + escMdTableCell(r.commitsBehindSummary), + ...implIssuesBlockersCells(r), + ]); + break; + } + + lines.push(markdownTableOrNone(headers, dataRows)); + return lines.join("\n"); +} + +function byBucket( + rows: RuleApprovalRow[], + bucket: ReportBucket, +): RuleApprovalRow[] { + return rows.filter((r) => r.reportBucket === bucket); +} + +function formatChangeLine( + c: ChangeEntry, + githubOwner: string, + githubRepo: string, +): string { + const short = c.hash.slice(0, 7); + const commitUrl = `https://github.com/${githubOwner}/${githubRepo}/commit/${c.hash}`; + const parts: string[] = []; + if (c.touchedRule) parts.push("rule"); + if (c.touchedDefinitionKeys.length > 0) { + parts.push(`definition(s): ${c.touchedDefinitionKeys.join(", ")}`); + } + const suffix = parts.length > 0 ? ` (${parts.join("; ")})` : ""; + const hashLink = `${escHtml(short)}`; + return `${hashLink} ${escHtml(c.subject)}${escHtml(suffix)}`; +} + +function renderRuleSection( + r: RuleApprovalRow, + includeChanges: boolean, + github: GithubRepoRef, +): string[] { + const headingText = `${r.ruleId} — ${r.name}`; + const lines: string[] = [ + `

${escHtml(headingText)}

`, + "", + ]; + if (r.approvalIsoDate) { + lines.push(`**Approved (snapshot):** ${r.approvalIsoDate}`, ""); + } + if (r.implementations.length > 0) { + lines.push( + `**Implementations (complete):** ${r.implementations.join(", ")}`, + "", + ); + } else { + lines.push("**Implementations (complete):** _none_", ""); + } + + if (includeChanges && r.changes.length > 0) { + lines.push("#### Changes since approval", ""); + for (const c of r.changes) { + lines.push(`- ${formatChangeLine(c, github.owner, github.repo)}`); + } + lines.push(""); + } else if (includeChanges) { + lines.push( + "#### Changes since approval", + "", + "_No commits after approval date._", + "", + ); + } + + lines.push("#### Issues", ""); + if (r.issues.length === 0) { + lines.push("_None matched (by rule id in title or body)._", ""); + } else { + for (const issue of r.issues) { + const label = `#${issue.number}: ${issue.title.replace(/\s+/g, " ")}`; + lines.push( + `- ${escHtml(label)}`, + ); + } + lines.push(""); + } + + lines.push("---", ""); + return lines; +} diff --git a/src/approval-report/git-changes.ts b/src/approval-report/git-changes.ts new file mode 100644 index 0000000..82063e7 --- /dev/null +++ b/src/approval-report/git-changes.ts @@ -0,0 +1,150 @@ +import { execFileSync } from "node:child_process"; +import * as path from "node:path"; +import { ChangeEntry } from "./types"; + +/** + * Commits touching any of the given paths after `afterIsoDate` (YYYY-MM-DD), + * deduplicated by commit hash, newest first. + */ +export function getChangesSinceApproval( + actRulesRepo: string, + afterIsoDate: string, + ruleRepoRelPath: string, + glossaryRepoRelPaths: string[], +): ChangeEntry[] { + const paths = [ + toPosix(ruleRepoRelPath), + ...glossaryRepoRelPaths.map(toPosix), + ].filter(Boolean); + if (paths.length === 0) return []; + + let logOut: string; + try { + logOut = execFileSync( + "git", + [ + "log", + `--after=${afterIsoDate}`, + "--format=%H\t%s\t%cI", + "--", + ...paths, + ], + { + cwd: actRulesRepo, + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + }, + ); + } catch (err: unknown) { + const e = err as { status?: number }; + if (e.status === 0) return []; + throw err; + } + + const lines = logOut + .trim() + .split("\n") + .filter((line) => line.length > 0); + const seen = new Map(); + + for (const line of lines) { + const tab = line.indexOf("\t"); + const tab2 = line.indexOf("\t", tab + 1); + if (tab < 0 || tab2 < 0) continue; + const hash = line.slice(0, tab); + const subject = line.slice(tab + 1, tab2); + const dateIso = line.slice(tab2 + 1); + if (seen.has(hash)) continue; + + let files: string[]; + try { + const names = execFileSync( + "git", + ["diff-tree", "--no-commit-id", "--name-only", "-r", hash], + { + cwd: actRulesRepo, + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + }, + ); + files = names + .trim() + .split("\n") + .filter((f) => f.length > 0) + .map(toPosix); + } catch { + files = []; + } + + const ruleNorm = toPosix(ruleRepoRelPath); + const glossarySet = new Set(glossaryRepoRelPaths.map(toPosix)); + const touchedRule = files.some((f) => f === ruleNorm); + const touchedDefinitionKeys: string[] = []; + for (const f of files) { + if (!glossarySet.has(f)) continue; + const key = glossaryKeyFromPath(f); + if (key) touchedDefinitionKeys.push(key); + } + touchedDefinitionKeys.sort(); + + seen.set(hash, { + hash, + subject, + dateIso, + touchedRule, + touchedDefinitionKeys, + }); + } + + return Array.from(seen.values()).sort((a, b) => + b.dateIso.localeCompare(a.dateIso), + ); +} + +function toPosix(p: string): string { + return p.split(path.sep).join("/"); +} + +function glossaryKeyFromPath(repoRelPath: string): string | undefined { + const m = repoRelPath.match(/^pages\/glossary\/(.+)\.md$/); + return m ? m[1] : undefined; +} + +const isoDateFromGitLog = (cI: string): string => cI.trim().slice(0, 10); + +/** + * Latest commit date (YYYY-MM-DD) touching the rule file or any glossary path. + */ +export function getLatestCommitDateOnPaths( + actRulesRepo: string, + ruleRepoRelPath: string, + glossaryRepoRelPaths: string[], +): string | null { + const paths = [ + toPosix(ruleRepoRelPath), + ...glossaryRepoRelPaths.map(toPosix), + ].filter(Boolean); + if (paths.length === 0) return null; + + let out: string; + try { + out = execFileSync("git", ["log", "-1", "--format=%cI", "--", ...paths], { + cwd: actRulesRepo, + encoding: "utf8", + maxBuffer: 32 * 1024 * 1024, + }).trim(); + } catch { + return null; + } + if (!out) return null; + return isoDateFromGitLog(out); +} + +export function pathRelativeToRepo( + repoRoot: string, + absolutePath: string, +): string { + return toPosix( + path.relative(path.resolve(repoRoot), path.resolve(absolutePath)), + ); +} diff --git a/src/approval-report/github-issues.ts b/src/approval-report/github-issues.ts new file mode 100644 index 0000000..42b445d --- /dev/null +++ b/src/approval-report/github-issues.ts @@ -0,0 +1,76 @@ +import { Octokit } from "@octokit/rest"; + +import { GitHubIssueRef } from "./types"; + +type IssueBatch = Array<{ + number: number; + title: string; + html_url: string; + body?: string | null; + labels?: Array<{ name?: string } | string>; + pull_request?: unknown; +}>; + +/** GitHub label name that counts as a blocker for the summary table */ +export const BLOCKER_LABEL_NAME = "Blocker"; + +export async function fetchOpenIssues( + owner: string, + repo: string, +): Promise { + const octokit = new Octokit({ + auth: process.env.GITHUB_TOKEN, + }); + + const ghResponses = octokit.paginate.iterator( + octokit.rest.issues.listForRepo, + { + owner, + repo, + state: "open", + per_page: 100, + }, + ); + + const issues: GitHubIssueRef[] = []; + for await (const response of ghResponses) { + const batch = response.data as IssueBatch; + for (const issue of batch) { + if (issue.pull_request) continue; + issues.push({ + number: issue.number, + title: issue.title, + html_url: issue.html_url, + body: issue.body, + labelNames: labelNamesFromIssue(issue), + }); + } + } + + return issues; +} + +function labelNamesFromIssue(issue: { + labels?: Array<{ name?: string } | string>; +}): string[] { + const labels = issue.labels ?? []; + return labels + .map((l) => (typeof l === "string" ? l : (l.name ?? ""))) + .filter(Boolean); +} + +export function issuesForRuleId( + ruleId: string, + issues: GitHubIssueRef[], +): GitHubIssueRef[] { + const id = ruleId.toLowerCase(); + return issues.filter((i) => { + const t = i.title.toLowerCase(); + const b = (i.body ?? "").toLowerCase(); + return t.includes(id) || b.includes(id); + }); +} + +export function issueHasBlockerLabel(issue: GitHubIssueRef): boolean { + return (issue.labelNames ?? []).includes(BLOCKER_LABEL_NAME); +} diff --git a/src/approval-report/load-data.ts b/src/approval-report/load-data.ts new file mode 100644 index 0000000..6ff59e6 --- /dev/null +++ b/src/approval-report/load-data.ts @@ -0,0 +1,81 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as yaml from "js-yaml"; +import globby from "globby"; + +import { ApprovalRecord } from "./types"; + +type RuleVersionEntry = { + file: string; + url?: string; + /** YAML may parse plain dates as `Date` */ + isoDate?: string | Date; + w3cDate?: string; + changes?: string[]; +}; + +export function loadApprovalByRuleId( + wcagActRulesDir: string, +): Record { + const ymlPath = path.join( + wcagActRulesDir, + "_data", + "wcag-act-rules", + "rule-versions.yml", + ); + const raw = fs.readFileSync(ymlPath, "utf8"); + const doc = yaml.load(raw) as Record; + const out: Record = {}; + + for (const [ruleId, entries] of Object.entries(doc)) { + if (!Array.isArray(entries)) continue; + const indexEntry = entries.find((e) => e.file === "index.md"); + if (indexEntry?.isoDate != null && indexEntry.isoDate !== "") { + const iso = indexEntry.isoDate; + const approvalIsoDate = + iso instanceof Date ? iso.toISOString().slice(0, 10) : String(iso); + out[ruleId] = { approved: true, approvalIsoDate }; + } else { + out[ruleId] = { approved: false }; + } + } + + return out; +} + +/** ruleId -> sorted unique implementer names with consistency "complete" */ +export function loadCompleteImplementationsByRuleId( + wcagActRulesDir: string, +): Record { + const implDir = path.join( + wcagActRulesDir, + "_data", + "wcag-act-rules", + "implementations", + ); + const files = globby.sync(path.join(implDir, "*.json")); + const byRule: Record> = {}; + + for (const file of files) { + const json = JSON.parse(fs.readFileSync(file, "utf8")) as { + name?: string; + actRuleMapping?: Array<{ + ruleId: string; + consistency?: string; + }>; + }; + const toolName = json.name ?? path.basename(file, ".json"); + for (const row of json.actRuleMapping ?? []) { + if (row.consistency !== "complete") continue; + const ruleId = row.ruleId; + if (!byRule[ruleId]) byRule[ruleId] = new Set(); + byRule[ruleId].add(toolName); + } + } + + const result: Record = {}; + for (const [ruleId, toolNames] of Object.entries(byRule)) { + result[ruleId] = Array.from(toolNames).sort((a, b) => a.localeCompare(b)); + } + return result; +} diff --git a/src/approval-report/rule-type-summary.ts b/src/approval-report/rule-type-summary.ts new file mode 100644 index 0000000..0aa2f07 --- /dev/null +++ b/src/approval-report/rule-type-summary.ts @@ -0,0 +1,29 @@ +import { RulePage } from "../types"; +import type { RuleTypeSummary } from "./types"; + +/** + * All atomic rule ids that appear in at least one composite rule's `input_rules` + * (same source as ACT Rules Format; composites only reference atomics). + */ +export function buildAtomicIdsReferencedByComposites( + rules: RulePage[], +): Set { + const ids = new Set(); + for (const r of rules) { + if (r.frontmatter.rule_type !== "composite") continue; + for (const inputId of r.frontmatter.input_rules) { + ids.add(inputId); + } + } + return ids; +} + +export function getRuleTypeSummary( + rule: RulePage, + referencedAtomicIds: Set, +): RuleTypeSummary { + const fm = rule.frontmatter; + if (fm.rule_type === "composite") return "composite"; + if (referencedAtomicIds.has(fm.id)) return "composed"; + return "atomic"; +} diff --git a/src/approval-report/run.ts b/src/approval-report/run.ts new file mode 100644 index 0000000..5938d0e --- /dev/null +++ b/src/approval-report/run.ts @@ -0,0 +1,19 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; + +import { buildRuleApprovalRows } from "./build-rule-approval-rows"; +import { generateApprovalReportMarkdown } from "./generate-report"; +import { ApprovalReportOptions } from "./types"; + +export async function runApprovalReport( + opts: ApprovalReportOptions, +): Promise { + const rows = await buildRuleApprovalRows(opts); + const md = generateApprovalReportMarkdown(rows, { + owner: opts.githubOwner, + repo: opts.githubRepo, + }); + fs.mkdirSync(path.dirname(path.resolve(opts.outFile)), { recursive: true }); + fs.writeFileSync(opts.outFile, md, "utf8"); + console.log(`Wrote ${path.resolve(opts.outFile)} (${rows.length} rules)`); +} diff --git a/src/approval-report/types.ts b/src/approval-report/types.ts new file mode 100644 index 0000000..baa13dd --- /dev/null +++ b/src/approval-report/types.ts @@ -0,0 +1,67 @@ +export type ApprovalRecord = { + approved: boolean; + /** ISO date (YYYY-MM-DD) of the approved `index.md` snapshot */ + approvalIsoDate?: string; +}; + +export type ChangeEntry = { + hash: string; + subject: string; + dateIso: string; + touchedRule: boolean; + /** Glossary keys whose files were touched in this commit */ + touchedDefinitionKeys: string[]; +}; + +export type GitHubIssueRef = { + number: number; + title: string; + html_url: string; + /** Present when fetched from the API; used only for matching */ + body?: string | null; + /** Present on fetched issues; omitted on rows after stripIssueBody */ + labelNames?: string[]; +}; + +/** Rule shape in the summary: composite, standalone atomic, or atomic used as composite input */ +export type RuleTypeSummary = "atomic" | "composed" | "composite"; + +export type ReportBucket = + | "approvedReadyForUpdate" + | "proposedReadyForUpdate" + | "approvedUpToDate" + | "notReady"; + +export type RuleApprovalRow = { + ruleId: string; + name: string; + ruleTypeSummary: RuleTypeSummary; + /** Set for composite rules only: atomic ids from `input_rules` (for table ordering). */ + compositeInputs?: string[]; + /** Rule has an approved WAI snapshot (`index.md` in rule-versions) */ + waiApproved: boolean; + reportBucket: ReportBucket; + implementations: string[]; + issues: GitHubIssueRef[]; + changes: ChangeEntry[]; + approvalIsoDate?: string; + /** Summary table: YYYY-MM-DD when rule has an approved snapshot, else "-" */ + lastApprovedSummary: string; + /** Summary table: YYYY-MM-DD of latest commit touching rule + transitive glossary, else "-" */ + lastUpdatedSummary: string; + /** Unique commits after approval on rule + glossary paths; "-" if not approved */ + commitsBehindSummary: string; + /** Open issues for this rule that have the GitHub "Blocker" label */ + blockersCount: number; +}; + +export type ApprovalReportOptions = { + rulesDir: string; + glossaryDir: string; + testAssetsDir: string; + actRulesRepo: string; + wcagActRulesDir: string; + outFile: string; + githubOwner: string; + githubRepo: string; +}; diff --git a/src/cli/approval-report.ts b/src/cli/approval-report.ts new file mode 100644 index 0000000..61f13f5 --- /dev/null +++ b/src/cli/approval-report.ts @@ -0,0 +1,74 @@ +#!/usr/bin/env ts-node +import * as path from "node:path"; +import { Command } from "commander"; + +import { runApprovalReport } from "../approval-report/run"; +import { ApprovalReportOptions } from "../approval-report/types"; + +const defaultSibling = (segment: string): string => + path.resolve(process.cwd(), "..", segment); + +const program = new Command(); +program + .description( + "List ACT rules ready for WAI approval: updated approved rules, or proposed rules with a complete implementation", + ) + .option( + "-r, --rulesDir ", + "Path to act-rules.github.io _rules directory", + defaultSibling("act-rules.github.io/_rules"), + ) + .option( + "-g, --glossaryDir ", + "Path to act-rules.github.io pages/glossary directory", + defaultSibling("act-rules.github.io/pages/glossary"), + ) + .option( + "-t, --testAssetsDir ", + "Path to act-rules.github.io test-assets directory", + defaultSibling("act-rules.github.io/test-assets"), + ) + .option( + "-a, --actRulesRepo ", + "Path to act-rules.github.io git repository root (for git log)", + defaultSibling("act-rules.github.io"), + ) + .option( + "-w, --wcagActRulesDir ", + "Path to wcag-act-rules repository root", + defaultSibling("wcag-act-rules"), + ) + .option( + "-o, --outFile ", + "Output markdown file", + path.resolve(process.cwd(), "approval-report.md"), + ) + .option( + "--githubOwner ", + "GitHub owner for issues lookup and commit links in report details", + "act-rules", + ) + .option( + "--githubRepo ", + "GitHub repository name for issues lookup and commit links in report details", + "act-rules.github.io", + ); + +program.parse(process.argv); +const o = program.opts(); + +const opts: ApprovalReportOptions = { + rulesDir: path.resolve(o.rulesDir), + glossaryDir: path.resolve(o.glossaryDir), + testAssetsDir: path.resolve(o.testAssetsDir), + actRulesRepo: path.resolve(o.actRulesRepo), + wcagActRulesDir: path.resolve(o.wcagActRulesDir), + outFile: path.resolve(o.outFile), + githubOwner: o.githubOwner, + githubRepo: o.githubRepo, +}; + +runApprovalReport(opts).catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/yarn.lock b/yarn.lock index 986ac1b..1663d62 100644 --- a/yarn.lock +++ b/yarn.lock @@ -834,6 +834,131 @@ __metadata: languageName: node linkType: hard +"@octokit/auth-token@npm:^6.0.0": + version: 6.0.0 + resolution: "@octokit/auth-token@npm:6.0.0" + checksum: 10c0/32ecc904c5f6f4e5d090bfcc679d70318690c0a0b5040cd9a25811ad9dcd44c33f2cf96b6dbee1cd56cf58fde28fb1819c01b58718aa5c971f79c822357cb5c0 + languageName: node + linkType: hard + +"@octokit/core@npm:^7.0.6": + version: 7.0.6 + resolution: "@octokit/core@npm:7.0.6" + dependencies: + "@octokit/auth-token": "npm:^6.0.0" + "@octokit/graphql": "npm:^9.0.3" + "@octokit/request": "npm:^10.0.6" + "@octokit/request-error": "npm:^7.0.2" + "@octokit/types": "npm:^16.0.0" + before-after-hook: "npm:^4.0.0" + universal-user-agent: "npm:^7.0.0" + checksum: 10c0/95a328ff7c7223d9eb4aa778c63171828514ae0e0f588d33beb81a4dc03bbeae055382f6060ce23c979ab46272409942ff2cf3172109999e48429c47055b1fbe + languageName: node + linkType: hard + +"@octokit/endpoint@npm:^11.0.3": + version: 11.0.3 + resolution: "@octokit/endpoint@npm:11.0.3" + dependencies: + "@octokit/types": "npm:^16.0.0" + universal-user-agent: "npm:^7.0.2" + checksum: 10c0/3f9b67e6923ece5009aebb0dcbae5837fb574bc422561424049a43ead7fea6f132234edb72239d6ec067cf734937a608e4081af81c109de2cb754528f0d00520 + languageName: node + linkType: hard + +"@octokit/graphql@npm:^9.0.3": + version: 9.0.3 + resolution: "@octokit/graphql@npm:9.0.3" + dependencies: + "@octokit/request": "npm:^10.0.6" + "@octokit/types": "npm:^16.0.0" + universal-user-agent: "npm:^7.0.0" + checksum: 10c0/58588d3fb2834f64244fa5376ca7922a30117b001b621e141fab0d52806370803ab0c046ac99b120fa5f45b770f52a815157fb6ffc147fc6c1da4047c1f1af49 + languageName: node + linkType: hard + +"@octokit/openapi-types@npm:^27.0.0": + version: 27.0.0 + resolution: "@octokit/openapi-types@npm:27.0.0" + checksum: 10c0/602d1de033da180a2e982cdbd3646bd5b2e16ecf36b9955a0f23e37ae9e6cb086abb48ff2ae6f2de000fce03e8ae9051794611ae4a95a8f5f6fb63276e7b8e31 + languageName: node + linkType: hard + +"@octokit/plugin-paginate-rest@npm:^14.0.0": + version: 14.0.0 + resolution: "@octokit/plugin-paginate-rest@npm:14.0.0" + dependencies: + "@octokit/types": "npm:^16.0.0" + peerDependencies: + "@octokit/core": ">=6" + checksum: 10c0/841d79d4ccfe18fc809a4a67529b75c1dcdda13399bf4bf5b48ce7559c8b4b2cd422e3204bad4cbdea31c0cf0943521067415268e5bcfc615a3b813e058cad6b + languageName: node + linkType: hard + +"@octokit/plugin-request-log@npm:^6.0.0": + version: 6.0.0 + resolution: "@octokit/plugin-request-log@npm:6.0.0" + peerDependencies: + "@octokit/core": ">=6" + checksum: 10c0/40e46ad0c77235742d0bf698ab4e17df1ae06e0d7824ffc5867ed71e27de860875adb73d89629b823fe8647459a8f262c26ed1aa6ee374873fa94095f37df0bb + languageName: node + linkType: hard + +"@octokit/plugin-rest-endpoint-methods@npm:^17.0.0": + version: 17.0.0 + resolution: "@octokit/plugin-rest-endpoint-methods@npm:17.0.0" + dependencies: + "@octokit/types": "npm:^16.0.0" + peerDependencies: + "@octokit/core": ">=6" + checksum: 10c0/cf9984d7cf6a36ff7ff1b86078ae45fe246e3df10fcef0bccf20c8cfd27bf5e7d98dcb9cf5a7b56332b9c6fa30be28d159c2987d272a4758f77056903d94402f + languageName: node + linkType: hard + +"@octokit/request-error@npm:^7.0.2": + version: 7.1.0 + resolution: "@octokit/request-error@npm:7.1.0" + dependencies: + "@octokit/types": "npm:^16.0.0" + checksum: 10c0/62b90a54545c36a30b5ffdda42e302c751be184d85b68ffc7f1242c51d7ca54dbd185b7d0027b491991776923a910c85c9c51269fe0d86111bac187507a5abc4 + languageName: node + linkType: hard + +"@octokit/request@npm:^10.0.6": + version: 10.0.8 + resolution: "@octokit/request@npm:10.0.8" + dependencies: + "@octokit/endpoint": "npm:^11.0.3" + "@octokit/request-error": "npm:^7.0.2" + "@octokit/types": "npm:^16.0.0" + fast-content-type-parse: "npm:^3.0.0" + json-with-bigint: "npm:^3.5.3" + universal-user-agent: "npm:^7.0.2" + checksum: 10c0/7ee384dbeb489d4e00856eeaaf6a70060c61b036919c539809c3288e2ba14b8f3f63a5b16b8d5b7fdc93d7b6fa5c45bc3d181a712031279f6e192f019e52d7fe + languageName: node + linkType: hard + +"@octokit/rest@npm:^22.0.0": + version: 22.0.1 + resolution: "@octokit/rest@npm:22.0.1" + dependencies: + "@octokit/core": "npm:^7.0.6" + "@octokit/plugin-paginate-rest": "npm:^14.0.0" + "@octokit/plugin-request-log": "npm:^6.0.0" + "@octokit/plugin-rest-endpoint-methods": "npm:^17.0.0" + checksum: 10c0/f3abd84e887cc837973214ce70720a9bba53f5575f40601c6122aa25206e9055d859c0388437f0a137f6cd0e4ff405e1b46b903475b0db32a17bada0c6513d5b + languageName: node + linkType: hard + +"@octokit/types@npm:^16.0.0": + version: 16.0.0 + resolution: "@octokit/types@npm:16.0.0" + dependencies: + "@octokit/openapi-types": "npm:^27.0.0" + checksum: 10c0/b8d41098ba6fc194d13d641f9441347e3a3b96c0efabac0e14f57319340a2d4d1c8676e4cb37ab3062c5c323c617e790b0126916e9bf7b201b0cced0826f8ae2 + languageName: node + linkType: hard + "@sinclair/typebox@npm:^0.27.8": version: 0.27.10 resolution: "@sinclair/typebox@npm:0.27.10" @@ -1218,6 +1343,7 @@ __metadata: version: 0.0.0-use.local resolution: "act-tools@workspace:." dependencies: + "@octokit/rest": "npm:^22.0.0" "@types/debug": "npm:^4.1.12" "@types/jest": "npm:^29.5.1" "@types/js-yaml": "npm:^4.0.2" @@ -1235,6 +1361,7 @@ __metadata: globby: "npm:^11.0.4" husky: "npm:^7.0.1" jest: "npm:^29.5.0" + js-yaml: "npm:^4.1.0" jsonld: "npm:^9.0.0" lint-staged: "npm:^11.0.1" markdown-table: "npm:2.0.0" @@ -1369,6 +1496,13 @@ __metadata: languageName: node linkType: hard +"argparse@npm:^2.0.1": + version: 2.0.1 + resolution: "argparse@npm:2.0.1" + checksum: 10c0/c5640c2d89045371c7cedd6a70212a04e360fd34d6edeae32f6952c63949e3525ea77dbec0289d8213a99bbaeab5abfa860b5c12cf88a2e6cf8106e90dd27a7e + languageName: node + linkType: hard + "array-union@npm:^2.1.0": version: 2.1.0 resolution: "array-union@npm:2.1.0" @@ -1517,6 +1651,13 @@ __metadata: languageName: node linkType: hard +"before-after-hook@npm:^4.0.0": + version: 4.0.0 + resolution: "before-after-hook@npm:4.0.0" + checksum: 10c0/9f8ae8d1b06142bcfb9ef6625226b5e50348bb11210f266660eddcf9734e0db6f9afc4cb48397ee3f5ac0a3728f3ae401cdeea88413f7bed748a71db84657be2 + languageName: node + linkType: hard + "brace-expansion@npm:^1.1.7": version: 1.1.12 resolution: "brace-expansion@npm:1.1.12" @@ -2378,6 +2519,13 @@ __metadata: languageName: node linkType: hard +"fast-content-type-parse@npm:^3.0.0": + version: 3.0.0 + resolution: "fast-content-type-parse@npm:3.0.0" + checksum: 10c0/06251880c83b7118af3a5e66e8bcee60d44f48b39396fc60acc2b4630bd5f3e77552b999b5c8e943d45a818854360e5e97164c374ec4b562b4df96a2cdf2e188 + languageName: node + linkType: hard + "fast-deep-equal@npm:^3.1.1, fast-deep-equal@npm:^3.1.3": version: 3.1.3 resolution: "fast-deep-equal@npm:3.1.3" @@ -3631,6 +3779,17 @@ __metadata: languageName: node linkType: hard +"js-yaml@npm:^4.1.0": + version: 4.1.1 + resolution: "js-yaml@npm:4.1.1" + dependencies: + argparse: "npm:^2.0.1" + bin: + js-yaml: bin/js-yaml.js + checksum: 10c0/561c7d7088c40a9bb53cc75becbfb1df6ae49b34b5e6e5a81744b14ae8667ec564ad2527709d1a6e7d5e5fa6d483aa0f373a50ad98d42fde368ec4a190d4fae7 + languageName: node + linkType: hard + "jsesc@npm:^3.0.2": version: 3.1.0 resolution: "jsesc@npm:3.1.0" @@ -3675,6 +3834,13 @@ __metadata: languageName: node linkType: hard +"json-with-bigint@npm:^3.5.3": + version: 3.5.8 + resolution: "json-with-bigint@npm:3.5.8" + checksum: 10c0/a0c4e37626d74a9a493539f9f9a94855933fa15ea2f028859a787229a42c5f11803db6f94f1ce7b1d89756c1e80a7c1f11006bac266ec7ce819b75701765ca0a + languageName: node + linkType: hard + "json5@npm:^2.2.3": version: 2.2.3 resolution: "json5@npm:2.2.3" @@ -5576,6 +5742,13 @@ __metadata: languageName: node linkType: hard +"universal-user-agent@npm:^7.0.0, universal-user-agent@npm:^7.0.2": + version: 7.0.3 + resolution: "universal-user-agent@npm:7.0.3" + checksum: 10c0/6043be466a9bb96c0ce82392842d9fddf4c37e296f7bacc2cb25f47123990eb436c82df824644f9c5070a94dbdb117be17f66d54599ab143648ec57ef93dbcc8 + languageName: node + linkType: hard + "update-browserslist-db@npm:^1.2.0": version: 1.2.3 resolution: "update-browserslist-db@npm:1.2.3" From e8cd9a7bc45bb6c2a21cbf655f6d2e0623851d05 Mon Sep 17 00:00:00 2001 From: Wilco Fiers Date: Mon, 6 Apr 2026 17:05:25 +0200 Subject: [PATCH 2/2] Not ready details --- .../__tests__/generate-report.test.ts | 59 +++++++++++++++++++ src/approval-report/generate-report.ts | 7 +++ 2 files changed, 66 insertions(+) diff --git a/src/approval-report/__tests__/generate-report.test.ts b/src/approval-report/__tests__/generate-report.test.ts index 6e30c8c..bc37bab 100644 --- a/src/approval-report/__tests__/generate-report.test.ts +++ b/src/approval-report/__tests__/generate-report.test.ts @@ -309,6 +309,7 @@ describe("generateApprovalReportMarkdown", () => { ); expect(md).not.toContain("Approved ready for update — details"); expect(md).not.toContain("Proposed ready for update — details"); + expect(md).not.toContain("Not ready — details"); }); it("includes approved and proposed detail sections when buckets are non-empty", () => { @@ -358,6 +359,64 @@ describe("generateApprovalReportMarkdown", () => { expect(propDetail).not.toContain("#### Changes since approval"); }); + it("includes not ready detail section when not ready bucket is non-empty", () => { + const md = generateApprovalReportMarkdown( + [ + baseRow("nr-prop", { + reportBucket: "notReady", + implementations: [], + waiApproved: false, + commitsBehindSummary: "2", + lastApprovedSummary: "-", + blockersCount: 0, + issues: [ + { + number: 12, + title: "Need impl", + html_url: "https://github.com/a/b/issues/12", + }, + ], + }), + baseRow("nr-appr", { + reportBucket: "notReady", + implementations: ["axe"], + waiApproved: true, + approvalIsoDate: "2023-06-01", + lastApprovedSummary: "2023-06-01", + commitsBehindSummary: "2", + blockersCount: 1, + changes: [ + { + hash: "e".repeat(40), + subject: "touch rule", + dateIso: "", + touchedRule: true, + touchedDefinitionKeys: [], + }, + { + hash: "f".repeat(40), + subject: "other", + dateIso: "", + touchedRule: false, + touchedDefinitionKeys: [], + }, + ], + }), + ], + github, + ); + expect(md).toContain("## Not ready — details"); + expect(md).toContain('

'); + expect(md).toContain('

'); + const afterNotReady = md.split("## Not ready — details")[1] ?? ""; + const iProp = afterNotReady.indexOf('

'); + const iAppr = afterNotReady.indexOf('

'); + expect(iProp).toBeLessThan(iAppr); + expect(afterNotReady).toContain("#12:"); + expect(afterNotReady).toContain("#### Changes since approval"); + expect(afterNotReady).toContain("touch rule"); + }); + it("renders not-ready sort: fewer blockers before more when commits tie", () => { const mk = ( id: string, diff --git a/src/approval-report/generate-report.ts b/src/approval-report/generate-report.ts index b4b9483..5ab4bba 100644 --- a/src/approval-report/generate-report.ts +++ b/src/approval-report/generate-report.ts @@ -74,6 +74,13 @@ export function generateApprovalReportMarkdown( } } + if (notReady.length > 0) { + sections.push("## Not ready — details", ""); + for (const r of notReady) { + sections.push(...renderRuleSection(r, r.waiApproved, github)); + } + } + return sections.join("\n").trimEnd() + "\n"; }