Skip to content

Commit

Permalink
Workflow to check key/value issues
Browse files Browse the repository at this point in the history
  • Loading branch information
thoukydides committed Dec 2, 2024
1 parent 59932b9 commit 3e0828b
Show file tree
Hide file tree
Showing 4 changed files with 328 additions and 2 deletions.
27 changes: 27 additions & 0 deletions .github/workflows/issue-api-types.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Validate 'api keys/values' Issue

on:
issues:
types: [opened]
workflow_dispatch:

jobs:
validate-api-keys:
if: (github.event_name == 'issues' && contains(github.event.issue.labels.*.name, 'api keys/values')) ||
(github.event_name == 'workflow_dispatch')
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: '18'

- name: Install dependencies
run: npm ci

- name: Run the validation script
run: npx tsx bin/issue-api-keys.ts
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
295 changes: 295 additions & 0 deletions bin/issue-api-keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
// Homebridge plugin for Home Connect home appliances
// Copyright © 2024 Alexander Thoukydides

import * as core from '@actions/core';
import * as github from '@actions/github';
import { components } from '@octokit/openapi-types';
import { readFile } from 'node:fs/promises';

// Parsed API key/value types
type APIInterface = Map<string, string>;
type APIInterfaces = Map<string, APIInterface>;
type APILiteral = Set<string>; // (string literal or enum)
type APILiterals = Map<string, APILiteral>;
interface APITypes {
interfaces: APIInterfaces;
literals: APILiterals;
}

// Issue description returned by GitHub REST API
type Issue = components['schemas']['issue'];

// Source file defining API types
const SOURCE_FILE = './src/api-value-types.ts';

// File containing the current plugin version
const PACKAGE_JSON_FILE = './package.json';

// Issue labels
const LABEL_KEY_VALUE = 'api keys/values';
const LABEL_INVALID = 'invalid';

// API values and types to ignore when reported (they are likely to be spurious)
const IGNORE_VALUES: string[] = [
'Espresso',
'Micha: Caffè Grande',
'number | string',
'unknown',
"''",
"'BSH.Common.EnumType.PowerState.Undefined'",
"'Micha: Caffè Grande'",
"'Unknown'"
];

// Prepare an Octokit client
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
if (!GITHUB_TOKEN) throw new Error('GITHUB_TOKEN environment variable not set');
const octokit = github.getOctokit(GITHUB_TOKEN);
const { repo } = github.context; // (uses env.GITHUB_REPOSITORY)

// Parse the source code
const sourceFile = await readFile(SOURCE_FILE, 'utf8');
const sourceTypes = parseTypes(SOURCE_FILE, sourceFile);
const sourceVersion = await getCurrentVersion();
core.info(`Current plugin version: ${sourceVersion}`);

// Action depends on whether a particular issue was specified
const issue_number = github.context.issue.number as number | undefined;
if (issue_number === undefined) {
// Check all issues with the appropriate label, but do not comment on them
const summary: string[] = [];
for await (const response of octokit.paginate.iterator(octokit.rest.issues.listForRepo, {
...repo, labels: LABEL_KEY_VALUE, state: 'all', sort: 'created', direction: 'asc'
})) {
for (const issue of response.data) {
const { number, state } = issue;
core.startGroup(`Issue #${number}`);
const issueSummary = await reviewIssue(issue, false);
if (issueSummary) {
summary.push(...issueSummary.map(line => `${line} // #${number} [${state}]`));
}
core.endGroup();
}
}

// Display a final summary
core.info('='.repeat(80));
if (summary.length) {
core.info(`🔴 ${summary.length} key/value updates required:`);
for (const line of summary.sort()) core.info(line);
} else {
core.info('🟢 No updates required');
}
} else {
// Check a single issue and add a comment
const { data } = await octokit.rest.issues.get({ ...repo, issue_number });
reviewIssue(data, true);
}

// Read the current source code version
async function getCurrentVersion(): Promise<string> {
const packageText = await readFile(PACKAGE_JSON_FILE, 'utf-8');
const packageJSON = JSON.parse(packageText) as { version: string };
return packageJSON.version;
}

// Review a single issue
async function reviewIssue(issue: Issue, addComment = false): Promise<string[] | undefined> {
const { number: issue_number, title, state, body } = issue;
core.info(`Issue #${issue_number}: ${title} [${state}]`);
core.info(issue.html_url);

// Parse the issue into its component fields
const issueFields = parseIssueBody(body ?? '');
core.info(`Appliance(s): ${issueFields.get('Home Connect Appliance(s)') ?? '(not specified)'}`);

// Parse the types from the provided log file
const issueTypes = parseTypes(`#${issue_number}`, issueFields.get('Log File') ?? '');
const countTypes = (types: APITypes): number => types.interfaces.size + types.literals.size;
if (!countTypes(issueTypes)) {
core.warning(`Issue #${issue_number}: No API keys/values in log file`);
if (addComment) {
const body = '⚠️ This does **not** appear to be an API key/value report. The supplied log file does not list any types.';
await octokit.rest.issues.createComment({ ...repo, issue_number, body });
await octokit.rest.issues.setLabels({ ...repo, issue_number, labels: [{ name: LABEL_INVALID }] });
}
return;
}

// Generate a comment to add to the issue
let comment = 'Thank you for taking the time to report this issue. 👍';
comment += '\n\n---\n\n';

// Check for discrepancies between the current source and the issue log
const discrepancies = checkDiscrepancies(sourceTypes, issueTypes);
let summary: string[] | undefined;
if (countTypes(discrepancies)) {
core.notice(`Issue #${issue_number}: Updates required to API key/value types`);
comment += '🔴 The following key/value updates appear to be required:\n';
summary = [];
if (discrepancies.interfaces.size) {
comment += '\nInterface | Property | Type\n--- | --- | ---\n';
for (const [interfaceName, fields] of sortMapByKey(discrepancies.interfaces)) {
const sortedFields = sortMapByKey(fields);
for (const [key, type] of sortedFields) {
summary.push(`interface ${interfaceName} { ${key}?: ${type}; }`);
comment += [interfaceName, key, type].map(v => `\`${v}\``).join(' | ') + '\n';
}
}
}
if (discrepancies.literals.size) {
comment += '\nType | Literal\n--- | ---\n';
for (const [typeName, values] of sortMapByKey(discrepancies.literals)) {
const sortedValues = Array.from(values).sort((a, b) => a.localeCompare(b));
for (const value of sortedValues) {
summary.push(`enum ${typeName} = ${value}`);
comment += [typeName, value].map(v => `\`${v}\``).join(' | ') + '\n';
}
}
}
comment += '\nThese should be included in the next release.';
} else {
comment += '🟢 The current source code appears to include all required key/value updates.';
}

// Warn if the report is not for the current plugin version
const issueVersion = issueFields.get('Plugin Version');
if (issueVersion && issueVersion !== sourceVersion) {
core.info(`Not current plugin version (${issueVersion}${sourceVersion})`);
comment += '\n\n---\n\n';
comment += '💡 This report is for an out-of-date version of the plugin.';
comment += ` The current version is \`${sourceVersion}\`, but the issue is for \`${issueVersion}\`.`;
}

// Add a comment to the issue, if required
core.info(`Comment:\n${comment.replace(/^/gm, ' ')}`);
if (addComment) {
await octokit.rest.issues.createComment({ ...repo, issue_number, body: comment });
}
return summary;
}

// Parse the issue body
function parseIssueBody(body: string): Map<string, string> {
const fields = new Map<string, string[]>();
let currentValue: string[] | undefined;

const HEADING_REGEX = /^### (.*?)$/;
for (const line of body.split(/\r?\n/)) {
const match = HEADING_REGEX.exec(line);
if (match) {
currentValue = [];
fields.set(match[1].trim(), currentValue);
} else if (currentValue) {
currentValue.push(line);
} else {
core.debug(`Ignoring line: ${line}`);
}
}

return new Map(Array.from(fields.entries(),
([key, value]) => [key, value.join('\n').trim()]));
}

// Extract interface and literal definitions from the source file or issue log
function parseTypes(description: string, source: string): APITypes {
const interfaces = new Map<string, APIInterface>();
const literals = new Map<string, APILiteral>();

// Remove all log file prefixes and line comments
source = source.replace(/^.*] /gm, '').replace(/\/\/.*$/gm, '');

// Regular expressions to match different type definitions
const interfaceRegex = /export\s+interface\s+(\w.*?\w)(?:\s+extends\s+(\w+(?:\s*,\s*\w+)*))?\s*\{([^}]+?)\}/gs;
const literalRegex = /export\s+type\s+(\w.*?\w)\s*=\s*([^;]+?);/gs;
const enumRegex = /export\s+enum\s+(\w.*?\w)\s*\{([^}]+?)\}/gs;

// Parse interface type definitions
for (const match of source.matchAll(interfaceRegex)) {
const [, interfaceName, interfaceExtends, interfaceBody] = match;
const properties = new Map<string, string>();
if (interfaceExtends) {
for (const base of interfaceExtends.split(',')) {
const baseProperties = interfaces.get(base.trim());
baseProperties?.forEach((value, key) => properties.set(key, value));
}
}
for (const property of interfaceBody.split(';')) {
const [key, type] = property.split('?:');
if (key && type) properties.set(key.trim(), type.trim());
}
if (properties.size) interfaces.set(interfaceName, properties);
if (core.isDebug()) core.debug(`Parsed interface '${interfaceName}': ${JSON.stringify(Object.fromEntries(properties))}`);
}

// Process literal-like type definitions
const processLiteralType = (match: RegExpMatchArray, separator: string): void => {
const [, typeName, typeBody] = match;
const values = new Set(typeBody
.split(separator)
.map(value => value.replace(/^.*=/s, '').trim())
.filter(Boolean)
);
if (values.size) literals.set(typeName, values);
if (core.isDebug()) core.debug(`Parsed literal-like '${typeName}': ${[...values].join(', ')}`);
};

// Parse string literal type definitions
for (const match of source.matchAll(literalRegex)) {
processLiteralType(match, '|');
}

// Parse enum type definitions (treating them as string literals)
for (const match of source.matchAll(enumRegex)) {
processLiteralType(match, ',');
}

// Return the parsed types
core.info(`Parsed ${description}: ${interfaces.size} interfaces and ${literals.size} literal types`);
return { interfaces, literals };
}

// Identify all updates required to the source code
function checkDiscrepancies(sourceTypes: APITypes, issueTypes: APITypes): APITypes {
const interfaces = new Map<string, APIInterface>();
const literals = new Map<string, APILiteral>();

// Check the interface types
for (const [interfaceName, issueProperties] of issueTypes.interfaces) {
const sourceProperties = sourceTypes.interfaces.get(interfaceName);
if (!sourceProperties) {
// The whole interface type is missing
interfaces.set(interfaceName, issueProperties);
} else {
// Check that individual properties exist and include the right type
const mismatched = [...issueProperties].filter(([key, type]) =>
!IGNORE_VALUES.includes(type)
&& !sourceProperties.get(key)?.includes(type));
if (mismatched.length) {
interfaces.set(interfaceName, new Map(mismatched));
}
}
}

// Check the string literal and enum types
const sourceLiterals = new Set(
[...sourceTypes.literals.values()].flatMap(values => [...values])
);
for (const [literalName, issueValues] of issueTypes.literals) {
// Only check the values; the type names do not need to match
const missing = [...issueValues].filter(value =>
!IGNORE_VALUES.includes(value) && !sourceLiterals.has(value));
if (missing.length) {
literals.set(literalName, new Set(missing));
}
}

// Return the discrepancies
core.info(`Discrepancies: ${interfaces.size} interfaces and ${literals.size} literal types`);
return { interfaces, literals };
}

// Convert a Map to entries and sort lexicographically by key
function sortMapByKey<T>(map: Map<string, T>): [string, T][] {
return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]));
}
6 changes: 5 additions & 1 deletion bin/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
{
"extends": "@tsconfig/node18/tsconfig.json"
"extends": "@tsconfig/node18/tsconfig.json",
"compilerOptions": {
"moduleResolution": "node16",
"module": "node16"
}
}
2 changes: 1 addition & 1 deletion src/api-value-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -944,7 +944,7 @@ export interface EventNotifyValues extends OptionValues, SettingValues {
'BSH.Common.Root.SelectedProgram'?: ProgramKey | null;
'BSH.Common.Root.ActiveProgram'?: ProgramKey | null;
}
export type EventStatusValues = StatusValues
export type EventStatusValues = StatusValues;
export interface EventEventValues {
// Program progress events
'BSH.Common.Event.AlarmClockElapsed'?: EventPresentState;
Expand Down

0 comments on commit 3e0828b

Please sign in to comment.