feat(workflows): add DBX bot automation

This commit is contained in:
t8y2
2026-07-21 17:44:34 +08:00
parent 423d20bdd7
commit 5a0c886438
4 changed files with 962 additions and 11 deletions
+575
View File
@@ -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);
});
+21 -11
View File
@@ -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
+39
View File
@@ -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 }}