diff --git a/.gitignore b/.gitignore index 426cfc2..1c810c6 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ logs/ .tmp/ coverage/ **/*.tsbuildinfo +.worktrees/ # yarn ignores # https://next.yarnpkg.com/getting-started/qa#which-files-should-be-gitignored diff --git a/package.json b/package.json index 94fa62d..3f4bbcd 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,8 @@ "implementations-update": "ts-node src/cli/implementations-update.ts", "approval-report": "ts-node src/cli/approval-report.ts", "upsert-act-board": "ts-node src/cli/upsert-act-board.ts", + "sync-act-board-project": "ts-node src/cli/sync-act-board-project.ts", + "write-act-board-snapshot": "ts-node src/cli/write-act-board-snapshot.ts", "test": "jest", "prepare": "husky install" }, diff --git a/readme.md b/readme.md index 7570dec..577755f 100644 --- a/readme.md +++ b/readme.md @@ -116,3 +116,20 @@ GITHUB_TOKEN=... yarn upsert-act-board --input approval-report.json The upsert command owns act-board issue titles, bodies, state, and sub-issue relationships. It does not update GitHub Project fields. + +```sh +GITHUB_TOKEN=... ACT_BOARD_PROJECT_NUMBER=1 yarn sync-act-board-project \ + --input approval-report.json +yarn write-act-board-snapshot --input approval-report.json \ + --outFile ../act-board/data/snapshot.json +``` + +`sync-act-board-project` writes Projects v2 Status and optional numeric/date/URL +fields. It skips GitHub writes when values already match, warns and skips +missing or mistyped optional fields, and exits if the Status field is missing. +`--projectOwner` must name a GitHub organization; user-owned Projects are not +supported. Row failures are reported after the remaining rows are attempted and +cause a non-zero exit status. Both +`upsert-act-board` and `sync-act-board-project` fail clearly when `GITHUB_TOKEN` +is unset. The snapshot command rewrites `data/snapshot.json` only when the +classifier JSON actually changed. diff --git a/src/approval-report/__tests__/load-rows.test.ts b/src/approval-report/__tests__/load-rows.test.ts new file mode 100644 index 0000000..09e390a --- /dev/null +++ b/src/approval-report/__tests__/load-rows.test.ts @@ -0,0 +1,42 @@ +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; + +import { loadRuleApprovalRows } from "../load-rows"; + +describe("loadRuleApprovalRows", () => { + let directory: string; + + beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), "act-tools-rows-")); + }); + + afterEach(() => { + fs.rmSync(directory, { recursive: true, force: true }); + }); + + function write(contents: string): string { + const filePath = path.join(directory, "rows.json"); + fs.writeFileSync(filePath, contents); + return filePath; + } + + it("loads an array containing rule ids", () => { + expect(loadRuleApprovalRows(write('[{"ruleId":"674b10"}]'))).toEqual([ + { ruleId: "674b10" }, + ]); + }); + + it("rejects malformed JSON", () => { + expect(() => loadRuleApprovalRows(write("{not json"))).toThrow(SyntaxError); + }); + + it("rejects JSON that is not a RuleApprovalRow array", () => { + expect(() => + loadRuleApprovalRows(write('[{"name":"Missing id"}]')), + ).toThrow(/not a RuleApprovalRow JSON array/); + expect(() => loadRuleApprovalRows(write("{}"))).toThrow( + /not a RuleApprovalRow JSON array/, + ); + }); +}); diff --git a/src/approval-report/__tests__/snapshot.test.ts b/src/approval-report/__tests__/snapshot.test.ts new file mode 100644 index 0000000..6ee0517 --- /dev/null +++ b/src/approval-report/__tests__/snapshot.test.ts @@ -0,0 +1,27 @@ +import { formatSnapshotJson, snapshotsEqual } from "../snapshot"; +import { RuleApprovalRow } from "../types"; + +describe("snapshot formatting", () => { + it("formats rows with a trailing newline", () => { + const rows = [{ ruleId: "674b10" }] as RuleApprovalRow[]; + expect(formatSnapshotJson(rows)).toBe( + '[\n {\n "ruleId": "674b10"\n }\n]\n', + ); + }); + + it("skips equal formatted content but rewrites stale key order", () => { + expect( + snapshotsEqual( + '{\n "ruleId": "674b10",\n "name": "A"\n}\n', + '{"ruleId":"674b10","name":"A"}', + ), + ).toBe(true); + expect( + snapshotsEqual( + '{"ruleId":"674b10","name":"A"}', + '{"name":"A","ruleId":"674b10"}', + ), + ).toBe(false); + expect(snapshotsEqual('{"ruleId":"a"}', '{"ruleId":"b"}')).toBe(false); + }); +}); diff --git a/src/approval-report/__tests__/sync-project.test.ts b/src/approval-report/__tests__/sync-project.test.ts new file mode 100644 index 0000000..baa26cf --- /dev/null +++ b/src/approval-report/__tests__/sync-project.test.ts @@ -0,0 +1,697 @@ +import { BoardIssue } from "../act-board"; +import { + ActBoardProjectClient, + DEFAULT_PROJECT_FIELD_NAMES, + OctokitActBoardProjectClient, + parseProjectNumber, + ProjectField, + ProjectItem, + ProjectSnapshot, + RULE_STATUSES, + syncActBoardProject, +} from "../sync-project"; +import { RuleApprovalRow } from "../types"; + +function row( + ruleId: string, + overrides: Partial = {}, +): RuleApprovalRow { + return { + ruleId, + name: `Rule ${ruleId}`, + filename: `rule-${ruleId}.md`, + ruleTypeSummary: "atomic", + waiApproved: true, + status: "Approved, current", + reviewPrUrl: null, + reportBucket: "approvedUpToDate", + implementations: ["axe-core"], + issues: [], + blockers: [], + changes: [], + approvalIsoDate: "2024-01-01", + lastUpdatedIsoDate: "2024-01-01", + ruleCommitCount: 0, + definitionCommitCount: 0, + lastApprovedSummary: "2024-01-01", + lastUpdatedSummary: "2024-01-01", + commitsBehindSummary: "0", + blockersCount: 0, + ...overrides, + }; +} + +function statusField(): ProjectField { + return { + id: "FIELD_STATUS", + name: DEFAULT_PROJECT_FIELD_NAMES.status, + dataType: "SINGLE_SELECT", + options: RULE_STATUSES.map((name, index) => ({ + id: `OPT_${index}`, + name, + })), + }; +} + +function typeField(): ProjectField { + return { + id: "FIELD_TYPE", + name: DEFAULT_PROJECT_FIELD_NAMES.type, + dataType: "SINGLE_SELECT", + options: ["atomic", "composed", "composite"].map((name) => ({ + id: `TYPE_${name}`, + name, + })), + }; +} + +function numberField(id: string, name: string): ProjectField { + return { id, name, dataType: "NUMBER" }; +} + +function dateField(id: string, name: string): ProjectField { + return { id, name, dataType: "DATE" }; +} + +function textField(id: string, name: string): ProjectField { + return { id, name, dataType: "TEXT" }; +} + +function allFields(): ProjectField[] { + return [ + statusField(), + typeField(), + numberField("FIELD_IMPL", DEFAULT_PROJECT_FIELD_NAMES.implCount), + numberField("FIELD_BLOCKERS", DEFAULT_PROJECT_FIELD_NAMES.blockerCount), + numberField("FIELD_ISSUES", DEFAULT_PROJECT_FIELD_NAMES.openIssueCount), + numberField("FIELD_RULE", DEFAULT_PROJECT_FIELD_NAMES.ruleCommits), + numberField("FIELD_DEFS", DEFAULT_PROJECT_FIELD_NAMES.definitionCommits), + dateField("FIELD_APPROVED", DEFAULT_PROJECT_FIELD_NAMES.lastApproved), + dateField("FIELD_UPDATED", DEFAULT_PROJECT_FIELD_NAMES.lastUpdated), + textField("FIELD_PR", DEFAULT_PROJECT_FIELD_NAMES.reviewPrUrl), + ]; +} + +function boardIssue(rule: RuleApprovalRow): BoardIssue { + return { + number: 1, + title: `[${rule.ruleId}] ${rule.name}`, + body: "body", + state: "open", + nodeId: `ISSUE_${rule.ruleId}`, + }; +} + +function matchingItem(rule: RuleApprovalRow): ProjectItem { + const approvedOption = statusField().options?.find( + (option) => option.name === rule.status, + ); + return { + id: `ITEM_${rule.ruleId}`, + issueNodeId: `ISSUE_${rule.ruleId}`, + issueNumber: 1, + issueTitle: `[${rule.ruleId}] ${rule.name}`, + fieldValues: [ + { + fieldId: "FIELD_STATUS", + fieldName: "Status", + optionId: approvedOption?.id, + optionName: rule.status, + }, + { + fieldId: "FIELD_TYPE", + fieldName: "type", + optionId: "TYPE_atomic", + optionName: "atomic", + }, + { + fieldId: "FIELD_IMPL", + fieldName: DEFAULT_PROJECT_FIELD_NAMES.implCount, + number: 1, + }, + { + fieldId: "FIELD_BLOCKERS", + fieldName: DEFAULT_PROJECT_FIELD_NAMES.blockerCount, + number: 0, + }, + { + fieldId: "FIELD_ISSUES", + fieldName: DEFAULT_PROJECT_FIELD_NAMES.openIssueCount, + number: 0, + }, + { + fieldId: "FIELD_RULE", + fieldName: DEFAULT_PROJECT_FIELD_NAMES.ruleCommits, + number: 0, + }, + { + fieldId: "FIELD_DEFS", + fieldName: DEFAULT_PROJECT_FIELD_NAMES.definitionCommits, + number: 0, + }, + { + fieldId: "FIELD_APPROVED", + fieldName: DEFAULT_PROJECT_FIELD_NAMES.lastApproved, + date: "2024-01-01", + }, + { + fieldId: "FIELD_UPDATED", + fieldName: DEFAULT_PROJECT_FIELD_NAMES.lastUpdated, + date: "2024-01-01", + }, + ], + }; +} + +function fakeProject(snapshot: ProjectSnapshot): ActBoardProjectClient & { + addItem: jest.Mock; + updateFieldValue: jest.Mock; + clearFieldValue: jest.Mock; +} { + return { + getProject: jest.fn().mockResolvedValue(snapshot), + addItem: jest.fn().mockResolvedValue("ITEM_NEW"), + updateFieldValue: jest.fn().mockResolvedValue(undefined), + clearFieldValue: jest.fn().mockResolvedValue(undefined), + }; +} + +describe("syncActBoardProject", () => { + const current = row("674b10"); + + it("skips writes when Status and fields already match", async () => { + const project = fakeProject({ + id: "PROJECT", + title: "ACT board", + fields: allFields(), + items: [matchingItem(current)], + }); + const result = await syncActBoardProject( + [current], + { + listBoardIssues: jest.fn().mockResolvedValue([boardIssue(current)]), + project, + }, + { projectNumber: 1 }, + ); + + expect(result.skipped).toBe(1); + expect(result.fieldsUpdated).toBe(0); + expect(project.updateFieldValue).not.toHaveBeenCalled(); + expect(project.clearFieldValue).not.toHaveBeenCalled(); + expect(project.addItem).not.toHaveBeenCalled(); + }); + + it("updates Status when it changed", async () => { + const stale = matchingItem(current); + stale.fieldValues = stale.fieldValues.map((value) => + value.fieldId === "FIELD_STATUS" + ? { + ...value, + optionId: "OPT_OTHER", + optionName: "Proposed, reviewable", + } + : value, + ); + const project = fakeProject({ + id: "PROJECT", + title: "ACT board", + fields: allFields(), + items: [stale], + }); + + const result = await syncActBoardProject( + [current], + { + listBoardIssues: jest.fn().mockResolvedValue([boardIssue(current)]), + project, + }, + { projectNumber: 1 }, + ); + + expect(result.fieldsUpdated).toBeGreaterThan(0); + expect(project.updateFieldValue).toHaveBeenCalledWith( + "PROJECT", + stale.id, + "FIELD_STATUS", + { + singleSelectOptionId: statusField().options?.find( + (option) => option.name === "Approved, current", + )?.id, + }, + ); + }); + + it("skips missing optional fields and still syncs Status", async () => { + const warnings: string[] = []; + const second = row("2ee8b8"); + const project = fakeProject({ + id: "PROJECT", + title: "ACT board", + fields: [statusField()], + items: [ + { + id: "ITEM_674b10", + issueNodeId: "ISSUE_674b10", + fieldValues: [ + { + fieldId: "FIELD_STATUS", + fieldName: "Status", + optionId: statusField().options?.find( + (option) => option.name === "Approved, current", + )?.id, + optionName: "Approved, current", + }, + ], + }, + { + id: "ITEM_2ee8b8", + issueNodeId: "ISSUE_2ee8b8", + fieldValues: [ + { + fieldId: "FIELD_STATUS", + fieldName: "Status", + optionId: statusField().options?.find( + (option) => option.name === "Approved, current", + )?.id, + optionName: "Approved, current", + }, + ], + }, + ], + }); + + const result = await syncActBoardProject( + [current, second], + { + listBoardIssues: jest + .fn() + .mockResolvedValue([boardIssue(current), boardIssue(second)]), + project, + }, + { + projectNumber: 1, + warn: (message) => warnings.push(message), + }, + ); + + expect(result.optionalFieldsSkipped).toBe(9); + expect(project.updateFieldValue).not.toHaveBeenCalled(); + expect(warnings.some((message) => message.includes("impl count"))).toBe( + true, + ); + expect(warnings).toHaveLength(9); + }); + + it("skips an optional field with the wrong data type once", async () => { + const wrongType = textField( + "FIELD_IMPL", + DEFAULT_PROJECT_FIELD_NAMES.implCount, + ); + const warnings: string[] = []; + const project = fakeProject({ + id: "PROJECT", + title: "ACT board", + fields: [statusField(), wrongType], + items: [matchingItem(current)], + }); + + const result = await syncActBoardProject( + [current], + { + listBoardIssues: jest.fn().mockResolvedValue([boardIssue(current)]), + project, + }, + { projectNumber: 1, warn: (message) => warnings.push(message) }, + ); + + expect(result.optionalFieldsSkipped).toBe(9); + expect(warnings).toContain( + 'Skipping optional Project field "impl count": expected data type NUMBER, got TEXT', + ); + expect(project.updateFieldValue).not.toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + "FIELD_IMPL", + expect.anything(), + ); + }); + + it("fails when the Status field is missing", async () => { + const project = fakeProject({ + id: "PROJECT", + title: "ACT board", + fields: [typeField()], + items: [], + }); + + await expect( + syncActBoardProject( + [current], + { + listBoardIssues: jest.fn().mockResolvedValue([boardIssue(current)]), + project, + }, + { projectNumber: 1, warn: () => undefined }, + ), + ).rejects.toThrow(/missing required field "Status"/i); + }); + + it("adds a Project item when the issue is not on the board", async () => { + const project = fakeProject({ + id: "PROJECT", + title: "ACT board", + fields: [statusField()], + items: [], + }); + + const result = await syncActBoardProject( + [current], + { + listBoardIssues: jest.fn().mockResolvedValue([boardIssue(current)]), + project, + }, + { projectNumber: 1, warn: () => undefined }, + ); + + expect(result.itemsAdded).toBe(1); + expect(project.addItem).toHaveBeenCalledWith("PROJECT", "ISSUE_674b10"); + expect(project.updateFieldValue).toHaveBeenCalledWith( + "PROJECT", + "ITEM_NEW", + "FIELD_STATUS", + expect.objectContaining({ singleSelectOptionId: expect.any(String) }), + ); + }); + + it("clears an empty review PR URL field", async () => { + const item = matchingItem(current); + item.fieldValues.push({ + fieldId: "FIELD_PR", + fieldName: DEFAULT_PROJECT_FIELD_NAMES.reviewPrUrl, + text: "https://example.com/old", + }); + const project = fakeProject({ + id: "PROJECT", + title: "ACT board", + fields: allFields(), + items: [item], + }); + + await syncActBoardProject( + [current], + { + listBoardIssues: jest.fn().mockResolvedValue([boardIssue(current)]), + project, + }, + { projectNumber: 1 }, + ); + + expect(project.clearFieldValue).toHaveBeenCalledWith( + "PROJECT", + item.id, + "FIELD_PR", + ); + }); + + it("prefers an open higher-numbered issue over a closed duplicate", async () => { + const closed = { + ...boardIssue(current), + number: 10, + state: "closed" as const, + nodeId: "ISSUE_CLOSED", + }; + const open = { + ...boardIssue(current), + number: 20, + state: "open" as const, + nodeId: "ISSUE_OPEN", + }; + const project = fakeProject({ + id: "PROJECT", + title: "ACT board", + fields: [statusField()], + items: [], + }); + + await syncActBoardProject( + [current], + { + listBoardIssues: jest.fn().mockResolvedValue([closed, open]), + project, + }, + { projectNumber: 1, warn: () => undefined }, + ); + + expect(project.addItem).toHaveBeenCalledWith("PROJECT", "ISSUE_OPEN"); + }); + + it("continues after a row failure and reports rows without issues", async () => { + const failed = row("2ee8b8"); + const missing = row("abcdef"); + const project = fakeProject({ + id: "PROJECT", + title: "ACT board", + fields: [statusField()], + items: [], + }); + project.addItem + .mockRejectedValueOnce(new Error("mutation failed")) + .mockResolvedValueOnce("ITEM_OK"); + + const result = await syncActBoardProject( + [failed, current, missing], + { + listBoardIssues: jest + .fn() + .mockResolvedValue([boardIssue(failed), boardIssue(current)]), + project, + }, + { projectNumber: 1, warn: () => undefined }, + ); + + expect(result.failures).toEqual([ + { ruleId: failed.ruleId, message: "mutation failed" }, + ]); + expect(result.rowsWithoutIssue).toBe(1); + expect(result.itemsAdded).toBe(1); + expect(project.addItem).toHaveBeenCalledTimes(2); + }); + + it("does not count a field update when its mutation fails", async () => { + const stale = matchingItem(current); + const staleStatus = stale.fieldValues.find( + (value) => value.fieldId === "FIELD_STATUS", + ); + if (!staleStatus) throw new Error("Missing status fixture"); + staleStatus.optionId = "STALE"; + staleStatus.optionName = "In review"; + const project = fakeProject({ + id: "PROJECT", + title: "ACT board", + fields: allFields(), + items: [stale], + }); + project.updateFieldValue.mockRejectedValueOnce(new Error("write failed")); + + const result = await syncActBoardProject( + [current], + { + listBoardIssues: jest.fn().mockResolvedValue([boardIssue(current)]), + project, + }, + { projectNumber: 1, warn: () => undefined }, + ); + + expect(result.fieldsUpdated).toBe(0); + expect(result.failures).toHaveLength(1); + }); + + it("normalizes both date values before comparing", async () => { + const item = matchingItem(current); + const approved = item.fieldValues.find( + (value) => value.fieldId === "FIELD_APPROVED", + ); + if (approved) approved.date = "2024-01-01T23:59:59Z"; + const project = fakeProject({ + id: "PROJECT", + title: "ACT board", + fields: allFields(), + items: [item], + }); + + await syncActBoardProject( + [row(current.ruleId, { approvalIsoDate: "2024-01-01T00:00:00Z" })], + { + listBoardIssues: jest.fn().mockResolvedValue([boardIssue(current)]), + project, + }, + { projectNumber: 1 }, + ); + + expect(project.updateFieldValue).not.toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + "FIELD_APPROVED", + expect.anything(), + ); + }); +}); + +describe("parseProjectNumber", () => { + it("accepts only positive integer strings", () => { + expect(parseProjectNumber("12")).toBe(12); + expect(parseProjectNumber("12abc")).toBeNull(); + expect(parseProjectNumber("1.5")).toBeNull(); + expect(parseProjectNumber("0")).toBeNull(); + }); +}); + +describe("OctokitActBoardProjectClient", () => { + function mockOctokit( + graphql: jest.Mock, + ): ConstructorParameters[0] { + return { graphql } as unknown as ConstructorParameters< + typeof OctokitActBoardProjectClient + >[0]; + } + + it("paginates project fields, items, and each item's field values", async () => { + const graphql = jest + .fn() + .mockImplementation( + async (query: string, variables: Record) => { + if (query.includes("query ActBoardProject(")) { + return { + organization: { + projectV2: { + id: "PROJECT", + title: "ACT board", + fields: { + nodes: [ + { + id: "STATUS", + name: "Status", + dataType: "SINGLE_SELECT", + }, + ], + pageInfo: { hasNextPage: true, endCursor: "FIELDS_2" }, + }, + }, + }, + }; + } + if (query.includes("ActBoardProjectFields")) { + expect(variables.cursor).toBe("FIELDS_2"); + return { + node: { + fields: { + nodes: [ + { id: "COUNT", name: "impl count", dataType: "NUMBER" }, + ], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }; + } + if (query.includes("ActBoardProjectItems")) { + return { + node: { + items: { + nodes: + variables.cursor === null + ? [ + { + id: "ITEM", + content: { + id: "ISSUE", + number: 1, + title: "[674b10] Rule", + }, + }, + ] + : [], + pageInfo: + variables.cursor === null + ? { hasNextPage: true, endCursor: "ITEMS_2" } + : { hasNextPage: false, endCursor: null }, + }, + }, + }; + } + if (query.includes("ActBoardProjectItemFieldValues")) { + return { + node: { + fieldValues: { + nodes: + variables.cursor === null + ? [ + { + number: 1, + field: { id: "COUNT", name: "impl count" }, + }, + ] + : [ + { + text: "https://example.com/pr", + field: { id: "PR", name: "review PR URL" }, + }, + ], + pageInfo: + variables.cursor === null + ? { hasNextPage: true, endCursor: "VALUES_2" } + : { hasNextPage: false, endCursor: null }, + }, + }, + }; + } + throw new Error(`Unexpected query: ${query}`); + }, + ); + const client = new OctokitActBoardProjectClient(mockOctokit(graphql)); + + const snapshot = await client.getProject("act-rules", 1); + + expect(snapshot.fields.map((field) => field.id)).toEqual([ + "STATUS", + "COUNT", + ]); + expect(snapshot.items[0].fieldValues).toHaveLength(2); + expect( + graphql.mock.calls.filter(([query]) => + String(query).includes("ActBoardProjectItemFieldValues"), + ), + ).toHaveLength(2); + expect( + graphql.mock.calls.every( + ([query]) => !String(query).includes("first: 20"), + ), + ).toBe(true); + }); + + it("sends add, update, and clear mutations with their variables", async () => { + const graphql = jest + .fn() + .mockResolvedValueOnce({ addProjectV2ItemById: { item: { id: "ITEM" } } }) + .mockResolvedValue({}); + const client = new OctokitActBoardProjectClient(mockOctokit(graphql)); + + await expect(client.addItem("PROJECT", "ISSUE")).resolves.toBe("ITEM"); + await client.updateFieldValue("PROJECT", "ITEM", "FIELD", { number: 3 }); + await client.clearFieldValue("PROJECT", "ITEM", "FIELD"); + + expect(graphql.mock.calls[0][0]).toContain("addProjectV2ItemById"); + expect(graphql.mock.calls[0][1]).toEqual({ + projectId: "PROJECT", + contentId: "ISSUE", + }); + expect(graphql.mock.calls[1][0]).toContain("updateProjectV2ItemFieldValue"); + expect(graphql.mock.calls[1][1]).toEqual({ + projectId: "PROJECT", + itemId: "ITEM", + fieldId: "FIELD", + value: { number: 3 }, + }); + expect(graphql.mock.calls[2][0]).toContain("clearProjectV2ItemFieldValue"); + }); +}); diff --git a/src/approval-report/act-board.ts b/src/approval-report/act-board.ts index 0e88864..3a2fafa 100644 --- a/src/approval-report/act-board.ts +++ b/src/approval-report/act-board.ts @@ -300,7 +300,7 @@ export async function upsertActBoardIssues( * Pick the board issue to manage per rule id, preferring an open issue over a * lower-numbered closed one. Remaining issues are duplicates to close. */ -function selectManagedIssues(issues: BoardIssue[]): { +export function selectManagedIssues(issues: BoardIssue[]): { managedIssues: Map; duplicateIssues: BoardIssue[]; } { diff --git a/src/approval-report/load-rows.ts b/src/approval-report/load-rows.ts new file mode 100644 index 0000000..5819118 --- /dev/null +++ b/src/approval-report/load-rows.ts @@ -0,0 +1,19 @@ +import * as fs from "node:fs"; + +import { RuleApprovalRow } from "./types"; + +export function loadRuleApprovalRows(filePath: string): RuleApprovalRow[] { + const parsed: unknown = JSON.parse(fs.readFileSync(filePath, "utf8")); + if ( + !Array.isArray(parsed) || + parsed.some( + (row) => + typeof row !== "object" || + row === null || + typeof (row as { ruleId?: unknown }).ruleId !== "string", + ) + ) { + throw new Error(`${filePath} is not a RuleApprovalRow JSON array`); + } + return parsed as RuleApprovalRow[]; +} diff --git a/src/approval-report/snapshot.ts b/src/approval-report/snapshot.ts new file mode 100644 index 0000000..41128bc --- /dev/null +++ b/src/approval-report/snapshot.ts @@ -0,0 +1,19 @@ +import { RuleApprovalRow } from "./types"; + +export function formatSnapshotJson(rows: RuleApprovalRow[]): string { + return `${JSON.stringify(rows, null, 2)}\n`; +} + +export function snapshotsEqual(left: string, right: string): boolean { + try { + return ( + formatParsedJson(JSON.parse(left)) === formatParsedJson(JSON.parse(right)) + ); + } catch { + return left === right; + } +} + +function formatParsedJson(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} diff --git a/src/approval-report/sync-project.ts b/src/approval-report/sync-project.ts new file mode 100644 index 0000000..b60460b --- /dev/null +++ b/src/approval-report/sync-project.ts @@ -0,0 +1,812 @@ +import { Octokit } from "@octokit/rest"; + +import { + BoardIssue, + DEFAULT_BOARD_REPOSITORY, + RepositoryRef, + selectManagedIssues, +} from "./act-board"; +import { RuleApprovalRow, RuleStatus } from "./types"; + +export const DEFAULT_PROJECT_OWNER = "act-rules"; + +export const DEFAULT_PROJECT_FIELD_NAMES = { + status: "Status", + type: "type", + implCount: "impl count", + blockerCount: "blocker count", + openIssueCount: "open issue count", + ruleCommits: "rule-commits", + definitionCommits: "definition-commits", + lastApproved: "last approved", + lastUpdated: "last updated", + reviewPrUrl: "review PR URL", +} as const; + +export type ProjectFieldNames = typeof DEFAULT_PROJECT_FIELD_NAMES; + +export type ProjectField = { + id: string; + name: string; + dataType: string; + options?: Array<{ id: string; name: string }>; +}; + +export type ProjectItemFieldValue = { + fieldId: string; + fieldName: string; + optionId?: string | null; + optionName?: string | null; + number?: number | null; + date?: string | null; + text?: string | null; +}; + +export type ProjectItem = { + id: string; + issueNodeId?: string; + issueNumber?: number; + issueTitle?: string; + fieldValues: ProjectItemFieldValue[]; +}; + +export type ProjectSnapshot = { + id: string; + title: string; + fields: ProjectField[]; + items: ProjectItem[]; +}; + +export type ActBoardProjectClient = { + getProject(owner: string, projectNumber: number): Promise; + addItem(projectId: string, contentNodeId: string): Promise; + updateFieldValue( + projectId: string, + itemId: string, + fieldId: string, + value: + | { singleSelectOptionId: string } + | { number: number } + | { date: string } + | { text: string }, + ): Promise; + clearFieldValue( + projectId: string, + itemId: string, + fieldId: string, + ): Promise; +}; + +export type SyncActBoardProjectOptions = { + boardRepository?: RepositoryRef; + projectOwner?: string; + projectNumber: number; + fieldNames?: Partial; + warn?: (message: string) => void; +}; + +export type SyncActBoardProjectResult = { + itemsAdded: number; + fieldsUpdated: number; + skipped: number; + optionalFieldsSkipped: number; + rowsWithoutIssue: number; + failures: Array<{ ruleId: string; message: string }>; +}; + +type ListBoardIssues = (repository: RepositoryRef) => Promise; + +export async function syncActBoardProject( + rows: RuleApprovalRow[], + deps: { + listBoardIssues: ListBoardIssues; + project: ActBoardProjectClient; + }, + options: SyncActBoardProjectOptions, +): Promise { + const warn = options.warn ?? console.warn; + const boardRepository = options.boardRepository ?? DEFAULT_BOARD_REPOSITORY; + const projectOwner = options.projectOwner ?? DEFAULT_PROJECT_OWNER; + const fieldNames = { + ...DEFAULT_PROJECT_FIELD_NAMES, + ...options.fieldNames, + }; + const result: SyncActBoardProjectResult = { + itemsAdded: 0, + fieldsUpdated: 0, + skipped: 0, + optionalFieldsSkipped: 0, + rowsWithoutIssue: 0, + failures: [], + }; + + const project = await deps.project.getProject( + projectOwner, + options.projectNumber, + ); + const statusField = findField(project.fields, fieldNames.status); + if (!statusField) { + throw new Error( + `Project "${project.title}" is missing required field "${fieldNames.status}"`, + ); + } + if (statusField.dataType !== "SINGLE_SELECT") { + throw new Error( + `Required Project field "${fieldNames.status}" must have data type SINGLE_SELECT, got ${statusField.dataType}`, + ); + } + const fieldPlan = resolveFieldPlan(project.fields, fieldNames, warn); + result.optionalFieldsSkipped = fieldPlan.optionalFieldsSkipped; + + const issues = (await deps.listBoardIssues(boardRepository)).filter( + (issue) => !issue.isPullRequest, + ); + const { managedIssues: issueByRuleId } = selectManagedIssues(issues); + + const itemByIssueNodeId = new Map(); + for (const item of project.items) { + if (item.issueNodeId) itemByIssueNodeId.set(item.issueNodeId, item); + } + + for (const row of [...rows].sort((a, b) => + a.ruleId.localeCompare(b.ruleId), + )) { + const issue = issueByRuleId.get(row.ruleId.toLowerCase()); + if (!issue) { + warn(`No act-board issue found for [${row.ruleId}]; skip Project sync`); + result.rowsWithoutIssue += 1; + continue; + } + + try { + let item = itemByIssueNodeId.get(issue.nodeId); + if (!item) { + const itemId = await deps.project.addItem(project.id, issue.nodeId); + item = { + id: itemId, + issueNodeId: issue.nodeId, + issueNumber: issue.number, + issueTitle: issue.title, + fieldValues: [], + }; + itemByIssueNodeId.set(issue.nodeId, item); + result.itemsAdded += 1; + } + + const updates = desiredFieldUpdates(row, fieldPlan, warn); + let changed = false; + for (const update of updates) { + const current = item.fieldValues.find( + (value) => value.fieldId === update.field.id, + ); + if (fieldValueMatches(current, update)) continue; + if (update.clear) { + await deps.project.clearFieldValue( + project.id, + item.id, + update.field.id, + ); + changed = true; + result.fieldsUpdated += 1; + } else if (update.payload) { + await deps.project.updateFieldValue( + project.id, + item.id, + update.field.id, + update.payload, + ); + changed = true; + result.fieldsUpdated += 1; + } + } + + if (!changed) result.skipped += 1; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + warn(`Failed Project sync for [${row.ruleId}]: ${message}`); + result.failures.push({ ruleId: row.ruleId, message }); + } + } + + return result; +} + +type PreparedUpdate = { + field: ProjectField; + clear: boolean; + payload?: + | { singleSelectOptionId: string } + | { number: number } + | { date: string } + | { text: string }; + optionName?: string; + number?: number | null; + date?: string | null; + text?: string | null; +}; + +type FieldPlan = { + status: ProjectField; + type?: ProjectField; + implCount?: ProjectField; + blockerCount?: ProjectField; + openIssueCount?: ProjectField; + ruleCommits?: ProjectField; + definitionCommits?: ProjectField; + lastApproved?: ProjectField; + lastUpdated?: ProjectField; + reviewPrUrl?: ProjectField; + optionalFieldsSkipped: number; +}; + +const OPTIONAL_FIELD_TYPES: Array<{ + key: Exclude; + nameKey: Exclude; + dataType: string; +}> = [ + { key: "type", nameKey: "type", dataType: "SINGLE_SELECT" }, + { key: "implCount", nameKey: "implCount", dataType: "NUMBER" }, + { key: "blockerCount", nameKey: "blockerCount", dataType: "NUMBER" }, + { key: "openIssueCount", nameKey: "openIssueCount", dataType: "NUMBER" }, + { key: "ruleCommits", nameKey: "ruleCommits", dataType: "NUMBER" }, + { + key: "definitionCommits", + nameKey: "definitionCommits", + dataType: "NUMBER", + }, + { key: "lastApproved", nameKey: "lastApproved", dataType: "DATE" }, + { key: "lastUpdated", nameKey: "lastUpdated", dataType: "DATE" }, + { key: "reviewPrUrl", nameKey: "reviewPrUrl", dataType: "TEXT" }, +]; + +function resolveFieldPlan( + fields: ProjectField[], + names: ProjectFieldNames, + warn: (message: string) => void, +): FieldPlan { + const status = findField(fields, names.status); + if (!status) { + throw new Error(`Missing required Project field "${names.status}"`); + } + const plan: FieldPlan = { status, optionalFieldsSkipped: 0 }; + for (const descriptor of OPTIONAL_FIELD_TYPES) { + const name = names[descriptor.nameKey]; + const field = findField(fields, name); + if (!field) { + warn(`Skipping missing optional Project field "${name}"`); + plan.optionalFieldsSkipped += 1; + continue; + } + if (field.dataType !== descriptor.dataType) { + warn( + `Skipping optional Project field "${name}": expected data type ${descriptor.dataType}, got ${field.dataType}`, + ); + plan.optionalFieldsSkipped += 1; + continue; + } + plan[descriptor.key] = field; + } + return plan; +} + +function desiredFieldUpdates( + row: RuleApprovalRow, + plan: FieldPlan, + warn: (message: string) => void, +): PreparedUpdate[] { + const values: PreparedUpdate[] = []; + values.push(requiredSingleSelect(plan.status, row.status)); + pushIfDefined( + values, + optionalSingleSelect(plan.type, row.ruleTypeSummary, warn), + ); + pushIfDefined( + values, + optionalNumber(plan.implCount, row.implementations.length), + ); + pushIfDefined(values, optionalNumber(plan.blockerCount, row.blockersCount)); + pushIfDefined(values, optionalNumber(plan.openIssueCount, row.issues.length)); + pushIfDefined(values, optionalNumber(plan.ruleCommits, row.ruleCommitCount)); + pushIfDefined( + values, + optionalNumber(plan.definitionCommits, row.definitionCommitCount), + ); + pushIfDefined(values, optionalDate(plan.lastApproved, row.approvalIsoDate)); + pushIfDefined(values, optionalDate(plan.lastUpdated, row.lastUpdatedIsoDate)); + pushIfDefined(values, optionalText(plan.reviewPrUrl, row.reviewPrUrl)); + return values; +} + +function requiredSingleSelect( + field: ProjectField, + optionName: string, +): PreparedUpdate { + const option = findOption(field, optionName); + if (!option) { + throw new Error( + `Project field "${field.name}" has no option "${optionName}"`, + ); + } + return { + field, + clear: false, + payload: { singleSelectOptionId: option.id }, + optionName, + }; +} + +function optionalSingleSelect( + field: ProjectField | undefined, + optionName: string, + warn: (message: string) => void, +): PreparedUpdate | undefined { + if (!field) return undefined; + const option = findOption(field, optionName); + if (!option) { + warn( + `Skipping optional Project field "${field.name}": no option "${optionName}"`, + ); + return undefined; + } + return { + field, + clear: false, + payload: { singleSelectOptionId: option.id }, + optionName, + }; +} + +function optionalNumber( + field: ProjectField | undefined, + number: number, +): PreparedUpdate | undefined { + if (!field) return undefined; + return { + field, + clear: false, + payload: { number }, + number, + }; +} + +function optionalDate( + field: ProjectField | undefined, + date: string | null, +): PreparedUpdate | undefined { + if (!field) return undefined; + if (!date) { + return { field, clear: true, date: null }; + } + return { + field, + clear: false, + payload: { date }, + date, + }; +} + +function optionalText( + field: ProjectField | undefined, + text: string | null, +): PreparedUpdate | undefined { + if (!field) return undefined; + if (!text) { + return { field, clear: true, text: null }; + } + return { + field, + clear: false, + payload: { text }, + text, + }; +} + +function pushIfDefined( + updates: PreparedUpdate[], + update: PreparedUpdate | undefined, +): void { + if (update) updates.push(update); +} + +function fieldValueMatches( + current: ProjectItemFieldValue | undefined, + update: PreparedUpdate, +): boolean { + if (update.clear) { + if (!current) return true; + return ( + empty(current.optionId) && + empty(current.optionName) && + (current.number === null || current.number === undefined) && + empty(current.date) && + empty(current.text) + ); + } + if (!current || !update.payload) return false; + if ("singleSelectOptionId" in update.payload) { + return ( + current.optionId === update.payload.singleSelectOptionId || + (update.optionName != null && + current.optionName?.toLowerCase() === update.optionName.toLowerCase()) + ); + } + if ("number" in update.payload) { + return Number(current.number) === Number(update.payload.number); + } + if ("date" in update.payload) { + return normalizeDate(current.date) === normalizeDate(update.payload.date); + } + return (current.text ?? "") === update.payload.text; +} + +function findField( + fields: ProjectField[], + name: string, +): ProjectField | undefined { + const needle = name.toLowerCase(); + return fields.find((field) => field.name.toLowerCase() === needle); +} + +function findOption( + field: ProjectField, + optionName: string, +): { id: string; name: string } | undefined { + const needle = optionName.toLowerCase(); + return field.options?.find((option) => option.name.toLowerCase() === needle); +} + +function empty(value: string | null | undefined): boolean { + return value == null || value === ""; +} + +function normalizeDate(value: string | null | undefined): string { + return (value ?? "").slice(0, 10); +} + +const RULE_STATUS_RECORD = { + Deprecated: true, + "In review": true, + "Blocked by issue": true, + "Awaiting implementation": true, + "Approved, current": true, + "Approved, unpublished changes": true, + "Proposed, reviewable": true, +} satisfies Record; + +export const RULE_STATUSES = Object.keys(RULE_STATUS_RECORD) as RuleStatus[]; + +export function parseProjectNumber(value: unknown): number | null { + const text = String(value ?? ""); + if (!/^[1-9]\d*$/.test(text)) return null; + const number = Number(text); + return Number.isSafeInteger(number) ? number : null; +} + +type OrgProjectResponse = { + organization: { + projectV2: { + id: string; + title: string; + fields: GraphQlFieldsConnection; + } | null; + } | null; +}; + +type GraphQlFieldNode = { + id?: string; + name?: string; + dataType?: string; + options?: Array<{ id: string; name: string }>; +}; + +type GraphQlFieldsConnection = { + nodes: GraphQlFieldNode[]; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; +}; + +type ProjectFieldsResponse = { + node: { + fields: GraphQlFieldsConnection; + } | null; +}; + +type ProjectItemsResponse = { + node: { + items: { + nodes: Array<{ + id: string; + content: { + id?: string; + number?: number; + title?: string; + } | null; + }>; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + }; + } | null; +}; + +type GraphQlFieldValueNode = { + field?: { id?: string; name?: string } | null; + name?: string | null; + optionId?: string | null; + number?: number | null; + date?: string | null; + text?: string | null; +}; + +type ProjectItemFieldValuesResponse = { + node: { + fieldValues: { + nodes: GraphQlFieldValueNode[]; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + }; + } | null; +}; + +export class OctokitActBoardProjectClient implements ActBoardProjectClient { + public constructor(private readonly octokit: Octokit) {} + + public async getProject( + owner: string, + projectNumber: number, + ): Promise { + const response: OrgProjectResponse = await this.octokit.graphql( + `query ActBoardProject($login: String!, $number: Int!) { + organization(login: $login) { + projectV2(number: $number) { + id + title + fields(first: 100) { + pageInfo { hasNextPage endCursor } + nodes { + ... on ProjectV2Field { + id + name + dataType + } + ... on ProjectV2SingleSelectField { + id + name + dataType + options { id name } + } + ... on ProjectV2IterationField { + id + name + dataType + } + } + } + } + } + }`, + { login: owner, number: projectNumber }, + ); + const project = response.organization?.projectV2; + if (!project) { + throw new Error( + `GitHub Project #${projectNumber} was not found on organization ${owner}. Check ACT_BOARD_PROJECT_NUMBER and ensure the token can access the Project.`, + ); + } + + const fieldNodes = [...project.fields.nodes]; + let fieldsCursor = project.fields.pageInfo.hasNextPage + ? project.fields.pageInfo.endCursor + : null; + while (fieldsCursor) { + const page: ProjectFieldsResponse = await this.octokit.graphql( + `query ActBoardProjectFields($id: ID!, $cursor: String) { + node(id: $id) { + ... on ProjectV2 { + fields(first: 100, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + ... on ProjectV2Field { + id + name + dataType + } + ... on ProjectV2SingleSelectField { + id + name + dataType + options { id name } + } + ... on ProjectV2IterationField { + id + name + dataType + } + } + } + } + } + }`, + { id: project.id, cursor: fieldsCursor }, + ); + if (!page.node) break; + fieldNodes.push(...page.node.fields.nodes); + fieldsCursor = page.node.fields.pageInfo.hasNextPage + ? page.node.fields.pageInfo.endCursor + : null; + } + + const fields: ProjectField[] = fieldNodes + .filter((node): node is GraphQlFieldNode & { id: string; name: string } => + Boolean(node?.id && node?.name), + ) + .map((node) => ({ + id: node.id, + name: node.name, + dataType: node.dataType ?? "UNKNOWN", + options: node.options, + })); + + const items: ProjectItem[] = []; + let cursor: string | null = null; + do { + const page: ProjectItemsResponse = await this.octokit.graphql( + `query ActBoardProjectItems($id: ID!, $cursor: String) { + node(id: $id) { + ... on ProjectV2 { + items(first: 100, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + id + content { + ... on Issue { id number title } + } + } + } + } + } + }`, + { id: project.id, cursor }, + ); + if (!page.node) break; + for (const node of page.node.items.nodes) { + items.push({ + id: node.id, + issueNodeId: node.content?.id, + issueNumber: node.content?.number, + issueTitle: node.content?.title, + fieldValues: await this.getItemFieldValues(node.id), + }); + } + cursor = page.node.items.pageInfo.hasNextPage + ? page.node.items.pageInfo.endCursor + : null; + } while (cursor); + + return { id: project.id, title: project.title, fields, items }; + } + + private async getItemFieldValues( + itemId: string, + ): Promise { + const values: ProjectItemFieldValue[] = []; + let cursor: string | null = null; + do { + const page: ProjectItemFieldValuesResponse = await this.octokit.graphql( + `query ActBoardProjectItemFieldValues($id: ID!, $cursor: String) { + node(id: $id) { + ... on ProjectV2Item { + fieldValues(first: 100, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + ... on ProjectV2ItemFieldSingleSelectValue { + name + optionId + field { ... on ProjectV2SingleSelectField { id name } } + } + ... on ProjectV2ItemFieldNumberValue { + number + field { ... on ProjectV2Field { id name } } + } + ... on ProjectV2ItemFieldDateValue { + date + field { ... on ProjectV2Field { id name } } + } + ... on ProjectV2ItemFieldTextValue { + text + field { ... on ProjectV2Field { id name } } + } + } + } + } + } + }`, + { id: itemId, cursor }, + ); + if (!page.node) break; + for (const value of page.node.fieldValues.nodes) { + const fieldId = value.field?.id; + const fieldName = value.field?.name; + if (!fieldId || !fieldName) continue; + values.push({ + fieldId, + fieldName, + optionId: value.optionId, + optionName: value.name, + number: value.number, + date: value.date, + text: value.text, + }); + } + cursor = page.node.fieldValues.pageInfo.hasNextPage + ? page.node.fieldValues.pageInfo.endCursor + : null; + } while (cursor); + return values; + } + + public async addItem( + projectId: string, + contentNodeId: string, + ): Promise { + const response: { addProjectV2ItemById: { item: { id: string } } } = + await this.octokit.graphql( + `mutation AddActBoardProjectItem($projectId: ID!, $contentId: ID!) { + addProjectV2ItemById(input: { projectId: $projectId, contentId: $contentId }) { + item { id } + } + }`, + { projectId, contentId: contentNodeId }, + ); + return response.addProjectV2ItemById.item.id; + } + + public async updateFieldValue( + projectId: string, + itemId: string, + fieldId: string, + value: + | { singleSelectOptionId: string } + | { number: number } + | { date: string } + | { text: string }, + ): Promise { + await this.octokit.graphql( + `mutation UpdateActBoardProjectField( + $projectId: ID! + $itemId: ID! + $fieldId: ID! + $value: ProjectV2FieldValue! + ) { + updateProjectV2ItemFieldValue( + input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: $value + } + ) { + projectV2Item { id } + } + }`, + { projectId, itemId, fieldId, value }, + ); + } + + public async clearFieldValue( + projectId: string, + itemId: string, + fieldId: string, + ): Promise { + await this.octokit.graphql( + `mutation ClearActBoardProjectField( + $projectId: ID! + $itemId: ID! + $fieldId: ID! + ) { + clearProjectV2ItemFieldValue( + input: { projectId: $projectId, itemId: $itemId, fieldId: $fieldId } + ) { + projectV2Item { id } + } + }`, + { projectId, itemId, fieldId }, + ); + } +} diff --git a/src/cli/sync-act-board-project.ts b/src/cli/sync-act-board-project.ts new file mode 100644 index 0000000..f9f1eae --- /dev/null +++ b/src/cli/sync-act-board-project.ts @@ -0,0 +1,83 @@ +#!/usr/bin/env ts-node +import * as path from "node:path"; +import { Octokit } from "@octokit/rest"; +import { Command } from "commander"; + +import { OctokitActBoardClient } from "../approval-report/act-board"; +import { loadRuleApprovalRows } from "../approval-report/load-rows"; +import { + OctokitActBoardProjectClient, + parseProjectNumber, + syncActBoardProject, +} from "../approval-report/sync-project"; + +const program = new Command(); +program + .description( + "Sync act-board GitHub Project fields from classifier RuleApprovalRow JSON", + ) + .option( + "-i, --input ", + "Classifier RuleApprovalRow JSON file", + path.resolve(process.cwd(), "approval-report.json"), + ) + .option("--boardOwner ", "act-board repository owner", "act-rules") + .option("--boardRepo ", "act-board repository name", "act-board") + .option( + "--projectOwner ", + "GitHub organization (not user) that owns the Project", + "act-rules", + ) + .option( + "--projectNumber ", + "GitHub Projects v2 number", + process.env.ACT_BOARD_PROJECT_NUMBER, + ); + +program.parse(process.argv); +const options = program.opts(); + +if (!process.env.GITHUB_TOKEN) { + console.error( + "GITHUB_TOKEN is required (GitHub App installation token or PAT with project write).", + ); + process.exit(1); +} + +const projectNumber = parseProjectNumber(options.projectNumber); +if (projectNumber === null) { + console.error( + "ACT_BOARD_PROJECT_NUMBER or --projectNumber is required and must be a positive integer.", + ); + process.exit(1); +} + +const inputPath = path.resolve(options.input); +const rows = loadRuleApprovalRows(inputPath); +const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN }); +const issues = new OctokitActBoardClient(octokit); +const project = new OctokitActBoardProjectClient(octokit); + +syncActBoardProject( + rows, + { + listBoardIssues: (repository) => issues.listBoardIssues(repository), + project, + }, + { + boardRepository: { + owner: options.boardOwner, + repo: options.boardRepo, + }, + projectOwner: options.projectOwner, + projectNumber, + }, +) + .then((result) => { + console.log(`Synced Project fields: ${JSON.stringify(result)}`); + if (result.failures.length > 0) process.exitCode = 1; + }) + .catch((error) => { + console.error(error); + process.exit(1); + }); diff --git a/src/cli/upsert-act-board.ts b/src/cli/upsert-act-board.ts index acf33aa..01f492c 100644 --- a/src/cli/upsert-act-board.ts +++ b/src/cli/upsert-act-board.ts @@ -1,5 +1,4 @@ #!/usr/bin/env ts-node -import * as fs from "node:fs"; import * as path from "node:path"; import { Octokit } from "@octokit/rest"; import { Command } from "commander"; @@ -8,7 +7,7 @@ import { OctokitActBoardClient, upsertActBoardIssues, } from "../approval-report/act-board"; -import { RuleApprovalRow } from "../approval-report/types"; +import { loadRuleApprovalRows } from "../approval-report/load-rows"; const program = new Command(); program @@ -31,8 +30,16 @@ program program.parse(process.argv); const options = program.opts(); + +if (!process.env.GITHUB_TOKEN) { + console.error( + "GITHUB_TOKEN is required (GitHub App installation token or PAT).", + ); + process.exit(1); +} + const inputPath = path.resolve(options.input); -const rows = readRows(inputPath); +const rows = loadRuleApprovalRows(inputPath); const client = new OctokitActBoardClient( new Octokit({ auth: process.env.GITHUB_TOKEN }), ); @@ -54,19 +61,3 @@ upsertActBoardIssues(rows, client, { console.error(error); process.exit(1); }); - -function readRows(filePath: string): RuleApprovalRow[] { - const parsed: unknown = JSON.parse(fs.readFileSync(filePath, "utf8")); - if ( - !Array.isArray(parsed) || - parsed.some( - (row) => - typeof row !== "object" || - row === null || - typeof (row as { ruleId?: unknown }).ruleId !== "string", - ) - ) { - throw new Error(`${filePath} is not a RuleApprovalRow JSON array`); - } - return parsed as RuleApprovalRow[]; -} diff --git a/src/cli/write-act-board-snapshot.ts b/src/cli/write-act-board-snapshot.ts new file mode 100644 index 0000000..da7da4e --- /dev/null +++ b/src/cli/write-act-board-snapshot.ts @@ -0,0 +1,43 @@ +#!/usr/bin/env ts-node +import * as fs from "node:fs"; +import * as path from "node:path"; +import { Command } from "commander"; + +import { loadRuleApprovalRows } from "../approval-report/load-rows"; +import { + formatSnapshotJson, + snapshotsEqual, +} from "../approval-report/snapshot"; + +const program = new Command(); +program + .description( + "Write data/snapshot.json from classifier JSON only when contents changed", + ) + .option( + "-i, --input ", + "Classifier RuleApprovalRow JSON file", + path.resolve(process.cwd(), "approval-report.json"), + ) + .option( + "-o, --outFile ", + "Snapshot path on act-board", + path.resolve(process.cwd(), "data/snapshot.json"), + ); + +program.parse(process.argv); +const options = program.opts(); +const inputPath = path.resolve(options.input); +const outFile = path.resolve(options.outFile); +const rows = loadRuleApprovalRows(inputPath); +const next = formatSnapshotJson(rows); +const previous = fs.existsSync(outFile) ? fs.readFileSync(outFile, "utf8") : ""; + +if (snapshotsEqual(previous, next)) { + console.log(`Snapshot unchanged: ${outFile}`); + process.exit(0); +} + +fs.mkdirSync(path.dirname(outFile), { recursive: true }); +fs.writeFileSync(outFile, next, "utf8"); +console.log(`Wrote snapshot ${outFile} (${rows.length} rules)`);