mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(workflows): add DBX bot automation
This commit is contained in:
@@ -0,0 +1,575 @@
|
||||
#!/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 || ""));
|
||||
}
|
||||
|
||||
function safeTitle(value) {
|
||||
return String(value || "")
|
||||
.replace(/[\r\n]+/gu, " ")
|
||||
.replace(/([\\`*_{}\[\]()<>#+.!|~-])/gu, "\\$1")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function formatComment(issue, rankedCandidates) {
|
||||
const chinese = hasChinese(`${issue.title}\n${issue.body}`);
|
||||
const lines = rankedCandidates.map(({ candidate }) => {
|
||||
const state = String(candidate.state || "").toLowerCase() === "closed"
|
||||
? chinese ? "已关闭" : "Closed"
|
||||
: chinese ? "进行中" : "Open";
|
||||
const stateSuffix = chinese ? `(${state})` : `(${state})`;
|
||||
return `- #${candidate.number} — ${safeTitle(candidate.title)} ${stateSuffix}`;
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
GitHubClient,
|
||||
formatComment,
|
||||
rankCandidates,
|
||||
run,
|
||||
scoreCandidate,
|
||||
searchTitleTerms,
|
||||
} from "./suggest-similar-issues.mjs";
|
||||
|
||||
const baseIssue = {
|
||||
number: 4043,
|
||||
title: "[Bug] 设置快捷键(Ctrl+Enter)执行光标所在语句失效了",
|
||||
body: `### 数据库类型和版本
|
||||
|
||||
MySQL 8.0
|
||||
|
||||
### 问题描述
|
||||
|
||||
设置快捷键 Ctrl+Enter 执行光标所在语句,现在会弹出选择框。
|
||||
|
||||
### 支持信息
|
||||
|
||||
DBX 版本: v0.5.62`,
|
||||
labels: [{ name: "bug" }, { name: "db/mysql" }],
|
||||
};
|
||||
|
||||
test("accepts a candidate with matching shortcut and execution behavior", () => {
|
||||
const candidate = {
|
||||
number: 82,
|
||||
title: "[Feature] 设置里 ctrl+enter 执行功能添加模式选择",
|
||||
body: "希望 Ctrl+Enter 可以直接执行光标所在 SQL 语句,不要弹出选择框。",
|
||||
labels: [{ name: "enhancement" }, { name: "db/mysql" }],
|
||||
};
|
||||
|
||||
const result = scoreCandidate(baseIssue, candidate);
|
||||
assert.equal(result.accepted, true);
|
||||
assert.equal(result.signals.identifierCoverage, 1);
|
||||
});
|
||||
|
||||
test("builds a short title query with identifiers split safely", () => {
|
||||
const query = searchTitleTerms(baseIssue);
|
||||
assert.match(query, /\bctrl\b/u);
|
||||
assert.match(query, /\benter\b/u);
|
||||
assert.doesNotMatch(query, /ctrl\+enter/u);
|
||||
assert.match(query, /执行/u);
|
||||
assert.match(query, /光标/u);
|
||||
assert.match(query, /语句/u);
|
||||
assert.doesNotMatch(query, /失效|弹出/u);
|
||||
});
|
||||
|
||||
test("keeps compound Chinese product terms in the title query", () => {
|
||||
const query = searchTitleTerms({ title: "[Feature] 增加多列选中转置功能、增加转置列注释" });
|
||||
assert.match(query, /多列/u);
|
||||
assert.match(query, /转置/u);
|
||||
assert.match(query, /注释/u);
|
||||
});
|
||||
|
||||
test("rejects a candidate with conflicting database labels", () => {
|
||||
const candidate = {
|
||||
number: 100,
|
||||
title: "Ctrl+Enter 执行语句异常",
|
||||
body: "快捷键无法执行当前语句。",
|
||||
labels: [{ name: "bug" }, { name: "db/postgres" }],
|
||||
};
|
||||
|
||||
assert.deepEqual(scoreCandidate(baseIssue, candidate), {
|
||||
accepted: false,
|
||||
score: 0,
|
||||
reason: "database-mismatch",
|
||||
});
|
||||
});
|
||||
|
||||
test("detects database conflicts from issue forms before labels are added", () => {
|
||||
const issue = {
|
||||
number: 300,
|
||||
title: "[Bug] 查询保存失败",
|
||||
body: "### 数据库类型和版本\n\nPostgreSQL 16\n\n### 问题描述\n\n修改后保存失败。",
|
||||
labels: [{ name: "bug" }],
|
||||
};
|
||||
const mysqlCandidate = {
|
||||
number: 301,
|
||||
title: "[Bug] 查询保存失败",
|
||||
body: "### 数据库类型和版本\n\nMySQL 8.0\n\n### 问题描述\n\n修改后保存失败。",
|
||||
labels: [{ name: "bug" }],
|
||||
};
|
||||
const postgresCandidate = {
|
||||
number: 302,
|
||||
title: "[Bug] 查询保存失败",
|
||||
body: "### Database type and version\n\nPostgres 15\n\n### Description\n\nSaving changes fails.",
|
||||
labels: [{ name: "bug" }],
|
||||
};
|
||||
|
||||
assert.equal(scoreCandidate(issue, mysqlCandidate).reason, "database-mismatch");
|
||||
assert.equal(scoreCandidate(issue, postgresCandidate).accepted, true);
|
||||
});
|
||||
|
||||
test("does not treat a shared database product as issue similarity", () => {
|
||||
const issue = {
|
||||
number: 4048,
|
||||
title: "[Bug] OceanBase数据库Oracle模式下显示错误且无法修改数据",
|
||||
body: "### 数据库类型和版本\n\nOceanBase Oracle\n\n### 问题描述\n\n查询后无法修改表数据。",
|
||||
labels: [{ name: "bug" }],
|
||||
};
|
||||
const candidates = [
|
||||
{
|
||||
number: 2549,
|
||||
title: "[Feature] OceanBase数据库的Oracle模式显示更多内容",
|
||||
body: "### 数据库类型和版本\n\nOceanBase Oracle",
|
||||
labels: [{ name: "enhancement" }, { name: "db/oceanbase-oracle" }],
|
||||
},
|
||||
{
|
||||
number: 2489,
|
||||
title: "OceanBase Oracle模式下NUMBER类型查询精度丢失",
|
||||
body: "### 数据库类型和版本\n\nOceanBase Oracle",
|
||||
labels: [{ name: "bug" }, { name: "db/oceanbase-oracle" }],
|
||||
},
|
||||
];
|
||||
|
||||
assert.deepEqual(rankCandidates(issue, candidates), []);
|
||||
});
|
||||
|
||||
test("rejects broad semantic matches without concrete shared terms", () => {
|
||||
const candidate = {
|
||||
number: 200,
|
||||
title: "[Bug] SQL 编辑器光标显示不出来",
|
||||
body: "打开编辑器后看不到光标,但查询仍可执行。",
|
||||
labels: [{ name: "bug" }, { name: "db/mysql" }],
|
||||
};
|
||||
|
||||
assert.equal(scoreCandidate(baseIssue, candidate).accepted, false);
|
||||
});
|
||||
|
||||
test("prefers matching title concepts over a shared product reference", () => {
|
||||
const issue = {
|
||||
number: 4057,
|
||||
title: "[Feature] 增加多列选中转置功能、增加转置列注释",
|
||||
body: "希望参考 DBeaver,同时预览多行转置数据并显示列注释。",
|
||||
labels: [{ name: "enhancement" }],
|
||||
};
|
||||
const related = {
|
||||
number: 2201,
|
||||
title: "转置视图:同时显示字段注释和类型",
|
||||
body: "转置结果中显示字段注释。",
|
||||
labels: [{ name: "enhancement" }],
|
||||
};
|
||||
const unrelated = {
|
||||
number: 4055,
|
||||
title: "[Feature] 增加字段拖动到编辑器、输入框中",
|
||||
body: "参考 DBeaver,将字段拖进 SQL 编辑器。",
|
||||
labels: [{ name: "enhancement" }],
|
||||
};
|
||||
|
||||
assert.ok(scoreCandidate(issue, related).score > scoreCandidate(issue, unrelated).score);
|
||||
});
|
||||
|
||||
test("uses corpus rarity to reject generic real-world matches", () => {
|
||||
const issue = {
|
||||
number: 4057,
|
||||
title: "[Feature] 增加多列选中转置功能、增加转置列注释",
|
||||
body: "希望多行同时转置进行对比,并显示列注释。",
|
||||
labels: [{ name: "enhancement" }],
|
||||
};
|
||||
const candidates = [
|
||||
{ number: 3556, title: "优化左侧连接信息表中文注释的显示和拖动行为", labels: [{ name: "enhancement" }] },
|
||||
{ number: 3496, title: "查询结果支持多选行批量修改字段值", labels: [{ name: "enhancement" }] },
|
||||
{ number: 2160, title: "光标批量选中卡顿且建议增加批量注释功能", labels: [{ name: "enhancement" }] },
|
||||
{ number: 1652, title: "建议增加一键多行注释和一键取消多行注释功能", labels: [{ name: "enhancement" }] },
|
||||
{
|
||||
number: 2201,
|
||||
title: "转置视图:同时显示字段注释和类型",
|
||||
body: "转置结果中显示字段注释。",
|
||||
labels: [{ name: "enhancement" }],
|
||||
},
|
||||
{ number: 2129, title: "支持悬停显示字段注释并修复结果字段注释", labels: [{ name: "enhancement" }] },
|
||||
];
|
||||
|
||||
const ranked = rankCandidates(issue, candidates);
|
||||
assert.deepEqual(ranked.map(({ candidate }) => candidate.number), [2201]);
|
||||
assert.equal(ranked[0].signals.anchorHit, true);
|
||||
});
|
||||
|
||||
test("ranks candidates, excludes the current issue and limits output", () => {
|
||||
const candidates = [
|
||||
{ ...baseIssue },
|
||||
{
|
||||
number: 82,
|
||||
title: "Ctrl+Enter 执行当前 SQL 语句",
|
||||
body: "Ctrl+Enter 直接执行光标所在语句,不显示选择框。",
|
||||
state: "open",
|
||||
labels: [{ name: "bug" }, { name: "db/mysql" }],
|
||||
},
|
||||
{
|
||||
number: 83,
|
||||
title: "Ctrl+Enter 快捷键执行 SQL",
|
||||
body: "执行光标当前语句时快捷键弹出选择框。",
|
||||
state: "closed",
|
||||
labels: [{ name: "bug" }, { name: "db/mysql" }],
|
||||
},
|
||||
{
|
||||
number: 84,
|
||||
title: "SQL 编辑器光标颜色",
|
||||
body: "光标在暗色主题下不明显。",
|
||||
labels: [{ name: "bug" }],
|
||||
},
|
||||
];
|
||||
|
||||
const ranked = rankCandidates(baseIssue, candidates);
|
||||
assert.deepEqual(new Set(ranked.map(({ candidate }) => candidate.number)), new Set([82, 83]));
|
||||
});
|
||||
|
||||
test("formats a cautious localized comment and escapes titles", () => {
|
||||
const comment = formatComment(baseIssue, [
|
||||
{ candidate: { number: 82, title: "[Bug] [快捷键](https://example.com)", state: "closed" } },
|
||||
]);
|
||||
|
||||
assert.match(comment, /^<!-- dbx-similar-issues -->/u);
|
||||
assert.match(comment, /#82/u);
|
||||
assert.match(comment, /尚未确认重复/u);
|
||||
assert.match(comment, /已关闭/u);
|
||||
assert.doesNotMatch(comment, /\]\(https:/u);
|
||||
});
|
||||
|
||||
test("run searches and posts one idempotent comment", async (t) => {
|
||||
const requests = [];
|
||||
const server = createServer((request, response) => {
|
||||
const chunks = [];
|
||||
request.on("data", (chunk) => chunks.push(chunk));
|
||||
request.on("end", () => {
|
||||
const body = chunks.length > 0 ? JSON.parse(Buffer.concat(chunks).toString("utf8")) : null;
|
||||
requests.push({ method: request.method, url: request.url, body });
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
|
||||
if (request.method === "GET" && request.url.includes("/comments")) {
|
||||
response.end("[]");
|
||||
return;
|
||||
}
|
||||
if (request.method === "GET" && request.url.startsWith("/search/issues?")) {
|
||||
response.end(JSON.stringify({
|
||||
search_type: "hybrid",
|
||||
items: [{
|
||||
number: 82,
|
||||
title: "Ctrl+Enter 执行当前 SQL 语句",
|
||||
body: "Ctrl+Enter 直接执行光标所在语句,不显示选择框。",
|
||||
state: "open",
|
||||
labels: [{ name: "bug" }, { name: "db/mysql" }],
|
||||
}],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
if (request.method === "POST" && request.url.endsWith("/comments")) {
|
||||
response.statusCode = 201;
|
||||
response.end(JSON.stringify({ id: 1 }));
|
||||
return;
|
||||
}
|
||||
response.statusCode = 404;
|
||||
response.end(JSON.stringify({ message: "not found" }));
|
||||
});
|
||||
});
|
||||
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
t.after(() => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))));
|
||||
const address = server.address();
|
||||
assert.ok(address && typeof address !== "string");
|
||||
|
||||
const client = new GitHubClient({
|
||||
token: "test-token",
|
||||
repository: "t8y2/dbx",
|
||||
apiBase: `http://127.0.0.1:${address.port}`,
|
||||
});
|
||||
const candidates = await run({ issue: baseIssue, client });
|
||||
|
||||
assert.equal(candidates.length, 1);
|
||||
assert.equal(requests.filter(({ method }) => method === "POST").length, 1);
|
||||
const searchRequests = requests.filter(({ url }) => url.startsWith("/search/issues?"));
|
||||
assert.equal(searchRequests.length, 1);
|
||||
for (const searchRequest of searchRequests) {
|
||||
const parameters = new URL(searchRequest.url, "http://localhost").searchParams;
|
||||
assert.equal(parameters.get("search_type"), "hybrid");
|
||||
assert.equal(parameters.get("per_page"), "20");
|
||||
assert.match(parameters.get("q"), /^repo:t8y2\/dbx is:issue /u);
|
||||
assert.ok(parameters.get("q").length < 600);
|
||||
}
|
||||
});
|
||||
|
||||
test("run treats hybrid search rate limits as best-effort", async () => {
|
||||
const error = new Error("rate limit exceeded");
|
||||
error.status = 403;
|
||||
const client = {
|
||||
hasExistingComment: async () => false,
|
||||
searchIssues: async () => {
|
||||
throw error;
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(await run({ issue: baseIssue, client }), []);
|
||||
});
|
||||
|
||||
test("run does not hide non-rate-limit permission failures", async () => {
|
||||
const error = new Error("Resource not accessible by integration");
|
||||
error.status = 403;
|
||||
const client = {
|
||||
hasExistingComment: async () => false,
|
||||
searchIssues: async () => {
|
||||
throw error;
|
||||
},
|
||||
};
|
||||
|
||||
await assert.rejects(() => run({ issue: baseIssue, client }), /Resource not accessible/u);
|
||||
});
|
||||
|
||||
test("run skips search when its marker already exists", async () => {
|
||||
let searched = false;
|
||||
const client = {
|
||||
hasExistingComment: async () => true,
|
||||
searchIssues: async () => {
|
||||
searched = true;
|
||||
return { items: [] };
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(await run({ issue: baseIssue, client }), []);
|
||||
assert.equal(searched, false);
|
||||
});
|
||||
@@ -9,11 +9,7 @@ on:
|
||||
- ".github/workflows/i18n-autofill.yml"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
# 需要 write 才能通过 gh pr comment (addComment) 在 PR 上发评论;
|
||||
# 之前是 read,导致 autofill 成功 push 了翻译 commit 却无法发评论告知作者
|
||||
pull-requests: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: i18n-autofill-${{ github.event.pull_request.number }}
|
||||
@@ -32,16 +28,27 @@ jobs:
|
||||
- name: Copy trusted script
|
||||
run: cp .github/scripts/i18n-autofill.mjs "$RUNNER_TEMP/i18n-autofill.mjs"
|
||||
|
||||
- name: Create DBX bot token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
app-id: ${{ vars.DBX_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.DBX_BOT_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: dbx
|
||||
permission-contents: write
|
||||
permission-issues: write
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Checkout pull request branch
|
||||
uses: actions/checkout@v5
|
||||
with:
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
token: ${{ secrets.I18N_BOT_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
token: ${{ github.event.pull_request.head.repo.full_name == github.repository && steps.app-token.outputs.token || github.token }}
|
||||
fetch-depth: 0
|
||||
# The workflow executes the trusted base-branch script copied above;
|
||||
# fork contents are only parsed as translation data, never executed.
|
||||
allow-unsafe-pr-checkout: true
|
||||
|
||||
- name: Fetch base branch
|
||||
env:
|
||||
@@ -66,6 +73,8 @@ jobs:
|
||||
- name: Commit and push translation patch
|
||||
id: patch
|
||||
env:
|
||||
APP_SLUG: ${{ steps.app-token.outputs.app-slug }}
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
|
||||
run: |
|
||||
if git diff --quiet -- apps/desktop/src/i18n/locales; then
|
||||
@@ -74,8 +83,9 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git config user.name "dbx-i18n-bot"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
BOT_USER_ID="$(gh api "/users/${APP_SLUG}[bot]" --jq .id)"
|
||||
git config user.name "${APP_SLUG}[bot]"
|
||||
git config user.email "${BOT_USER_ID}+${APP_SLUG}[bot]@users.noreply.github.com"
|
||||
git add apps/desktop/src/i18n/locales
|
||||
git commit -m "chore(i18n): autofill new translations"
|
||||
if git push origin "HEAD:${PR_HEAD_REF}"; then
|
||||
@@ -89,7 +99,7 @@ jobs:
|
||||
- name: Comment autofill result
|
||||
if: steps.patch.outputs.changed == 'true' && steps.patch.outputs.push_failed != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.I18N_BOT_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
SUMMARY_FILE: ${{ runner.temp }}/i18n-summary.json
|
||||
BODY_FILE: ${{ runner.temp }}/i18n-autofill-comment.md
|
||||
@@ -120,7 +130,7 @@ jobs:
|
||||
- name: Comment push failure
|
||||
if: steps.patch.outputs.changed == 'true' && steps.patch.outputs.push_failed == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.I18N_BOT_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
SUMMARY_FILE: ${{ runner.temp }}/i18n-summary.json
|
||||
PATCH_FILE: ${{ runner.temp }}/i18n-autofill.patch
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
name: Suggest Similar Issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
concurrency:
|
||||
group: similar-issues-${{ github.event.issue.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
suggest:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !github.event.issue.pull_request }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Create DBX bot token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
app-id: ${{ vars.DBX_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.DBX_BOT_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: dbx
|
||||
permission-issues: write
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
|
||||
- name: Suggest similar issues
|
||||
run: node .github/scripts/suggest-similar-issues.mjs
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
Reference in New Issue
Block a user