dbx/.github/scripts/suggest-similar-issues.mjs

564 lines
18 KiB
JavaScript

#!/usr/bin/env node
import fs from "node:fs";
import { pathToFileURL } from "node:url";
const API_VERSION = "2022-11-28";
const COMMENT_MARKER = "<!-- dbx-similar-issues -->";
const MAX_QUERY_LENGTH = 480;
const MAX_CANDIDATES = 3;
const SEARCH_RESULT_LIMIT = 20;
const ignoredSectionPatterns = [
/数据库类型/i,
/database type/i,
/支持信息/i,
/support info/i,
/优先级/i,
/priority/i,
/补充信息/i,
/additional context/i,
/环境信息/i,
/environment/i,
];
const genericLatinTokens = new Set([
"ai",
"alter",
"bug",
"datagrip",
"dbeaver",
"dbx",
"delete",
"desktop",
"feature",
"from",
"insert",
"issue",
"navicat",
"question",
"request",
"select",
"sql",
"support",
"table",
"update",
"version",
"web",
"where",
"windows",
]);
const genericCjkTokens = new Set([
"一个",
"以及",
"使用",
"功能",
"可以",
"当前",
"支持",
"数据库",
"新增",
"增加",
"希望",
"异常",
"操作",
"所在",
"数据",
"显示",
"没有",
"现在",
"设置",
"问题",
"进行",
"错误",
"需要",
]);
const identifierQueryNoiseTokens = new Set([
"失效",
"弹出",
"快捷",
"提醒",
"时候",
"选择",
]);
const cjkSegmenter = new Intl.Segmenter("zh-CN", { granularity: "word" });
const driverManifest = JSON.parse(
fs.readFileSync(new URL("../../crates/dbx-core/assets/database-drivers.manifest.json", import.meta.url), "utf8"),
);
const extraDatabaseAliases = {
mysql: ["mariadb", "percona", "tidb"],
postgres: ["postgresql", "pgsql", "hologres"],
sqlserver: ["sql server", "mssql"],
mongodb: ["mongo"],
dameng: ["dm8", "达梦"],
kingbase: ["kingbasees", "人大金仓", "金仓"],
highgo: ["瀚高"],
yashandb: ["崖山"],
opengauss: ["open gauss"],
"oceanbase-oracle": ["oceanbase oracle"],
gbase: ["gbase8a", "gbase8s"],
};
const databaseDrivers = driverManifest.drivers.map((driver) => ({
dbType: driver.dbType,
aliases: [driver.dbType, driver.label, ...(extraDatabaseAliases[driver.dbType] || [])],
}));
function loadIssue() {
if (process.env.GITHUB_EVENT_PATH && fs.existsSync(process.env.GITHUB_EVENT_PATH)) {
return JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")).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 stripIssuePrefix(title) {
return String(title || "").replace(/^\s*\[[^\]]+\]\s*/u, "").trim();
}
function stripMarkdown(value) {
return String(value || "")
.replace(/<!--.*?-->/gsu, " ")
.replace(/<img\b[^>]*>/giu, " ")
.replace(/!\[[^\]]*\]\([^)]*\)/gu, " ")
.replace(/\[([^\]]+)\]\([^)]*\)/gu, "$1")
.replace(/https?:\/\/\S+/giu, " ")
.replace(/```[^\n]*\n?/gu, " ")
.replace(/^\s*[_*-]{3,}\s*$/gmu, " ");
}
function parseIssueSections(body) {
const text = String(body || "");
const headings = [...text.matchAll(/^###\s+(.+?)\s*$/gmu)];
if (headings.length === 0) return [{ heading: "", content: text }];
return headings.map((heading, index) => {
const start = heading.index + heading[0].length;
const end = headings[index + 1]?.index ?? text.length;
return { heading: heading[1].trim(), content: text.slice(start, end).trim() };
});
}
function databaseField(body) {
return parseIssueSections(body).find(({ heading }) => /数据库类型|database type/iu.test(heading))?.content || "";
}
function relevantBody(body) {
const sections = parseIssueSections(body).filter(
({ heading }) => !ignoredSectionPatterns.some((pattern) => pattern.test(heading)),
);
return stripMarkdown(sections.map(({ content }) => content).join("\n")).slice(0, 2400);
}
function normalizeText(value) {
return stripMarkdown(value)
.normalize("NFKC")
.toLocaleLowerCase("en-US")
.replace(/[^\p{Letter}\p{Number}+._-]+/gu, " ")
.replace(/\s+/gu, " ")
.trim();
}
export function searchTerms(issue) {
const title = normalizeText(stripIssuePrefix(issue.title));
const body = normalizeText(relevantBody(issue.body));
const terms = `${title} ${body}`
.split(/\s+/u)
.filter((term) => term.length >= 2)
.slice(0, 80)
.join(" ");
return terms.slice(0, MAX_QUERY_LENGTH).trim();
}
function semanticTokens(value) {
const normalized = normalizeText(value).replace(/[+._-]+/gu, " ");
const result = new Set(latinTokens(normalized));
let singleHanRun = "";
const flushSingleHanRun = () => {
for (let index = 0; index + 1 < singleHanRun.length; index += 2) {
const token = singleHanRun.slice(index, index + 2);
if (!genericCjkTokens.has(token)) result.add(token);
}
singleHanRun = "";
};
for (const part of cjkSegmenter.segment(normalized)) {
const token = part.segment.trim();
if (part.isWordLike && /^\p{Script=Han}$/u.test(token)) {
singleHanRun += token;
continue;
}
flushSingleHanRun();
if (!part.isWordLike || token.length < 2 || genericCjkTokens.has(token)) continue;
result.add(token);
}
flushSingleHanRun();
return result;
}
export function searchTitleTerms(issue) {
const identifiers = technicalIdentifiers(issue);
const tokens = [...semanticTokens(stripIssuePrefix(issue.title))].filter(
(token) => identifiers.size === 0 || !identifierQueryNoiseTokens.has(token),
);
return tokens.slice(0, 8).join(" ").slice(0, MAX_QUERY_LENGTH);
}
function latinTokens(value) {
return new Set(
normalizeText(value)
.match(/[a-z0-9][a-z0-9+._-]{1,}/gu)
?.filter((token) => (
!genericLatinTokens.has(token)
&& !/^v?\d+(?:[._-]\d+)*$/u.test(token)
&& !/^(.)\1{2,}$/u.test(token)
)) || [],
);
}
function characterNgrams(value, size = 3) {
const compact = normalizeText(value).replace(/\s+/gu, "");
const result = new Set();
for (let index = 0; index <= compact.length - size; index += 1) {
result.add(compact.slice(index, index + size));
}
return result;
}
function intersectionSize(left, right) {
let count = 0;
for (const value of left) {
if (right.has(value)) count += 1;
}
return count;
}
function diceCoefficient(left, right) {
if (left.size === 0 || right.size === 0) return 0;
return (2 * intersectionSize(left, right)) / (left.size + right.size);
}
function typeLabels(labels) {
const types = new Set(["bug", "enhancement", "question"]);
return new Set(labelNames(labels).filter((label) => types.has(label)));
}
function databaseLabels(labels) {
return new Set(labelNames(labels).filter((label) => label.startsWith("db/")));
}
function databaseTypes(issue) {
const result = new Set([...databaseLabels(issue.labels)].map((label) => label.slice(3)));
const compactField = normalizeText(databaseField(issue.body)).replace(/[^\p{Letter}\p{Number}]+/gu, "");
if (!compactField) return result;
for (const driver of databaseDrivers) {
const matched = driver.aliases.some((alias) => {
const compactAlias = normalizeText(alias).replace(/[^\p{Letter}\p{Number}]+/gu, "");
return compactAlias.length >= 3 && compactField.includes(compactAlias);
});
if (matched) result.add(driver.dbType);
}
return result;
}
function databaseContentTokens(issue) {
const result = new Set();
const types = databaseTypes(issue);
for (const driver of databaseDrivers) {
if (!types.has(driver.dbType)) continue;
for (const alias of driver.aliases) {
for (const token of semanticTokens(alias)) result.add(token);
}
}
return result;
}
function contentTitleTokens(issue) {
const tokens = semanticTokens(stripIssuePrefix(issue.title));
const databaseTokens = databaseContentTokens(issue);
return new Set([...tokens].filter((token) => !databaseTokens.has(token)));
}
function contentTitleText(issue) {
return [...contentTitleTokens(issue)].join(" ");
}
function setsOverlap(left, right) {
return intersectionSize(left, right) > 0;
}
function highSignalBody(body) {
const sections = parseIssueSections(body);
const selected = sections.filter(({ heading }) => /错误|异常|日志|error|exception|log/iu.test(heading));
return stripMarkdown(selected.map(({ content }) => content).join("\n")).slice(0, 1200);
}
function technicalIdentifiers(issue) {
const identifiers = latinTokens(`${stripIssuePrefix(issue.title)}\n${highSignalBody(issue.body)}`);
const databaseTokens = databaseContentTokens(issue);
return new Set([...identifiers].filter((token) => !databaseTokens.has(token)));
}
function inverseDocumentFrequency(documentCount, documentFrequency) {
return Math.log(1 + (documentCount - documentFrequency + 0.5) / (documentFrequency + 0.5));
}
function tokenOccurrenceCount(value, token) {
const normalized = normalizeText(value).replace(/[+._-]+/gu, " ");
let count = 0;
let offset = 0;
while ((offset = normalized.indexOf(token, offset)) !== -1) {
count += 1;
offset += token.length;
}
return count;
}
function weightedCoverage(queryTokens, candidateTokens, weights) {
let matchedWeight = 0;
let totalWeight = 0;
for (const token of queryTokens) {
const weight = weights.get(token) || 0;
if (weight === 0) continue;
totalWeight += weight;
if (candidateTokens.has(token)) matchedWeight += weight;
}
return totalWeight === 0 ? 0 : matchedWeight / totalWeight;
}
export function buildCorpusContext(issue, candidates) {
const issueTitle = stripIssuePrefix(issue.title);
const queryTitleTokens = contentTitleTokens(issue);
const candidateTitleTokens = candidates.map((candidate) => contentTitleTokens(candidate));
const weights = new Map();
const frequencies = new Map();
for (const token of queryTitleTokens) {
const documentFrequency = candidateTitleTokens.reduce(
(count, tokens) => count + (tokens.has(token) ? 1 : 0),
0,
);
frequencies.set(token, documentFrequency);
const queryFrequencyBoost = 1 + Math.log(Math.max(1, tokenOccurrenceCount(issueTitle, token)));
weights.set(token, inverseDocumentFrequency(candidates.length, documentFrequency) * queryFrequencyBoost);
}
const observedWeights = [...queryTitleTokens]
.filter((token) => (frequencies.get(token) || 0) > 0)
.map((token) => weights.get(token) || 0);
const maximumWeight = Math.max(0, ...observedWeights);
const anchorTokens = new Set(
[...queryTitleTokens].filter((token) => (
maximumWeight > 0
&& (frequencies.get(token) || 0) > 0
&& (weights.get(token) || 0) >= maximumWeight * 0.9
)),
);
return { queryTitleTokens, candidateTitleTokens, weights, anchorTokens };
}
export function scoreCandidate(issue, candidate, rank = 0, corpusContext) {
const issueDatabases = databaseTypes(issue);
const candidateDatabases = databaseTypes(candidate);
if (issueDatabases.size > 0 && candidateDatabases.size > 0 && !setsOverlap(issueDatabases, candidateDatabases)) {
return { accepted: false, score: 0, reason: "database-mismatch" };
}
const issueTitle = stripIssuePrefix(issue.title);
const candidateTitle = stripIssuePrefix(candidate.title);
const issueBody = relevantBody(issue.body).slice(0, 1200);
const candidateBody = relevantBody(candidate.body).slice(0, 1200);
const titleSimilarity = diceCoefficient(
characterNgrams(contentTitleText(issue)),
characterNgrams(contentTitleText(candidate)),
);
const bodySimilarity = diceCoefficient(characterNgrams(issueBody), characterNgrams(candidateBody));
const context = corpusContext || buildCorpusContext(issue, [candidate]);
const candidateTitleTokens = corpusContext
? context.candidateTitleTokens[rank]
: context.candidateTitleTokens[0];
const titleCoverage = weightedCoverage(context.queryTitleTokens, candidateTitleTokens, context.weights);
const anchorHit = setsOverlap(candidateTitleTokens, context.anchorTokens);
const issueIdentifiers = technicalIdentifiers(issue);
const candidateIdentifiers = technicalIdentifiers(candidate);
const identifierCoverage = issueIdentifiers.size === 0
? 0
: intersectionSize(issueIdentifiers, candidateIdentifiers) / issueIdentifiers.size;
const rankPrior = 1 / Math.log2(rank + 2);
let score = titleCoverage * 0.45
+ titleSimilarity * 0.25
+ bodySimilarity * 0.1
+ identifierCoverage * 0.15
+ rankPrior * 0.05;
const issueTypes = typeLabels(issue.labels);
const candidateTypes = typeLabels(candidate.labels);
if (issueTypes.size > 0 && candidateTypes.size > 0) {
score += setsOverlap(issueTypes, candidateTypes) ? 0.02 : -0.06;
}
if (issueDatabases.size > 0 && candidateDatabases.size > 0) score += 0.04;
// Like Discourse, retrieval is deliberately broad and public suggestions
// require a separate threshold. Rare title terms act as BM25F-style anchors.
const accepted = score >= 0.34
|| (anchorHit && titleCoverage >= 0.38 && score >= 0.2)
|| (identifierCoverage >= 0.5 && titleCoverage >= 0.2 && score >= 0.28)
|| (titleSimilarity + bodySimilarity >= 0.42 && score >= 0.28);
return {
accepted,
score,
signals: {
titleSimilarity,
titleCoverage,
bodySimilarity,
identifierCoverage,
anchorHit,
rankPrior,
},
};
}
export function rankCandidates(issue, items) {
const candidates = items.filter(
(candidate) => !candidate.pull_request && Number(candidate.number) !== Number(issue.number),
);
const context = buildCorpusContext(issue, candidates);
return candidates
.map((candidate, rank) => ({ candidate, ...scoreCandidate(issue, candidate, rank, context) }))
.filter((result) => result.accepted)
.sort((left, right) => right.score - left.score)
.slice(0, MAX_CANDIDATES);
}
function hasChinese(value) {
return /\p{Script=Han}/u.test(String(value || ""));
}
export function formatComment(issue, rankedCandidates) {
const chinese = hasChinese(`${issue.title}\n${issue.body}`);
// GitHub expands issue references into links containing the title and number.
const lines = rankedCandidates.map(({ candidate }) => `- #${candidate.number}`);
if (chinese) {
return `${COMMENT_MARKER}\n以下 Issue 可能与当前问题相关:\n\n${lines.join("\n")}\n\n这些结果由机器人自动检索,尚未确认重复。如属于同一问题,建议在已有 Issue 中补充信息。`;
}
return `${COMMENT_MARKER}\nThe following issues may be related:\n\n${lines.join("\n")}\n\nThese results were found automatically and are not confirmed duplicates. If this is the same problem, consider adding details to the existing issue.`;
}
export class GitHubClient {
constructor({ token, repository, apiBase = "https://api.github.com" }) {
if (!token) throw new Error("GITHUB_TOKEN is required");
if (!repository) throw new Error("GITHUB_REPOSITORY is required");
this.token = token;
this.repository = repository;
this.apiBase = apiBase.replace(/\/$/u, "");
}
async request(method, path, body) {
const response = await fetch(`${this.apiBase}${path}`, {
method,
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${this.token}`,
"Content-Type": "application/json",
"X-GitHub-Api-Version": API_VERSION,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await response.text();
const payload = text ? JSON.parse(text) : null;
if (!response.ok) {
const error = new Error(`${method} ${path} failed: ${response.status} ${payload?.message || response.statusText}`);
error.status = response.status;
throw error;
}
return payload;
}
async searchIssues(query) {
const parameters = new URLSearchParams({
q: `repo:${this.repository} is:issue ${query}`,
search_type: "hybrid",
per_page: String(SEARCH_RESULT_LIMIT),
});
return this.request("GET", `/search/issues?${parameters}`);
}
async hasExistingComment(issueNumber) {
const comments = await this.request("GET", `/repos/${this.repository}/issues/${issueNumber}/comments?per_page=100`);
return comments.some((comment) => String(comment.body || "").includes(COMMENT_MARKER));
}
async comment(issueNumber, body) {
return this.request("POST", `/repos/${this.repository}/issues/${issueNumber}/comments`, { body });
}
}
export async function run({ issue = loadIssue(), client } = {}) {
if (issue.pull_request) {
console.log("Skipping pull request event");
return [];
}
if (!issue.number) throw new Error("Issue number is required");
const query = searchTitleTerms(issue) || searchTerms(issue);
if (query.length < 2) {
console.log("Skipping similar issue search because the issue has too little searchable text");
return [];
}
const github = client || new GitHubClient({
token: process.env.GITHUB_TOKEN,
repository: process.env.GITHUB_REPOSITORY,
apiBase: process.env.GITHUB_API_URL,
});
if (await github.hasExistingComment(issue.number)) {
console.log("Similar issue comment already exists");
return [];
}
let result;
try {
result = await github.searchIssues(query);
} catch (error) {
// Similar-issue suggestions are best-effort and must not turn a temporary
// semantic-search rate limit into a failed issue workflow.
if (error.status === 403 && /rate limit/iu.test(error.message)) {
console.warn(`Skipping similar issue search: ${error.message}`);
return [];
}
throw error;
}
const candidates = rankCandidates(issue, result.items || []);
if (candidates.length === 0) {
console.log(`No sufficiently similar issues found (${result.search_type || "unknown"} search)`);
return [];
}
if (process.env.DRY_RUN === "1" || process.env.DRY_RUN === "true") {
console.log(formatComment(issue, candidates));
return candidates;
}
await github.comment(issue.number, formatComment(issue, candidates));
console.log(`Commented ${candidates.length} similar issue suggestion(s) on #${issue.number}`);
return candidates;
}
if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
await run();
}