feat(github): label issues by database
This commit is contained in:
parent
8bddf29776
commit
3f87a7f689
|
|
@ -0,0 +1,422 @@
|
|||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
|
||||
const LABEL_PREFIX = "db/";
|
||||
const API_VERSION = "2022-11-28";
|
||||
const dryRun = process.env.DRY_RUN === "1" || process.env.DRY_RUN === "true";
|
||||
|
||||
const LABEL_PALETTE = [
|
||||
"0e8a16",
|
||||
"1d76db",
|
||||
"5319e7",
|
||||
"c2e0c6",
|
||||
"bfd4f2",
|
||||
"d4c5f9",
|
||||
"fef2c0",
|
||||
"fbca04",
|
||||
"f9d0c4",
|
||||
"e99695",
|
||||
"f29513",
|
||||
"c5def5",
|
||||
];
|
||||
|
||||
const extraAliases = {
|
||||
mysql: ["mariadb", "percona", "tidb"],
|
||||
postgres: ["postgresql", "postgres", "pgsql", "pg", "hologres"],
|
||||
sqlite: ["sqlite3", "sql lite"],
|
||||
clickhouse: ["click house", "ch"],
|
||||
sqlserver: ["sql server", "mssql", "microsoft sql server", "sqlservice"],
|
||||
mongodb: ["mongo", "mongodb"],
|
||||
oracle: ["oracle database"],
|
||||
elasticsearch: ["elastic search"],
|
||||
chromadb: ["chroma"],
|
||||
manticoresearch: ["manticore"],
|
||||
dameng: ["dm8", "dameng", "达梦"],
|
||||
kingbase: ["kingbasees", "kingbase", "人大金仓", "金仓"],
|
||||
highgo: ["瀚高"],
|
||||
yashandb: ["yashan", "崖山"],
|
||||
saphana: ["hana", "sap hana"],
|
||||
opengauss: ["open gauss"],
|
||||
"oceanbase-oracle": ["oceanbase", "oceanbase oracle"],
|
||||
gbase: ["gbase 8a", "gbase8a", "gbase 8s", "gbase8s"],
|
||||
access: ["microsoft access", "ms access"],
|
||||
vastbase: ["vastbase g", "vastbaseg"],
|
||||
prestosql: ["presto", "presto sql"],
|
||||
hive: ["apache hive"],
|
||||
db2: ["ibm db2"],
|
||||
informix: ["ibm informix"],
|
||||
bigquery: ["google bigquery"],
|
||||
kylin: ["apache kylin"],
|
||||
oscar: ["shentong", "oscar", "神通"],
|
||||
xugu: ["xugudb", "xugu", "虚谷"],
|
||||
zookeeper: ["zoo keeper", "apache zookeeper"],
|
||||
mq: ["message queue", "kafka", "rabbitmq", "rocketmq"],
|
||||
iotdb: ["apache iotdb"],
|
||||
iris: ["intersystems iris", "intersystems cache", "intersystems caché", "cache", "caché", "ensemble"],
|
||||
spark: ["apache spark"],
|
||||
};
|
||||
|
||||
const globallyAmbiguousAliases = new Set([
|
||||
"access",
|
||||
"ch",
|
||||
"h2",
|
||||
"iris",
|
||||
"jdbc",
|
||||
"mq",
|
||||
"pg",
|
||||
"spark",
|
||||
]);
|
||||
|
||||
const genericDatabaseValues = [
|
||||
"general",
|
||||
"generic",
|
||||
"global",
|
||||
"all",
|
||||
"any",
|
||||
"none",
|
||||
"n/a",
|
||||
"na",
|
||||
"通用",
|
||||
"全部",
|
||||
"所有",
|
||||
"无",
|
||||
"不适用",
|
||||
];
|
||||
|
||||
const manifestUrl = new URL("../../crates/dbx-core/assets/database-drivers.manifest.json", import.meta.url);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestUrl, "utf8"));
|
||||
const drivers = manifest.drivers.map((driver) => ({
|
||||
dbType: driver.dbType,
|
||||
label: driver.label,
|
||||
aliases: [...new Set([driver.dbType, driver.label, ...(extraAliases[driver.dbType] || [])])],
|
||||
}));
|
||||
const exactAsciiAliases = new Set(
|
||||
drivers.flatMap((driver) => driver.aliases.map((alias) => compactAscii(alias)).filter((alias) => alias.length >= 5)),
|
||||
);
|
||||
|
||||
function loadIssue() {
|
||||
if (process.env.GITHUB_EVENT_PATH && fs.existsSync(process.env.GITHUB_EVENT_PATH)) {
|
||||
const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
|
||||
return event.issue || {};
|
||||
}
|
||||
|
||||
return {
|
||||
number: process.env.ISSUE_NUMBER,
|
||||
title: process.env.ISSUE_TITLE || "",
|
||||
body: process.env.ISSUE_BODY || "",
|
||||
labels: process.env.ISSUE_LABELS ? JSON.parse(process.env.ISSUE_LABELS) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function labelNames(labels) {
|
||||
return (labels || []).map((label) => (typeof label === "string" ? label : label.name)).filter(Boolean);
|
||||
}
|
||||
|
||||
function stablePaletteColor(value) {
|
||||
let hash = 0;
|
||||
for (const char of value) {
|
||||
hash = (hash * 31 + char.codePointAt(0)) >>> 0;
|
||||
}
|
||||
return LABEL_PALETTE[hash % LABEL_PALETTE.length];
|
||||
}
|
||||
|
||||
function extractDatabaseField(body) {
|
||||
const matches = [...String(body || "").matchAll(/^###\s+(.+?)\s*$/gm)];
|
||||
for (const [index, match] of matches.entries()) {
|
||||
const heading = match[1].trim();
|
||||
const isDatabaseHeading =
|
||||
heading.includes("数据库类型") ||
|
||||
heading.toLowerCase().includes("database type") ||
|
||||
/^database$/i.test(heading);
|
||||
|
||||
if (!isDatabaseHeading) continue;
|
||||
|
||||
const start = match.index + match[0].length;
|
||||
const end = matches[index + 1]?.index ?? body.length;
|
||||
return body.slice(start, end).trim();
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || "")
|
||||
.normalize("NFKC")
|
||||
.toLowerCase()
|
||||
.replace(/[·,。;:、]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function containsCjk(value) {
|
||||
return /[\u3400-\u9fff]/u.test(value);
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function compactAscii(value) {
|
||||
return normalizeText(value).replace(/[^a-z0-9]/g, "");
|
||||
}
|
||||
|
||||
function levenshteinDistance(left, right) {
|
||||
if (left === right) return 0;
|
||||
if (!left) return right.length;
|
||||
if (!right) return left.length;
|
||||
|
||||
let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
|
||||
for (let leftIndex = 0; leftIndex < left.length; leftIndex += 1) {
|
||||
const current = [leftIndex + 1];
|
||||
for (let rightIndex = 0; rightIndex < right.length; rightIndex += 1) {
|
||||
const substitutionCost = left[leftIndex] === right[rightIndex] ? 0 : 1;
|
||||
current[rightIndex + 1] = Math.min(
|
||||
current[rightIndex] + 1,
|
||||
previous[rightIndex + 1] + 1,
|
||||
previous[rightIndex] + substitutionCost,
|
||||
);
|
||||
}
|
||||
previous = current;
|
||||
}
|
||||
|
||||
return previous[right.length];
|
||||
}
|
||||
|
||||
function fuzzyThreshold(length) {
|
||||
if (length < 6) return 0;
|
||||
if (length <= 8) return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function candidateTokens(text) {
|
||||
const normalized = normalizeText(text);
|
||||
const tokens = new Set();
|
||||
|
||||
for (const match of normalized.matchAll(/[a-z][a-z0-9._-]*/g)) {
|
||||
const compact = compactAscii(match[0]);
|
||||
if (!compact) continue;
|
||||
|
||||
tokens.add(compact);
|
||||
|
||||
// User reports often write database names directly followed by versions,
|
||||
// for example mysql5.7, Oracle19c, or postgresql18.
|
||||
const withoutVersion = compact.replace(/(?:v?\d[\da-z.]*)$/i, "");
|
||||
if (withoutVersion && withoutVersion !== compact) tokens.add(withoutVersion);
|
||||
}
|
||||
|
||||
return [...tokens].filter((token) => token.length >= 5);
|
||||
}
|
||||
|
||||
function fuzzyAliasMatches(text, alias) {
|
||||
const normalizedAlias = normalizeText(alias);
|
||||
if (containsCjk(normalizedAlias)) return false;
|
||||
if (globallyAmbiguousAliases.has(normalizedAlias)) return false;
|
||||
|
||||
const compactAlias = compactAscii(normalizedAlias);
|
||||
const threshold = fuzzyThreshold(compactAlias.length);
|
||||
if (!threshold) return false;
|
||||
|
||||
return candidateTokens(text).some((token) => {
|
||||
if (token !== compactAlias && exactAsciiAliases.has(token)) return false;
|
||||
if (Math.abs(token.length - compactAlias.length) > threshold) return false;
|
||||
return levenshteinDistance(token, compactAlias) <= threshold;
|
||||
});
|
||||
}
|
||||
|
||||
function aliasMatches(text, alias) {
|
||||
const normalizedText = normalizeText(text);
|
||||
const normalizedAlias = normalizeText(alias);
|
||||
if (!normalizedAlias) return false;
|
||||
|
||||
if (containsCjk(normalizedAlias)) {
|
||||
return normalizedText.includes(normalizedAlias);
|
||||
}
|
||||
|
||||
const flexibleAlias = normalizedAlias
|
||||
.split(/[\s._/-]+/)
|
||||
.filter(Boolean)
|
||||
.map(escapeRegExp)
|
||||
.join("[\\s._/-]*");
|
||||
const trailingBoundary = /[0-9]$/.test(normalizedAlias)
|
||||
? "(?:[^a-z0-9]|$)"
|
||||
: "(?:[^a-z0-9]|$|(?=v?\\d))";
|
||||
const pattern = new RegExp(`(^|[^a-z0-9])${flexibleAlias}${trailingBoundary}`, "i");
|
||||
return pattern.test(normalizedText);
|
||||
}
|
||||
|
||||
function isGenericDatabaseValue(value) {
|
||||
const normalized = normalizeText(value);
|
||||
const parts = normalized
|
||||
.split(/[,\n/|;,、]+/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
parts.length > 0 &&
|
||||
parts.every((part) => genericDatabaseValues.some((generic) => aliasMatches(part, generic)))
|
||||
);
|
||||
}
|
||||
|
||||
function matchDrivers(text, { allowAmbiguous }) {
|
||||
const matches = [];
|
||||
|
||||
for (const driver of drivers) {
|
||||
const matchedAlias = driver.aliases.find((alias) => {
|
||||
const normalizedAlias = normalizeText(alias);
|
||||
if (!allowAmbiguous && globallyAmbiguousAliases.has(normalizedAlias)) return false;
|
||||
return aliasMatches(text, alias) || fuzzyAliasMatches(text, alias);
|
||||
});
|
||||
|
||||
if (matchedAlias) {
|
||||
matches.push({ ...driver, matchedAlias });
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
function uniqueByDbType(matches) {
|
||||
const seen = new Set();
|
||||
return matches.filter((match) => {
|
||||
if (seen.has(match.dbType)) return false;
|
||||
seen.add(match.dbType);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function githubRequest(method, path, body) {
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const repository = process.env.GITHUB_REPOSITORY;
|
||||
if (!token) throw new Error("GITHUB_TOKEN is required");
|
||||
if (!repository) throw new Error("GITHUB_REPOSITORY is required");
|
||||
|
||||
const response = await fetch(`https://api.github.com/repos/${repository}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-GitHub-Api-Version": API_VERSION,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const payload = text ? JSON.parse(text) : null;
|
||||
if (!response.ok) {
|
||||
const message = payload?.message || response.statusText;
|
||||
const error = new Error(`${method} ${path} failed: ${response.status} ${message}`);
|
||||
error.status = response.status;
|
||||
error.payload = payload;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function ensureLabel(name, description) {
|
||||
try {
|
||||
const dbType = name.startsWith(LABEL_PREFIX) ? name.slice(LABEL_PREFIX.length) : name;
|
||||
await githubRequest("POST", "/labels", { name, color: stablePaletteColor(dbType), description });
|
||||
console.log(`Created label ${name}`);
|
||||
} catch (error) {
|
||||
// GitHub returns 422 when the label already exists; that is the common path
|
||||
// after the first issue for a database type has been triaged.
|
||||
if (error.status === 422) {
|
||||
console.log(`Label ${name} already exists`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function addLabels(issueNumber, names) {
|
||||
await githubRequest("POST", `/issues/${issueNumber}/labels`, { labels: names });
|
||||
}
|
||||
|
||||
async function removeLabel(issueNumber, name) {
|
||||
try {
|
||||
await githubRequest("DELETE", `/issues/${issueNumber}/labels/${encodeURIComponent(name)}`);
|
||||
console.log(`Removed stale label ${name}`);
|
||||
} catch (error) {
|
||||
if (error.status === 404) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const issue = loadIssue();
|
||||
if (issue.pull_request) {
|
||||
console.log("Skipping pull request event");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!issue.number) {
|
||||
throw new Error("Issue number is required");
|
||||
}
|
||||
|
||||
const databaseField = extractDatabaseField(issue.body || "");
|
||||
const hasDatabaseField = databaseField.length > 0;
|
||||
const fieldIsGeneric = hasDatabaseField && isGenericDatabaseValue(databaseField);
|
||||
// Free-form bodies often mention comparison databases or examples; the title
|
||||
// and issue-form database field are the reliable labeling sources.
|
||||
const sourceText = hasDatabaseField ? databaseField : issue.title || "";
|
||||
const matchedDrivers = isGenericDatabaseValue(sourceText)
|
||||
? []
|
||||
: uniqueByDbType(matchDrivers(sourceText, { allowAmbiguous: hasDatabaseField }));
|
||||
const titleMatchedDrivers = uniqueByDbType(matchDrivers(issue.title || "", { allowAmbiguous: false }));
|
||||
if (hasDatabaseField && titleMatchedDrivers.length > 0) {
|
||||
matchedDrivers.push(...titleMatchedDrivers);
|
||||
}
|
||||
if (hasDatabaseField && matchedDrivers.length === 0 && !fieldIsGeneric) {
|
||||
// Some issue forms contain only a version in the database field and put the
|
||||
// actual database name in the title; keep that fallback conservative.
|
||||
matchedDrivers.push(
|
||||
...uniqueByDbType(matchDrivers(`${issue.title || ""}\n${databaseField}`, { allowAmbiguous: false })),
|
||||
);
|
||||
}
|
||||
const uniqueMatchedDrivers = uniqueByDbType(matchedDrivers);
|
||||
const targetLabels = uniqueMatchedDrivers.map((driver) => `${LABEL_PREFIX}${driver.dbType}`);
|
||||
const targetLabelColors = Object.fromEntries(
|
||||
uniqueMatchedDrivers.map((driver) => [`${LABEL_PREFIX}${driver.dbType}`, stablePaletteColor(driver.dbType)]),
|
||||
);
|
||||
const existingLabels = labelNames(issue.labels);
|
||||
const existingDatabaseLabels = existingLabels.filter((name) => name.startsWith(LABEL_PREFIX));
|
||||
const staleDatabaseLabels = hasDatabaseField
|
||||
? existingDatabaseLabels.filter((name) => !targetLabels.includes(name))
|
||||
: [];
|
||||
const labelsToAdd = targetLabels.filter((name) => !existingLabels.includes(name));
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
issue: issue.number,
|
||||
databaseField: hasDatabaseField ? databaseField : null,
|
||||
titleMatched: titleMatchedDrivers.map(({ dbType, label, matchedAlias }) => ({ dbType, label, matchedAlias })),
|
||||
matched: uniqueMatchedDrivers.map(({ dbType, label, matchedAlias }) => ({ dbType, label, matchedAlias })),
|
||||
labelsToAdd,
|
||||
targetLabelColors,
|
||||
staleDatabaseLabels,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
if (dryRun) {
|
||||
console.log("Dry run enabled; no GitHub API calls were made");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
for (const driver of uniqueMatchedDrivers) {
|
||||
await ensureLabel(`${LABEL_PREFIX}${driver.dbType}`, `Database: ${driver.label}`);
|
||||
}
|
||||
|
||||
if (labelsToAdd.length > 0) {
|
||||
await addLabels(issue.number, labelsToAdd);
|
||||
console.log(`Added labels: ${labelsToAdd.join(", ")}`);
|
||||
} else {
|
||||
console.log("No database labels to add");
|
||||
}
|
||||
|
||||
for (const label of staleDatabaseLabels) {
|
||||
await removeLabel(issue.number, label);
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
name: Issue Database Label
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited, reopened]
|
||||
|
||||
concurrency:
|
||||
group: issue-database-label-${{ github.event.issue.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !github.event.issue.pull_request }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
|
||||
- name: Label issue by database
|
||||
run: node .github/scripts/label-issue-database.mjs
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
Loading…
Reference in New Issue