Add script for validating PR title (#4050)

This commit is contained in:
Bart Louwers
2026-01-14 18:54:02 +01:00
committed by GitHub
parent e0dbd33f00
commit a4cf4d3ffa
14 changed files with 172 additions and 79 deletions

2
.github/labeler.yml vendored
View File

@@ -6,7 +6,7 @@ iOS:
- any-glob-to-any-file:
- 'platform/ios/**/*'
- 'platform/darwin/**/*'
cpp-core:
core:
- changed-files:
- any-glob-to-any-file:
- 'src/**/*'

View File

@@ -0,0 +1,30 @@
import test from "node:test";
import { validateTitle } from "./validate-pr-title.ts";
test("validateTitle - missing Android label", (t) => {
t.assert.deepEqual(validateTitle({
labels: new Set(["android"]),
title: "make a change"
}), {
mismatches: ["android"],
ok: false
});
});
test("validateTitle - android required and title contains Android", (t) => {
t.assert.deepEqual(validateTitle({
labels: new Set(["android"]),
title: "make change to Android"
}), {
ok: true
});
});
test("validateTitle - only core required when label contains core", (t) => {
t.assert.deepEqual(validateTitle({
labels: new Set(["core", "android"]),
title: "core: make some change"
}), {
ok: true
});
});

68
.github/scripts/validate-pr-title.ts vendored Normal file
View File

@@ -0,0 +1,68 @@
#!/usr/bin/env node
// This script validates that a PR title also contains its labels
// in the title. This is a requirement, because this makes it easier
// to parse commit messages. For example, for generating a changelog
// for a particular platform.
// Usage:
// PR_TITLE="Make some changes to Android" PR_LABELS="android" node validate-pr-title.ts
export function validateTitle({
title,
labels
}: {
title: string,
labels: Set<string>
}): { ok: true } | { ok: false, mismatches: string[] } {
// these labels are the ones we care about
const keywords = new Set(['node', 'core', 'ios', 'android', 'qt']);
// when labels includes core, only 'core' is required
const requiredKeyWords = labels.has('core')
? new Set(['core'])
: labels.intersection(keywords);
title = title.toLocaleLowerCase();
const actualKeywords = new Set();
for (const keyword of requiredKeyWords) {
if (title.includes(keyword)) actualKeywords.add(keyword);
}
const mismatches = requiredKeyWords.difference(actualKeywords);
if (mismatches.size === 0) return { ok: true };
return { ok: false, mismatches: [...mismatches] };
}
if (import.meta.main) {
if (!process.env.PR_TITLE) {
console.log('::error::PR_TITLE environment variable is not set');
process.exit(1);
}
if (!process.env.PR_LABELS) {
console.log('::error::PR_LABELS environment variable is not set');
process.exit(1);
}
const title = process.env.PR_TITLE;
const labelsString = process.env.PR_LABELS.toLowerCase();
const labels = new Set(labelsString.split(',').map(l => l.trim()).filter(l => l));
const result = validateTitle({
title,
labels
});
if (result.ok) {
console.log('✅ PR labels match the title');
process.exit(0);
}
if (!result.ok) {
if (result.mismatches.length > 0) {
console.log(`::error::PR title does not match PR labels. Mismatched: ${result.mismatches.join(', ')}. Title: "${process.env.PR_TITLE}". Labels: "${process.env.PR_LABELS}"`);
process.exit(1);
}
}
}

View File

@@ -69,7 +69,7 @@ jobs:
node-version-file: ".nvmrc"
- name: npm install
run: npm install --ignore-scripts
run: npm ci --ignore-scripts
working-directory: .
shell: bash

View File

@@ -1,18 +0,0 @@
name: typecheck-scripts
on:
workflow_dispatch:
push:
pull_request:
jobs:
typecheck-scripts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4
with:
persist-credentials: false
- run: |
npm install
npx tsc -p tsconfig.json

22
.github/workflows/validate-pr-title.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: Validate PR title
on:
pull_request:
types: [labeled, unlabeled, opened, edited]
jobs:
validate-tags-match-title:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v4
with:
node-version-file: ".nvmrc"
- name: Check if labels match PR title
env:
PR_TITLE: ${{ github.event.pull_request.title }}
PR_LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
run: node .github/scripts/validate-pr-title.ts

26
.github/workflows/validate-scripts.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: validate-scripts
on:
workflow_dispatch:
push:
pull_request:
jobs:
validate-scripts:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v4
with:
persist-credentials: false
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v4
with:
node-version-file: ".nvmrc"
- name: Typecheck
run: |
npm install
npx tsc -p tsconfig.json
- name: Run tests
run: node --test .github/scripts/**.test.ts

1
.gitignore vendored
View File

@@ -20,6 +20,7 @@ local.properties
.DS_Store
.cache
compile_commands.json
.build
/build
/build-*
**/.idea

2
.nvmrc
View File

@@ -1 +1 @@
23
24

25
package-lock.json generated
View File

@@ -23,13 +23,13 @@
"@mapbox/mvt-fixtures": "3.10.0",
"@octokit/rest": "^20.1.0",
"@types/ejs": "^3.1.5",
"@types/node": "^22.9.0",
"@types/node": "^24.1.0",
"csscolorparser": "^1.0.3",
"ejs": "^3.1.10",
"express": "^4.19.2",
"lodash": "^4.17.21",
"lodash.template": "4.5.0",
"typescript": "^5.4.5"
"typescript": "^5.9.3"
}
},
"node_modules/@actions/core": {
@@ -2069,13 +2069,13 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.9.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz",
"integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==",
"version": "24.10.8",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.8.tgz",
"integrity": "sha512-r0bBaXu5Swb05doFYO2kTWHMovJnNVbCsII0fhesM8bNRlLhXIuckley4a2DaD+vOdmm5G+zGkQZAPZsF80+YQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.19.8"
"undici-types": "~7.16.0"
}
},
"node_modules/accepts": {
@@ -4600,10 +4600,11 @@
}
},
"node_modules/typescript": {
"version": "5.4.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz",
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -4654,9 +4655,9 @@
}
},
"node_modules/undici-types": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
"integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
"dev": true,
"license": "MIT"
},

View File

@@ -20,14 +20,14 @@
"@mapbox/mvt-fixtures": "3.10.0",
"@octokit/rest": "^20.1.0",
"@types/ejs": "^3.1.5",
"@types/node": "^22.9.0",
"@types/node": "^24.1.0",
"csscolorparser": "^1.0.3",
"ejs": "^3.1.10",
"express": "^4.19.2",
"lodash": "^4.17.21",
"lodash.template": "4.5.0",
"@mapbox/mapbox-gl-styles": "2.1.1",
"typescript": "^5.4.5"
"typescript": "^5.9.3"
},
"scripts": {
"copy-style-spec": "cp node_modules/@maplibre/maplibre-gl-style-spec/dist/latest.json scripts/style-spec-reference/v8.json"

View File

@@ -2,7 +2,6 @@
import { simpleGit } from "simple-git";
import * as fs from "node:fs";
import { Octokit } from "@octokit/rest";
/**
* @returns {never}
@@ -27,12 +26,7 @@ function getTagLastVersion() {
const tagLastVersion = getTagLastVersion();
if (!process.env.GITHUB_ACCESS_TOKEN) throw new Error("!process.env.GITHUB_ACCESS_TOKEN");
const git = simpleGit();
const octokit = new Octokit({
auth: process.env.GITHUB_ACCESS_TOKEN
});
/**
*
@@ -71,41 +65,8 @@ function formatMessageToMarkdownLink(message) {
const commitLastVersion = await getCommit(tagLastVersion);
// Load all closed pull requests
const pulls = await octokit.paginate(octokit.pulls.list, {
owner: "maplibre",
repo: "maplibre-native",
state: "closed",
per_page: 100,
});
/**
* @type {Map<string, typeof pulls[0]>}[] }>}
*/
const pullRequestsMap = new Map();
pulls.forEach(pr => {
if (pr.merge_commit_sha) {
pullRequestsMap.set(pr.merge_commit_sha, pr);
}
});
const logs = await git.log({ from: commitLastVersion });
for await (const logEntry of logs.all.toReversed()) {
const pr = pullRequestsMap.get(logEntry.hash);
const log = () => console.log(`- ${formatMessageToMarkdownLink(logEntry.message)}.`);
if (!pr) {
log();
continue;
}
// only log changelog entry when PR has corresponding platform label (or none)
const hasLabel = (/** @type {string} **/ label) => pr.labels.some(({name}) => name === label);
if (!hasLabel("ios") && !hasLabel("android") && !hasLabel("node")) log();
else if (platform === "android" && hasLabel("android")) log();
else if (platform === "ios" && hasLabel("ios")) log();
console.log(`- ${formatMessageToMarkdownLink(logEntry.message)}.`);
}

View File

@@ -13,7 +13,7 @@ import { join } from "path";
*/
async function getFilePaths(baseDir, predicate) {
const entries = (await fs.readdir(baseDir, { withFileTypes: true }))
return entries.map((entry) => join(entry.path, entry.name)).filter(predicate);
return entries.map((entry) => join(baseDir, entry.name)).filter(predicate);
}
// documentation files that will be updated and source files that contain examples

View File

@@ -3,14 +3,16 @@
"strict": true,
"allowJs": true,
"checkJs": true,
"module": "NodeNext",
"module": "nodenext",
"target": "esnext",
"skipLibCheck": true,
"resolveJsonModule": true,
"maxNodeModuleJsDepth": 0,
"noEmit": true
"noEmit": true,
"allowImportingTsExtensions": true
},
"include": [
".github/scripts/**/*.ts",
".github/scripts/*.ts",
"scripts/**/*.mjs",
"scripts/**/*.d.ts"
],