diff --git a/.github/scripts/suggest-similar-issues.mjs b/.github/scripts/suggest-similar-issues.mjs new file mode 100644 index 0000000000..50b94f90af --- /dev/null +++ b/.github/scripts/suggest-similar-issues.mjs @@ -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 = ""; +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(/]*>/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(); +} diff --git a/.github/scripts/suggest-similar-issues.test.mjs b/.github/scripts/suggest-similar-issues.test.mjs new file mode 100644 index 0000000000..961b81ef8f --- /dev/null +++ b/.github/scripts/suggest-similar-issues.test.mjs @@ -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, /^/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); +}); diff --git a/.github/workflows/i18n-autofill.yml b/.github/workflows/i18n-autofill.yml index 11a3f1c5bb..9664f43802 100644 --- a/.github/workflows/i18n-autofill.yml +++ b/.github/workflows/i18n-autofill.yml @@ -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 diff --git a/.github/workflows/similar-issues.yml b/.github/workflows/similar-issues.yml new file mode 100644 index 0000000000..a25110a49c --- /dev/null +++ b/.github/workflows/similar-issues.yml @@ -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 }}