Files
t8y2--dbx/.github/scripts/suggest-similar-issues.mjs
2026-07-31 12:48:02 +08:00

554 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import fs from "node:fs";
import { pathToFileURL } from "node:url";
import { databaseIssueDrivers } from "./database-issue-catalog.mjs";
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,
/^source$/i,
/数据库类型/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 databaseDrivers = databaseIssueDrivers;
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) {
const section = parseIssueSections(body).find(({ heading }) => /数据库类型|database type/iu.test(heading))?.content;
if (section) return section;
// Bot-created issues historically used a bold inline metadata field instead of an issue-form heading.
const inline = String(body || "").match(/^\s*\*\*(?:数据库类型(?:和版本)?|database type)\*\*\s*[:]\s*(.+?)\s*$/imu);
return inline?.[1]?.trim() || "";
}
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();
}