From 4b444ed8db078398e2d7b7038339b8220a12175d Mon Sep 17 00:00:00 2001 From: Wilco Fiers Date: Fri, 18 Sep 2026 12:25:29 +0200 Subject: [PATCH 1/2] feat: sync act-board Project fields and snapshot.json Add a Projects v2 field sync and snapshot writer so the weekly board job can update Status without rewriting unchanged values. Co-authored-by: Cursor --- .gitignore | 1 + package.json | 2 + readme.md | 14 + .../__tests__/sync-project.test.ts | 375 ++++++++++ src/approval-report/load-rows.ts | 19 + src/approval-report/snapshot.ts | 29 + src/approval-report/sync-project.ts | 673 ++++++++++++++++++ src/cli/sync-act-board-project.ts | 81 +++ src/cli/upsert-act-board.ts | 29 +- src/cli/write-act-board-snapshot.ts | 43 ++ 10 files changed, 1247 insertions(+), 19 deletions(-) create mode 100644 src/approval-report/__tests__/sync-project.test.ts create mode 100644 src/approval-report/load-rows.ts create mode 100644 src/approval-report/snapshot.ts create mode 100644 src/approval-report/sync-project.ts create mode 100644 src/cli/sync-act-board-project.ts create mode 100644 src/cli/write-act-board-snapshot.ts 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..b946442 100644 --- a/readme.md +++ b/readme.md @@ -116,3 +116,17 @@ 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 optional fields, and exits if the Status field is missing. 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__/sync-project.test.ts b/src/approval-report/__tests__/sync-project.test.ts new file mode 100644 index 0000000..b7b14a0 --- /dev/null +++ b/src/approval-report/__tests__/sync-project.test.ts @@ -0,0 +1,375 @@ +import { BoardIssue } from "../act-board"; +import { snapshotsEqual } from "../snapshot"; +import { + ActBoardProjectClient, + DEFAULT_PROJECT_FIELD_NAMES, + 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 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", + }, + ], + }, + ], + }); + + 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(project.updateFieldValue).not.toHaveBeenCalled(); + expect(warnings.some((message) => message.includes("impl count"))).toBe( + true, + ); + }); + + 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", + ); + }); +}); + +describe("snapshotsEqual", () => { + it("ignores key order when deciding whether snapshot.json changed", () => { + expect( + snapshotsEqual( + '{"ruleId":"674b10","name":"A"}\n', + '{"name":"A","ruleId":"674b10"}', + ), + ).toBe(true); + expect(snapshotsEqual('{"ruleId":"a"}', '{"ruleId":"b"}')).toBe(false); + }); +}); 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..cc20cb3 --- /dev/null +++ b/src/approval-report/snapshot.ts @@ -0,0 +1,29 @@ +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 ( + stableStringify(JSON.parse(left)) === stableStringify(JSON.parse(right)) + ); + } catch { + return left === right; + } +} + +function stableStringify(value: unknown): string { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; + } + const record = value as Record; + const keys = Object.keys(record).sort(); + return `{${keys + .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`) + .join(",")}}`; +} diff --git a/src/approval-report/sync-project.ts b/src/approval-report/sync-project.ts new file mode 100644 index 0000000..a77cf89 --- /dev/null +++ b/src/approval-report/sync-project.ts @@ -0,0 +1,673 @@ +import { Octokit } from "@octokit/rest"; + +import { + BoardIssue, + DEFAULT_BOARD_REPOSITORY, + RepositoryRef, + ruleIdFromActBoardTitle, +} 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; +}; + +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, + }; + + 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}"`, + ); + } + + const issues = (await deps.listBoardIssues(boardRepository)).filter( + (issue) => !issue.isPullRequest, + ); + const issueByRuleId = new Map(); + for (const issue of [...issues].sort((a, b) => a.number - b.number)) { + const ruleId = ruleIdFromActBoardTitle(issue.title); + if (!ruleId || issueByRuleId.has(ruleId)) continue; + issueByRuleId.set(ruleId, issue); + } + + 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`); + continue; + } + + 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, project.fields, fieldNames, warn); + result.optionalFieldsSkipped += updates.optionalSkipped; + + let changed = false; + for (const update of updates.values) { + const current = item.fieldValues.find( + (value) => value.fieldId === update.field.id, + ); + if (fieldValueMatches(current, update)) continue; + changed = true; + if (update.clear) { + await deps.project.clearFieldValue( + project.id, + item.id, + update.field.id, + ); + } else if (update.payload) { + await deps.project.updateFieldValue( + project.id, + item.id, + update.field.id, + update.payload, + ); + } + result.fieldsUpdated += 1; + } + + if (!changed) result.skipped += 1; + } + + 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; +}; + +function desiredFieldUpdates( + row: RuleApprovalRow, + fields: ProjectField[], + names: ProjectFieldNames, + warn: (message: string) => void, +): { values: PreparedUpdate[]; optionalSkipped: number } { + const values: PreparedUpdate[] = []; + let optionalSkipped = 0; + + const status = requiredSingleSelect(fields, names.status, row.status); + values.push(status); + + const optional: Array<() => PreparedUpdate | "skipped"> = [ + () => optionalSingleSelect(fields, names.type, row.ruleTypeSummary, warn), + () => + optionalNumber(fields, names.implCount, row.implementations.length, warn), + () => optionalNumber(fields, names.blockerCount, row.blockersCount, warn), + () => optionalNumber(fields, names.openIssueCount, row.issues.length, warn), + () => optionalNumber(fields, names.ruleCommits, row.ruleCommitCount, warn), + () => + optionalNumber( + fields, + names.definitionCommits, + row.definitionCommitCount, + warn, + ), + () => optionalDate(fields, names.lastApproved, row.approvalIsoDate, warn), + () => optionalDate(fields, names.lastUpdated, row.lastUpdatedIsoDate, warn), + () => optionalText(fields, names.reviewPrUrl, row.reviewPrUrl, warn), + ]; + + for (const build of optional) { + const update = build(); + if (update === "skipped") { + optionalSkipped += 1; + continue; + } + values.push(update); + } + + return { values, optionalSkipped }; +} + +function requiredSingleSelect( + fields: ProjectField[], + fieldName: string, + optionName: string, +): PreparedUpdate { + const field = findField(fields, fieldName); + if (!field) { + throw new Error(`Missing required Project field "${fieldName}"`); + } + const option = findOption(field, optionName); + if (!option) { + throw new Error( + `Project field "${fieldName}" has no option "${optionName}"`, + ); + } + return { + field, + clear: false, + payload: { singleSelectOptionId: option.id }, + optionName, + }; +} + +function optionalSingleSelect( + fields: ProjectField[], + fieldName: string, + optionName: string, + warn: (message: string) => void, +): PreparedUpdate | "skipped" { + const field = findField(fields, fieldName); + if (!field) { + warn(`Skipping missing optional Project field "${fieldName}"`); + return "skipped"; + } + const option = findOption(field, optionName); + if (!option) { + warn( + `Skipping optional Project field "${fieldName}": no option "${optionName}"`, + ); + return "skipped"; + } + return { + field, + clear: false, + payload: { singleSelectOptionId: option.id }, + optionName, + }; +} + +function optionalNumber( + fields: ProjectField[], + fieldName: string, + number: number, + warn: (message: string) => void, +): PreparedUpdate | "skipped" { + const field = findField(fields, fieldName); + if (!field) { + warn(`Skipping missing optional Project field "${fieldName}"`); + return "skipped"; + } + return { + field, + clear: false, + payload: { number }, + number, + }; +} + +function optionalDate( + fields: ProjectField[], + fieldName: string, + date: string | null, + warn: (message: string) => void, +): PreparedUpdate | "skipped" { + const field = findField(fields, fieldName); + if (!field) { + warn(`Skipping missing optional Project field "${fieldName}"`); + return "skipped"; + } + if (!date) { + return { field, clear: true, date: null }; + } + return { + field, + clear: false, + payload: { date }, + date, + }; +} + +function optionalText( + fields: ProjectField[], + fieldName: string, + text: string | null, + warn: (message: string) => void, +): PreparedUpdate | "skipped" { + const field = findField(fields, fieldName); + if (!field) { + warn(`Skipping missing optional Project field "${fieldName}"`); + return "skipped"; + } + if (!text) { + return { field, clear: true, text: null }; + } + return { + field, + clear: false, + payload: { text }, + text, + }; +} + +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 (current.date ?? "").slice(0, 10) === 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 === ""; +} + +export const RULE_STATUSES: RuleStatus[] = [ + "Deprecated", + "In review", + "Blocked by issue", + "Awaiting implementation", + "Approved, current", + "Approved, unpublished changes", + "Proposed, reviewable", +]; + +type OrgProjectResponse = { + organization: { + projectV2: { + id: string; + title: string; + fields: { nodes: GraphQlFieldNode[] }; + } | null; + } | null; +}; + +type GraphQlFieldNode = { + id?: string; + name?: string; + dataType?: string; + options?: Array<{ id: string; name: string }>; +}; + +type ProjectItemsResponse = { + node: { + items: { + nodes: Array<{ + id: string; + content: { + id?: string; + number?: number; + title?: string; + } | null; + fieldValues: { + nodes: Array<{ + field?: { id?: string; name?: string } | null; + name?: string | null; + optionId?: string | null; + number?: number | null; + date?: string | null; + text?: string | null; + }>; + }; + }>; + 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: 50) { + 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 ${owner}. Set ACT_BOARD_PROJECT_NUMBER.`, + ); + } + + const fields: ProjectField[] = project.fields.nodes + .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 } + } + fieldValues(first: 20) { + 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: 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: node.fieldValues.nodes.flatMap((value) => { + const fieldId = value.field?.id; + const fieldName = value.field?.name; + if (!fieldId || !fieldName) return []; + return [ + { + fieldId, + fieldName, + optionId: value.optionId, + optionName: value.name, + number: value.number, + date: value.date, + text: value.text, + }, + ]; + }), + }); + } + cursor = page.node.items.pageInfo.hasNextPage + ? page.node.items.pageInfo.endCursor + : null; + } while (cursor); + + return { id: project.id, title: project.title, fields, items }; + } + + 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..f814f68 --- /dev/null +++ b/src/cli/sync-act-board-project.ts @@ -0,0 +1,81 @@ +#!/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, + 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 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 = Number.parseInt(String(options.projectNumber ?? ""), 10); +if (!Number.isFinite(projectNumber) || projectNumber < 1) { + 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)}`); + }) + .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)`); From 0ce47c9a7cf980b29aa04367e5c50651f13577d9 Mon Sep 17 00:00:00 2001 From: Wilco Fiers Date: Fri, 18 Sep 2026 12:38:49 +0200 Subject: [PATCH 2/2] fix: address project sync review findings Paginate project metadata and values, validate field plans once, and isolate per-row failures while preserving complete sync results. Reuse managed issue selection and expand tests for GraphQL and snapshots. Co-authored-by: Cursor --- readme.md | 5 +- .../__tests__/load-rows.test.ts | 42 ++ .../__tests__/snapshot.test.ts | 27 + .../__tests__/sync-project.test.ts | 340 +++++++++++- src/approval-report/act-board.ts | 2 +- src/approval-report/snapshot.ts | 16 +- src/approval-report/sync-project.ts | 507 +++++++++++------- src/cli/sync-act-board-project.ts | 8 +- 8 files changed, 736 insertions(+), 211 deletions(-) create mode 100644 src/approval-report/__tests__/load-rows.test.ts create mode 100644 src/approval-report/__tests__/snapshot.test.ts diff --git a/readme.md b/readme.md index b946442..577755f 100644 --- a/readme.md +++ b/readme.md @@ -126,7 +126,10 @@ yarn write-act-board-snapshot --input approval-report.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 optional fields, and exits if the Status field is missing. Both +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 index b7b14a0..baa26cf 100644 --- a/src/approval-report/__tests__/sync-project.test.ts +++ b/src/approval-report/__tests__/sync-project.test.ts @@ -1,8 +1,9 @@ import { BoardIssue } from "../act-board"; -import { snapshotsEqual } from "../snapshot"; import { ActBoardProjectClient, DEFAULT_PROJECT_FIELD_NAMES, + OctokitActBoardProjectClient, + parseProjectNumber, ProjectField, ProjectItem, ProjectSnapshot, @@ -243,6 +244,7 @@ describe("syncActBoardProject", () => { 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", @@ -262,13 +264,29 @@ describe("syncActBoardProject", () => { }, ], }, + { + 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], + [current, second], { - listBoardIssues: jest.fn().mockResolvedValue([boardIssue(current)]), + listBoardIssues: jest + .fn() + .mockResolvedValue([boardIssue(current), boardIssue(second)]), project, }, { @@ -282,6 +300,41 @@ describe("syncActBoardProject", () => { 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 () => { @@ -360,16 +413,285 @@ describe("syncActBoardProject", () => { "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("snapshotsEqual", () => { - it("ignores key order when deciding whether snapshot.json changed", () => { +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( - snapshotsEqual( - '{"ruleId":"674b10","name":"A"}\n', - '{"name":"A","ruleId":"674b10"}', + graphql.mock.calls.filter(([query]) => + String(query).includes("ActBoardProjectItemFieldValues"), + ), + ).toHaveLength(2); + expect( + graphql.mock.calls.every( + ([query]) => !String(query).includes("first: 20"), ), ).toBe(true); - expect(snapshotsEqual('{"ruleId":"a"}', '{"ruleId":"b"}')).toBe(false); + }); + + 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/snapshot.ts b/src/approval-report/snapshot.ts index cc20cb3..41128bc 100644 --- a/src/approval-report/snapshot.ts +++ b/src/approval-report/snapshot.ts @@ -7,23 +7,13 @@ export function formatSnapshotJson(rows: RuleApprovalRow[]): string { export function snapshotsEqual(left: string, right: string): boolean { try { return ( - stableStringify(JSON.parse(left)) === stableStringify(JSON.parse(right)) + formatParsedJson(JSON.parse(left)) === formatParsedJson(JSON.parse(right)) ); } catch { return left === right; } } -function stableStringify(value: unknown): string { - if (value === null || typeof value !== "object") { - return JSON.stringify(value); - } - if (Array.isArray(value)) { - return `[${value.map((entry) => stableStringify(entry)).join(",")}]`; - } - const record = value as Record; - const keys = Object.keys(record).sort(); - return `{${keys - .map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`) - .join(",")}}`; +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 index a77cf89..b60460b 100644 --- a/src/approval-report/sync-project.ts +++ b/src/approval-report/sync-project.ts @@ -4,7 +4,7 @@ import { BoardIssue, DEFAULT_BOARD_REPOSITORY, RepositoryRef, - ruleIdFromActBoardTitle, + selectManagedIssues, } from "./act-board"; import { RuleApprovalRow, RuleStatus } from "./types"; @@ -90,6 +90,8 @@ export type SyncActBoardProjectResult = { fieldsUpdated: number; skipped: number; optionalFieldsSkipped: number; + rowsWithoutIssue: number; + failures: Array<{ ruleId: string; message: string }>; }; type ListBoardIssues = (repository: RepositoryRef) => Promise; @@ -114,6 +116,8 @@ export async function syncActBoardProject( fieldsUpdated: 0, skipped: 0, optionalFieldsSkipped: 0, + rowsWithoutIssue: 0, + failures: [], }; const project = await deps.project.getProject( @@ -126,16 +130,18 @@ export async function syncActBoardProject( `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 issueByRuleId = new Map(); - for (const issue of [...issues].sort((a, b) => a.number - b.number)) { - const ruleId = ruleIdFromActBoardTitle(issue.title); - if (!ruleId || issueByRuleId.has(ruleId)) continue; - issueByRuleId.set(ruleId, issue); - } + const { managedIssues: issueByRuleId } = selectManagedIssues(issues); const itemByIssueNodeId = new Map(); for (const item of project.items) { @@ -148,51 +154,58 @@ export async function syncActBoardProject( 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; } - 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, project.fields, fieldNames, warn); - result.optionalFieldsSkipped += updates.optionalSkipped; + 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; + } - let changed = false; - for (const update of updates.values) { - const current = item.fieldValues.find( - (value) => value.fieldId === update.field.id, - ); - if (fieldValueMatches(current, update)) continue; - changed = true; - if (update.clear) { - await deps.project.clearFieldValue( - project.id, - item.id, - update.field.id, - ); - } else if (update.payload) { - await deps.project.updateFieldValue( - project.id, - item.id, - update.field.id, - update.payload, + 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; + } } - result.fieldsUpdated += 1; - } - if (!changed) result.skipped += 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; @@ -212,62 +225,106 @@ type PreparedUpdate = { text?: string | null; }; -function desiredFieldUpdates( - row: RuleApprovalRow, +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, -): { values: PreparedUpdate[]; optionalSkipped: number } { - const values: PreparedUpdate[] = []; - let optionalSkipped = 0; - - const status = requiredSingleSelect(fields, names.status, row.status); - values.push(status); - - const optional: Array<() => PreparedUpdate | "skipped"> = [ - () => optionalSingleSelect(fields, names.type, row.ruleTypeSummary, warn), - () => - optionalNumber(fields, names.implCount, row.implementations.length, warn), - () => optionalNumber(fields, names.blockerCount, row.blockersCount, warn), - () => optionalNumber(fields, names.openIssueCount, row.issues.length, warn), - () => optionalNumber(fields, names.ruleCommits, row.ruleCommitCount, warn), - () => - optionalNumber( - fields, - names.definitionCommits, - row.definitionCommitCount, - warn, - ), - () => optionalDate(fields, names.lastApproved, row.approvalIsoDate, warn), - () => optionalDate(fields, names.lastUpdated, row.lastUpdatedIsoDate, warn), - () => optionalText(fields, names.reviewPrUrl, row.reviewPrUrl, warn), - ]; - - for (const build of optional) { - const update = build(); - if (update === "skipped") { - optionalSkipped += 1; +): 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; } - values.push(update); + 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; +} - return { values, optionalSkipped }; +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( - fields: ProjectField[], - fieldName: string, + field: ProjectField, optionName: string, ): PreparedUpdate { - const field = findField(fields, fieldName); - if (!field) { - throw new Error(`Missing required Project field "${fieldName}"`); - } const option = findOption(field, optionName); if (!option) { throw new Error( - `Project field "${fieldName}" has no option "${optionName}"`, + `Project field "${field.name}" has no option "${optionName}"`, ); } return { @@ -279,22 +336,17 @@ function requiredSingleSelect( } function optionalSingleSelect( - fields: ProjectField[], - fieldName: string, + field: ProjectField | undefined, optionName: string, warn: (message: string) => void, -): PreparedUpdate | "skipped" { - const field = findField(fields, fieldName); - if (!field) { - warn(`Skipping missing optional Project field "${fieldName}"`); - return "skipped"; - } +): PreparedUpdate | undefined { + if (!field) return undefined; const option = findOption(field, optionName); if (!option) { warn( - `Skipping optional Project field "${fieldName}": no option "${optionName}"`, + `Skipping optional Project field "${field.name}": no option "${optionName}"`, ); - return "skipped"; + return undefined; } return { field, @@ -305,16 +357,10 @@ function optionalSingleSelect( } function optionalNumber( - fields: ProjectField[], - fieldName: string, + field: ProjectField | undefined, number: number, - warn: (message: string) => void, -): PreparedUpdate | "skipped" { - const field = findField(fields, fieldName); - if (!field) { - warn(`Skipping missing optional Project field "${fieldName}"`); - return "skipped"; - } +): PreparedUpdate | undefined { + if (!field) return undefined; return { field, clear: false, @@ -324,16 +370,10 @@ function optionalNumber( } function optionalDate( - fields: ProjectField[], - fieldName: string, + field: ProjectField | undefined, date: string | null, - warn: (message: string) => void, -): PreparedUpdate | "skipped" { - const field = findField(fields, fieldName); - if (!field) { - warn(`Skipping missing optional Project field "${fieldName}"`); - return "skipped"; - } +): PreparedUpdate | undefined { + if (!field) return undefined; if (!date) { return { field, clear: true, date: null }; } @@ -346,16 +386,10 @@ function optionalDate( } function optionalText( - fields: ProjectField[], - fieldName: string, + field: ProjectField | undefined, text: string | null, - warn: (message: string) => void, -): PreparedUpdate | "skipped" { - const field = findField(fields, fieldName); - if (!field) { - warn(`Skipping missing optional Project field "${fieldName}"`); - return "skipped"; - } +): PreparedUpdate | undefined { + if (!field) return undefined; if (!text) { return { field, clear: true, text: null }; } @@ -367,6 +401,13 @@ function optionalText( }; } +function pushIfDefined( + updates: PreparedUpdate[], + update: PreparedUpdate | undefined, +): void { + if (update) updates.push(update); +} + function fieldValueMatches( current: ProjectItemFieldValue | undefined, update: PreparedUpdate, @@ -393,7 +434,7 @@ function fieldValueMatches( return Number(current.number) === Number(update.payload.number); } if ("date" in update.payload) { - return (current.date ?? "").slice(0, 10) === update.payload.date; + return normalizeDate(current.date) === normalizeDate(update.payload.date); } return (current.text ?? "") === update.payload.text; } @@ -418,22 +459,35 @@ function empty(value: string | null | undefined): boolean { return value == null || value === ""; } -export const RULE_STATUSES: RuleStatus[] = [ - "Deprecated", - "In review", - "Blocked by issue", - "Awaiting implementation", - "Approved, current", - "Approved, unpublished changes", - "Proposed, reviewable", -]; +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: { nodes: GraphQlFieldNode[] }; + fields: GraphQlFieldsConnection; } | null; } | null; }; @@ -445,6 +499,17 @@ type GraphQlFieldNode = { 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: { @@ -455,22 +520,30 @@ type ProjectItemsResponse = { number?: number; title?: string; } | null; - fieldValues: { - nodes: Array<{ - field?: { id?: string; name?: string } | null; - name?: string | null; - optionId?: string | null; - number?: number | null; - date?: string | null; - text?: 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) {} @@ -484,7 +557,8 @@ export class OctokitActBoardProjectClient implements ActBoardProjectClient { projectV2(number: $number) { id title - fields(first: 50) { + fields(first: 100) { + pageInfo { hasNextPage endCursor } nodes { ... on ProjectV2Field { id @@ -512,11 +586,53 @@ export class OctokitActBoardProjectClient implements ActBoardProjectClient { const project = response.organization?.projectV2; if (!project) { throw new Error( - `GitHub Project #${projectNumber} was not found on ${owner}. Set ACT_BOARD_PROJECT_NUMBER.`, + `GitHub Project #${projectNumber} was not found on organization ${owner}. Check ACT_BOARD_PROJECT_NUMBER and ensure the token can access the Project.`, ); } - const fields: ProjectField[] = project.fields.nodes + 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), ) @@ -541,27 +657,6 @@ export class OctokitActBoardProjectClient implements ActBoardProjectClient { content { ... on Issue { id number title } } - fieldValues(first: 20) { - 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 } } - } - } - } } } } @@ -576,22 +671,7 @@ export class OctokitActBoardProjectClient implements ActBoardProjectClient { issueNodeId: node.content?.id, issueNumber: node.content?.number, issueTitle: node.content?.title, - fieldValues: node.fieldValues.nodes.flatMap((value) => { - const fieldId = value.field?.id; - const fieldName = value.field?.name; - if (!fieldId || !fieldName) return []; - return [ - { - fieldId, - fieldName, - optionId: value.optionId, - optionName: value.name, - number: value.number, - date: value.date, - text: value.text, - }, - ]; - }), + fieldValues: await this.getItemFieldValues(node.id), }); } cursor = page.node.items.pageInfo.hasNextPage @@ -602,6 +682,65 @@ export class OctokitActBoardProjectClient implements ActBoardProjectClient { 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, diff --git a/src/cli/sync-act-board-project.ts b/src/cli/sync-act-board-project.ts index f814f68..f9f1eae 100644 --- a/src/cli/sync-act-board-project.ts +++ b/src/cli/sync-act-board-project.ts @@ -7,6 +7,7 @@ import { OctokitActBoardClient } from "../approval-report/act-board"; import { loadRuleApprovalRows } from "../approval-report/load-rows"; import { OctokitActBoardProjectClient, + parseProjectNumber, syncActBoardProject, } from "../approval-report/sync-project"; @@ -24,7 +25,7 @@ program .option("--boardRepo ", "act-board repository name", "act-board") .option( "--projectOwner ", - "GitHub organization that owns the Project", + "GitHub organization (not user) that owns the Project", "act-rules", ) .option( @@ -43,8 +44,8 @@ if (!process.env.GITHUB_TOKEN) { process.exit(1); } -const projectNumber = Number.parseInt(String(options.projectNumber ?? ""), 10); -if (!Number.isFinite(projectNumber) || projectNumber < 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.", ); @@ -74,6 +75,7 @@ syncActBoardProject( ) .then((result) => { console.log(`Synced Project fields: ${JSON.stringify(result)}`); + if (result.failures.length > 0) process.exitCode = 1; }) .catch((error) => { console.error(error);