style: apply oxfmt across drifted files

This commit is contained in:
t8y2
2026-10-13 12:52:46 +08:00
parent 168fc4c63a
commit 1fb8f150fc
96 changed files with 2437 additions and 2735 deletions
+36 -20
View File
@@ -25,14 +25,21 @@ function evaluateCondition(content, github) {
const expression = (folded?.[1] ?? content.match(/^ if: (.+)$/m)?.[1])?.trim();
assert.ok(expression, "missing job condition");
const evaluate = new Function("github", "contains", `return (${expression.replace(/^\$\{\{\s*|\s*\}\}$/g, "")});`);
return evaluate(github, (search, item) => String(search ?? "").toLowerCase().includes(String(item).toLowerCase()));
return evaluate(github, (search, item) =>
String(search ?? "")
.toLowerCase()
.includes(String(item).toLowerCase()),
);
}
function commentEvent(body, { userType = "User", login = "contributor", pullRequest = false } = {}) {
return { repository_owner: "t8y2", event: {
issue: { pull_request: pullRequest ? { url: "https://api.github.com/repos/t8y2/dbx/pulls/123" } : undefined },
comment: { body, user: { type: userType, login } },
} };
return {
repository_owner: "t8y2",
event: {
issue: { pull_request: pullRequest ? { url: "https://api.github.com/repos/t8y2/dbx/pulls/123" } : undefined },
comment: { body, user: { type: userType, login } },
},
};
}
test("issue command prefilter admits every supported command and skips unrelated comments", async () => {
@@ -56,10 +63,15 @@ test("spam prefilter retains all existing malicious-link cases and never bypasse
const content = job(workflow("spam-comment-guard"), "delete-malicious-link");
const pattern = content.match(/grep -qiP '([^']+)'/)[1];
const matcher = new RegExp(pattern.replaceAll("[:space:]", "\\s"), "i");
for (const body of ["https://evil.example/update.zip", "https://evil.example/update.ZIP?download=1",
"[update.zip](//evil.example/download)", "[update.ZIP](https://evil%2Eexample/download)",
"[update.7z](https://evil.example/download)", "[update.exe](https://evil.example/download)",
"[update.SCR](//evil.example/download)"]) {
for (const body of [
"https://evil.example/update.zip",
"https://evil.example/update.ZIP?download=1",
"[update.zip](//evil.example/download)",
"[update.ZIP](https://evil%2Eexample/download)",
"[update.7z](https://evil.example/download)",
"[update.exe](https://evil.example/download)",
"[update.SCR](//evil.example/download)",
]) {
assert.equal(matcher.test(body), true, body);
assert.equal(evaluateCondition(content, commentEvent(body)), true, body);
assert.equal(evaluateCondition(content, commentEvent(body, { userType: "Bot" })), false);
@@ -69,9 +81,7 @@ test("spam prefilter retains all existing malicious-link cases and never bypasse
for (const body of ["Thanks!", "https://example.com/docs", "", null]) {
assert.equal(evaluateCondition(content, commentEvent(body)), false);
}
for (const body of ["Please upload a .zip", "https://github.com/t8y2/dbx/archive/main.zip",
"https://user-images.githubusercontent.com/files/logs.zip", "[logs.exe](https://github.com/user-attachments/files/123/logs.exe)",
"[logs.7z](//raw.githubusercontent.com/t8y2/dbx/main/logs.7z)"]) {
for (const body of ["Please upload a .zip", "https://github.com/t8y2/dbx/archive/main.zip", "https://user-images.githubusercontent.com/files/logs.zip", "[logs.exe](https://github.com/user-attachments/files/123/logs.exe)", "[logs.7z](//raw.githubusercontent.com/t8y2/dbx/main/logs.7z)"]) {
assert.equal(evaluateCondition(content, commentEvent(body)), true, body);
assert.equal(matcher.test(body), false, body);
}
@@ -98,8 +108,7 @@ test("plugin checks run once before unchanged cross-platform verification", () =
assert.match(tests, /needs: checks/);
assert.match(tests, /os: \[ubuntu-24\.04, macos-15, windows-2022\]/);
assert.doesNotMatch(tests, /npm run (?:lint|fmt:check)/);
for (const command of ["npm test --prefix plugins/sdk/dev-host", "cargo nextest run", "cargo test --doc",
"npm test --prefix packages/plugin-cli", "node scripts/verify-plugin-cli-package.mjs", "DBX_PLUGIN_CLI_VERIFY_NATIVE: '1'"]) {
for (const command of ["npm test --prefix plugins/sdk/dev-host", "cargo nextest run", "cargo test --doc", "npm test --prefix packages/plugin-cli", "node scripts/verify-plugin-cli-package.mjs", "DBX_PLUGIN_CLI_VERIFY_NATIVE: '1'"]) {
assert.ok(tests.includes(command), command);
}
assert.match(content, /group: \$\{\{ github\.workflow \}\}-\$\{\{ github\.ref \}\}\s+cancel-in-progress: true/);
@@ -118,11 +127,16 @@ test("GitHub script selection excludes planner tests only when fast checks own t
const files = readdirSync(new URL(".github/scripts/", repository)).filter((file) => file.endsWith(".test.mjs"));
for (const fast of ["true", "false", ""]) {
const result = spawnSync("bash", ["-e", "-o", "pipefail", "-c", `node() { printf '%s\\n' "$@"; }\n${script}`], {
cwd: repository, encoding: "utf8", env: { ...process.env, FAST_CHECKS: fast },
cwd: repository,
encoding: "utf8",
env: { ...process.env, FAST_CHECKS: fast },
});
assert.equal(result.status, 0, result.stderr);
const selected = result.stdout.trim().split("\n").slice(1).sort();
const expected = files.filter((file) => fast !== "true" || !file.startsWith("ci-")).map((file) => `.github/scripts/${file}`).sort();
const expected = files
.filter((file) => fast !== "true" || !file.startsWith("ci-"))
.map((file) => `.github/scripts/${file}`)
.sort();
assert.deepEqual(selected, expected);
assert.ok(selected.includes(".github/scripts/actions-efficiency.test.mjs"));
}
@@ -135,8 +149,7 @@ async function snapshotFixture(context) {
}
function snapshot() {
return { repository: "t8y2/dbx", generatedAt: "2026-10-01T18:00:00.000Z", stars: 10,
contributors: [{ login: "contributor", commits: 3, mergedPullRequests: 1 }] };
return { repository: "t8y2/dbx", generatedAt: "2026-10-01T18:00:00.000Z", stars: 10, contributors: [{ login: "contributor", commits: 3, mergedPullRequests: 1 }] };
}
test("a missing contributor snapshot is created and timestamp-only refreshes preserve exact bytes", async (context) => {
@@ -154,9 +167,12 @@ test("a missing contributor snapshot is created and timestamp-only refreshes pre
test("actual contributor, star and repository changes update the snapshot", async (context) => {
const outputPath = await snapshotFixture(context);
const original = snapshot();
for (const updated of [{ ...original, stars: 11 }, { ...original, repository: "other/dbx" },
for (const updated of [
{ ...original, stars: 11 },
{ ...original, repository: "other/dbx" },
{ ...original, contributors: [{ ...original.contributors[0], commits: 4 }] },
{ ...original, contributors: [] }]) {
{ ...original, contributors: [] },
]) {
await writeContributorSnapshot(outputPath, original);
const refreshed = { ...updated, generatedAt: "2026-10-08T18:00:00.000Z" };
assert.equal(await writeContributorSnapshot(outputPath, refreshed), true);
+35 -25
View File
@@ -52,26 +52,30 @@ If input is truncated, assess only visible evidence and express uncertainty abou
class PriorityError extends Error {}
function labelNames(issue) {
return (issue.labels || []).map((label) => typeof label === "string" ? label : label.name).filter(Boolean);
return (issue.labels || []).map((label) => (typeof label === "string" ? label : label.name)).filter(Boolean);
}
function withoutUserPriority(body) {
let inPrioritySection = false;
return String(body || "").split(/\r?\n/).filter((line) => {
const heading = line.match(/^#{1,6}\s+(.+)/);
if (heading) inPrioritySection = /优先级|priority|urgency/i.test(heading[1]);
return !inPrioritySection && !/^\s*\*\*[^*]*(?:优先级|priority|urgency)[^*]*\*\*\s*[::]/i.test(line);
}).join("\n").trim();
return String(body || "")
.split(/\r?\n/)
.filter((line) => {
const heading = line.match(/^#{1,6}\s+(.+)/);
if (heading) inPrioritySection = /优先级|priority|urgency/i.test(heading[1]);
return !inPrioritySection && !/^\s*\*\*[^*]*(?:优先级|priority|urgency)[^*]*\*\*\s*[::]/i.test(line);
})
.join("\n")
.trim();
}
export function prepareIssue(issue) {
const body = withoutUserPriority(issue.body);
return {
title: String(issue.title || "").slice(0, 500),
body: body.length > MAX_BODY_LENGTH
? `${body.slice(0, 10000)}\n[TRUNCATED]\n${body.slice(-4000)}`
: body,
labels: labelNames(issue).filter((label) => /^(db\/|bug$|enhancement$|feature$|question$)/.test(label)).sort(),
body: body.length > MAX_BODY_LENGTH ? `${body.slice(0, 10000)}\n[TRUNCATED]\n${body.slice(-4000)}` : body,
labels: labelNames(issue)
.filter((label) => /^(db\/|bug$|enhancement$|feature$|question$)/.test(label))
.sort(),
truncated: body.length > MAX_BODY_LENGTH,
};
}
@@ -83,21 +87,26 @@ export function parseAssessment(content, issue) {
} catch {
throw new PriorityError("AtlasCloud returned invalid priority JSON");
}
const validTextList = (value, maxLength) => Array.isArray(value) && value.length <= 3
&& value.every((entry) => typeof entry === "string" && entry.trim() && entry.length <= maxLength);
if (!result || typeof result.priority !== "string" || !Object.hasOwn(PRIORITY_LABELS, result.priority)
|| !["high", "medium", "low"].includes(result.confidence)
|| !["security", "data-loss", "core-blocked", "functional", "feature", "cosmetic", "unknown"].includes(result.risk)
|| typeof result.reason !== "string" || !result.reason.trim() || result.reason.length > 600
|| !validTextList(result.evidence, 300) || !validTextList(result.missing_information, 250)) {
const validTextList = (value, maxLength) => Array.isArray(value) && value.length <= 3 && value.every((entry) => typeof entry === "string" && entry.trim() && entry.length <= maxLength);
if (
!result ||
typeof result.priority !== "string" ||
!Object.hasOwn(PRIORITY_LABELS, result.priority) ||
!["high", "medium", "low"].includes(result.confidence) ||
!["security", "data-loss", "core-blocked", "functional", "feature", "cosmetic", "unknown"].includes(result.risk) ||
typeof result.reason !== "string" ||
!result.reason.trim() ||
result.reason.length > 600 ||
!validTextList(result.evidence, 300) ||
!validTextList(result.missing_information, 250)
) {
throw new PriorityError("AtlasCloud returned an invalid priority assessment");
}
if (!result.evidence.every((quote) => issue.title.includes(quote) || issue.body.includes(quote))) {
throw new PriorityError("AtlasCloud priority evidence does not match the issue text");
}
const insufficientEvidence = result.confidence === "low" || result.evidence.length === 0 || result.risk === "unknown";
const unsupportedCriticalPriority = result.priority === "P0" && (result.confidence !== "high"
|| !["security", "data-loss", "core-blocked"].includes(result.risk));
const unsupportedCriticalPriority = result.priority === "P0" && (result.confidence !== "high" || !["security", "data-loss", "core-blocked"].includes(result.risk));
return {
priority: insufficientEvidence || unsupportedCriticalPriority ? "needs-info" : result.priority,
confidence: result.confidence,
@@ -219,8 +228,7 @@ export async function run({ event, apiKey, github, model = DEFAULT_MODEL, fetchI
await github("POST", `${issuePath}/labels`, { labels: [targetLabel] });
}
for (const label of existingLabels) {
if (label !== targetLabel && label.startsWith(LABEL_PREFIX)
&& Object.hasOwn(PRIORITY_LABELS, label.slice(LABEL_PREFIX.length))) {
if (label !== targetLabel && label.startsWith(LABEL_PREFIX) && Object.hasOwn(PRIORITY_LABELS, label.slice(LABEL_PREFIX.length))) {
try {
await github("DELETE", `${issuePath}/labels/${encodeURIComponent(label)}`);
} catch (error) {
@@ -234,10 +242,12 @@ export async function run({ event, apiKey, github, model = DEFAULT_MODEL, fetchI
export function formatSummary(result) {
const escape = (value) => String(value).replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
if (result.skipped) return `### AI issue priority\n\nSkipped: ${escape(result.skipped)}\n`;
return `### AI issue priority${result.dryRun ? " (dry run)" : ""}\n\n`
+ "AI suggestion based on issue text only, not a verified diagnosis or release commitment. "
+ "User-reported and maintainer priority labels are unchanged.\n\n"
+ `<pre>${escape(JSON.stringify({ model: result.model, ...result.assessment }, null, 2))}</pre>\n`;
return (
`### AI issue priority${result.dryRun ? " (dry run)" : ""}\n\n` +
"AI suggestion based on issue text only, not a verified diagnosis or release commitment. " +
"User-reported and maintainer priority labels are unchanged.\n\n" +
`<pre>${escape(JSON.stringify({ model: result.model, ...result.assessment }, null, 2))}</pre>\n`
);
}
async function main() {
+53 -44
View File
@@ -1,22 +1,13 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
assessIssue,
createGitHubClient,
DEFAULT_MODEL,
formatSummary,
parseAssessment,
prepareIssue,
run,
} from "./ai-issue-priority.mjs";
import { assessIssue, createGitHubClient, DEFAULT_MODEL, formatSummary, parseAssessment, prepareIssue, run } from "./ai-issue-priority.mjs";
const baseIssue = {
number: 42,
state: "open",
title: "[Bug] Grid column alignment is inconsistent",
body: "### Description\n\nOnly the column header is misaligned; queries and editing work.\n\n"
+ "### Priority (urgency)\n\nP0 Must fix next release\n\n### Additional information\n\nDBX on Linux.",
body: "### Description\n\nOnly the column header is misaligned; queries and editing work.\n\n" + "### Priority (urgency)\n\nP0 Must fix next release\n\n### Additional information\n\nDBX on Linux.",
labels: [{ name: "bug" }, { name: "db/mysql" }, { name: "user-priority/P0" }],
};
@@ -51,7 +42,9 @@ function fixture({ issue = baseIssue, latest = issue, result = assessment(), act
if (method === "GET") return ++reads === 1 ? issue : latest;
return {};
},
fetchImpl: modelFetch(result, () => { modelCalls += 1; }),
fetchImpl: modelFetch(result, () => {
modelCalls += 1;
}),
};
return { options, calls, modelCalls: () => modelCalls };
}
@@ -114,14 +107,7 @@ test("validates every priority and normalizes low-confidence or ungrounded decis
for (const priority of ["P1", "P2", "P3", "needs-info"]) {
assert.equal(parseAssessment(JSON.stringify(assessment({ priority })), prepared).priority, priority);
}
for (const override of [
{ confidence: "low" },
{ evidence: [] },
{ risk: "unknown" },
{ priority: "P0", risk: "cosmetic" },
{ priority: "P0", risk: "feature" },
{ priority: "P0", risk: "data-loss", confidence: "medium" },
]) {
for (const override of [{ confidence: "low" }, { evidence: [] }, { risk: "unknown" }, { priority: "P0", risk: "cosmetic" }, { priority: "P0", risk: "feature" }, { priority: "P0", risk: "data-loss", confidence: "medium" }]) {
assert.equal(parseAssessment(JSON.stringify(assessment(override)), prepared).priority, "needs-info");
}
const criticalIssue = prepareIssue({ title: "Data loss", body: "Saving one row overwrites every row in the table." });
@@ -132,13 +118,25 @@ test("validates every priority and normalizes low-confidence or ungrounded decis
test("rejects malformed, oversized, unknown and fabricated model results", () => {
const prepared = prepareIssue(baseIssue);
for (const invalid of [
"not JSON", "null", "[]", "```json\n{}\n```",
"not JSON",
"null",
"[]",
"```json\n{}\n```",
...[
{ priority: "P9" }, { priority: "__proto__" }, { priority: "constructor" }, { priority: ["P0"] },
{ confidence: "certain" }, { risk: "urgent" }, { reason: " " }, { reason: "x".repeat(601) },
{ evidence: ["Invented widespread data loss"] }, { evidence: ["P0 Must fix next release"] },
{ evidence: [""] }, { evidence: "not an array" },
{ missing_information: ["x".repeat(251)] }, { missing_information: ["a", "b", "c", "d"] },
{ priority: "P9" },
{ priority: "__proto__" },
{ priority: "constructor" },
{ priority: ["P0"] },
{ confidence: "certain" },
{ risk: "urgent" },
{ reason: " " },
{ reason: "x".repeat(601) },
{ evidence: ["Invented widespread data loss"] },
{ evidence: ["P0 Must fix next release"] },
{ evidence: [""] },
{ evidence: "not an array" },
{ missing_information: ["x".repeat(251)] },
{ missing_information: ["a", "b", "c", "d"] },
].map((override) => JSON.stringify(assessment(override))),
]) {
assert.throws(() => parseAssessment(invalid, prepared));
@@ -151,14 +149,22 @@ test("labels independently of reporter and maintainer priority, removing only ob
const result = await run(state.options);
assert.equal(result.label, "ai-priority/P3");
assert.equal(state.modelCalls(), 1);
assert.deepEqual(state.calls.filter(({ method }) => method !== "GET"), [
{ method: "POST", path: "/labels", body: {
name: "ai-priority/P3", color: "0e8a16",
description: "AI: low repair/implementation priority; minor or optional improvement",
} },
{ method: "POST", path: "/issues/42/labels", body: { labels: ["ai-priority/P3"] } },
{ method: "DELETE", path: "/issues/42/labels/ai-priority%2FP1", body: undefined },
]);
assert.deepEqual(
state.calls.filter(({ method }) => method !== "GET"),
[
{
method: "POST",
path: "/labels",
body: {
name: "ai-priority/P3",
color: "0e8a16",
description: "AI: low repair/implementation priority; minor or optional improvement",
},
},
{ method: "POST", path: "/issues/42/labels", body: { labels: ["ai-priority/P3"] } },
{ method: "DELETE", path: "/issues/42/labels/ai-priority%2FP1", body: undefined },
],
);
});
test("does not rewrite an unchanged AI label", async () => {
@@ -187,8 +193,7 @@ test("skips missing key, unrelated events, PRs, closed and opted-out issues befo
test("edits reconcile priority even when a preceding workflow may have been canceled", async () => {
for (const config of [
{ action: "edited", changes: { body: { from: baseIssue.body.replace("P0", "P3") } } },
{ action: "edited", issue: { ...baseIssue, labels: [...baseIssue.labels, "ai-priority/P1"] },
changes: { body: { from: baseIssue.body.replace("P0", "P3") } } },
{ action: "edited", issue: { ...baseIssue, labels: [...baseIssue.labels, "ai-priority/P1"] }, changes: { body: { from: baseIssue.body.replace("P0", "P3") } } },
{ action: "reopened" },
{ action: "edited", changes: { title: { from: "Old title" } } },
{ action: "edited", changes: { body: { from: "Old body" } } },
@@ -211,10 +216,7 @@ test("uses fresh issue content and rechecks eligibility before paid assessment",
});
test("does not publish stale results after changes, closure or maintainer opt-out", async () => {
for (const override of [
{ title: "New title" }, { body: "New evidence" }, { labels: ["db/postgres"] },
{ state: "closed" }, { labels: ["ai-priority/skip"] },
]) {
for (const override of [{ title: "New title" }, { body: "New evidence" }, { labels: ["db/postgres"] }, { state: "closed" }, { labels: ["ai-priority/skip"] }]) {
const state = fixture({ latest: { ...baseIssue, ...override } });
assert.equal((await run(state.options)).skipped, "issue changed during assessment");
assert.equal(state.modelCalls(), 1);
@@ -247,7 +249,9 @@ test("provider failures and invalid outputs preserve all existing labels and do
async () => Response.json({ error: "secret-response-body" }, { status: 401 }),
async () => Response.json({ error: "secret-response-body" }, { status: 429 }),
async () => Response.json({ error: "secret-response-body" }, { status: 500 }),
async () => { throw new Error("secret-response-body"); },
async () => {
throw new Error("secret-response-body");
},
async () => new Response("secret-response-body"),
async () => Response.json({ choices: [] }),
async () => Response.json({ choices: [{ finish_reason: "length", message: { content: "secret-response-body" } }] }),
@@ -282,7 +286,10 @@ test("failed label addition never removes the previous AI priority", async () =>
return original(method, path, body);
};
await assert.rejects(run(state.options), /GitHub unavailable/);
assert.equal(state.calls.some(({ method }) => method === "DELETE"), false);
assert.equal(
state.calls.some(({ method }) => method === "DELETE"),
false,
);
});
test("invalid issue numbers are rejected before requests", async () => {
@@ -299,7 +306,8 @@ test("GitHub client keeps credentials separate, uses timeouts and never includes
assert.throws(() => createGitHubClient({ token: "fixture-token", repository: "https://other.example" }));
const calls = [];
const github = createGitHubClient({
token: "fixture-token", repository: "t8y2/dbx",
token: "fixture-token",
repository: "t8y2/dbx",
fetchImpl: async (url, options) => {
calls.push({ url, options });
return options.method === "DELETE" ? new Response(null, { status: 204 }) : Response.json(baseIssue);
@@ -311,7 +319,8 @@ test("GitHub client keeps credentials separate, uses timeouts and never includes
assert.ok(calls[0].options.signal instanceof AbortSignal);
assert.equal(await github("DELETE", "/issues/42/labels/test"), null);
const failing = createGitHubClient({
token: "fixture-token", repository: "t8y2/dbx",
token: "fixture-token",
repository: "t8y2/dbx",
fetchImpl: async () => new Response("secret-response-body", { status: 403 }),
});
await assert.rejects(failing("GET", "/issues/42"), (error) => {
+3 -16
View File
@@ -33,23 +33,14 @@ export function selectChunk(issues, { chunkIndex, chunkCount, maxIssues = 0 }) {
export async function listOpenUnassignedIssues(client) {
const issues = [];
for (let page = 1; ; page += 1) {
const items = await client.request(
"GET",
`/repos/${client.repository}/issues?state=open&sort=created&direction=desc&per_page=100&page=${page}`,
);
const items = await client.request("GET", `/repos/${client.repository}/issues?state=open&sort=created&direction=desc&per_page=100&page=${page}`);
issues.push(...items.filter((issue) => !issue.pull_request && (issue.assignees || []).length === 0));
if (items.length < 100) break;
}
return issues;
}
export async function backfill({
client,
chunkIndex,
chunkCount,
maxIssues = 0,
searchDelayMs = DEFAULT_SEARCH_DELAY_MS,
}) {
export async function backfill({ client, chunkIndex, chunkCount, maxIssues = 0, searchDelayMs = DEFAULT_SEARCH_DELAY_MS }) {
const issues = selectChunk(await listOpenUnassignedIssues(client), { chunkIndex, chunkCount, maxIssues });
const originalSearchIssues = client.searchIssues.bind(client);
let lastSearchStartedAt = 0;
@@ -98,10 +89,6 @@ if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url)
chunkIndex: integerValue(process.env.BACKFILL_CHUNK_INDEX, 0, "BACKFILL_CHUNK_INDEX"),
chunkCount: integerValue(process.env.BACKFILL_CHUNK_COUNT, 1, "BACKFILL_CHUNK_COUNT"),
maxIssues: integerValue(process.env.BACKFILL_MAX_ISSUES, 0, "BACKFILL_MAX_ISSUES"),
searchDelayMs: integerValue(
process.env.BACKFILL_SEARCH_DELAY_MS,
DEFAULT_SEARCH_DELAY_MS,
"BACKFILL_SEARCH_DELAY_MS",
),
searchDelayMs: integerValue(process.env.BACKFILL_SEARCH_DELAY_MS, DEFAULT_SEARCH_DELAY_MS, "BACKFILL_SEARCH_DELAY_MS"),
});
}
@@ -1,17 +1,19 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
backfill,
listOpenUnassignedIssues,
selectChunk,
} from "./backfill-similar-issues.mjs";
import { backfill, listOpenUnassignedIssues, selectChunk } from "./backfill-similar-issues.mjs";
test("selectChunk partitions issue numbers deterministically", () => {
const issues = [1, 2, 3, 4, 5, 6].map((number) => ({ number }));
assert.deepEqual(selectChunk(issues, { chunkIndex: 0, chunkCount: 3 }).map(({ number }) => number), [6, 3]);
assert.deepEqual(selectChunk(issues, { chunkIndex: 1, chunkCount: 3 }).map(({ number }) => number), [4, 1]);
assert.deepEqual(
selectChunk(issues, { chunkIndex: 0, chunkCount: 3 }).map(({ number }) => number),
[6, 3],
);
assert.deepEqual(
selectChunk(issues, { chunkIndex: 1, chunkCount: 3 }).map(({ number }) => number),
[4, 1],
);
assert.deepEqual(
selectChunk(issues, { chunkIndex: 2, chunkCount: 3, maxIssues: 1 }).map(({ number }) => number),
[5],
@@ -34,8 +36,14 @@ test("listOpenUnassignedIssues filters pull requests and assigned issues across
const issues = await listOpenUnassignedIssues(client);
assert.equal(issues.length, 99);
assert.equal(issues.some(({ number }) => number === 1), false);
assert.equal(issues.some(({ number }) => number === 2), false);
assert.equal(
issues.some(({ number }) => number === 1),
false,
);
assert.equal(
issues.some(({ number }) => number === 2),
false,
);
assert.equal(issues.at(-1).number, 101);
});
+15 -56
View File
@@ -5,10 +5,7 @@ import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs
const VERSIONS_PATH = "agents/versions.json";
const VERSION_SYNC_SUBJECT = "chore: bump module versions [skip ci]";
const JRE_BUILD_PATHS = new Set([".github/workflows/agents-release.yml"]);
const NATIVE_RELEASE_PACKAGING_PATHS = new Set([
".github/scripts/reuse-agent-release-assets.mjs",
"agents/scripts/version_agent_artifacts.py",
]);
const NATIVE_RELEASE_PACKAGING_PATHS = new Set([".github/scripts/reuse-agent-release-assets.mjs", "agents/scripts/version_agent_artifacts.py"]);
function bumpPatchVersion(version) {
const match = /^(\d+)\.(\d+)\.(\d+)(.*)$/.exec(version);
@@ -35,11 +32,7 @@ export function parseLegacyStandaloneProjects(buildGradle) {
const match = /legacyStandaloneProjects\s*=\s*\[([^\]]*)\]/m.exec(buildGradle);
if (!match) return new Set();
return new Set(
[...match[1].matchAll(/['"]([^'"]+)['"]/g)]
.map((entry) => entry[1])
.filter(Boolean),
);
return new Set([...match[1].matchAll(/['"]([^'"]+)['"]/g)].map((entry) => entry[1]).filter(Boolean));
}
function fileContainsCommonDependency(path, moduleExists, readModuleFile) {
@@ -72,23 +65,10 @@ const crateNativeDriverDirectories = {
};
const nativeDriverModules = new Set(["cassandra", "duckdb", "hive", "argo", "oracle", "oracle-oci", "xugu", "kingbase", "iotdb", "neo4j", "nebula", "vastbase", "rabbitmq", "rocketmq", "zookeeper", "tdengine", "etcd", "etcd2", "sqlite-worker"]);
const nativeDriverSharedPaths = {
hive: [
"agents/go-common/go-gssapi",
"agents/go-common/gohive",
"agents/go-common/gosasl",
],
argo: [
"agents/go-common/go-gssapi",
"agents/go-common/gohive",
"agents/go-common/gosasl",
],
zookeeper: [
"agents/go-common/go-gssapi",
"agents/go-common/gosasl",
],
etcd: [
"agents/go-common/go-semver",
],
hive: ["agents/go-common/go-gssapi", "agents/go-common/gohive", "agents/go-common/gosasl"],
argo: ["agents/go-common/go-gssapi", "agents/go-common/gohive", "agents/go-common/gosasl"],
zookeeper: ["agents/go-common/go-gssapi", "agents/go-common/gosasl"],
etcd: ["agents/go-common/go-semver"],
};
function resolveAgentModule(moduleName, { legacyStandaloneModules, moduleExists, readModuleFile }) {
@@ -137,16 +117,7 @@ function classifyModules(versions, options) {
.filter(({ module }) => module);
}
export function evaluateAgentVersionBump({
versions,
prevVersions = versions,
changedFiles,
legacyStandaloneModules = new Set(),
moduleExists = existsSync,
readModuleFile = (path) => readFileSync(path, "utf8"),
skipBump = false,
manualVersionsChanged = changedFiles.includes(VERSIONS_PATH),
}) {
export function evaluateAgentVersionBump({ versions, prevVersions = versions, changedFiles, legacyStandaloneModules = new Set(), moduleExists = existsSync, readModuleFile = (path) => readFileSync(path, "utf8"), skipBump = false, manualVersionsChanged = changedFiles.includes(VERSIONS_PATH) }) {
const nextVersions = { ...versions };
const logs = [];
let changed = false;
@@ -180,8 +151,7 @@ export function evaluateAgentVersionBump({
}
for (const { moduleName, module } of resolvedModules) {
const moduleChanged = [module.modulePath, ...module.sharedPaths]
.some((path) => pathChanged(changedFiles, path));
const moduleChanged = [module.modulePath, ...module.sharedPaths].some((path) => pathChanged(changedFiles, path));
// Only modules that package agents/common need installer-visible updates
// for shared Java runtime changes; native and standalone agents do not.
const commonAffectsModule = commonChanged && module.commonDependent;
@@ -233,22 +203,15 @@ function git(args) {
}
function lines(value) {
return value.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
return value
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
export function resolveAgentReleaseBaseline({ prevTag, headRef = "HEAD", gitOutput = git }) {
const allChangedFiles = lines(gitOutput(["diff", "--name-only", `${prevTag}..${headRef}`]));
const versionCommits = lines(
gitOutput([
"log",
"--reverse",
"--ancestry-path",
"--format=%H%x09%s",
`${prevTag}..${headRef}`,
"--",
VERSIONS_PATH,
]),
);
const versionCommits = lines(gitOutput(["log", "--reverse", "--ancestry-path", "--format=%H%x09%s", `${prevTag}..${headRef}`, "--", VERSIONS_PATH]));
let syncCommit = "";
for (const entry of versionCommits) {
@@ -266,12 +229,8 @@ export function resolveAgentReleaseBaseline({ prevTag, headRef = "HEAD", gitOutp
const versionsRef = syncCommit || prevTag;
const versions = JSON.parse(gitOutput(["show", `${versionsRef}:${VERSIONS_PATH}`]));
const versionsChangedAfterSync = syncCommit
? lines(gitOutput(["log", "--format=%H", `${syncCommit}..${headRef}`, "--", VERSIONS_PATH])).length > 0
: false;
const changedFiles = syncCommit && !versionsChangedAfterSync
? allChangedFiles.filter((file) => file !== VERSIONS_PATH)
: allChangedFiles;
const versionsChangedAfterSync = syncCommit ? lines(gitOutput(["log", "--format=%H", `${syncCommit}..${headRef}`, "--", VERSIONS_PATH])).length > 0 : false;
const changedFiles = syncCommit && !versionsChangedAfterSync ? allChangedFiles.filter((file) => file !== VERSIONS_PATH) : allChangedFiles;
return {
prevTag,
+5 -24
View File
@@ -118,11 +118,7 @@ test("bumps the native RocketMQ agent from its Go directory", () => {
});
test("bumps ZooKeeper from native and shared SASL source directories", () => {
for (const changedFile of [
"agents/drivers/zookeeper/main.go",
"agents/go-common/gosasl/sasl.go",
"agents/go-common/go-gssapi/krb5/krb5.go",
]) {
for (const changedFile of ["agents/drivers/zookeeper/main.go", "agents/go-common/gosasl/sasl.go", "agents/go-common/go-gssapi/krb5/krb5.go"]) {
const result = evaluateAgentVersionBump({
versions: { zookeeper: "0.1.0" },
changedFiles: [changedFile],
@@ -161,12 +157,7 @@ test("bumps Cassandra from its native Go source directory", () => {
});
test("bumps Hive from native and shared Kerberos source directories", () => {
for (const changedFile of [
"agents/drivers/hive-go/main.go",
"agents/go-common/gohive/driver.go",
"agents/go-common/gosasl/gssapi.go",
"agents/go-common/go-gssapi/krb5/krb5.go",
]) {
for (const changedFile of ["agents/drivers/hive-go/main.go", "agents/go-common/gohive/driver.go", "agents/go-common/gosasl/gssapi.go", "agents/go-common/go-gssapi/krb5/krb5.go"]) {
const result = evaluateAgentVersionBump({
versions: { hive: "0.1.43" },
changedFiles: [changedFile],
@@ -207,12 +198,7 @@ test("bumps IoTDB from its native Go source directory", () => {
});
test("rebuilds native modules when shared native packaging changes", () => {
const existing = new Set([
"agents/drivers/access",
"agents/drivers/access/build.gradle",
"agents/drivers/duckdb",
"agents/drivers/neo4j-go",
]);
const existing = new Set(["agents/drivers/access", "agents/drivers/access/build.gradle", "agents/drivers/duckdb", "agents/drivers/neo4j-go"]);
const result = evaluateAgentVersionBump({
versions: { access: "0.1.37", duckdb: "0.1.3", neo4j: "0.1.40" },
changedFiles: ["agents/scripts/version_agent_artifacts.py"],
@@ -255,12 +241,7 @@ test("bumps DuckDB when its Cargo target configuration changes", () => {
});
test("builds only common-dependent Java modules for a shared runtime change", () => {
const existing = new Set([
"agents/drivers/access",
"agents/drivers/access/build.gradle",
"agents/drivers/mongodb",
"agents/drivers/mongodb/build.gradle",
]);
const existing = new Set(["agents/drivers/access", "agents/drivers/access/build.gradle", "agents/drivers/mongodb", "agents/drivers/mongodb/build.gradle"]);
const result = evaluateAgentVersionBump({
versions: { access: "0.1.0", mongodb: "0.1.0" },
changedFiles: ["agents/common/src/main/java/com/dbx/Agent.java"],
@@ -341,7 +322,7 @@ function createRepository(versions) {
mkdirSync(join(repository, "agents/drivers/duckdb"), { recursive: true });
writeVersions(repository, versions);
writeFileSync(join(repository, "agents/drivers/kingbase-go/kingbase_metadata.go"), "package main\n");
writeFileSync(join(repository, "agents/drivers/duckdb/Cargo.toml"), "[package]\nname = \"duckdb-test\"\n");
writeFileSync(join(repository, "agents/drivers/duckdb/Cargo.toml"), '[package]\nname = "duckdb-test"\n');
commitAll(repository, "feat(agents): initial release state");
return repository;
}
+6 -2
View File
@@ -5,8 +5,12 @@ import { pathToFileURL } from "node:url";
export function changeBase({ eventName, before, root = process.cwd() }) {
if (eventName === "pull_request") {
const commits = execFileSync("git", ["rev-list", "--parents", "-n", "1", "HEAD"], {
cwd: root, encoding: "utf8", stdio: "pipe",
}).trim().split(/\s+/);
cwd: root,
encoding: "utf8",
stdio: "pipe",
})
.trim()
.split(/\s+/);
if (commits.length !== 3) throw new Error("PR change detection requires the checked-out merge commit and both parents");
return commits[1];
}
+8 -6
View File
@@ -52,10 +52,7 @@ test("PR routing uses the exact test merge first parent, excluding unrelated mai
assert.equal(base, fixture.actualBase);
assert.notEqual(base, fixture.pullRequestHead);
const files = changedPaths(base, fixture.root);
assert.deepEqual(files.toSorted(), [
"crates/dbx-core/tests/old.rs", "crates/dbx-core/tests/deleted.rs", "crates/dbx-core/tests/renamed\nfile.rs",
"crates/dbx-core/tests/runtime_diagnostic_history.rs", "crates/dbx-web/src/routes/transfer.rs",
].toSorted());
assert.deepEqual(files.toSorted(), ["crates/dbx-core/tests/old.rs", "crates/dbx-core/tests/deleted.rs", "crates/dbx-core/tests/renamed\nfile.rs", "crates/dbx-core/tests/runtime_diagnostic_history.rs", "crates/dbx-web/src/routes/transfer.rs"].toSorted());
const metadata = {
workspace_members: ["dbx-core", "dbx-web", "dbx"],
packages: [
@@ -66,7 +63,10 @@ test("PR routing uses the exact test merge first parent, excluding unrelated mai
};
const corrected = planCi({ files, metadata, root: fixture.root });
assert.equal(corrected.rust_full, false);
assert.deepEqual(corrected.rust_matrix.include.map((entry) => entry.group), ["application"]);
assert.deepEqual(
corrected.rust_matrix.include.map((entry) => entry.group),
["application"],
);
assert.deepEqual(corrected.agent_rust.include, [{ driver: "duckdb" }]);
assert.equal(corrected.agent_java, false);
assert.deepEqual(corrected.agent_go.include, []);
@@ -90,7 +90,9 @@ test("a depth-two checkout preserves the PR parents and supports the base CLI",
assert.equal(changeBase({ eventName: "pull_request", root: checkout }), fixture.actualBase);
assert.deepEqual(changedPaths(fixture.actualBase, checkout), changedPaths(fixture.actualBase, fixture.root));
const output = execFileSync(process.execPath, [path.join(import.meta.dirname, "ci-change-base.mjs")], {
cwd: checkout, encoding: "utf8", env: { ...process.env, GITHUB_EVENT_NAME: "pull_request", BEFORE_SHA: fixture.staleBase },
cwd: checkout,
encoding: "utf8",
env: { ...process.env, GITHUB_EVENT_NAME: "pull_request", BEFORE_SHA: fixture.staleBase },
});
assert.equal(output.trim(), fixture.actualBase);
});
+5 -10
View File
@@ -1,13 +1,6 @@
export const rustGroups = {
foundation: [
"dbx-types", "dbx-platform", "dbx-sql-core", "dbx-sql-dialect", "dbx-sql-data", "dbx-sql-schema", "dbx-sql",
"dbx-formats", "dbx-ai-provider", "dbx-plugin-runtime",
],
drivers: [
"dbx-driver-support", "dbx-driver-agent", "dbx-driver-elasticsearch", "dbx-driver-mongodb",
"dbx-driver-mysql", "dbx-driver-postgres", "dbx-driver-redis", "dbx-driver-sqlserver", "dbx-drivers",
"dbx-sqlite-worker",
],
foundation: ["dbx-types", "dbx-platform", "dbx-sql-core", "dbx-sql-dialect", "dbx-sql-data", "dbx-sql-schema", "dbx-sql", "dbx-formats", "dbx-ai-provider", "dbx-plugin-runtime"],
drivers: ["dbx-driver-support", "dbx-driver-agent", "dbx-driver-elasticsearch", "dbx-driver-mongodb", "dbx-driver-mysql", "dbx-driver-postgres", "dbx-driver-redis", "dbx-driver-sqlserver", "dbx-drivers", "dbx-sqlite-worker"],
application: ["dbx", "dbx-core", "dbx-web", "dbx-cli", "dbx-mcp", "dbx-tauri-consul", "dbx-tauri-schema"],
};
@@ -32,7 +25,9 @@ export const integrationCases = [
{ driver: "zookeeper", scenario: "zookeeper-sasl", version: "3.7.0" },
...["4.9.8", "5.3.1"].map((version) => ({ driver: "rocketmq", scenario: "rocketmq", version })),
...["2.4.0.14", "2.6.0.34", "3.0.7.1", "3.3.6.13", "3.4.2.2"].map((version) => ({
driver: "tdengine", scenario: "tdengine", version,
driver: "tdengine",
scenario: "tdengine",
version,
image: `tdengine/${version === "3.4.2.2" ? "tsdb" : "tdengine"}:${version}`,
})),
...["3.11.19", "5.0.6"].map((version) => ({ driver: "cassandra-go", scenario: "cassandra", version })),
+131 -84
View File
@@ -9,10 +9,24 @@ import { goAgents, integrationCases } from "./ci-config.mjs";
import { lockfileManifests } from "./ci-lockfiles.mjs";
const root = fileURLToPath(new URL("../../", import.meta.url));
const environmentKeys = ["CGO_ENABLED", "GOOS", "GOARCH", "GONOSUMDB", "DBX_ZOOKEEPER_TEST_CONNECT_STRING",
"DBX_ZOOKEEPER_TEST_AUTH_SCHEME", "DBX_ZOOKEEPER_TEST_USERNAME", "DBX_ZOOKEEPER_TEST_PASSWORD", "TDENGINE_INTEGRATION",
"TDENGINE_TEST_HOST", "TDENGINE_TEST_PORT", "CASSANDRA_TEST_HOST", "CASSANDRA_TEST_PORT", "RABBITMQ_INTEGRATION",
"RABBITMQ_USERNAME", "RABBITMQ_PASSWORD"];
const environmentKeys = [
"CGO_ENABLED",
"GOOS",
"GOARCH",
"GONOSUMDB",
"DBX_ZOOKEEPER_TEST_CONNECT_STRING",
"DBX_ZOOKEEPER_TEST_AUTH_SCHEME",
"DBX_ZOOKEEPER_TEST_USERNAME",
"DBX_ZOOKEEPER_TEST_PASSWORD",
"TDENGINE_INTEGRATION",
"TDENGINE_TEST_HOST",
"TDENGINE_TEST_PORT",
"CASSANDRA_TEST_HOST",
"CASSANDRA_TEST_PORT",
"RABBITMQ_INTEGRATION",
"RABBITMQ_USERNAME",
"RABBITMQ_PASSWORD",
];
function withToolStubs(run, extraEnv = {}) {
const directory = mkdtempSync(path.join(tmpdir(), "dbx-ci-execution-"));
@@ -20,7 +34,9 @@ function withToolStubs(run, extraEnv = {}) {
mkdirSync(binaries);
const log = path.join(directory, "commands.jsonl");
const stub = path.join(binaries, "stub.cjs");
writeFileSync(stub, `#!/usr/bin/env node
writeFileSync(
stub,
`#!/usr/bin/env node
const fs = require('node:fs');
const path = require('node:path');
const tool = path.basename(process.argv[1]);
@@ -29,100 +45,131 @@ const env = Object.fromEntries(${JSON.stringify(environmentKeys)}.filter(name =>
fs.appendFileSync(process.env.CI_STUB_LOG, JSON.stringify({tool,args,cwd:process.cwd(),env})+'\\n');
if (process.env.CI_STUB_FAIL === tool) process.exit(23);
if (tool === 'docker' && args.includes('taos')) console.log('| ready |');
`, { mode: 0o755 });
`,
{ mode: 0o755 },
);
for (const name of ["docker", "go", "cargo", "timeout", "curl", "sleep", "bash"]) symlinkSync(stub, path.join(binaries, name));
try {
const env = { ...process.env, ...extraEnv, TMPDIR: directory, CI_STUB_LOG: log, PATH: `${binaries}${path.delimiter}${process.env.PATH}` };
return run(env, () => readFileSync(log, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line)));
} finally { rmSync(directory, { recursive: true, force: true }); }
return run(env, () =>
readFileSync(log, "utf8")
.trim()
.split("\n")
.filter(Boolean)
.map((line) => JSON.parse(line)),
);
} finally {
rmSync(directory, { recursive: true, force: true });
}
}
for (const agent of goAgents) {
test(`Go job preserves ${agent.driver} tests and target builds without executing compilers`, () => withToolStubs((env, commands) => {
const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-go.sh", agent.driver, agent.binary, String(agent.race)], { cwd: root, env, encoding: "utf8" });
assert.equal(result.status, 0, result.stderr);
const invocations = commands();
assert.deepEqual(invocations[0].args, agent.race ? ["test", "-race", "./..."] : ["test", "./..."]);
assert.ok(invocations.every((entry) => entry.cwd === path.join(root, "agents/drivers", agent.driver)));
const builds = invocations.slice(1);
assert.equal(builds.length, agent.driver === "zookeeper" ? 6 : 1);
for (const build of builds) {
assert.deepEqual(build.args.slice(0, 3), ["build", "-trimpath", "-ldflags=-s -w"]);
assert.equal(build.env.CGO_ENABLED, "0");
}
if (agent.driver === "zookeeper") assert.deepEqual(builds.map((entry) => `${entry.env.GOOS}/${entry.env.GOARCH}`),
["darwin/arm64", "darwin/amd64", "linux/arm64", "linux/amd64", "windows/arm64", "windows/amd64"]);
else assert.ok(builds[0].args.includes(`/tmp/dbx-agent-${agent.binary}-linux-x64`));
if (agent.driver === "xugu") assert.ok(invocations.every((entry) => entry.env.GONOSUMDB === "gitee.com/XuguDB/go-xugu-driver"));
}));
test(`Go job preserves ${agent.driver} tests and target builds without executing compilers`, () =>
withToolStubs((env, commands) => {
const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-go.sh", agent.driver, agent.binary, String(agent.race)], { cwd: root, env, encoding: "utf8" });
assert.equal(result.status, 0, result.stderr);
const invocations = commands();
assert.deepEqual(invocations[0].args, agent.race ? ["test", "-race", "./..."] : ["test", "./..."]);
assert.ok(invocations.every((entry) => entry.cwd === path.join(root, "agents/drivers", agent.driver)));
const builds = invocations.slice(1);
assert.equal(builds.length, agent.driver === "zookeeper" ? 6 : 1);
for (const build of builds) {
assert.deepEqual(build.args.slice(0, 3), ["build", "-trimpath", "-ldflags=-s -w"]);
assert.equal(build.env.CGO_ENABLED, "0");
}
if (agent.driver === "zookeeper")
assert.deepEqual(
builds.map((entry) => `${entry.env.GOOS}/${entry.env.GOARCH}`),
["darwin/arm64", "darwin/amd64", "linux/arm64", "linux/amd64", "windows/arm64", "windows/amd64"],
);
else assert.ok(builds[0].args.includes(`/tmp/dbx-agent-${agent.binary}-linux-x64`));
if (agent.driver === "xugu") assert.ok(invocations.every((entry) => entry.env.GONOSUMDB === "gitee.com/XuguDB/go-xugu-driver"));
}));
}
for (const entry of integrationCases) {
test(`live matrix retains ${entry.scenario}/${entry.version} commands without starting any container`, () => withToolStubs((env, commands) => {
const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-integration.sh", entry.scenario, entry.version, entry.image ?? ""], { cwd: root, env, encoding: "utf8" });
assert.equal(result.status, 0, result.stderr);
const invocations = commands();
if (entry.scenario === "rocketmq") {
assert.ok(invocations.some((call) => call.tool === "bash" && call.args.at(-1) === entry.version));
return;
}
const start = invocations.find((call) => call.tool === "docker" && call.args[0] === "run");
assert.ok(start, "starts the selected container");
const expectedImage = entry.image ?? (entry.scenario.startsWith("zookeeper") ? `zookeeper:${entry.version}`
: entry.scenario === "rabbitmq" ? `rabbitmq:${entry.version}-management` : `cassandra:${entry.version}`);
assert.equal(start.args.at(-1), expectedImage);
const last = invocations.at(-1);
assert.equal(last.tool, "docker");
assert.deepEqual(last.args.slice(0, 2), ["rm", "-fv"]);
const tests = invocations.filter((call) => ["go", "cargo"].includes(call.tool));
assert.ok(tests.length);
if (entry.scenario === "zookeeper") {
assert.equal(tests.length, entry.version === "3.7.0" ? 2 : 1);
assert.ok(tests[0].args.includes("^TestZooKeeperIntegration$"));
if (entry.version === "3.7.0") assert.ok(tests[1].args.includes("^TestZooKeeperLargeChildrenIntegration$"));
} else if (entry.scenario === "zookeeper-sasl") {
assert.equal(tests[0].env.DBX_ZOOKEEPER_TEST_AUTH_SCHEME, "sasl_digest");
assert.equal(tests[0].env.DBX_ZOOKEEPER_TEST_USERNAME, "dbx");
assert.ok(start.args.some((arg) => arg.includes("sessionRequireClientSASLAuth=true")));
} else if (entry.scenario === "tdengine") {
assert.deepEqual(tests[0].args, ["nextest", "run", "--manifest-path", "drivers/tdengine/Cargo.toml", "--locked", "--test", "live", "--no-capture", "--no-fail-fast"]);
assert.equal(tests[0].env.TDENGINE_INTEGRATION, "1");
assert.equal(tests[0].env.TDENGINE_TEST_PORT, "6041");
} else if (entry.scenario === "cassandra") assert.ok(tests[0].args.includes("^TestCassandraIntegration$"));
else {
assert.ok(tests[0].args.includes("^TestRabbitMQIntegration$"));
assert.equal(tests[0].env.RABBITMQ_INTEGRATION, "1");
assert.ok(start.args.includes("999:999"));
assert.ok(start.args.includes("/var/lib/rabbitmq:rw,exec,uid=999,gid=999,mode=700"));
}
}));
test(`live matrix retains ${entry.scenario}/${entry.version} commands without starting any container`, () =>
withToolStubs((env, commands) => {
const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-integration.sh", entry.scenario, entry.version, entry.image ?? ""], { cwd: root, env, encoding: "utf8" });
assert.equal(result.status, 0, result.stderr);
const invocations = commands();
if (entry.scenario === "rocketmq") {
assert.ok(invocations.some((call) => call.tool === "bash" && call.args.at(-1) === entry.version));
return;
}
const start = invocations.find((call) => call.tool === "docker" && call.args[0] === "run");
assert.ok(start, "starts the selected container");
const expectedImage = entry.image ?? (entry.scenario.startsWith("zookeeper") ? `zookeeper:${entry.version}` : entry.scenario === "rabbitmq" ? `rabbitmq:${entry.version}-management` : `cassandra:${entry.version}`);
assert.equal(start.args.at(-1), expectedImage);
const last = invocations.at(-1);
assert.equal(last.tool, "docker");
assert.deepEqual(last.args.slice(0, 2), ["rm", "-fv"]);
const tests = invocations.filter((call) => ["go", "cargo"].includes(call.tool));
assert.ok(tests.length);
if (entry.scenario === "zookeeper") {
assert.equal(tests.length, entry.version === "3.7.0" ? 2 : 1);
assert.ok(tests[0].args.includes("^TestZooKeeperIntegration$"));
if (entry.version === "3.7.0") assert.ok(tests[1].args.includes("^TestZooKeeperLargeChildrenIntegration$"));
} else if (entry.scenario === "zookeeper-sasl") {
assert.equal(tests[0].env.DBX_ZOOKEEPER_TEST_AUTH_SCHEME, "sasl_digest");
assert.equal(tests[0].env.DBX_ZOOKEEPER_TEST_USERNAME, "dbx");
assert.ok(start.args.some((arg) => arg.includes("sessionRequireClientSASLAuth=true")));
} else if (entry.scenario === "tdengine") {
assert.deepEqual(tests[0].args, ["nextest", "run", "--manifest-path", "drivers/tdengine/Cargo.toml", "--locked", "--test", "live", "--no-capture", "--no-fail-fast"]);
assert.equal(tests[0].env.TDENGINE_INTEGRATION, "1");
assert.equal(tests[0].env.TDENGINE_TEST_PORT, "6041");
} else if (entry.scenario === "cassandra") assert.ok(tests[0].args.includes("^TestCassandraIntegration$"));
else {
assert.ok(tests[0].args.includes("^TestRabbitMQIntegration$"));
assert.equal(tests[0].env.RABBITMQ_INTEGRATION, "1");
assert.ok(start.args.includes("999:999"));
assert.ok(start.args.includes("/var/lib/rabbitmq:rw,exec,uid=999,gid=999,mode=700"));
}
}));
}
test("failed live tests stay failed and still clean their container", () => withToolStubs((env, commands) => {
const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-integration.sh", "cassandra", "5.0.6"], { cwd: root, env, encoding: "utf8" });
assert.equal(result.status, 23);
assert.deepEqual(commands().at(-1).args.slice(0, 2), ["rm", "-fv"]);
}, { CI_STUB_FAIL: "go" }));
test("failed live tests stay failed and still clean their container", () =>
withToolStubs(
(env, commands) => {
const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-integration.sh", "cassandra", "5.0.6"], { cwd: root, env, encoding: "utf8" });
assert.equal(result.status, 23);
assert.deepEqual(commands().at(-1).args.slice(0, 2), ["rm", "-fv"]);
},
{ CI_STUB_FAIL: "go" },
));
test("failed nextest live tests propagate failure and still clean their container", () => withToolStubs((env, commands) => {
const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-integration.sh", "tdengine", "3.4.2.2"], { cwd: root, env, encoding: "utf8" });
assert.equal(result.status, 23);
assert.deepEqual(commands().find((command) => command.tool === "cargo").args.slice(0, 2), ["nextest", "run"]);
assert.deepEqual(commands().at(-1).args.slice(0, 2), ["rm", "-fv"]);
}, { CI_STUB_FAIL: "cargo" }));
test("failed nextest live tests propagate failure and still clean their container", () =>
withToolStubs(
(env, commands) => {
const result = spawnSync("/bin/bash", [".github/scripts/ci-agent-integration.sh", "tdengine", "3.4.2.2"], { cwd: root, env, encoding: "utf8" });
assert.equal(result.status, 23);
assert.deepEqual(
commands()
.find((command) => command.tool === "cargo")
.args.slice(0, 2),
["nextest", "run"],
);
assert.deepEqual(commands().at(-1).args.slice(0, 2), ["rm", "-fv"]);
},
{ CI_STUB_FAIL: "cargo" },
));
test("lock preflight resolves full graphs, keeps --locked and propagates failures", () => {
const plan = { rust: true, agent_rust: { include: [{ driver: "duckdb" }, { driver: "tdengine" }] } };
assert.deepEqual(lockfileManifests(plan), ["Cargo.toml", "agents/drivers/duckdb/Cargo.toml", "agents/drivers/tdengine/Cargo.toml"]);
assert.throws(() => lockfileManifests({ rust: false, agent_rust: { include: [{ driver: "../../unknown" }] } }));
for (const failure of ["", "cargo"]) withToolStubs((env, commands) => {
const result = spawnSync(process.execPath, [".github/scripts/ci-lockfiles.mjs"], { cwd: root, env, encoding: "utf8" });
assert.equal(result.status, failure ? 23 : 0, result.stderr);
assert.equal(commands().length, failure ? 1 : 3);
for (const command of commands()) {
assert.equal(command.args[0], "metadata");
assert.ok(command.args.includes("--locked"));
assert.ok(!command.args.includes("--no-deps"));
}
}, { CI_PLAN: JSON.stringify(plan), CI_STUB_FAIL: failure });
for (const failure of ["", "cargo"])
withToolStubs(
(env, commands) => {
const result = spawnSync(process.execPath, [".github/scripts/ci-lockfiles.mjs"], { cwd: root, env, encoding: "utf8" });
assert.equal(result.status, failure ? 23 : 0, result.stderr);
assert.equal(commands().length, failure ? 1 : 3);
for (const command of commands()) {
assert.equal(command.args[0], "metadata");
assert.ok(command.args.includes("--locked"));
assert.ok(!command.args.includes("--no-deps"));
}
},
{ CI_PLAN: JSON.stringify(plan), CI_STUB_FAIL: failure },
);
});
+46 -18
View File
@@ -4,33 +4,61 @@ import { pathToFileURL } from "node:url";
export function gateFailures(needs, mode) {
if (needs.changes?.result !== "success") return ["changes did not succeed"];
let plan;
try { plan = JSON.parse(needs.changes.outputs.plan); } catch { return ["missing or invalid CI plan"]; }
try {
plan = JSON.parse(needs.changes.outputs.plan);
} catch {
return ["missing or invalid CI plan"];
}
if (!plan || typeof plan !== "object" || Array.isArray(plan)) return ["incomplete CI plan"];
const flags = ["rust", "agents", "fast", "agent_java", "agent_go_changed", "agent_rust_changed", "agent_integration_changed"];
if (!flags.every((flag) => typeof plan[flag] === "boolean")) return ["incomplete CI plan"];
const routedJobs = { frontend: "frontend", packages: "packages", "github-scripts": "github_scripts",
"windows-standard-check": "windows_standard", "windows-win7-bundle": "windows_win7_bundle",
"duckdb-windows-driver": "duckdb_windows", jdbc: "jdbc", "offline-jdbc-release": "offline_jdbc", "nix-packaging": "nix" };
const routedJobs = {
frontend: "frontend",
packages: "packages",
"github-scripts": "github_scripts",
"windows-standard-check": "windows_standard",
"windows-win7-bundle": "windows_win7_bundle",
"duckdb-windows-driver": "duckdb_windows",
jdbc: "jdbc",
"offline-jdbc-release": "offline_jdbc",
"nix-packaging": "nix",
};
if (mode === "all" && !Object.values(routedJobs).every((output) => ["true", "false"].includes(needs.changes.outputs[output]))) {
return ["missing or invalid job selection outputs"];
}
if (mode === "frontend" && !["true", "false"].includes(needs.changes.outputs.frontend)) {
return ["missing or invalid frontend selection output"];
}
const expected = mode === "rust" ? {
"fast-checks": plan.fast, "rust-fmt-clippy": plan.rust, "rust-test": plan.rust,
} : mode === "agents" ? {
"fast-checks": plan.fast, "agent-checks": plan.agents, "agent-java": plan.agent_java, "agent-go": plan.agent_go_changed,
"agent-rust": plan.agent_rust_changed, "agent-integration": plan.agent_integration_changed,
} : mode === "frontend" ? {
"frontend-checks": needs.changes.outputs.frontend === "true",
"frontend-typecheck": needs.changes.outputs.frontend === "true",
"frontend-test": needs.changes.outputs.frontend === "true",
} : mode === "all" ? {
rust: true, agents: true, "fast-checks": plan.fast,
...Object.fromEntries(Object.entries(routedJobs)
.map(([job, output]) => [job, needs.changes.outputs[output] === "true"])),
} : null;
const expected =
mode === "rust"
? {
"fast-checks": plan.fast,
"rust-fmt-clippy": plan.rust,
"rust-test": plan.rust,
}
: mode === "agents"
? {
"fast-checks": plan.fast,
"agent-checks": plan.agents,
"agent-java": plan.agent_java,
"agent-go": plan.agent_go_changed,
"agent-rust": plan.agent_rust_changed,
"agent-integration": plan.agent_integration_changed,
}
: mode === "frontend"
? {
"frontend-checks": needs.changes.outputs.frontend === "true",
"frontend-typecheck": needs.changes.outputs.frontend === "true",
"frontend-test": needs.changes.outputs.frontend === "true",
}
: mode === "all"
? {
rust: true,
agents: true,
"fast-checks": plan.fast,
...Object.fromEntries(Object.entries(routedJobs).map(([job, output]) => [job, needs.changes.outputs[output] === "true"])),
}
: null;
if (!expected) return [`unknown gate: ${mode}`];
return Object.entries(expected).flatMap(([job, required]) => {
const result = needs[job]?.result;
+4 -2
View File
@@ -3,11 +3,13 @@ import path from "node:path";
import { pathToFileURL } from "node:url";
export function lockfileManifests(plan) {
return [...(plan.rust ? ["Cargo.toml"] : []),
return [
...(plan.rust ? ["Cargo.toml"] : []),
...plan.agent_rust.include.map(({ driver }) => {
if (!["duckdb", "tdengine"].includes(driver)) throw new Error(`Unknown native Rust driver: ${driver}`);
return `agents/drivers/${driver}/Cargo.toml`;
})];
}),
];
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
+54 -40
View File
@@ -16,16 +16,7 @@ const win7InfrastructureFiles = new Set([
"src-tauri/tauri.webview2-win7-fixed.conf.json",
"src-tauri/build.rs",
]);
const win7InfrastructurePrefixes = [
".cargo/",
"rust-toolchain",
".github/fixtures/win7-",
"src-tauri/windows/nsis/",
"src-tauri/src/commands/update",
"crates/dbx-core/src/host/update",
"crates/dbx-platform/",
"vendor/",
];
const win7InfrastructurePrefixes = [".cargo/", "rust-toolchain", ".github/fixtures/win7-", "src-tauri/windows/nsis/", "src-tauri/src/commands/update", "crates/dbx-core/src/host/update", "crates/dbx-platform/", "vendor/"];
function expandAffectedPackages(packages, members, affected) {
let expanded;
@@ -44,17 +35,19 @@ export function changedPaths(base, root) {
if (!base || /^0+$/.test(base)) return null;
execFileSync("git", ["rev-parse", "--verify", `${base}^{commit}`], { cwd: root, stdio: "pipe" });
return execFileSync("git", ["diff", "--no-renames", "--name-only", "-z", base, "HEAD", "--"], {
cwd: root, encoding: "utf8", maxBuffer: 16 * 1024 * 1024,
}).split("\0").filter(Boolean);
cwd: root,
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024,
})
.split("\0")
.filter(Boolean);
}
export function knownJavaDrivers(root, nativeDrivers) {
try {
const driversRoot = path.join(root, "agents/drivers");
return readdirSync(driversRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory()
&& existsSync(path.join(driversRoot, entry.name, "build.gradle"))
&& !nativeDrivers.includes(entry.name))
.filter((entry) => entry.isDirectory() && existsSync(path.join(driversRoot, entry.name, "build.gradle")) && !nativeDrivers.includes(entry.name))
.map((entry) => entry.name)
.sort();
} catch {
@@ -63,9 +56,13 @@ export function knownJavaDrivers(root, nativeDrivers) {
}
export function cargoMetadata(root, execute = execFileSync) {
return JSON.parse(execute("cargo", ["+stable", "metadata", "--locked", "--offline", "--no-deps", "--format-version", "1"], {
cwd: root, encoding: "utf8", maxBuffer: 16 * 1024 * 1024,
}));
return JSON.parse(
execute("cargo", ["+stable", "metadata", "--locked", "--offline", "--no-deps", "--format-version", "1"], {
cwd: root,
encoding: "utf8",
maxBuffer: 16 * 1024 * 1024,
}),
);
}
export function planCi({ files, metadata, root, eventName = "pull_request", rustChanged = false, agentsChanged = false, javaDrivers }) {
@@ -75,8 +72,7 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust
const members = new Map(packages.map((pkg) => [pkg.name, pkg]));
const packagePaths = packages.map((pkg) => [pkg.name, `${path.relative(root, path.dirname(pkg.manifest_path)).split(path.sep).join("/")}/`]);
const ciChanged = unknownDiff || files.some((file) => /^\.github\/(?:workflows\/ci(?:[.-]|\/)|scripts\/ci-|actions\/ci-)/.test(file));
const sharedRust = ciChanged || files.some((file) => /^(?:Cargo\.(?:toml|lock)$|rust-toolchain|\.cargo\/|vendor\/)/.test(file)
|| /^(?:crates\/[^/]+|src-tauri)\/Cargo\.toml$/.test(file));
const sharedRust = ciChanged || files.some((file) => /^(?:Cargo\.(?:toml|lock)$|rust-toolchain|\.cargo\/|vendor\/)/.test(file) || /^(?:crates\/[^/]+|src-tauri)\/Cargo\.toml$/.test(file));
const affected = new Set();
let unknownRust = false;
for (const file of files) {
@@ -94,11 +90,8 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust
expandAffectedPackages(packages, members, win7Affected);
if (full) for (const pkg of packages) affected.add(pkg.name);
expandAffectedPackages(packages, members, affected);
const win7InfrastructureChanged = unknownDiff || files.some((file) => win7InfrastructureFiles.has(file)
|| win7InfrastructurePrefixes.some((prefix) => file.startsWith(prefix))
|| /^src-tauri\/tauri.*\.json$/.test(file));
const win7DependencyChanged = files.includes("Cargo.toml") || files.includes("Cargo.lock")
|| files.some((file) => /^(?:crates\/[^/]+|src-tauri)\/Cargo\.toml$/.test(file));
const win7InfrastructureChanged = unknownDiff || files.some((file) => win7InfrastructureFiles.has(file) || win7InfrastructurePrefixes.some((prefix) => file.startsWith(prefix)) || /^src-tauri\/tauri.*\.json$/.test(file));
const win7DependencyChanged = files.includes("Cargo.toml") || files.includes("Cargo.lock") || files.some((file) => /^(?:crates\/[^/]+|src-tauri)\/Cargo\.toml$/.test(file));
const windowsWin7Reasons = {
infrastructure: win7InfrastructureChanged,
dependency_input: win7DependencyChanged,
@@ -107,20 +100,24 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust
unknown_member: rust && unknownMember,
};
const windowsWin7Candidate = Object.values(windowsWin7Reasons).some(Boolean);
const rustMatrix = !rust ? [] : full ? [{ group: "workspace" }] : Object.entries(rustGroups)
.filter(([, names]) => names.some((name) => affected.has(name)))
.map(([group]) => ({ group }));
const rustMatrix = !rust
? []
: full
? [{ group: "workspace" }]
: Object.entries(rustGroups)
.filter(([, names]) => names.some((name) => affected.has(name)))
.map(([group]) => ({ group }));
const nativeDrivers = [...goAgents.map((agent) => agent.driver), ...rustAgents];
const jdbcDrivers = javaDrivers ?? knownJavaDrivers(root, nativeDrivers);
const nativeChanges = new Set(nativeDrivers.filter((driver) => files.some((file) => file.startsWith(`agents/drivers/${driver}/`))));
const javaDriverChanges = jdbcDrivers.some((driver) => files.some((file) => file.startsWith(`agents/drivers/${driver}/`)));
// Driver-scoped JDBC/Go/Rust trees are not "shared Agent inputs". Unknown
// agents/drivers/<name>/ paths still fail open to full coverage below.
const sharedAgents = ciChanged || sharedRust || files.some((file) => file.startsWith("agents/")
&& !nativeDrivers.some((driver) => file.startsWith(`agents/drivers/${driver}/`))
&& !jdbcDrivers.some((driver) => file.startsWith(`agents/drivers/${driver}/`)))
|| files.some((file) => file.startsWith(".github/scripts/bump-agent-versions.") || file === ".github/workflows/agents-release.yml"
|| file === "crates/dbx-driver-agent/assets/agent-protocol-v2.json");
const sharedAgents =
ciChanged ||
sharedRust ||
files.some((file) => file.startsWith("agents/") && !nativeDrivers.some((driver) => file.startsWith(`agents/drivers/${driver}/`)) && !jdbcDrivers.some((driver) => file.startsWith(`agents/drivers/${driver}/`))) ||
files.some((file) => file.startsWith(".github/scripts/bump-agent-versions.") || file === ".github/workflows/agents-release.yml" || file === "crates/dbx-driver-agent/assets/agent-protocol-v2.json");
const allAgents = sharedAgents || (agentsChanged && nativeChanges.size === 0 && !javaDriverChanges);
if (allAgents) {
for (const driver of nativeDrivers) nativeChanges.add(driver);
@@ -133,11 +130,19 @@ export function planCi({ files, metadata, root, eventName = "pull_request", rust
const agents = agentsChanged || sharedAgents || nativeChanges.size > 0 || javaDriverChanges;
return {
contributor_snapshot_only: files.length > 0 && files.every((file) => file === "docs/data/contributors.json"),
rust, rust_full: full, rust_matrix: { include: rustMatrix }, rust_groups_known: !unknownMember,
rust,
rust_full: full,
rust_matrix: { include: rustMatrix },
rust_groups_known: !unknownMember,
affected_packages: [...affected].sort(),
agents, agent_java: java, agent_go: { include: goMatrix }, agent_rust: { include: nativeRustMatrix },
agent_integration: { include: liveMatrix }, agent_go_changed: goMatrix.length > 0,
agent_rust_changed: nativeRustMatrix.length > 0, agent_integration_changed: liveMatrix.length > 0,
agents,
agent_java: java,
agent_go: { include: goMatrix },
agent_rust: { include: nativeRustMatrix },
agent_integration: { include: liveMatrix },
agent_go_changed: goMatrix.length > 0,
agent_rust_changed: nativeRustMatrix.length > 0,
agent_integration_changed: liveMatrix.length > 0,
duckdb_changed: nativeChanges.has("duckdb"),
duckdb_windows: sharedRust || files.some((file) => file.startsWith("agents/drivers/duckdb/")),
windows_standard: windowsWin7Candidate,
@@ -153,12 +158,21 @@ if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.ar
const root = process.cwd();
const metadata = cargoMetadata(root);
const plan = planCi({
files: changedPaths(process.env.BASE_SHA, root), metadata, root, eventName: process.env.GITHUB_EVENT_NAME,
rustChanged: process.env.RUST_CHANGED === "true", agentsChanged: process.env.AGENTS_CHANGED === "true",
files: changedPaths(process.env.BASE_SHA, root),
metadata,
root,
eventName: process.env.GITHUB_EVENT_NAME,
rustChanged: process.env.RUST_CHANGED === "true",
agentsChanged: process.env.AGENTS_CHANGED === "true",
});
if (process.env.GITHUB_OUTPUT) {
const outputs = { ...plan, plan };
appendFileSync(process.env.GITHUB_OUTPUT, Object.entries(outputs).map(([name, value]) => `${name}=${JSON.stringify(value)}\n`).join(""));
appendFileSync(
process.env.GITHUB_OUTPUT,
Object.entries(outputs)
.map(([name, value]) => `${name}=${JSON.stringify(value)}\n`)
.join(""),
);
}
console.log(JSON.stringify(plan, null, 2));
}
+89 -47
View File
@@ -12,11 +12,16 @@ import { assertCoverage, parseCoverage } from "./ci-rust-coverage.mjs";
const root = "/fixture";
const graph = {
"dbx-types": [], "dbx-platform": [], "dbx-formats": [], "dbx-sql-core": ["dbx-types"],
"dbx-sql-dialect": ["dbx-types"], "dbx-sql-data": ["dbx-sql-core", "dbx-sql-dialect", "dbx-types"],
"dbx-types": [],
"dbx-platform": [],
"dbx-formats": [],
"dbx-sql-core": ["dbx-types"],
"dbx-sql-dialect": ["dbx-types"],
"dbx-sql-data": ["dbx-sql-core", "dbx-sql-dialect", "dbx-types"],
"dbx-sql-schema": ["dbx-sql-core", "dbx-sql-dialect", "dbx-types"],
"dbx-sql": ["dbx-sql-core", "dbx-sql-data", "dbx-sql-dialect", "dbx-sql-schema", "dbx-types"],
"dbx-ai-provider": ["dbx-platform"], "dbx-plugin-runtime": ["dbx-platform", "dbx-types"],
"dbx-ai-provider": ["dbx-platform"],
"dbx-plugin-runtime": ["dbx-platform", "dbx-types"],
"dbx-driver-support": ["dbx-platform", "dbx-types"],
"dbx-driver-agent": ["dbx-platform", "dbx-sql-core", "dbx-types"],
"dbx-driver-elasticsearch": ["dbx-driver-support", "dbx-types"],
@@ -25,17 +30,22 @@ const graph = {
"dbx-driver-postgres": ["dbx-driver-support", "dbx-sql-core", "dbx-types"],
"dbx-driver-redis": ["dbx-driver-support", "dbx-types"],
"dbx-driver-sqlserver": ["dbx-driver-support", "dbx-sql-core", "dbx-sql-data", "dbx-types"],
"dbx-sqlite-worker": [], "dbx-drivers": ["dbx-driver-agent", "dbx-driver-elasticsearch", "dbx-driver-mongodb",
"dbx-driver-mysql", "dbx-driver-postgres", "dbx-driver-redis", "dbx-driver-sqlserver", "dbx-driver-support",
"dbx-platform", "dbx-sql-core", "dbx-sql-data", "dbx-sql-dialect", "dbx-types", "dbx-sqlite-worker"],
"dbx-sqlite-worker": [],
"dbx-drivers": ["dbx-driver-agent", "dbx-driver-elasticsearch", "dbx-driver-mongodb", "dbx-driver-mysql", "dbx-driver-postgres", "dbx-driver-redis", "dbx-driver-sqlserver", "dbx-driver-support", "dbx-platform", "dbx-sql-core", "dbx-sql-data", "dbx-sql-dialect", "dbx-types", "dbx-sqlite-worker"],
"dbx-core": ["dbx-drivers", "dbx-sql", "dbx-types", "dbx-platform", "dbx-ai-provider", "dbx-plugin-runtime", "dbx-formats"],
"dbx-mcp": ["dbx-core"], "dbx-cli": ["dbx-core", "dbx-mcp"], "dbx-web": ["dbx-core", "dbx-mcp"], "dbx": ["dbx-core", "dbx-mcp", "dbx-tauri-consul", "dbx-tauri-schema"],
"dbx-tauri-consul": ["dbx-core"], "dbx-tauri-schema": ["dbx-core"],
"dbx-mcp": ["dbx-core"],
"dbx-cli": ["dbx-core", "dbx-mcp"],
"dbx-web": ["dbx-core", "dbx-mcp"],
dbx: ["dbx-core", "dbx-mcp", "dbx-tauri-consul", "dbx-tauri-schema"],
"dbx-tauri-consul": ["dbx-core"],
"dbx-tauri-schema": ["dbx-core"],
};
const metadata = {
workspace_members: Object.keys(graph),
packages: Object.entries(graph).map(([name, dependencies]) => ({
id: name, name, manifest_path: `${root}/${name === "dbx" ? "src-tauri" : `crates/${name}`}/Cargo.toml`,
id: name,
name,
manifest_path: `${root}/${name === "dbx" ? "src-tauri" : `crates/${name}`}/Cargo.toml`,
dependencies: dependencies.map((dependency) => ({ name: dependency })),
})),
};
@@ -53,17 +63,22 @@ test("the planner uses the runner stable Cargo only for metadata", () => {
assert.deepEqual(invocation.args, ["+stable", "metadata", "--locked", "--offline", "--no-deps", "--format-version", "1"]);
assert.equal(invocation.options.cwd, "/workspace");
});
const routedJobs = { frontend: "frontend", packages: "packages", "github-scripts": "github_scripts",
"windows-standard-check": "windows_standard", "windows-win7-bundle": "windows_win7_bundle",
"duckdb-windows-driver": "duckdb_windows", jdbc: "jdbc", "offline-jdbc-release": "offline_jdbc", "nix-packaging": "nix" };
const routedJobs = {
frontend: "frontend",
packages: "packages",
"github-scripts": "github_scripts",
"windows-standard-check": "windows_standard",
"windows-win7-bundle": "windows_win7_bundle",
"duckdb-windows-driver": "duckdb_windows",
jdbc: "jdbc",
"offline-jdbc-release": "offline_jdbc",
"nix-packaging": "nix",
};
test("only an exact contributor snapshot diff skips desktop frontend validation", () => {
for (const eventName of ["pull_request", "push"]) {
assert.equal(plan(["docs/data/contributors.json"], { eventName }).contributor_snapshot_only, true);
for (const files of [null, [], ["docs/lib/contributorActivity.test.ts"],
["docs/data/contributors.json", "apps/desktop/src/main.ts"],
["docs/data/contributors.json", "docs/lib/contributorActivity.ts"],
["docs/data/contributors.json", "Cargo.lock"]]) {
for (const files of [null, [], ["docs/lib/contributorActivity.test.ts"], ["docs/data/contributors.json", "apps/desktop/src/main.ts"], ["docs/data/contributors.json", "docs/lib/contributorActivity.ts"], ["docs/data/contributors.json", "Cargo.lock"]]) {
assert.equal(plan(files, { eventName }).contributor_snapshot_only, false);
}
}
@@ -97,9 +112,16 @@ test("Win7 candidate follows the desktop package dependency graph", () => {
});
test("ordinary desktop Rust changes keep Windows compilation without full Win7 packaging", () => {
for (const file of ["crates/dbx-core/src/query/mod.rs", "crates/dbx-core/tests/runtime_diagnostic_history.rs",
"crates/dbx-drivers/src/lib.rs", "crates/dbx-driver-postgres/src/postgres.rs", "crates/dbx-driver-mysql/tests/query.rs",
"src-tauri/src/commands/query.rs", "crates/dbx-tauri-consul/src/lib.rs", "crates/dbx-tauri-schema/src/lib.rs"]) {
for (const file of [
"crates/dbx-core/src/query/mod.rs",
"crates/dbx-core/tests/runtime_diagnostic_history.rs",
"crates/dbx-drivers/src/lib.rs",
"crates/dbx-driver-postgres/src/postgres.rs",
"crates/dbx-driver-mysql/tests/query.rs",
"src-tauri/src/commands/query.rs",
"crates/dbx-tauri-consul/src/lib.rs",
"crates/dbx-tauri-schema/src/lib.rs",
]) {
const result = plan([file]);
assert.equal(result.windows_standard, true, file);
assert.equal(result.windows_win7_bundle, false, file);
@@ -112,12 +134,7 @@ test("ordinary desktop Rust changes keep Windows compilation without full Win7 p
});
test("old Win7 Rust paths remain candidates through the dependency graph", () => {
for (const file of [
"src-tauri/src/commands/update.rs",
"crates/dbx-core/src/host/update.rs",
"crates/dbx-driver-postgres/src/postgres.rs",
"crates/dbx-platform/src/lib.rs",
]) {
for (const file of ["src-tauri/src/commands/update.rs", "crates/dbx-core/src/host/update.rs", "crates/dbx-driver-postgres/src/postgres.rs", "crates/dbx-platform/src/lib.rs"]) {
assert.equal(plan([file]).windows_win7_candidate, true, file);
}
});
@@ -215,8 +232,7 @@ test("code generation and test-only dependencies participate in impact analysis"
assert.ok(plan(["crates/dbx-formats/src/lib.rs"], { metadata: fixture }).affected_packages.includes("dbx-types"));
});
for (const file of ["Cargo.toml", "Cargo.lock", ".cargo/config.toml", "rust-toolchain.toml", "vendor/wry/src/lib.rs",
"crates/dbx-sql/Cargo.toml", ".github/workflows/ci.yml", ".github/scripts/ci-plan.mjs", ".github/scripts/ci-gate.test.mjs"]) {
for (const file of ["Cargo.toml", "Cargo.lock", ".cargo/config.toml", "rust-toolchain.toml", "vendor/wry/src/lib.rs", "crates/dbx-sql/Cargo.toml", ".github/workflows/ci.yml", ".github/scripts/ci-plan.mjs", ".github/scripts/ci-gate.test.mjs"]) {
test(`shared input ${file} retains full workspace and Agent coverage`, () => {
const result = plan([file]);
assert.deepEqual(groups(result), ["workspace"]);
@@ -275,8 +291,7 @@ test("known JDBC driver changes only select Java agent tests", () => {
});
test("shared Agent inputs and unknown native modules never silently lose coverage", () => {
for (const file of ["agents/common/src/main/java/Protocol.java", "agents/scripts/validate_agents.py", "agents/build.gradle",
"agents/drivers/new-driver/main.go", "crates/dbx-driver-agent/assets/agent-protocol-v2.json", ".github/workflows/agents-release.yml"]) {
for (const file of ["agents/common/src/main/java/Protocol.java", "agents/scripts/validate_agents.py", "agents/build.gradle", "agents/drivers/new-driver/main.go", "crates/dbx-driver-agent/assets/agent-protocol-v2.json", ".github/workflows/agents-release.yml"]) {
const result = plan([file]);
assert.equal(result.agent_go.include.length, goAgents.length, file);
assert.equal(result.agent_integration.include.length, 16, file);
@@ -289,13 +304,32 @@ test("shared Agent inputs and unknown native modules never silently lose coverag
});
test("the independent live matrix preserves every old version and authentication case", () => {
assert.deepEqual(integrationCases.map((entry) => `${entry.scenario}:${entry.version}`), [
"zookeeper:3.4.14", "zookeeper:3.5.5", "zookeeper:3.7.0", "zookeeper:3.9.5", "zookeeper-sasl:3.7.0",
"rocketmq:4.9.8", "rocketmq:5.3.1", "tdengine:2.4.0.14", "tdengine:2.6.0.34", "tdengine:3.0.7.1",
"tdengine:3.3.6.13", "tdengine:3.4.2.2", "cassandra:3.11.19", "cassandra:5.0.6", "rabbitmq:3.13", "rabbitmq:4.3",
]);
assert.deepEqual(
integrationCases.map((entry) => `${entry.scenario}:${entry.version}`),
[
"zookeeper:3.4.14",
"zookeeper:3.5.5",
"zookeeper:3.7.0",
"zookeeper:3.9.5",
"zookeeper-sasl:3.7.0",
"rocketmq:4.9.8",
"rocketmq:5.3.1",
"tdengine:2.4.0.14",
"tdengine:2.6.0.34",
"tdengine:3.0.7.1",
"tdengine:3.3.6.13",
"tdengine:3.4.2.2",
"cassandra:3.11.19",
"cassandra:5.0.6",
"rabbitmq:3.13",
"rabbitmq:4.3",
],
);
assert.equal(integrationCases.find((entry) => entry.version === "3.4.2.2").image, "tdengine/tsdb:3.4.2.2");
assert.deepEqual(goAgents.filter((entry) => entry.race).map((entry) => entry.driver), ["rocketmq", "zookeeper"]);
assert.deepEqual(
goAgents.filter((entry) => entry.race).map((entry) => entry.driver),
["rocketmq", "zookeeper"],
);
});
test("test groups partition every current workspace package exactly once", () => {
@@ -309,8 +343,7 @@ test("Rust groups cover the actual workspace metadata, including extracted Tauri
const actual = cargoMetadata(repository);
const members = actual.packages.filter((pkg) => actual.workspace_members.includes(pkg.id)).map((pkg) => pkg.name);
assert.deepEqual(Object.values(rustGroups).flat().toSorted(), members.toSorted());
const result = planCi({ files: ["crates/dbx-core/tests/runtime_diagnostic_history.rs", "crates/dbx-web/src/routes/transfer.rs"],
metadata: actual, root: repository });
const result = planCi({ files: ["crates/dbx-core/tests/runtime_diagnostic_history.rs", "crates/dbx-web/src/routes/transfer.rs"], metadata: actual, root: repository });
assert.equal(result.rust_groups_known, true);
assert.equal(result.rust_full, false);
assert.deepEqual(groups(result), ["application"]);
@@ -361,13 +394,17 @@ test("nextest, doctests and coverage select identical packages and features in e
function results(files) {
const selected = plan(files);
return { changes: { result: "success", outputs: { plan: JSON.stringify(selected) } },
return {
changes: { result: "success", outputs: { plan: JSON.stringify(selected) } },
"fast-checks": { result: selected.fast ? "success" : "skipped" },
"rust-fmt-clippy": { result: selected.rust ? "success" : "skipped" }, "rust-test": { result: selected.rust ? "success" : "skipped" },
"agent-checks": { result: selected.agents ? "success" : "skipped" }, "agent-java": { result: selected.agent_java ? "success" : "skipped" },
"rust-fmt-clippy": { result: selected.rust ? "success" : "skipped" },
"rust-test": { result: selected.rust ? "success" : "skipped" },
"agent-checks": { result: selected.agents ? "success" : "skipped" },
"agent-java": { result: selected.agent_java ? "success" : "skipped" },
"agent-go": { result: selected.agent_go_changed ? "success" : "skipped" },
"agent-rust": { result: selected.agent_rust_changed ? "success" : "skipped" },
"agent-integration": { result: selected.agent_integration_changed ? "success" : "skipped" } };
"agent-integration": { result: selected.agent_integration_changed ? "success" : "skipped" },
};
}
test("gates accept only successful selected jobs and intentionally skipped unselected jobs", () => {
@@ -410,17 +447,23 @@ test("git diff routing includes both sides of renames, deleted files, and unusua
const directory = mkdtempSync(path.join(tmpdir(), "dbx-ci-plan-"));
const git = (...args) => execFileSync("git", args, { cwd: directory, encoding: "utf8", stdio: "pipe" }).trim();
try {
git("init"); git("config", "user.email", "fixture@example.invalid"); git("config", "user.name", "CI Fixture");
git("init");
git("config", "user.email", "fixture@example.invalid");
git("config", "user.name", "CI Fixture");
writeFileSync(path.join(directory, "old.rs"), "unchanged source\n");
writeFileSync(path.join(directory, "deleted.rs"), "gone\n");
git("add", "."); git("-c", "core.hooksPath=/dev/null", "commit", "-m", "fixture baseline");
git("add", ".");
git("-c", "core.hooksPath=/dev/null", "commit", "-m", "fixture baseline");
const base = git("rev-parse", "HEAD");
git("mv", "old.rs", "renamed\nfile.rs"); git("rm", "deleted.rs");
git("mv", "old.rs", "renamed\nfile.rs");
git("rm", "deleted.rs");
git("-c", "core.hooksPath=/dev/null", "commit", "-m", "fixture change");
assert.deepEqual(changedPaths(base, directory).toSorted(), ["old.rs", "deleted.rs", "renamed\nfile.rs"].toSorted());
assert.equal(changedPaths("0".repeat(40), directory), null);
assert.throws(() => changedPaths("not-a-ref", directory));
} finally { rmSync(directory, { recursive: true, force: true }); }
} finally {
rmSync(directory, { recursive: true, force: true });
}
});
test("the final gate rejects absent routing outputs and skipped selected jobs", () => {
@@ -483,8 +526,7 @@ test("the final gate independently requires standard Windows compilation without
});
test("coverage audit rejects lost features, omitted packages, and overlapping group selection", () => {
const coverage = { workspace: Object.fromEntries(Object.keys(graph).map((name) => [name, ["fixture"]])),
...Object.fromEntries(Object.entries(rustGroups).map(([group, packages]) => [group, Object.fromEntries(packages.map((name) => [name, ["fixture"]]))])) };
const coverage = { workspace: Object.fromEntries(Object.keys(graph).map((name) => [name, ["fixture"]])), ...Object.fromEntries(Object.entries(rustGroups).map(([group, packages]) => [group, Object.fromEntries(packages.map((name) => [name, ["fixture"]]))])) };
assertCoverage(coverage);
const lost = structuredClone(coverage);
lost.foundation["dbx-types"] = [];
+11 -5
View File
@@ -6,11 +6,17 @@ import { rustGroups } from "./ci-config.mjs";
import { rustCommand } from "./ci-rust.mjs";
export function parseCoverage(output) {
return Object.fromEntries(output.trim().split("\n").filter(Boolean).map((line) => {
const [name, features] = line.split("|");
assert.ok(features !== undefined, `Invalid Cargo tree output: ${line}`);
return [name.split(" ")[0], features.split(",").filter(Boolean)];
}));
return Object.fromEntries(
output
.trim()
.split("\n")
.filter(Boolean)
.map((line) => {
const [name, features] = line.split("|");
assert.ok(features !== undefined, `Invalid Cargo tree output: ${line}`);
return [name.split(" ")[0], features.split(",").filter(Boolean)];
}),
);
}
export function assertCoverage(coverage) {
+30 -13
View File
@@ -4,26 +4,43 @@ import { pathToFileURL } from "node:url";
import { rustGroups } from "./ci-config.mjs";
export function rustCommand(action, group, mode) {
if (!["test", "doctest", "clippy", "tree"].includes(action) || !["full", "fast"].includes(mode)
|| (group !== "workspace" && !Object.hasOwn(rustGroups, group)) || (action === "clippy" && group !== "workspace")) {
if (!["test", "doctest", "clippy", "tree"].includes(action) || !["full", "fast"].includes(mode) || (group !== "workspace" && !Object.hasOwn(rustGroups, group)) || (action === "clippy" && group !== "workspace")) {
throw new Error(`Unknown Rust CI configuration: ${action}/${group}/${mode}`);
}
const capabilityFeatures = ["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-sqlcipher", "sqlserver-native-attention"];
if (mode === "full") capabilityFeatures.push("system-fonts");
const appFeatures = ["dbx", "dbx-core", "dbx-web"].flatMap((name) => capabilityFeatures.map((feature) => `${name}/${feature}`));
const foundationFeatures = ["dbx-types/mq-admin", "dbx-types/openapi", "dbx-sql/duckdb-sidecar", "dbx-sql/openapi",
"dbx-sql-data/openapi", "dbx-sql-schema/duckdb-sidecar",
"dbx-platform/downloads", "dbx-platform/host-prompts", "dbx-platform/test-support", "dbx-plugin-runtime/default", "dbx-plugin-runtime/test-support"];
const features = group === "foundation" ? foundationFeatures : group === "drivers" ? [
...["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-bundled", "sqlite-sqlcipher", "sqlserver-native-attention", "test-support"].map((feature) => `dbx-drivers/${feature}`),
"dbx-driver-agent/test-support", "dbx-driver-mysql/test-support", "dbx-driver-redis/test-support",
"dbx-driver-support/test-support",
"dbx-sqlite-worker/runtime", "dbx-types/openapi", "dbx-sql-data/openapi",
] : appFeatures;
const foundationFeatures = [
"dbx-types/mq-admin",
"dbx-types/openapi",
"dbx-sql/duckdb-sidecar",
"dbx-sql/openapi",
"dbx-sql-data/openapi",
"dbx-sql-schema/duckdb-sidecar",
"dbx-platform/downloads",
"dbx-platform/host-prompts",
"dbx-platform/test-support",
"dbx-plugin-runtime/default",
"dbx-plugin-runtime/test-support",
];
const features =
group === "foundation"
? foundationFeatures
: group === "drivers"
? [
...["duckdb-sidecar", "dynamodb", "mq-admin", "sqlite-bundled", "sqlite-sqlcipher", "sqlserver-native-attention", "test-support"].map((feature) => `dbx-drivers/${feature}`),
"dbx-driver-agent/test-support",
"dbx-driver-mysql/test-support",
"dbx-driver-redis/test-support",
"dbx-driver-support/test-support",
"dbx-sqlite-worker/runtime",
"dbx-types/openapi",
"dbx-sql-data/openapi",
]
: appFeatures;
const packages = group === "workspace" ? ["--workspace"] : rustGroups[group].flatMap((name) => ["--package", name]);
const command = action === "test" ? ["nextest", "run", "--no-fail-fast"] : action === "doctest" ? ["test", "--doc"] : [action];
return [...command, ...packages, "--locked", ...(["test", "clippy"].includes(action) ? ["--timings"] : []), ...(action === "clippy" ? ["--all-targets"] : []),
"--no-default-features", "--features", features.join(","), ...(action === "clippy" ? ["--", "-D", "warnings"] : [])];
return [...command, ...packages, "--locked", ...(["test", "clippy"].includes(action) ? ["--timings"] : []), ...(action === "clippy" ? ["--all-targets"] : []), "--no-default-features", "--features", features.join(","), ...(action === "clippy" ? ["--", "-D", "warnings"] : [])];
}
if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) {
@@ -29,10 +29,13 @@ test("file timing reports sort total work and keep timing phases", () => {
}),
]);
assert.deepEqual(rows.map(({ file, totalMs, testsMs, collectMs, setupMs }) => ({ file, totalMs, testsMs, collectMs, setupMs })), [
{ file: "slow.spec.ts", totalMs: 200, testsMs: 60, collectMs: 50, setupMs: 90 },
{ file: "fast.spec.ts", totalMs: 100, testsMs: 100, collectMs: 0, setupMs: 0 },
]);
assert.deepEqual(
rows.map(({ file, totalMs, testsMs, collectMs, setupMs }) => ({ file, totalMs, testsMs, collectMs, setupMs })),
[
{ file: "slow.spec.ts", totalMs: 200, testsMs: 60, collectMs: 50, setupMs: 90 },
{ file: "fast.spec.ts", totalMs: 100, testsMs: 100, collectMs: 0, setupMs: 0 },
],
);
assert.match(consoleReport(rows, 1), /0\.20s slow\.spec\.ts/);
assert.doesNotMatch(consoleReport(rows, 1), /fast\.spec\.ts/);
assert.match(markdownReport(rows, "1\/2"), /Vitest file timings \(1\/2\)/);
+40 -28
View File
@@ -20,26 +20,27 @@ function concurrencySettings(github) {
assert.ok(block, "missing workflow concurrency");
const format = (template, ...values) => template.replace(/\{(\d+)\}/g, (_, index) => String(values[Number(index)]));
const endsWith = (search, value) => String(search).toLowerCase().endsWith(String(value).toLowerCase());
return Object.fromEntries(block.trim().split("\n").map((line) => {
const [, setting, value] = line.match(/^\s*([\w-]+):\s*(.+)$/);
const evaluate = (expression) =>
new Function("github", "format", "endsWith", `return (${expression});`)(github, format, endsWith);
const expressions = [...value.matchAll(/\$\{\{\s*(.*?)\s*\}\}/g)];
return [setting, expressions.length === 1 && expressions[0][0] === value
? evaluate(expressions[0][1]) : value.replace(/\$\{\{\s*(.*?)\s*\}\}/g, (_, expression) => evaluate(expression))];
}));
return Object.fromEntries(
block
.trim()
.split("\n")
.map((line) => {
const [, setting, value] = line.match(/^\s*([\w-]+):\s*(.+)$/);
const evaluate = (expression) => new Function("github", "format", "endsWith", `return (${expression});`)(github, format, endsWith);
const expressions = [...value.matchAll(/\$\{\{\s*(.*?)\s*\}\}/g)];
return [setting, expressions.length === 1 && expressions[0][0] === value ? evaluate(expressions[0][1]) : value.replace(/\$\{\{\s*(.*?)\s*\}\}/g, (_, expression) => evaluate(expression))];
}),
);
}
function concurrencyContext({ authorId = 101, author = "contributor", actor = author, number = 100, ref = `refs/pull/${number}/merge`, eventName = "pull_request" } = {}) {
return { workflow: "CI", repository_owner: "t8y2", actor, ref, event_name: eventName,
event: eventName === "pull_request" ? { pull_request: { number, user: { id: authorId, login: author } } } : {} };
return { workflow: "CI", repository_owner: "t8y2", actor, ref, event_name: eventName, event: eventName === "pull_request" ? { pull_request: { number, user: { id: authorId, login: author } } } : {} };
}
test("a contributor's PRs share two main CI queues split by PR number parity, regardless of trigger actor", () => {
const even = concurrencySettings(concurrencyContext());
assert.deepEqual(even, { group: "CI-pr-author-101-even", "cancel-in-progress": false, queue: "max" });
for (const context of [concurrencyContext({ number: 202 }), concurrencyContext({ actor: "t8y2" }),
concurrencyContext({ actor: "github-actions[bot]" }), concurrencyContext({ author: "renamed-contributor" })]) {
for (const context of [concurrencyContext({ number: 202 }), concurrencyContext({ actor: "t8y2" }), concurrencyContext({ actor: "github-actions[bot]" }), concurrencyContext({ author: "renamed-contributor" })]) {
assert.deepEqual(concurrencySettings(context), even);
}
for (const number of [101, 11849, 11853, 7]) {
@@ -64,8 +65,7 @@ test("main pushes retain their independent cancellation group without PR context
});
test("expanded author queues never combine max queuing with in-progress cancellation", () => {
for (const context of [concurrencyContext(), concurrencyContext({ author: "t8y2" }),
concurrencyContext({ eventName: "push", ref: "refs/heads/main" })]) {
for (const context of [concurrencyContext(), concurrencyContext({ author: "t8y2" }), concurrencyContext({ eventName: "push", ref: "refs/heads/main" })]) {
const settings = concurrencySettings(context);
assert.ok(settings.queue === "single" || settings.queue === "max");
assert.equal(settings.queue === "max" && settings["cancel-in-progress"], false);
@@ -202,7 +202,11 @@ test("S3 and secretless Rust jobs export identical compiler wrappers", (context)
const setup = job(name).match(/- name: Configure sccache\n([\s\S]*?)(?=\n - name: Rust cache)/)?.[1];
assert.ok(setup, name);
assert.doesNotMatch(setup, /^\s+if:/m);
const script = setup.match(/ run: \|\n([\s\S]*)/)[1].split("\n").map((line) => line.slice(10)).join("\n");
const script = setup
.match(/ run: \|\n([\s\S]*)/)[1]
.split("\n")
.map((line) => line.slice(10))
.join("\n");
for (const remote of [true, false]) {
const output = path.join(directory, `${name}-${remote}.env`);
const result = spawnSync("bash", ["-e", "-o", "pipefail", "-c", script], {
@@ -221,16 +225,25 @@ test("S3 and secretless Rust jobs export identical compiler wrappers", (context)
});
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout, "");
const exported = Object.fromEntries(readFileSync(output, "utf8").trim().split("\n").map((line) => {
const separator = line.indexOf("=");
return [line.slice(0, separator), line.slice(separator + 1)];
}));
assert.deepEqual(exported, {
...(remote ? backend : {}),
SCCACHE_IDLE_TIMEOUT: "0",
CC: `${compiler} cc`,
CXX: `${compiler} c++`,
}, `${name}: ${remote ? "S3" : "local"}`);
const exported = Object.fromEntries(
readFileSync(output, "utf8")
.trim()
.split("\n")
.map((line) => {
const separator = line.indexOf("=");
return [line.slice(0, separator), line.slice(separator + 1)];
}),
);
assert.deepEqual(
exported,
{
...(remote ? backend : {}),
SCCACHE_IDLE_TIMEOUT: "0",
CC: `${compiler} cc`,
CXX: `${compiler} c++`,
},
`${name}: ${remote ? "S3" : "local"}`,
);
}
}
});
@@ -323,7 +336,7 @@ test("Windows compatibility jobs cache Rust compilation without wrapping C or C+
assert.ok(win7.includes("--config .github/fixtures/win7-host-repro-config.toml"));
const hostConfig = readFileSync(new URL("../fixtures/win7-host-repro-config.toml", import.meta.url), "utf8");
assert.ok(hostConfig.includes("target-applies-to-host = false"));
assert.ok(hostConfig.includes('[host.x86_64-pc-windows-msvc]'));
assert.ok(hostConfig.includes("[host.x86_64-pc-windows-msvc]"));
assert.ok(hostConfig.includes('rustflags = ["-Clink-arg=/Brepro"]'));
assert.ok(win7.includes("--timings"));
assert.ok(win7.includes("name: DBX-win7-cargo-timings"));
@@ -366,8 +379,7 @@ test("path filtering and the planner use the exact test merge first parent", ()
assert.ok(changes.includes("node .github/scripts/ci-plan.mjs"));
assert.doesNotMatch(changes, /fetch-depth:\s*0/);
assert.doesNotMatch(changes, /dtolnay\/rust-toolchain/);
for (const flag of ["rust", "rust_full", "rust_matrix", "agents", "agent_go", "agent_rust", "agent_integration",
"windows_standard", "windows_win7_bundle", "windows_win7_candidate", "windows_win7_affected_packages", "windows_win7_reasons", "plan"]) {
for (const flag of ["rust", "rust_full", "rust_matrix", "agents", "agent_go", "agent_rust", "agent_integration", "windows_standard", "windows_win7_bundle", "windows_win7_candidate", "windows_win7_affected_packages", "windows_win7_reasons", "plan"]) {
assert.ok(changes.includes(`steps.plan.outputs.${flag}`));
}
assert.doesNotMatch(changes, /steps\.filter\.outputs\.windows_win7_bundle|github\.event\.pull_request\.base\.sha|WIN7_CURRENT/);
+1 -3
View File
@@ -33,9 +33,7 @@ function parseArgs(argv) {
}
if (!args.token) throw new Error("CNB_TOKEN is required.");
if (!args.currentTag || !args.tagPattern || !Number.isInteger(args.retain) || args.retain < 1) {
throw new Error(
"Usage: cleanup-cnb-releases.mjs --current-tag <tag> --tag-pattern <regex> --retain <count> [--apply]",
);
throw new Error("Usage: cleanup-cnb-releases.mjs --current-tag <tag> --tag-pattern <regex> --retain <count> [--apply]");
}
try {
new RegExp(args.tagPattern);
+5 -12
View File
@@ -17,18 +17,11 @@ const appReleases = [
];
test("retention only removes old releases from the selected family", () => {
const plan = planReleaseRetention(
[
release("agents-latest", "agents-latest", "2026-01-01T00:00:00Z"),
release("agent", "agents-v0.2.67", "2026-07-29T05:35:19Z"),
...appReleases.toReversed(),
],
{
currentTag: "v0.5.69",
tagPattern: "^v[0-9]+[.][0-9]+[.][0-9]+$",
retain: 5,
},
);
const plan = planReleaseRetention([release("agents-latest", "agents-latest", "2026-01-01T00:00:00Z"), release("agent", "agents-v0.2.67", "2026-07-29T05:35:19Z"), ...appReleases.toReversed()], {
currentTag: "v0.5.69",
tagPattern: "^v[0-9]+[.][0-9]+[.][0-9]+$",
retain: 5,
});
assert.equal(plan.skipped, "");
assert.deepEqual(
+4 -41
View File
@@ -1,36 +1,8 @@
import fs from "node:fs";
const extraAliases = {
mysql: [
"mariadb",
"percona",
"tidb",
"greatsql",
"oceanbase mysql",
"oceanbase mysql mode",
"oceanbase mysql 模式",
"tdsql mysql",
"tdsql-mysql",
"polardb mysql",
"polardb for mysql",
"polardb-x",
"polardbx",
],
postgres: [
"postgresql",
"postgres",
"pgsql",
"pg",
"hologres",
"cloudberry",
"apache cloudberry",
"cockroachdb",
"cockroach db",
"polardb postgresql",
"polardb for postgresql",
"polardb postgres",
"polardb for postgres",
],
mysql: ["mariadb", "percona", "tidb", "greatsql", "oceanbase mysql", "oceanbase mysql mode", "oceanbase mysql 模式", "tdsql mysql", "tdsql-mysql", "polardb mysql", "polardb for mysql", "polardb-x", "polardbx"],
postgres: ["postgresql", "postgres", "pgsql", "pg", "hologres", "cloudberry", "apache cloudberry", "cockroachdb", "cockroach db", "polardb postgresql", "polardb for postgresql", "polardb postgres", "polardb for postgres"],
sqlite: ["sqlite3", "sql lite"],
clickhouse: ["click house", "ch"],
sqlserver: ["sql server", "mssql", "microsoft sql server", "sqlservice"],
@@ -104,9 +76,7 @@ const catalogEntries = [
}),
...supplementalDrivers.filter((driver) => !manifestDbTypes.has(driver.dbType)),
];
const duplicateDbTypes = catalogEntries
.map((driver) => driver.dbType)
.filter((dbType, index, values) => values.indexOf(dbType) !== index);
const duplicateDbTypes = catalogEntries.map((driver) => driver.dbType).filter((dbType, index, values) => values.indexOf(dbType) !== index);
if (duplicateDbTypes.length > 0) {
throw new Error(`Duplicate database issue catalog entries: ${[...new Set(duplicateDbTypes)].join(", ")}`);
@@ -115,12 +85,5 @@ if (duplicateDbTypes.length > 0) {
export const databaseIssueDrivers = catalogEntries.map((driver) => ({
dbType: driver.dbType,
label: driver.label,
aliases: [
...new Set([
driver.dbType,
driver.label,
...(driver.aliases || []),
...(extraAliases[driver.dbType] || []),
]),
],
aliases: [...new Set([driver.dbType, driver.label, ...(driver.aliases || []), ...(extraAliases[driver.dbType] || [])])],
}));
+43 -18
View File
@@ -21,20 +21,32 @@ function fixture(context, platform = "darwin-arm64") {
const binary = join(source, "package/bin/dbx-mcp");
writeFileSync(binary, "#!/bin/sh\nprintf 'dbx-mcp 0.4.96\\n'\n", { mode: 0o755 });
copyFileSync(binary, join(source, "dbx-mcp"));
for (const [archive, member] of [["npm.tgz", "package/bin/dbx-mcp"], ["github.tar.gz", "dbx-mcp"]]) {
for (const [archive, member] of [
["npm.tgz", "package/bin/dbx-mcp"],
["github.tar.gz", "dbx-mcp"],
]) {
const result = spawnSync("tar", ["-czf", join(root, archive), "-C", source, member]);
assert.equal(result.status, 0, result.stderr.toString());
}
const integrity = "sha512-" + createHash("sha512").update(readFileSync(join(root, "npm.tgz"))).digest("base64");
const checksum = createHash("sha256").update(readFileSync(join(root, "github.tar.gz"))).digest("hex");
const integrity =
"sha512-" +
createHash("sha512")
.update(readFileSync(join(root, "npm.tgz")))
.digest("base64");
const checksum = createHash("sha256")
.update(readFileSync(join(root, "github.tar.gz")))
.digest("hex");
writeFileSync(join(root, "latest.json"), JSON.stringify({ description: 'ignore "version":"99.0.0"', version: "0.4.96" }));
writeFileSync(join(root, "metadata.json"), JSON.stringify({ name: "test", version: "0.4.96", dist: { integrity } }));
writeFileSync(join(root, "refs.json"), JSON.stringify([
{ ref: "refs/tags/packages-v0.4.9", object: {} },
{ ref: "refs/tags/packages-v0.4.96", object: {} },
{ ref: "refs/tags/packages-v1.0.0-beta.1", object: {} },
{ ref: "refs/tags/packages-v0.4.10", object: {} },
]));
writeFileSync(
join(root, "refs.json"),
JSON.stringify([
{ ref: "refs/tags/packages-v0.4.9", object: {} },
{ ref: "refs/tags/packages-v0.4.96", object: {} },
{ ref: "refs/tags/packages-v1.0.0-beta.1", object: {} },
{ ref: "refs/tags/packages-v0.4.10", object: {} },
]),
);
writeFileSync(join(root, "SHA256SUMS"), `${checksum} dbx-mcp-${platform}.tar.gz\n`);
const routes = {};
for (const registry of ["https://registry.npmjs.org", "https://registry.npmmirror.com"]) {
@@ -46,7 +58,9 @@ function fixture(context, platform = "darwin-arm64") {
routes["https://github.com/t8y2/dbx/releases/download/packages-v0.4.96/SHA256SUMS"] = "SHA256SUMS";
routes[`https://github.com/t8y2/dbx/releases/download/packages-v0.4.96/dbx-mcp-${platform}.tar.gz`] = "github.tar.gz";
writeFileSync(join(root, "routes.json"), JSON.stringify(routes));
writeFileSync(join(mocks, "curl"), `#!${process.execPath}
writeFileSync(
join(mocks, "curl"),
`#!${process.execPath}
const fs = require('node:fs');
const path = require('node:path');
const args = process.argv.slice(2);
@@ -57,9 +71,13 @@ if (process.env.FAIL_URL && new RegExp(process.env.FAIL_URL).test(url)) process.
const routes = JSON.parse(fs.readFileSync(path.join(root, 'routes.json')));
if (!routes[url]) process.exit(22);
fs.copyFileSync(path.join(root, routes[url]), args[args.indexOf('-o') + 1]);
`, { mode: 0o755 });
`,
{ mode: 0o755 },
);
writeFileSync(join(mocks, "uname"), `#!/bin/sh\ncase "$1" in -s) echo '${platform.startsWith("darwin") ? "Darwin" : "Linux"}';; -m) echo '${platform.includes("arm64") ? "aarch64" : "x86_64"}';; esac\n`, { mode: 0o755 });
writeFileSync(join(mocks, "codesign"), `#!${process.execPath}
writeFileSync(
join(mocks, "codesign"),
`#!${process.execPath}
const args = process.argv.slice(2);
const mode = process.env.SIGNATURE_MODE || 'valid';
const requirement = 'identifier "com.dbx.app.mcp" and anchor apple generic and certificate 1[field.1.2.840.113635.100.6.2.6] exists and certificate leaf[field.1.2.840.113635.100.6.1.13] exists and certificate leaf[subject.OU] = "TVDM965TDL"';
@@ -73,13 +91,17 @@ console.error('Identifier=com.dbx.app.mcp');
console.error('TeamIdentifier=TVDM965TDL');
console.error('Timestamp=' + (mode === 'no-timestamp' ? 'none' : 'Oct 3, 2026'));
console.error('designated => ' + (mode === 'hash-bound' ? 'cdhash H"' + 'ab'.repeat(20) + '"' : requirement.replaceAll('exists', '/* exists */')));
`, { mode: 0o755 });
`,
{ mode: 0o755 },
);
const installedBinary = join(home, ".dbx/bin/dbx-mcp");
const marker = join(home, ".dbx/bin/.dbx-mcp-version");
const run = (extra = {}) => spawnSync("sh", [installer], {
env: { ...process.env, HOME: home, SHELL: "/bin/zsh", ZDOTDIR: home, PATH: `${mocks}:${process.env.PATH}`, FIXTURE: root, FIXTURE_PLATFORM: platform, ...extra },
encoding: "utf8", timeout: 30_000,
});
const run = (extra = {}) =>
spawnSync("sh", [installer], {
env: { ...process.env, HOME: home, SHELL: "/bin/zsh", ZDOTDIR: home, PATH: `${mocks}:${process.env.PATH}`, FIXTURE: root, FIXTURE_PLATFORM: platform, ...extra },
encoding: "utf8",
timeout: 30_000,
});
return { root, home, mocks, binary: installedBinary, marker, run };
}
@@ -92,7 +114,10 @@ for (const platform of platforms) {
assert.equal(readFileSync(setup.marker, "utf8"), "0.4.96\n");
const json = JSON.parse(result.stdout.split("\n").find((line) => line.startsWith('{"mcpServers"')));
assert.equal(json.mcpServers.dbx.command, setup.binary);
const tomlCommand = result.stdout.split("\n").find((line) => line.startsWith("command = ")).slice(10);
const tomlCommand = result.stdout
.split("\n")
.find((line) => line.startsWith("command = "))
.slice(10);
assert.equal(JSON.parse(tomlCommand), setup.binary);
assert.deepEqual(readdirSync(join(setup.home, ".dbx/bin")).sort(), [".dbx-mcp-version", "dbx-mcp"]);
});
+9 -66
View File
@@ -63,13 +63,7 @@ export function detectIssueCommand(commentBody) {
export function findEligibleMergedPullRequest({ context, commentUser, repository }) {
return context.pullRequests
.filter(
(pullRequest) =>
pullRequest.state === "MERGED" &&
pullRequest.baseRefName === context.defaultBranch &&
pullRequest.repository?.nameWithOwner === repository &&
sameLogin(pullRequest.author?.login, commentUser),
)
.filter((pullRequest) => pullRequest.state === "MERGED" && pullRequest.baseRefName === context.defaultBranch && pullRequest.repository?.nameWithOwner === repository && sameLogin(pullRequest.author?.login, commentUser))
.sort((left, right) => (right.mergedAt || "").localeCompare(left.mergedAt || ""))[0];
}
@@ -92,10 +86,7 @@ async function handleUnclaim({ issueNumber, commentUser, client }) {
const assignee = context.assignees.find(({ login }) => sameLogin(login, commentUser));
if (!assignee) {
await client.commentIssue(
issueNumber,
`❌ @${commentUser} 你当前没有认领 #${issueNumber},无法使用 \`/unclaim\`。`,
);
await client.commentIssue(issueNumber, `❌ @${commentUser} 你当前没有认领 #${issueNumber},无法使用 \`/unclaim\`。`);
return { status: "not-assignee" };
}
@@ -116,28 +107,15 @@ async function handleClose({ issueNumber, commentUser, repository, client }) {
const pullRequest = findEligibleMergedPullRequest({ context, commentUser, repository });
if (!pullRequest) {
await client.commentIssue(
issueNumber,
`❌ @${commentUser} 暂不能关闭 #${issueNumber}:未找到由你提交、已合并到 \`${context.defaultBranch}\` 且关联此 Issue 的 PR。`,
);
await client.commentIssue(issueNumber, `❌ @${commentUser} 暂不能关闭 #${issueNumber}:未找到由你提交、已合并到 \`${context.defaultBranch}\` 且关联此 Issue 的 PR。`);
return { status: "no-merged-pull-request" };
}
await client.closeIssue(
issueNumber,
`✅ @${commentUser} 的关联 PR #${pullRequest.number} 已合并,关闭 #${issueNumber}。`,
);
await client.closeIssue(issueNumber, `✅ @${commentUser} 的关联 PR #${pullRequest.number} 已合并,关闭 #${issueNumber}。`);
return { status: "closed", pullRequest };
}
export async function handleIssueCommand({
issueNumber,
commentBody,
commentUser,
commentUserType,
repository,
client,
}) {
export async function handleIssueCommand({ issueNumber, commentBody, commentUser, commentUserType, repository, client }) {
const command = detectIssueCommand(commentBody);
if (!command) return { status: "ignored" };
if (commentUserType === "Bot") return { status: "ignored-bot" };
@@ -158,17 +136,7 @@ export function createGitHubClient({ repository, runGh = gh }) {
return {
async getIssueContext(issueNumber) {
return JSON.parse(
await runGh([
"issue",
"view",
String(issueNumber),
"--repo",
repository,
"--json",
"state,assignees",
]),
);
return JSON.parse(await runGh(["issue", "view", String(issueNumber), "--repo", repository, "--json", "state,assignees"]));
},
async getIssueCloseContext(issueNumber) {
@@ -176,18 +144,7 @@ export function createGitHubClient({ repository, runGh = gh }) {
let context;
do {
const args = [
"api",
"graphql",
"-f",
`owner=${owner}`,
"-f",
`name=${name}`,
"-F",
`number=${issueNumber}`,
"-f",
`query=${ISSUE_CLOSE_CONTEXT_QUERY}`,
];
const args = ["api", "graphql", "-f", `owner=${owner}`, "-f", `name=${name}`, "-F", `number=${issueNumber}`, "-f", `query=${ISSUE_CLOSE_CONTEXT_QUERY}`];
if (endCursor) args.push("-f", `endCursor=${endCursor}`);
const response = JSON.parse(await runGh(args));
@@ -205,11 +162,7 @@ export function createGitHubClient({ repository, runGh = gh }) {
if (!context.defaultBranch) throw new Error(`Default branch was not found for ${repository}`);
}
context.pullRequests.push(
...issue.timelineItems.nodes
.map((node) => node.source)
.filter((source) => source?.__typename === "PullRequest"),
);
context.pullRequests.push(...issue.timelineItems.nodes.map((node) => node.source).filter((source) => source?.__typename === "PullRequest"));
const { hasNextPage, endCursor: nextCursor } = issue.timelineItems.pageInfo;
endCursor = hasNextPage ? nextCursor : undefined;
@@ -231,17 +184,7 @@ export function createGitHubClient({ repository, runGh = gh }) {
},
async closeIssue(issueNumber, comment) {
await runGh([
"issue",
"close",
String(issueNumber),
"--repo",
repository,
"--reason",
"completed",
"--comment",
comment,
]);
await runGh(["issue", "close", String(issueNumber), "--repo", repository, "--reason", "completed", "--comment", comment]);
},
};
}
+8 -21
View File
@@ -1,12 +1,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createGitHubClient,
detectIssueCommand,
findEligibleMergedPullRequest,
handleIssueCommand,
} from "./issue-commands.mjs";
import { createGitHubClient, detectIssueCommand, findEligibleMergedPullRequest, handleIssueCommand } from "./issue-commands.mjs";
const repository = "t8y2/dbx";
@@ -117,13 +112,7 @@ test("findEligibleMergedPullRequest requires the assignee's merged PR on the def
repository: { nameWithOwner: repository },
};
const context = closeContext({
pullRequests: [
{ ...eligible, number: 101, state: "OPEN" },
{ ...eligible, number: 102, baseRefName: "release" },
{ ...eligible, number: 103, author: { login: "someone-else" } },
{ ...eligible, number: 104, repository: { nameWithOwner: "someone/dbx" } },
eligible,
],
pullRequests: [{ ...eligible, number: 101, state: "OPEN" }, { ...eligible, number: 102, baseRefName: "release" }, { ...eligible, number: 103, author: { login: "someone-else" } }, { ...eligible, number: 104, repository: { nameWithOwner: "someone/dbx" } }, eligible],
});
assert.equal(findEligibleMergedPullRequest({ context, commentUser: "contributor", repository }), eligible);
@@ -175,9 +164,7 @@ test("close succeeds after the assignee's linked PR is merged", async () => {
test("commands ignore bots and closed unclaim requests", async () => {
const botClient = recordingClient();
const botResult = await handleIssueCommand(
commandOptions("/claim", botClient, { commentUserType: "Bot" }),
);
const botResult = await handleIssueCommand(commandOptions("/claim", botClient, { commentUserType: "Bot" }));
assert.equal(botResult.status, "ignored-bot");
assert.deepEqual(botClient.calls, []);
@@ -213,10 +200,7 @@ test("GitHub client aggregates paginated pull request cross references", async (
assignees: { nodes: [{ login: "contributor" }] },
timelineItems: {
pageInfo: { hasNextPage: false, endCursor: "done" },
nodes: [
{ source: { __typename: "Issue", number: 11 } },
{ source: { __typename: "PullRequest", number: 12 } },
],
nodes: [{ source: { __typename: "Issue", number: 11 } }, { source: { __typename: "PullRequest", number: 12 } }],
},
},
},
@@ -233,7 +217,10 @@ test("GitHub client aggregates paginated pull request cross references", async (
});
const context = await client.getIssueCloseContext("123");
assert.deepEqual(context.pullRequests.map(({ number }) => number), [10, 12]);
assert.deepEqual(
context.pullRequests.map(({ number }) => number),
[10, 12],
);
assert.equal(args.length, 2);
assert.equal(args[1].includes("endCursor=next-page"), true);
});
+2 -8
View File
@@ -17,14 +17,8 @@ test("reads the JDBC plugin version from Gradle", () => {
});
test("checks Gradle and manifest versions together", () => {
assert.deepEqual(
evaluateJdbcPluginVersionChange({ headGradleVersion: "0.1.26", headManifestVersion: "0.1.26" }),
[],
);
assert.match(
evaluateJdbcPluginVersionChange({ headGradleVersion: "0.1.27", headManifestVersion: "0.1.26" })[0],
/build\.gradle is 0\.1\.27/,
);
assert.deepEqual(evaluateJdbcPluginVersionChange({ headGradleVersion: "0.1.26", headManifestVersion: "0.1.26" }), []);
assert.match(evaluateJdbcPluginVersionChange({ headGradleVersion: "0.1.27", headManifestVersion: "0.1.26" })[0], /build\.gradle is 0\.1\.27/);
});
test("bumps Gradle and manifest versions for JDBC source changes", () => {
+27 -105
View File
@@ -17,52 +17,14 @@ const USER_PRIORITY_LABELS = {
P3: { color: "bfd4f2", description: "User-reported priority: low" },
};
const LABEL_PALETTE = [
"0e8a16",
"1d76db",
"5319e7",
"c2e0c6",
"bfd4f2",
"d4c5f9",
"fef2c0",
"fbca04",
"f9d0c4",
"e99695",
"f29513",
"c5def5",
];
const LABEL_PALETTE = ["0e8a16", "1d76db", "5319e7", "c2e0c6", "bfd4f2", "d4c5f9", "fef2c0", "fbca04", "f9d0c4", "e99695", "f29513", "c5def5"];
const globallyAmbiguousAliases = new Set([
"access",
"ch",
"h2",
"iris",
"jdbc",
"mq",
"pg",
"spark",
]);
const globallyAmbiguousAliases = new Set(["access", "ch", "h2", "iris", "jdbc", "mq", "pg", "spark"]);
const genericDatabaseValues = [
"general",
"generic",
"global",
"all",
"any",
"none",
"n/a",
"na",
"通用",
"全部",
"所有",
"无",
"不适用",
];
const genericDatabaseValues = ["general", "generic", "global", "all", "any", "none", "n/a", "na", "通用", "全部", "所有", "无", "不适用"];
const drivers = databaseIssueDrivers;
const exactAsciiAliases = new Set(
drivers.flatMap((driver) => driver.aliases.map((alias) => compactAscii(alias)).filter((alias) => alias.length >= 5)),
);
const exactAsciiAliases = new Set(drivers.flatMap((driver) => driver.aliases.map((alias) => compactAscii(alias)).filter((alias) => alias.length >= 5)));
function loadIssue() {
if (process.env.GITHUB_EVENT_PATH && fs.existsSync(process.env.GITHUB_EVENT_PATH)) {
@@ -113,20 +75,12 @@ function extractInlineField(body, predicate) {
}
function extractDatabaseField(body) {
const matchesDatabaseHeading = (heading) => (
heading.includes("数据库类型") ||
heading.toLowerCase().includes("database type") ||
/^database$/i.test(heading)
);
return extractIssueFormSection(body, matchesDatabaseHeading)
|| extractInlineField(body, matchesDatabaseHeading);
const matchesDatabaseHeading = (heading) => heading.includes("数据库类型") || heading.toLowerCase().includes("database type") || /^database$/i.test(heading);
return extractIssueFormSection(body, matchesDatabaseHeading) || extractInlineField(body, matchesDatabaseHeading);
}
function extractPriorityField(body) {
return extractIssueFormSection(body, (heading) => (
heading.includes("优先级") ||
heading.toLowerCase().includes("priority")
));
return extractIssueFormSection(body, (heading) => heading.includes("优先级") || heading.toLowerCase().includes("priority"));
}
function extractTitleSummaryField(body) {
@@ -146,21 +100,24 @@ function extractTitleSummaryField(body) {
}
function matchPriorityLabel(value) {
const match = String(value || "").normalize("NFKC").match(/\bP([0-3])\b/i);
const match = String(value || "")
.normalize("NFKC")
.match(/\bP([0-3])\b/i);
return match ? `P${match[1]}` : null;
}
function genericIssueTitlePrefix(title) {
const normalized = String(title || "").normalize("NFKC").trim().replace(/\s+/g, " ");
const normalized = String(title || "")
.normalize("NFKC")
.trim()
.replace(/\s+/g, " ");
const bracketMatch = normalized.match(/^([\[【][^\]】]*(?:bug|feature|feat|compatibility|question)[^\]】]*[\]】])$/i);
if (bracketMatch) return bracketMatch[1];
const bareMatch = normalized.match(/^(bug|feature|feat|compatibility|question):?$/i);
if (!bareMatch) return null;
const normalizedPrefix = bareMatch[1].toLowerCase() === "feat" ? "Feat" : (
bareMatch[1].charAt(0).toUpperCase() + bareMatch[1].slice(1).toLowerCase()
);
const normalizedPrefix = bareMatch[1].toLowerCase() === "feat" ? "Feat" : bareMatch[1].charAt(0).toUpperCase() + bareMatch[1].slice(1).toLowerCase();
return `[${normalizedPrefix}]`;
}
@@ -179,14 +136,7 @@ function cleanTitleSummaryLine(line) {
}
function isSkippableTitleSummaryLine(line) {
return (
/^DBX debug log$/i.test(line) ||
/^(Exported|User agent|Platform|Timezone):/i.test(line) ||
/^(Native|Tauri) log dir:/i.test(line) ||
/^=+.*logs?.*=+$/i.test(line) ||
/^\[\d{4}-\d{2}-\d{2}(?:T|\])/.test(line) ||
/^\[(DEBUG|INFO|WARN|ERROR|LOG|STARTUP)\]/i.test(line)
);
return /^DBX debug log$/i.test(line) || /^(Exported|User agent|Platform|Timezone):/i.test(line) || /^(Native|Tauri) log dir:/i.test(line) || /^=+.*logs?.*=+$/i.test(line) || /^\[\d{4}-\d{2}-\d{2}(?:T|\])/.test(line) || /^\[(DEBUG|INFO|WARN|ERROR|LOG|STARTUP)\]/i.test(line);
}
function firstSentence(value) {
@@ -199,9 +149,7 @@ function firstSentence(value) {
for (const line of lines) {
const sentenceMatch = line.match(/^(.{4,120}?[。!?!?]|.{12,120}?\.(?=\s|$))/u);
const sentence = (sentenceMatch ? sentenceMatch[1] : line)
.replace(/[。!?!?.]+$/u, "")
.trim();
const sentence = (sentenceMatch ? sentenceMatch[1] : line).replace(/[。!?!?.]+$/u, "").trim();
if (sentence.length >= 4) {
return sentence.length > 90 ? `${sentence.slice(0, 89)}…` : sentence;
}
@@ -251,11 +199,7 @@ function levenshteinDistance(left, right) {
const current = [leftIndex + 1];
for (let rightIndex = 0; rightIndex < right.length; rightIndex += 1) {
const substitutionCost = left[leftIndex] === right[rightIndex] ? 0 : 1;
current[rightIndex + 1] = Math.min(
current[rightIndex] + 1,
previous[rightIndex + 1] + 1,
previous[rightIndex] + substitutionCost,
);
current[rightIndex + 1] = Math.min(current[rightIndex] + 1, previous[rightIndex + 1] + 1, previous[rightIndex] + substitutionCost);
}
previous = current;
}
@@ -318,9 +262,7 @@ function aliasMatches(text, alias) {
.filter(Boolean)
.map(escapeRegExp)
.join("[\\s._/-]*");
const trailingBoundary = /[0-9]$/.test(normalizedAlias)
? "(?:[^a-z0-9]|$)"
: "(?:[^a-z0-9]|$|(?=v?\\d))";
const trailingBoundary = /[0-9]$/.test(normalizedAlias) ? "(?:[^a-z0-9]|$)" : "(?:[^a-z0-9]|$|(?=v?\\d))";
const pattern = new RegExp(`(^|[^a-z0-9])${flexibleAlias}${trailingBoundary}`, "i");
return pattern.test(normalizedText);
}
@@ -332,10 +274,7 @@ function isGenericDatabaseValue(value) {
.map((part) => part.trim())
.filter(Boolean);
return (
parts.length > 0 &&
parts.every((part) => genericDatabaseValues.some((generic) => aliasMatches(part, generic)))
);
return parts.length > 0 && parts.every((part) => genericDatabaseValues.some((generic) => aliasMatches(part, generic)));
}
function matchDrivers(text, { allowAmbiguous }) {
@@ -456,24 +395,18 @@ export function triageIssue(issue) {
const hasDatabaseField = databaseField.length > 0;
const fieldIsGeneric = hasDatabaseField && isGenericDatabaseValue(databaseField);
const sourceText = hasDatabaseField ? databaseField : issue.title || "";
const matchedDrivers = isGenericDatabaseValue(sourceText)
? []
: uniqueByDbType(matchDrivers(sourceText, { allowAmbiguous: hasDatabaseField }));
const matchedDrivers = isGenericDatabaseValue(sourceText) ? [] : uniqueByDbType(matchDrivers(sourceText, { allowAmbiguous: hasDatabaseField }));
const titleMatchedDrivers = uniqueByDbType(matchDrivers(issue.title || "", { allowAmbiguous: false }));
if (hasDatabaseField && titleMatchedDrivers.length > 0) {
matchedDrivers.push(...titleMatchedDrivers);
}
if (hasDatabaseField && matchedDrivers.length === 0 && !fieldIsGeneric) {
matchedDrivers.push(
...uniqueByDbType(matchDrivers(`${issue.title || ""}\n${databaseField}`, { allowAmbiguous: false })),
);
matchedDrivers.push(...uniqueByDbType(matchDrivers(`${issue.title || ""}\n${databaseField}`, { allowAmbiguous: false })));
}
const uniqueMatchedDrivers = uniqueByDbType(matchedDrivers);
const targetLabels = uniqueMatchedDrivers.map((driver) => `${LABEL_PREFIX}${driver.dbType}`);
const targetLabelColors = Object.fromEntries(
uniqueMatchedDrivers.map((driver) => [`${LABEL_PREFIX}${driver.dbType}`, stablePaletteColor(driver.dbType)]),
);
const targetLabelColors = Object.fromEntries(uniqueMatchedDrivers.map((driver) => [`${LABEL_PREFIX}${driver.dbType}`, stablePaletteColor(driver.dbType)]));
const priorityField = extractPriorityField(issue.body || "");
const targetPriorityValue = matchPriorityLabel(priorityField);
const targetPriorityLabel = targetPriorityValue ? `${USER_PRIORITY_PREFIX}${targetPriorityValue}` : null;
@@ -484,22 +417,11 @@ export function triageIssue(issue) {
const existingLabels = labelNames(issue.labels);
const titleToUpdate = inferIssueTitle(issue.title || "", issue.body || "");
const existingDatabaseLabels = existingLabels.filter((name) => name.startsWith(LABEL_PREFIX));
const staleDatabaseLabels = hasDatabaseField
? existingDatabaseLabels.filter((name) => !targetLabels.includes(name))
: [];
const staleDatabaseLabels = hasDatabaseField ? existingDatabaseLabels.filter((name) => !targetLabels.includes(name)) : [];
const existingPriorityLabels = existingLabels.filter((name) => name.startsWith(USER_PRIORITY_PREFIX));
const stalePriorityLabels = targetPriorityLabel
? existingPriorityLabels.filter((name) => name !== targetPriorityLabel)
: [];
const labelsToAdd = [...targetLabels, targetPriorityLabel]
.filter(Boolean)
.filter((name) => !existingLabels.includes(name));
const labelSpecs = [
...uniqueMatchedDrivers.map(databaseLabelSpec),
...(targetPriorityValue
? [{ name: targetPriorityLabel, ...USER_PRIORITY_LABELS[targetPriorityValue] }]
: []),
];
const stalePriorityLabels = targetPriorityLabel ? existingPriorityLabels.filter((name) => name !== targetPriorityLabel) : [];
const labelsToAdd = [...targetLabels, targetPriorityLabel].filter(Boolean).filter((name) => !existingLabels.includes(name));
const labelSpecs = [...uniqueMatchedDrivers.map(databaseLabelSpec), ...(targetPriorityValue ? [{ name: targetPriorityLabel, ...USER_PRIORITY_LABELS[targetPriorityValue] }] : [])];
const summary = {
issue: issue.number,
databaseField: hasDatabaseField ? databaseField : null,
@@ -21,8 +21,8 @@ function issueWithDatabase(database, title = "[Bug]") {
}
function matchedLabels(database, title) {
return triageIssue(issueWithDatabase(database, title)).summary.labelsToAdd
.filter((label) => label.startsWith("db/"))
return triageIssue(issueWithDatabase(database, title))
.summary.labelsToAdd.filter((label) => label.startsWith("db/"))
.sort();
}
@@ -35,10 +35,7 @@ test("database issue catalog covers every DatabaseType", () => {
test("database label synchronization includes every catalog entry once", () => {
const labelNames = allDatabaseLabelSpecs().map((label) => label.name);
assert.equal(new Set(labelNames).size, databaseIssueDrivers.length);
assert.deepEqual(
labelNames.sort(),
databaseIssueDrivers.map((driver) => `db/${driver.dbType}`).sort(),
);
assert.deepEqual(labelNames.sort(), databaseIssueDrivers.map((driver) => `db/${driver.dbType}`).sort());
});
test("Kingbase aliases retain historical and current company names", () => {
+32 -86
View File
@@ -33,20 +33,7 @@ export const STATIC_LABEL_SPECS = {
const CORE_CRATE_PATH = /^crates\/dbx-(?:core|driver(?:s|-[^/]+)|sql(?:-[^/]+)?|formats|types|platform|ai-provider|plugin-runtime)\//;
const TYPE_LABELS = new Set(["bug", "enhancement", "documentation", "maintenance"]);
const LABEL_PALETTE = [
"0e8a16",
"1d76db",
"5319e7",
"c2e0c6",
"bfd4f2",
"d4c5f9",
"fef2c0",
"fbca04",
"f9d0c4",
"e99695",
"f29513",
"c5def5",
];
const LABEL_PALETTE = ["0e8a16", "1d76db", "5319e7", "c2e0c6", "bfd4f2", "d4c5f9", "fef2c0", "fbca04", "f9d0c4", "e99695", "f29513", "c5def5"];
const DRIVER_DATABASE_ALIASES = {
"cassandra-go": "cassandra",
@@ -86,12 +73,7 @@ const DATABASE_PATH_TOKENS = [
[/sqlserver/i, "sqlserver"],
];
const PACKAGE_DEPENDENCY_SECTIONS = [
"dependencies",
"devDependencies",
"optionalDependencies",
"peerDependencies",
];
const PACKAGE_DEPENDENCY_SECTIONS = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
function stablePaletteColor(value) {
let hash = 0;
@@ -106,14 +88,14 @@ function normalizeChangedFiles(changedFiles) {
}
function conventionalType(title) {
const normalized = String(title || "").trim().replace(/^[^\p{L}\p{N}]+/u, "");
const normalized = String(title || "")
.trim()
.replace(/^[^\p{L}\p{N}]+/u, "");
return /^([a-z]+)(?:\([^)]*\))?!?[::]/i.exec(normalized)?.[1]?.toLowerCase() || null;
}
export function isDocumentationFile(file) {
return file.startsWith("docs/")
|| /(^|\/)README(?:\.[^/]+)?\.md$/i.test(file)
|| /(^|\/)(SECURITY|CONTRIBUTING|CODE_OF_CONDUCT)\.md$/i.test(file);
return file.startsWith("docs/") || /(^|\/)README(?:\.[^/]+)?\.md$/i.test(file) || /(^|\/)(SECURITY|CONTRIBUTING|CODE_OF_CONDUCT)\.md$/i.test(file);
}
export function inferTypeLabel(title, changedFiles) {
@@ -130,10 +112,7 @@ export function inferAreaLabels(changedFiles) {
const labels = new Set();
const has = (predicate) => changedFiles.some(predicate);
if (has((file) => file === "package.json"
|| file.startsWith("apps/desktop/")
|| file.startsWith("packages/app-tests/")
|| file.startsWith("src-tauri/"))) {
if (has((file) => file === "package.json" || file.startsWith("apps/desktop/") || file.startsWith("packages/app-tests/") || file.startsWith("src-tauri/"))) {
labels.add("area/desktop");
}
if (has((file) => CORE_CRATE_PATH.test(file) || file.startsWith("packages/node-core/"))) {
@@ -143,27 +122,16 @@ export function inferAreaLabels(changedFiles) {
labels.add("area/web");
}
if (has((file) => file.startsWith("agents/"))) labels.add("area/agents");
if (has((file) => file.startsWith("crates/dbx-mcp/")
|| file.startsWith("packages/mcp-")
|| file.startsWith("packages/mcp-server/")
|| file.startsWith("examples/mcp/"))) {
if (has((file) => file.startsWith("crates/dbx-mcp/") || file.startsWith("packages/mcp-") || file.startsWith("packages/mcp-server/") || file.startsWith("examples/mcp/"))) {
labels.add("area/mcp");
}
if (has((file) => file.startsWith("crates/dbx-cli/")
|| file.startsWith("packages/cli-")
|| file.startsWith("packages/cli/")
|| file.startsWith("skills/dbx/")
|| file.startsWith("examples/cli/"))) {
if (has((file) => file.startsWith("crates/dbx-cli/") || file.startsWith("packages/cli-") || file.startsWith("packages/cli/") || file.startsWith("skills/dbx/") || file.startsWith("examples/cli/"))) {
labels.add("area/cli");
}
if (has((file) => file.startsWith("plugins/"))) labels.add("area/plugins");
if (has(isDocumentationFile)) labels.add("area/docs");
if (has((file) => file.startsWith(".github/"))) labels.add("area/ci");
if (has((file) => file.startsWith("deploy/")
|| file === "flake.nix"
|| file === "flake.lock"
|| file === "Makefile"
|| /^packages\/(?:cli|mcp)-(?:darwin|linux|win32)-/.test(file))) {
if (has((file) => file.startsWith("deploy/") || file === "flake.nix" || file === "flake.lock" || file === "Makefile" || /^packages\/(?:cli|mcp)-(?:darwin|linux|win32)-/.test(file))) {
labels.add("area/deploy");
}
@@ -178,29 +146,20 @@ export function isUiChangeFile(file) {
if (isTestFile(file)) return false;
if (file.startsWith("apps/desktop/")) {
return /\.(?:vue|css|less|sass|scss|styl)$/i.test(file)
|| file.startsWith("apps/desktop/src/components/")
|| file.startsWith("apps/desktop/src/i18n/")
|| file.startsWith("apps/desktop/src/styles/")
|| file.startsWith("apps/desktop/public/")
|| isVisualAsset(file);
return /\.(?:vue|css|less|sass|scss|styl)$/i.test(file) || file.startsWith("apps/desktop/src/components/") || file.startsWith("apps/desktop/src/i18n/") || file.startsWith("apps/desktop/src/styles/") || file.startsWith("apps/desktop/public/") || isVisualAsset(file);
}
if (file.startsWith("src-tauri/icons/")) return true;
if (file.startsWith("docs/")) {
return /\.(?:css|jsx|tsx)$/i.test(file)
|| file.startsWith("docs/components/")
|| (file.startsWith("docs/public/") && isVisualAsset(file));
return /\.(?:css|jsx|tsx)$/i.test(file) || file.startsWith("docs/components/") || (file.startsWith("docs/public/") && isVisualAsset(file));
}
return false;
}
export function isTestFile(file) {
return /(^|\/)(?:__tests__|fixtures|snapshots|test|tests|test-support)(\/|$)/.test(file)
|| /\.(?:spec|test)\.[^/]+$/i.test(file)
|| /(^|\/)packages\/app-tests\//.test(file);
return /(^|\/)(?:__tests__|fixtures|snapshots|test|tests|test-support)(\/|$)/.test(file) || /\.(?:spec|test)\.[^/]+$/i.test(file) || /(^|\/)packages\/app-tests\//.test(file);
}
function normalizeDatabaseType(value, aliases) {
@@ -248,8 +207,7 @@ export function parsePackageDependencyNames(source) {
function unquoteTomlKey(value) {
const trimmed = String(value || "").trim();
if ((trimmed.startsWith('"') && trimmed.endsWith('"'))
|| (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
return trimmed.slice(1, -1);
}
return trimmed;
@@ -340,17 +298,10 @@ function dependencySide(file) {
if (file === "package.json" || file === "docs/package.json" || file === "packages/mongo-shell/package.json") {
return "frontend";
}
if (/^packages\/(?:cli(?:-|\/)|mcp(?:-|server\/)).*\/package\.json$/.test(file)
|| file === "packages/cli/package.json"
|| file === "packages/mcp-server/package.json") {
if (/^packages\/(?:cli(?:-|\/)|mcp(?:-|server\/)).*\/package\.json$/.test(file) || file === "packages/cli/package.json" || file === "packages/mcp-server/package.json") {
return "backend";
}
if (file === "Cargo.toml"
|| file.endsWith("/Cargo.toml")
|| file.endsWith("/build.gradle")
|| file.endsWith("/build.gradle.kts")
|| file.endsWith("/go.mod")
|| file.endsWith("/pom.xml")) {
if (file === "Cargo.toml" || file.endsWith("/Cargo.toml") || file.endsWith("/build.gradle") || file.endsWith("/build.gradle.kts") || file.endsWith("/go.mod") || file.endsWith("/pom.xml")) {
return "backend";
}
return null;
@@ -382,13 +333,7 @@ export function inferAddedDependencies(changedFiles, readBaseFile, readHeadFile)
return added;
}
export function evaluatePullRequestLabels({
title,
changedFiles,
knownDatabaseTypes,
readBaseFile = () => null,
readHeadFile = () => null,
}) {
export function evaluatePullRequestLabels({ title, changedFiles, knownDatabaseTypes, readBaseFile = () => null, readHeadFile = () => null }) {
const files = normalizeChangedFiles(changedFiles);
const areaLabels = inferAreaLabels(files);
const labels = new Set(areaLabels.length > 3 ? ["area/multiple"] : areaLabels);
@@ -510,12 +455,7 @@ async function removeLabel(pullRequestNumber, name) {
}
function isManagedLabel(name) {
return TYPE_LABELS.has(name)
|| name.startsWith("area/")
|| name.startsWith(DATABASE_LABEL_PREFIX)
|| name.startsWith("dependencies/")
|| name === "tests-only"
|| name === "ui-change";
return TYPE_LABELS.has(name) || name.startsWith("area/") || name.startsWith(DATABASE_LABEL_PREFIX) || name.startsWith("dependencies/") || name === "tests-only" || name === "ui-change";
}
async function main() {
@@ -547,14 +487,20 @@ async function main() {
};
}
console.log(JSON.stringify({
pullRequest: pullRequest.number,
title: pullRequest.title,
...result,
existingLabels,
labelsToAdd,
labelsToRemove,
}, null, 2));
console.log(
JSON.stringify(
{
pullRequest: pullRequest.number,
title: pullRequest.title,
...result,
existingLabels,
labelsToAdd,
labelsToRemove,
},
null,
2,
),
);
if (dryRun) {
console.log("Dry run enabled; no GitHub API calls were made");
+34 -78
View File
@@ -1,42 +1,14 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
evaluatePullRequestLabels,
inferAddedDependencies,
inferDatabaseTypes,
parseCargoDependencyNames,
parseGoDependencyNames,
parseGradleDependencyNames,
parseMavenDependencyNames,
parsePackageDependencyNames,
} from "./label-pull-request.mjs";
import { evaluatePullRequestLabels, inferAddedDependencies, inferDatabaseTypes, parseCargoDependencyNames, parseGoDependencyNames, parseGradleDependencyNames, parseMavenDependencyNames, parsePackageDependencyNames } from "./label-pull-request.mjs";
const knownDatabaseTypes = new Set([
"access",
"cassandra",
"doris",
"jdbc",
"mongodb",
"mq",
"mysql",
"neo4j",
"oceanbase-oracle",
"oracle",
"postgres",
"redis",
"sqlite",
"sqlserver",
"vastbase",
]);
const knownDatabaseTypes = new Set(["access", "cassandra", "doris", "jdbc", "mongodb", "mq", "mysql", "neo4j", "oceanbase-oracle", "oracle", "postgres", "redis", "sqlite", "sqlserver", "vastbase"]);
test("labels a desktop MySQL UI fix", () => {
const result = evaluatePullRequestLabels({
title: "fix(mysql): improve connection dialog",
changedFiles: [
"apps/desktop/src/components/connection/MySqlConnectionDialog.vue",
"apps/desktop/src/i18n/en.ts",
],
changedFiles: ["apps/desktop/src/components/connection/MySqlConnectionDialog.vue", "apps/desktop/src/i18n/en.ts"],
knownDatabaseTypes,
});
@@ -65,15 +37,7 @@ test("labels the bundled DBX skill as CLI work", () => {
test("maps agent and dialect paths to existing database types", () => {
assert.deepEqual(
inferDatabaseTypes([
"agents/drivers/oracle-go/go.mod",
"agents/drivers/cassandra-go/go.mod",
"agents/drivers/neo4j-go/go.mod",
"agents/drivers/vastbase-go/go.mod",
"agents/drivers/kafka/build.gradle",
"plugins/dialects/postgresql.yaml",
"plugins/dialects/oceanbase.yaml",
], knownDatabaseTypes),
inferDatabaseTypes(["agents/drivers/oracle-go/go.mod", "agents/drivers/cassandra-go/go.mod", "agents/drivers/neo4j-go/go.mod", "agents/drivers/vastbase-go/go.mod", "agents/drivers/kafka/build.gradle", "plugins/dialects/postgresql.yaml", "plugins/dialects/oceanbase.yaml"], knownDatabaseTypes),
["cassandra", "mq", "neo4j", "oceanbase-oracle", "oracle", "postgres", "vastbase"],
);
});
@@ -81,10 +45,7 @@ test("maps agent and dialect paths to existing database types", () => {
test("labels tests-only changes", () => {
const result = evaluatePullRequestLabels({
title: "test(core): cover transaction recovery",
changedFiles: [
"crates/dbx-core/tests/transaction_recovery.rs",
"apps/desktop/src/lib/__tests__/transactionRecovery.spec.ts",
],
changedFiles: ["crates/dbx-core/tests/transaction_recovery.rs", "apps/desktop/src/lib/__tests__/transactionRecovery.spec.ts"],
knownDatabaseTypes,
});
@@ -94,10 +55,7 @@ test("labels tests-only changes", () => {
test("does not treat component tests or MCP server code as UI or deploy changes", () => {
const result = evaluatePullRequestLabels({
title: "🐛 fix(mcp): cover server routing",
changedFiles: [
"apps/desktop/src/components/mcp/McpPanel.spec.ts",
"packages/mcp-server/src/index.ts",
],
changedFiles: ["apps/desktop/src/components/mcp/McpPanel.spec.ts", "packages/mcp-server/src/index.ts"],
knownDatabaseTypes,
});
@@ -117,15 +75,7 @@ test("recognizes conventional titles with a full-width colon", () => {
test("recognizes extracted core crates and their database sources", () => {
const result = evaluatePullRequestLabels({
title: "fix(sql): preserve PostgreSQL query contracts",
changedFiles: [
"crates/dbx-driver-postgres/src/postgres.rs",
"crates/dbx-sql-core/src/sql.rs",
"crates/dbx-types/src/types.rs",
"crates/dbx-platform/src/process.rs",
"crates/dbx-ai-provider/src/ai.rs",
"crates/dbx-formats/src/csv_export.rs",
"crates/dbx-plugin-runtime/src/plugins.rs",
],
changedFiles: ["crates/dbx-driver-postgres/src/postgres.rs", "crates/dbx-sql-core/src/sql.rs", "crates/dbx-types/src/types.rs", "crates/dbx-platform/src/process.rs", "crates/dbx-ai-provider/src/ai.rs", "crates/dbx-formats/src/csv_export.rs", "crates/dbx-plugin-runtime/src/plugins.rs"],
knownDatabaseTypes,
});
@@ -168,11 +118,11 @@ test("detects frontend and backend additions in the same pull request", () => {
const manifests = {
base: {
"package.json": JSON.stringify({ dependencies: { vue: "3.5.0" } }),
"crates/dbx-core/Cargo.toml": "[dependencies]\ntokio = \"1\"\n",
"crates/dbx-core/Cargo.toml": '[dependencies]\ntokio = "1"\n',
},
head: {
"package.json": JSON.stringify({ dependencies: { vue: "3.5.0", zod: "4" } }),
"crates/dbx-core/Cargo.toml": "[dependencies]\ntokio = \"1\"\nserde = \"1\"\n",
"crates/dbx-core/Cargo.toml": '[dependencies]\ntokio = "1"\nserde = "1"\n',
},
};
const result = evaluatePullRequestLabels({
@@ -183,13 +133,7 @@ test("detects frontend and backend additions in the same pull request", () => {
readHeadFile: (file) => manifests.head[file],
});
assert.deepEqual(result.labels, [
"area/core",
"area/desktop",
"dependencies/backend",
"dependencies/frontend",
"enhancement",
]);
assert.deepEqual(result.labels, ["area/core", "area/desktop", "dependencies/backend", "dependencies/frontend", "enhancement"]);
});
test("ignores lockfile-only dependency changes", () => {
@@ -204,7 +148,8 @@ test("ignores lockfile-only dependency changes", () => {
test("parses dependency names from supported backend manifests", () => {
assert.deepEqual(
[...parseCargoDependencyNames(`
[
...parseCargoDependencyNames(`
[workspace.dependencies]
serde = "1"
@@ -213,39 +158,50 @@ windows-sys = "0.61"
[dependencies.mysql_async]
git = "https://example.com/mysql_async"
`)].sort(),
`),
].sort(),
["mysql_async", "serde", "windows-sys"],
);
assert.deepEqual(
[...parseGradleDependencyNames(`
[
...parseGradleDependencyNames(`
implementation 'com.zaxxer:HikariCP:7.0.2'
testImplementation project(':test-support')
runtimeOnly libs.slf4j.nop
`)].sort(),
`),
].sort(),
["catalog:libs.slf4j.nop", "com.zaxxer:HikariCP", "project::test-support"],
);
assert.deepEqual(
[...parseGoDependencyNames(`
[
...parseGoDependencyNames(`
require github.com/example/direct v1.0.0
require (
github.com/example/block v2.0.0
)
`)].sort(),
`),
].sort(),
["github.com/example/block", "github.com/example/direct"],
);
assert.deepEqual(
[...parseMavenDependencyNames(`
[
...parseMavenDependencyNames(`
<dependencies>
<dependency><groupId>org.example</groupId><artifactId>client</artifactId><version>1</version></dependency>
</dependencies>
`)],
`),
],
["org.example:client"],
);
assert.deepEqual(
[...parsePackageDependencyNames(JSON.stringify({
dependencies: { vue: "3" },
devDependencies: { vitest: "4" },
}))].sort(),
[
...parsePackageDependencyNames(
JSON.stringify({
dependencies: { vue: "3" },
devDependencies: { vitest: "4" },
}),
),
].sort(),
["vitest", "vue"],
);
});
+31 -56
View File
@@ -1,16 +1,7 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
} from "node:fs";
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
import { basename, join } from "node:path";
import { tmpdir } from "node:os";
@@ -23,14 +14,7 @@ const NATIVE_MODULES = new Set(["duckdb", "oracle", "oracle-oci", "xugu", "kingb
const NATIVE_MODULE_PLATFORMS = {
"oracle-oci": ["windows-x64"],
};
const PLATFORMS = [
"macos-aarch64",
"macos-x64",
"linux-aarch64",
"linux-x64",
"windows-aarch64",
"windows-x64",
];
const PLATFORMS = ["macos-aarch64", "macos-x64", "linux-aarch64", "linux-x64", "windows-aarch64", "windows-x64"];
function expectedNativePlatforms(moduleName) {
return [...(NATIVE_MODULE_PLATFORMS[moduleName] ?? PLATFORMS)].sort();
@@ -93,9 +77,7 @@ export function collectReusableAssetPlan({ registry, release, versions, modules,
const missingPlatforms = expectedPlatforms.filter((platform) => !nativePlatforms.includes(platform));
const extraPlatforms = nativePlatforms.filter((platform) => !expectedPlatforms.includes(platform));
if (missingPlatforms.length > 0 || extraPlatforms.length > 0) {
throw new Error(
`Previous native artifacts are incomplete for ${moduleName}: missing=${missingPlatforms.join(",") || "none"}, extra=${extraPlatforms.join(",") || "none"}`,
);
throw new Error(`Previous native artifacts are incomplete for ${moduleName}: missing=${missingPlatforms.join(",") || "none"}, extra=${extraPlatforms.join(",") || "none"}`);
}
}
@@ -119,9 +101,7 @@ export function collectReusableAssetPlan({ registry, release, versions, modules,
const missingPlatforms = PLATFORMS.filter((platform) => !platforms.includes(platform));
const extraPlatforms = platforms.filter((platform) => !PLATFORMS.includes(platform));
if (missingPlatforms.length > 0 || extraPlatforms.length > 0) {
throw new Error(
`Previous JRE ${jreKey} artifacts are incomplete: missing=${missingPlatforms.join(",") || "none"}, extra=${extraPlatforms.join(",") || "none"}`,
);
throw new Error(`Previous JRE ${jreKey} artifacts are incomplete: missing=${missingPlatforms.join(",") || "none"}, extra=${extraPlatforms.join(",") || "none"}`);
}
for (const platform of platforms) {
jreAssets.push({
@@ -210,13 +190,17 @@ export function extractReusableDriverPackages({ packagesDir, outputDir, versions
const javaName = `dbx-agent-${moduleName}-${releaseVersion}.tar.zst`;
if (filenames.has(javaName)) {
extractRawDriver(join(packagesDir, javaName), {
name: javaName,
moduleName,
kind: "jar",
platform: "",
releaseVersion,
}, outputDir);
extractRawDriver(
join(packagesDir, javaName),
{
name: javaName,
moduleName,
kind: "jar",
platform: "",
releaseVersion,
},
outputDir,
);
extracted += 1;
}
@@ -225,13 +209,17 @@ export function extractReusableDriverPackages({ packagesDir, outputDir, versions
const nativeName = `dbx-agent-${moduleName}-${releaseVersion}-${platform}.tar.zst`;
if (!filenames.has(nativeName)) continue;
nativePlatforms.push(platform);
extractRawDriver(join(packagesDir, nativeName), {
name: nativeName,
moduleName,
kind: "native",
platform,
releaseVersion,
}, outputDir);
extractRawDriver(
join(packagesDir, nativeName),
{
name: nativeName,
moduleName,
kind: "native",
platform,
releaseVersion,
},
outputDir,
);
extracted += 1;
}
@@ -253,23 +241,7 @@ function gh(args, options = {}) {
function downloadReleaseAssets(assets, downloadDir) {
if (assets.length === 0) return;
const args = [
"--fail",
"--location",
"--silent",
"--show-error",
"--retry",
"5",
"--retry-all-errors",
"--retry-delay",
"2",
"--connect-timeout",
"30",
"--parallel",
"--parallel-immediate",
"--parallel-max",
"6",
];
const args = ["--fail", "--location", "--silent", "--show-error", "--retry", "5", "--retry-all-errors", "--retry-delay", "2", "--connect-timeout", "30", "--parallel", "--parallel-immediate", "--parallel-max", "6"];
for (const asset of assets) {
if (!asset.browser_download_url) {
throw new Error(`GitHub release asset is missing browser_download_url: ${asset.name}`);
@@ -359,7 +331,10 @@ function main() {
}));
if (plannedAssets.length > 0) {
downloadReleaseAssets(plannedAssets.map((asset) => asset.releaseAsset), downloadDir);
downloadReleaseAssets(
plannedAssets.map((asset) => asset.releaseAsset),
downloadDir,
);
}
for (const asset of plannedAssets) {
@@ -3,23 +3,12 @@ import test from "node:test";
import { collectReusableAssetPlan } from "./reuse-agent-release-assets.mjs";
const platforms = [
"macos-aarch64",
"macos-x64",
"linux-aarch64",
"linux-x64",
"windows-aarch64",
"windows-x64",
];
const platforms = ["macos-aarch64", "macos-x64", "linux-aarch64", "linux-x64", "windows-aarch64", "windows-x64"];
test("collects complete reusable Java, native, and JRE assets", () => {
const access = artifact("dbx-agent-access-0.1.34.tar.zst", "a");
const kingbase = Object.fromEntries(
platforms.map((platform, index) => [platform, artifact(`dbx-agent-kingbase-0.1.40-${platform}.tar.zst`, String(index + 1))]),
);
const jre = Object.fromEntries(
platforms.map((platform, index) => [platform, artifact(`dbx-jre-21-${platform}.tar.zst`, String(index + 7))]),
);
const kingbase = Object.fromEntries(platforms.map((platform, index) => [platform, artifact(`dbx-agent-kingbase-0.1.40-${platform}.tar.zst`, String(index + 1))]));
const jre = Object.fromEntries(platforms.map((platform, index) => [platform, artifact(`dbx-jre-21-${platform}.tar.zst`, String(index + 7))]));
const registry = {
drivers: {
access: { version: "0.1.34", jar: access },
@@ -39,95 +28,93 @@ test("collects complete reusable Java, native, and JRE assets", () => {
assert.equal(plan.driverAssets.length, 7);
assert.equal(plan.jreAssets.length, 6);
assert.deepEqual(plan.driverAssets.map((asset) => asset.moduleName), ["access", ...Array(6).fill("kingbase")]);
assert.deepEqual(
plan.driverAssets.map((asset) => asset.moduleName),
["access", ...Array(6).fill("kingbase")],
);
});
test("rejects an incomplete reusable native platform set", () => {
const native = Object.fromEntries(
platforms.slice(1).map((platform, index) => [platform, artifact(`dbx-agent-vastbase-0.1.38-${platform}.tar.zst`, String(index + 1))]),
);
const native = Object.fromEntries(platforms.slice(1).map((platform, index) => [platform, artifact(`dbx-agent-vastbase-0.1.38-${platform}.tar.zst`, String(index + 1))]));
const registry = { drivers: { vastbase: { version: "0.1.38", native } }, jres: {} };
assert.throws(
() => collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { vastbase: "0.1.38" },
modules: ["vastbase"],
reuseJre: false,
}),
() =>
collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { vastbase: "0.1.38" },
modules: ["vastbase"],
reuseJre: false,
}),
/missing=macos-aarch64/,
);
});
test("requires all TDengine native platforms when reusing a release", () => {
const native = Object.fromEntries(
platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-tdengine-0.1.40-${platform}.tar.zst`, String(index + 1))]),
);
const native = Object.fromEntries(platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-tdengine-0.1.40-${platform}.tar.zst`, String(index + 1))]));
const registry = { drivers: { tdengine: { version: "0.1.40", native } }, jres: {} };
assert.throws(
() => collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { tdengine: "0.1.40" },
modules: ["tdengine"],
reuseJre: false,
}),
() =>
collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { tdengine: "0.1.40" },
modules: ["tdengine"],
reuseJre: false,
}),
/missing=windows-x64/,
);
});
test("requires all Neo4j native platforms when reusing a release", () => {
const native = Object.fromEntries(
platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-neo4j-0.1.40-${platform}.tar.zst`, String(index + 1))]),
);
const native = Object.fromEntries(platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-neo4j-0.1.40-${platform}.tar.zst`, String(index + 1))]));
const registry = { drivers: { neo4j: { version: "0.1.40", native } }, jres: {} };
assert.throws(
() => collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { neo4j: "0.1.40" },
modules: ["neo4j"],
reuseJre: false,
}),
() =>
collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { neo4j: "0.1.40" },
modules: ["neo4j"],
reuseJre: false,
}),
/missing=windows-x64/,
);
});
test("requires all IoTDB native platforms when reusing a release", () => {
const native = Object.fromEntries(
platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-iotdb-0.1.30-${platform}.tar.zst`, String(index + 1))]),
);
const native = Object.fromEntries(platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-iotdb-0.1.30-${platform}.tar.zst`, String(index + 1))]));
const registry = { drivers: { iotdb: { version: "0.1.30", native } }, jres: {} };
assert.throws(
() => collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { iotdb: "0.1.30" },
modules: ["iotdb"],
reuseJre: false,
}),
() =>
collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { iotdb: "0.1.30" },
modules: ["iotdb"],
reuseJre: false,
}),
/missing=windows-x64/,
);
});
test("requires all RocketMQ native platforms when reusing a release", () => {
const native = Object.fromEntries(
platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-rocketmq-0.1.40-${platform}.tar.zst`, String(index + 1))]),
);
const native = Object.fromEntries(platforms.slice(0, -1).map((platform, index) => [platform, artifact(`dbx-agent-rocketmq-0.1.40-${platform}.tar.zst`, String(index + 1))]));
const registry = { drivers: { rocketmq: { version: "0.1.40", native } }, jres: {} };
assert.throws(
() => collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { rocketmq: "0.1.40" },
modules: ["rocketmq"],
reuseJre: false,
}),
() =>
collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { rocketmq: "0.1.40" },
modules: ["rocketmq"],
reuseJre: false,
}),
/missing=windows-x64/,
);
});
@@ -146,7 +133,10 @@ test("reuses the Windows-only Oracle OCI native package", () => {
// Windows-only is the complete platform set for this module, so requiring all
// six platforms would reject a correct release.
assert.deepEqual(plan.driverAssets.map((asset) => asset.platform), ["windows-x64"]);
assert.deepEqual(
plan.driverAssets.map((asset) => asset.platform),
["windows-x64"],
);
});
test("rejects an Oracle OCI release that lost its Windows native package", () => {
@@ -154,21 +144,20 @@ test("rejects an Oracle OCI release that lost its Windows native package", () =>
const registry = { drivers: { "oracle-oci": { version: "0.1.0", native } }, jres: {} };
assert.throws(
() => collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { "oracle-oci": "0.1.0" },
modules: ["oracle-oci"],
reuseJre: false,
}),
() =>
collectReusableAssetPlan({
registry,
release: releaseFor(Object.values(native)),
versions: { "oracle-oci": "0.1.0" },
modules: ["oracle-oci"],
reuseJre: false,
}),
/missing=windows-x64, extra=linux-x64/,
);
});
test("ignores zero-size legacy JAR placeholders for native-only modules", () => {
const native = Object.fromEntries(
platforms.map((platform, index) => [platform, artifact(`dbx-agent-duckdb-0.1.2-${platform}.tar.zst`, String(index + 1))]),
);
const native = Object.fromEntries(platforms.map((platform, index) => [platform, artifact(`dbx-agent-duckdb-0.1.2-${platform}.tar.zst`, String(index + 1))]));
const registry = {
drivers: {
duckdb: {
@@ -193,7 +182,10 @@ test("ignores zero-size legacy JAR placeholders for native-only modules", () =>
});
assert.equal(plan.driverAssets.length, 6);
assert.equal(plan.driverAssets.some((asset) => asset.kind === "jar"), false);
assert.equal(
plan.driverAssets.some((asset) => asset.kind === "jar"),
false,
);
});
test("rejects a registry version that differs from the effective baseline", () => {
@@ -201,13 +193,14 @@ test("rejects a registry version that differs from the effective baseline", () =
const registry = { drivers: { access: { version: "0.1.33", jar: access } }, jres: {} };
assert.throws(
() => collectReusableAssetPlan({
registry,
release: releaseFor([access]),
versions: { access: "0.1.34" },
modules: ["access"],
reuseJre: false,
}),
() =>
collectReusableAssetPlan({
registry,
release: releaseFor([access]),
versions: { access: "0.1.34" },
modules: ["access"],
reuseJre: false,
}),
/registry=0\.1\.33, expected=0\.1\.34/,
);
});
+33 -16
View File
@@ -22,12 +22,18 @@ async function command(executable, args, signal) {
return new Promise((resolveCommand, reject) => {
const child = spawn(executable, args, { stdio: ["ignore", "pipe", "pipe"], signal });
let output = "";
child.stdout.on("data", (data) => { output += data; });
child.stderr.on("data", (data) => { output += data; });
child.stdout.on("data", (data) => {
output += data;
});
child.stderr.on("data", (data) => {
output += data;
});
const timer = setTimeout(() => child.kill("SIGKILL"), 120000);
const fail = () => reject(new Error(`${executable.split("/").at(-1)} ${args[0]} failed.`));
let failed = false;
child.on("error", () => { failed = true; });
child.on("error", () => {
failed = true;
});
child.on("close", (code) => {
clearTimeout(timer);
if (code === 0 && !failed) resolveCommand(output);
@@ -43,10 +49,7 @@ export async function verifyMcp(binary, options = {}) {
if (!statSync(binary).isFile()) throw new Error("MCP binary must be a regular file.");
await command(ctx.codesign, ["--verify", "--strict", "--all-architectures", "-R", `=${ctx.requirement}`, binary], ctx.signal);
const info = await command(ctx.codesign, ["--display", "--verbose=4", "--requirements", "-", binary], ctx.signal);
if (!info.split(/\r?\n/).includes(`Identifier=${mcpIdentifier}`)
|| !info.split(/\r?\n/).includes(`TeamIdentifier=${ctx.env.APPLE_TEAM_ID}`)
|| !/^Timestamp=(?!none$).+/m.test(info)
|| /Signature=adhoc/.test(info)) {
if (!info.split(/\r?\n/).includes(`Identifier=${mcpIdentifier}`) || !info.split(/\r?\n/).includes(`TeamIdentifier=${ctx.env.APPLE_TEAM_ID}`) || !/^Timestamp=(?!none$).+/m.test(info) || /Signature=adhoc/.test(info)) {
throw new Error("MCP signature must have the expected identifier, team and secure timestamp.");
}
const designated = info.match(/^designated => (.+)$/m)?.[1];
@@ -66,7 +69,10 @@ export async function signMcp(binary, options = {}) {
throw new Error("APPLE_CERTIFICATE must be a base64 PKCS12 bundle.");
}
const originalList = await command(ctx.security, ["list-keychains", "-d", "user"], ctx.signal);
const originalKeychains = originalList.split(/\r?\n/).filter((line) => line.trim()).map((line) => JSON.parse(line.trim()));
const originalKeychains = originalList
.split(/\r?\n/)
.filter((line) => line.trim())
.map((line) => JSON.parse(line.trim()));
if (!originalKeychains.every((keychain) => typeof keychain === "string")) throw new Error("Invalid keychain search list.");
const directory = mkdtempSync(join(ctx.env.RUNNER_TEMP || ctx.env.TMPDIR || tmpdir(), "dbx-mcp-signing-"));
chmodSync(directory, 0o700);
@@ -84,22 +90,33 @@ export async function signMcp(binary, options = {}) {
await command(ctx.security, ["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychain], ctx.signal);
await command(ctx.security, ["list-keychains", "-d", "user", "-s", keychain, ...originalKeychains], ctx.signal);
const identities = await command(ctx.security, ["find-identity", "-v", "-p", "codesigning", keychain], ctx.signal);
const candidates = [...identities.matchAll(/\b([A-Fa-f0-9]{40}) "(Developer ID Application: [^"\r\n]+)"/g)]
.filter(([, hash, name]) => name.endsWith(` (${ctx.env.APPLE_TEAM_ID})`)
&& (name === ctx.env.APPLE_SIGNING_IDENTITY || hash.toUpperCase() === ctx.env.APPLE_SIGNING_IDENTITY.toUpperCase()));
const candidates = [...identities.matchAll(/\b([A-Fa-f0-9]{40}) "(Developer ID Application: [^"\r\n]+)"/g)].filter(
([, hash, name]) => name.endsWith(` (${ctx.env.APPLE_TEAM_ID})`) && (name === ctx.env.APPLE_SIGNING_IDENTITY || hash.toUpperCase() === ctx.env.APPLE_SIGNING_IDENTITY.toUpperCase()),
);
if (candidates.length !== 1) throw new Error("Expected exactly one valid configured Developer ID Application identity in the temporary keychain.");
await command(ctx.codesign, ["--force", "--sign", candidates[0][1], "--keychain", keychain, "--identifier", mcpIdentifier,
"--requirements", `=designated => ${ctx.requirement}`, "--timestamp", binary], ctx.signal);
await command(ctx.codesign, ["--force", "--sign", candidates[0][1], "--keychain", keychain, "--identifier", mcpIdentifier, "--requirements", `=designated => ${ctx.requirement}`, "--timestamp", binary], ctx.signal);
await verifyMcp(binary, options);
} catch (failure) {
error = failure;
} finally {
const failures = [];
try { await command(ctx.security, ["list-keychains", "-d", "user", "-s", ...originalKeychains]); } catch (failure) { failures.push(failure); }
try {
await command(ctx.security, ["list-keychains", "-d", "user", "-s", ...originalKeychains]);
} catch (failure) {
failures.push(failure);
}
if (existsSync(keychain)) {
try { await command(ctx.security, ["delete-keychain", keychain]); } catch (failure) { failures.push(failure); }
try {
await command(ctx.security, ["delete-keychain", keychain]);
} catch (failure) {
failures.push(failure);
}
}
try {
rmSync(directory, { recursive: true, force: true });
} catch (failure) {
failures.push(failure);
}
try { rmSync(directory, { recursive: true, force: true }); } catch (failure) { failures.push(failure); }
if (failures.length) error = new Error("MCP signing keychain cleanup failed; refusing publication.", { cause: error });
}
if (error) throw error;
+26 -126
View File
@@ -10,81 +10,12 @@ const MAX_QUERY_LENGTH = 480;
const MAX_CANDIDATES = 3;
const SEARCH_RESULT_LIMIT = 20;
const ignoredSectionPatterns = [
/^来源$/i,
/^source$/i,
/数据库类型/i,
/database type/i,
/支持信息/i,
/support info/i,
/优先级/i,
/priority/i,
/补充信息/i,
/additional context/i,
/环境信息/i,
/environment/i,
];
const ignoredSectionPatterns = [/^来源$/i, /^source$/i, /数据库类型/i, /database type/i, /支持信息/i, /support info/i, /优先级/i, /priority/i, /补充信息/i, /additional context/i, /环境信息/i, /environment/i];
const genericLatinTokens = new Set([
"ai",
"alter",
"bug",
"datagrip",
"dbeaver",
"dbx",
"delete",
"desktop",
"feature",
"from",
"insert",
"issue",
"navicat",
"question",
"request",
"select",
"sql",
"support",
"table",
"update",
"version",
"web",
"where",
"windows",
]);
const 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 genericCjkTokens = new Set(["一个", "以及", "使用", "功能", "可以", "当前", "支持", "数据库", "新增", "增加", "希望", "异常", "操作", "所在", "数据", "显示", "没有", "现在", "设置", "问题", "进行", "错误", "需要"]);
const identifierQueryNoiseTokens = new Set(["失效", "弹出", "快捷", "提醒", "时候", "选择"]);
const cjkSegmenter = new Intl.Segmenter("zh-CN", { granularity: "word" });
const databaseDrivers = databaseIssueDrivers;
@@ -107,7 +38,9 @@ function labelNames(labels) {
}
function stripIssuePrefix(title) {
return String(title || "").replace(/^\s*\[[^\]]+\]\s*/u, "").trim();
return String(title || "")
.replace(/^\s*\[[^\]]+\]\s*/u, "")
.trim();
}
function stripMarkdown(value) {
@@ -143,9 +76,7 @@ function databaseField(body) {
}
function relevantBody(body) {
const sections = parseIssueSections(body).filter(
({ heading }) => !ignoredSectionPatterns.some((pattern) => pattern.test(heading)),
);
const sections = parseIssueSections(body).filter(({ heading }) => !ignoredSectionPatterns.some((pattern) => pattern.test(heading)));
return stripMarkdown(sections.map(({ content }) => content).join("\n")).slice(0, 2400);
}
@@ -197,9 +128,7 @@ function semanticTokens(value) {
export function searchTitleTerms(issue) {
const identifiers = technicalIdentifiers(issue);
const tokens = [...semanticTokens(stripIssuePrefix(issue.title))].filter(
(token) => identifiers.size === 0 || !identifierQueryNoiseTokens.has(token),
);
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);
}
@@ -207,11 +136,7 @@ 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)
)) || [],
?.filter((token) => !genericLatinTokens.has(token) && !/^v?\d+(?:[._-]\d+)*$/u.test(token) && !/^(.)\1{2,}$/u.test(token)) || [],
);
}
@@ -334,26 +259,15 @@ export function buildCorpusContext(issue, candidates) {
const frequencies = new Map();
for (const token of queryTitleTokens) {
const documentFrequency = candidateTitleTokens.reduce(
(count, tokens) => count + (tokens.has(token) ? 1 : 0),
0,
);
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 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
)),
);
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 };
}
@@ -369,29 +283,18 @@ export function scoreCandidate(issue, candidate, rank = 0, corpusContext) {
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 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 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 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;
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) {
@@ -401,10 +304,7 @@ export function scoreCandidate(issue, candidate, rank = 0, corpusContext) {
// 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);
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,
@@ -421,9 +321,7 @@ export function scoreCandidate(issue, candidate, rank = 0, corpusContext) {
}
export function rankCandidates(issue, items) {
const candidates = items.filter(
(candidate) => !candidate.pull_request && Number(candidate.number) !== Number(issue.number),
);
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) }))
@@ -510,11 +408,13 @@ export async function run({ issue = loadIssue(), client } = {}) {
return [];
}
const github = client || new GitHubClient({
token: process.env.GITHUB_TOKEN,
repository: process.env.GITHUB_REPOSITORY,
apiBase: process.env.GITHUB_API_URL,
});
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 [];
+20 -23
View File
@@ -2,15 +2,7 @@ import assert from "node:assert/strict";
import { createServer } from "node:http";
import test from "node:test";
import {
GitHubClient,
formatComment,
rankCandidates,
run,
scoreCandidate,
searchTerms,
searchTitleTerms,
} from "./suggest-similar-issues.mjs";
import { GitHubClient, formatComment, rankCandidates, run, scoreCandidate, searchTerms, searchTitleTerms } from "./suggest-similar-issues.mjs";
const baseIssue = {
number: 4043,
@@ -213,7 +205,10 @@ test("uses corpus rarity to reject generic real-world matches", () => {
];
const ranked = rankCandidates(issue, candidates);
assert.deepEqual(ranked.map(({ candidate }) => candidate.number), [2201]);
assert.deepEqual(
ranked.map(({ candidate }) => candidate.number),
[2201],
);
assert.equal(ranked[0].signals.anchorHit, true);
});
@@ -247,9 +242,7 @@ test("ranks candidates, excludes the current issue and limits output", () => {
});
test("formats a cautious localized comment with concise issue references", () => {
const comment = formatComment(baseIssue, [
{ candidate: { number: 82, title: "[Bug] [快捷键](https://example.com)", state: "closed" } },
]);
const comment = formatComment(baseIssue, [{ candidate: { number: 82, title: "[Bug] [快捷键](https://example.com)", state: "closed" } }]);
assert.match(comment, /^<!-- dbx-similar-issues -->/u);
assert.match(comment, /\n- #82\n/u);
@@ -272,16 +265,20 @@ test("run searches and posts one idempotent comment", async (t) => {
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" }],
}],
}));
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")) {
+10 -10
View File
@@ -59,13 +59,15 @@ export function stripDownloadSection(body) {
// 剥离 release notes 里的 issue 引用 (closes #xxx),保留贡献者署名 (contributed by @xxx)。
// 同时处理纯 closes 括号和混在 contributed by 括号里的 closes 片段。
function stripIssueRefs(text) {
return text
// 纯 closes 括号整体去掉,如 (closes #123) 或 (closes #123, closes #456)
.replace(/\s*\(closes #\d+(?:,\s*closes #\d+)*\)/g, "")
// closes 在前、contributed by 在后,如 (closes #88, contributed by @xxx) → (contributed by @xxx)
.replace(/\(closes #\d+,\s*/g, "(")
// contributed by 在前、closes 在后,如 (contributed by @xxx, closes #123) → (contributed by @xxx)
.replace(/,\s*closes #\d+/g, "");
return (
text
// 纯 closes 括号整体去掉,如 (closes #123) 或 (closes #123, closes #456)
.replace(/\s*\(closes #\d+(?:,\s*closes #\d+)*\)/g, "")
// closes 在前、contributed by 在后,如 (closes #88, contributed by @xxx) → (contributed by @xxx)
.replace(/\(closes #\d+,\s*/g, "(")
// contributed by 在前、closes 在后,如 (contributed by @xxx, closes #123) → (contributed by @xxx)
.replace(/,\s*closes #\d+/g, "")
);
}
// 对整份 releases JSON 统一剥离 closes # 引用。cache 复用的英文翻译来自 R2,
@@ -187,9 +189,7 @@ function buildLatestEnNotes(enReleasesJson) {
}
export function buildLatestReleaseNotes(releases) {
const latest = releases
.filter(isAppRelease)
.sort((a, b) => new Date(b.published_at) - new Date(a.published_at))[0];
const latest = releases.filter(isAppRelease).sort((a, b) => new Date(b.published_at) - new Date(a.published_at))[0];
if (!latest) return null;
return { version: latest.tag_name, notes: latest.body || "" };
}
+4 -14
View File
@@ -40,9 +40,7 @@ async function main() {
});
console.log(`Uploading ${assets.length} CNB asset(s) with concurrency ${args.concurrency}.`);
await mapWithConcurrency(assets, args.concurrency, (assetPath) =>
uploadWithRetry(client, release.id, assetPath, args.overwriteExisting),
);
await mapWithConcurrency(assets, args.concurrency, (assetPath) => uploadWithRetry(client, release.id, assetPath, args.overwriteExisting));
}
function parseArgs(argv) {
@@ -70,9 +68,7 @@ function parseArgs(argv) {
}
if (!args.token) throw new Error("CNB_TOKEN is required.");
if (!args.githubReleasePath || (!args.metadataOnly && !args.assetsDir)) {
throw new Error(
"Usage: sync-cnb-release.mjs --github-release <release.json> (--assets-dir <dir> | --metadata-only) [--prune-assets] [--make-latest]",
);
throw new Error("Usage: sync-cnb-release.mjs --github-release <release.json> (--assets-dir <dir> | --metadata-only) [--prune-assets] [--make-latest]");
}
if (!Number.isInteger(args.concurrency) || args.concurrency < 1) {
throw new Error("CNB_UPLOAD_CONCURRENCY must be a positive integer.");
@@ -162,19 +158,13 @@ export class CnbClient {
}
async deleteAsset(releaseId, assetId) {
await this.request(
"DELETE",
`/${this.repository}/-/releases/${encodeURIComponent(releaseId)}/assets/${encodeURIComponent(assetId)}`,
);
await this.request("DELETE", `/${this.repository}/-/releases/${encodeURIComponent(releaseId)}/assets/${encodeURIComponent(assetId)}`);
}
async listReleases(pageSize = 100) {
const releases = [];
for (let page = 1; ; page++) {
const batch = await this.request(
"GET",
`/${this.repository}/-/releases?page=${page}&page_size=${pageSize}`,
);
const batch = await this.request("GET", `/${this.repository}/-/releases?page=${page}&page_size=${pageSize}`);
if (!Array.isArray(batch)) throw new Error("CNB release list response must be an array.");
releases.push(...batch);
if (batch.length < pageSize) return releases;
+9 -20
View File
@@ -103,11 +103,7 @@ test("ensureRelease explicitly marks a promoted release as latest", async (t) =>
token: "test-token",
});
await client.ensureRelease(
"v1.2.3",
{ name: "DBX v1.2.3", body: "Stable", isPrerelease: false },
{ makeLatest: true },
);
await client.ensureRelease("v1.2.3", { name: "DBX v1.2.3", body: "Stable", isPrerelease: false }, { makeLatest: true });
assert.deepEqual(requests, [
{ method: "GET", url: "/dbxio.com/dbx/-/releases/tags/v1.2.3", body: null },
@@ -157,26 +153,19 @@ test("metadata-only sync can mark the release as latest without reading an asset
const tempDir = await mkdtemp(join(tmpdir(), "sync-cnb-release-"));
t.after(() => rm(tempDir, { recursive: true, force: true }));
const releasePath = join(tempDir, "release.json");
await writeFile(
releasePath,
JSON.stringify({ tagName: "v1.2.3", name: "DBX v1.2.3", body: "Stable", isPrerelease: false }),
);
await writeFile(releasePath, JSON.stringify({ tagName: "v1.2.3", name: "DBX v1.2.3", body: "Stable", isPrerelease: false }));
const address = server.address();
assert.ok(address && typeof address !== "string");
const result = await new Promise((resolve) => {
const child = spawn(
process.execPath,
[scriptPath, "--github-release", releasePath, "--metadata-only", "--make-latest"],
{
env: {
...process.env,
CNB_API_BASE: `http://127.0.0.1:${address.port}`,
CNB_REPOSITORY: "dbxio.com/dbx",
CNB_TOKEN: "test-token",
},
const child = spawn(process.execPath, [scriptPath, "--github-release", releasePath, "--metadata-only", "--make-latest"], {
env: {
...process.env,
CNB_API_BASE: `http://127.0.0.1:${address.port}`,
CNB_REPOSITORY: "dbxio.com/dbx",
CNB_TOKEN: "test-token",
},
);
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk) => (stdout += chunk));
+19 -10
View File
@@ -55,7 +55,8 @@ async function files() {
// the description with the block in place of the old one, or at its foot
function withBlock(body, block) {
body = body || "";
const a = body.indexOf(BEGIN), b = body.indexOf(END);
const a = body.indexOf(BEGIN),
b = body.indexOf(END);
if (a >= 0 && b > a) return body.slice(0, a) + block + body.slice(b + END.length);
return body.replace(/\s*$/, "") + "\n\n" + block;
}
@@ -102,7 +103,9 @@ async function pushMedia(sha, dir) {
git(["fetch", "-q", "--depth=1", remote, BRANCH], work);
base = git(["rev-parse", "FETCH_HEAD"], work);
git(["checkout", "-q", "-f", "--detach", "FETCH_HEAD"], work);
} catch { /* the first one */ }
} catch {
/* the first one */
}
// this PR's latest run only, and no PR that's been closed
for (const d of await fs.readdir(work).catch(() => [])) {
const m = /^pr-(\d+)$/.exec(d);
@@ -153,8 +156,12 @@ ${shots.map((x) => `<figure${x.warn ? ' class="shaky"' : ""}><img src="${esc(x.f
}
async function pagesURL() {
try { return (await gh(`/repos/${repo}/pages`)).html_url.replace(/\/?$/, "/"); }
catch { const [o, n] = repo.split("/"); return `https://${o.toLowerCase()}.github.io/${n}/`; }
try {
return (await gh(`/repos/${repo}/pages`)).html_url.replace(/\/?$/, "/");
} catch {
const [o, n] = repo.split("/");
return `https://${o.toLowerCase()}.github.io/${n}/`;
}
}
async function publish() {
@@ -163,12 +170,12 @@ async function publish() {
const dir = process.env.OUT_DIR;
const result = process.env.RECORD_RESULT || "success";
let m = null;
try { m = JSON.parse(await fs.readFile(path.join(dir, "manifest.json"), "utf8")); } catch {}
try {
m = JSON.parse(await fs.readFile(path.join(dir, "manifest.json"), "utf8"));
} catch {}
if (result === "cancelled" && !m) return; // a newer commit took over
if (!m || m.leak || (!m.scenes.some((s) => s.shots.length) && !m.video)) {
const why = m?.leak ? "录制时屏幕上出现了密钥,已全部丢弃,没有发布任何截图"
: m?.skipped === "no visible UI change found in the diff" ? "看了改动,没有找到用户能看到的界面变化,所以没有录制"
: `这次没录成(${result})`;
const why = m?.leak ? "录制时屏幕上出现了密钥,已全部丢弃,没有发布任何截图" : m?.skipped === "no visible UI change found in the diff" ? "看了改动,没有找到用户能看到的界面变化,所以没有录制" : `这次没录成(${result})`;
const errs = m?.errors?.length ? `\n\n<details><summary>细节</summary>\n\n${m.errors.map((e) => "- " + e).join("\n")}\n</details>` : "";
await setBlock(`${head(sha)}\n${m?.summary ? `**改动**(按代码):${m.summary}\n\n` : ""}${m?.mismatch ? `> [!WARNING]\n> **描述与代码不符**:${m.mismatch}\n\n` : ""}${why}。${runURL ? `详见[运行记录](${runURL})。` : ""}${errs}`);
return;
@@ -195,5 +202,7 @@ async function publish() {
}
const cmd = process.argv[2];
(cmd === "detect" ? detect() : cmd === "publish" ? publish() : Promise.reject(new Error("detect | publish")))
.catch((e) => { console.error(e); process.exit(1); });
(cmd === "detect" ? detect() : cmd === "publish" ? publish() : Promise.reject(new Error("detect | publish"))).catch((e) => {
console.error(e);
process.exit(1);
});
+134 -60
View File
@@ -34,7 +34,10 @@ const MODEL = env("PLAN_MODEL", "deepseek-flash");
const VIEW = { width: 1440, height: 900 };
// never pressed: they delete the sandbox's own data or its setup
const FORBIDDEN = /删除|清空|移除|卸载|重置|格式化|delete|remove|clear all|drop\b|truncate\b/i;
const secrets = env("SECRETS", "").split("\n").map((s) => s.trim()).filter((s) => s.length >= 8);
const secrets = env("SECRETS", "")
.split("\n")
.map((s) => s.trim())
.filter((s) => s.length >= 8);
const log = (...a) => console.log("[ui-preview]", ...a);
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -46,7 +49,13 @@ const manifest = { summary: "", scenes: [], video: null, poster: null, errors: [
function outline() {
const lines = [];
const MAX = 420;
const own = (e) => [...e.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join(" ").replace(/\s+/g, " ").slice(0, 70);
const own = (e) =>
[...e.childNodes]
.filter((n) => n.nodeType === 3)
.map((n) => n.textContent.trim())
.join(" ")
.replace(/\s+/g, " ")
.slice(0, 70);
const shown = (e) => {
const r = e.getBoundingClientRect();
if (!r.width || !r.height) return false;
@@ -58,9 +67,19 @@ function outline() {
if (lines.length >= MAX || e.hasAttribute?.("data-ui-preview")) return;
const tag = e.tagName.toLowerCase();
if (["script", "style", "svg", "link", "meta", "noscript", "template"].includes(tag) || !shown(e)) return;
const cls = [...e.classList].slice(0, 4).map((c) => "." + c).join("");
const data = [...e.attributes].filter((a) => a.name.startsWith("data-") && a.value.length < 40).slice(0, 3).map((a) => `[${a.name}="${a.value}"]`).join("");
const aria = ["role", "aria-label", "title", "placeholder", "type"].map((k) => e.getAttribute(k) ? `${k}="${e.getAttribute(k).slice(0, 40)}"` : "").filter(Boolean).join(" ");
const cls = [...e.classList]
.slice(0, 4)
.map((c) => "." + c)
.join("");
const data = [...e.attributes]
.filter((a) => a.name.startsWith("data-") && a.value.length < 40)
.slice(0, 3)
.map((a) => `[${a.name}="${a.value}"]`)
.join("");
const aria = ["role", "aria-label", "title", "placeholder", "type"]
.map((k) => (e.getAttribute(k) ? `${k}="${e.getAttribute(k).slice(0, 40)}"` : ""))
.filter(Boolean)
.join(" ");
const text = own(e);
const r = e.getBoundingClientRect();
const away = r.top >= fold ? " (below the fold)" : "";
@@ -75,8 +94,7 @@ function outline() {
}
async function screenText(page) {
return page.evaluate(() => document.documentElement.innerText + "\n" +
[...document.querySelectorAll("input, textarea")].map((e) => e.value).join("\n")).catch(() => "");
return page.evaluate(() => document.documentElement.innerText + "\n" + [...document.querySelectorAll("input, textarea")].map((e) => e.value).join("\n")).catch(() => "");
}
async function checkLeak(page, where) {
if (!secrets.length) return;
@@ -109,8 +127,12 @@ async function askPlanner(messages) {
if (!res.ok) throw new Error(`planner API ${res.status}: ${(await res.text()).slice(0, 300)}`);
const j = await res.json();
const text = j.choices?.[0]?.message?.content ?? "";
try { return JSON.parse(text.replace(/^```(?:json)?\s*|\s+```$/g, "")); }
catch (e) { last = new Error(`reply not JSON (${j.choices?.[0]?.finish_reason}): ${e.message}`); log(last.message, "— asking again"); }
try {
return JSON.parse(text.replace(/^```(?:json)?\s*|\s+```$/g, ""));
} catch (e) {
last = new Error(`reply not JSON (${j.choices?.[0]?.finish_reason}): ${e.message}`);
log(last.message, "— asking again");
}
}
throw last;
}
@@ -141,16 +163,22 @@ async function readTree(root) {
async function codeContext(diff, src) {
if (!src) return "";
const changed = [...new Set([...diff.matchAll(/^diff --git a\/(\S+) b\/\1$/gm)].map((m) => m[1]))]
.filter((f) => /^apps\/desktop\/src\//.test(f) && !SKIP.test(f.slice("apps/desktop/src/".length - 1)) && !SKIP.test(f));
const changed = [...new Set([...diff.matchAll(/^diff --git a\/(\S+) b\/\1$/gm)].map((m) => m[1]))].filter((f) => /^apps\/desktop\/src\//.test(f) && !SKIP.test(f.slice("apps/desktop/src/".length - 1)) && !SKIP.test(f));
// 1. the changed files
let out = "";
for (const f of changed.slice(0, 12)) {
let body;
try { body = await fs.readFile(path.join(src, f), "utf8"); } catch { continue; }
try {
body = await fs.readFile(path.join(src, f), "utf8");
} catch {
continue;
}
const chunk = `--- ${f}\n${body.length > 8000 ? body.slice(0, 8000) + "\n… (cut)" : body}\n`;
if (out.length + chunk.length > 20e3) { out += "… (cut)\n"; break; }
if (out.length + chunk.length > 20e3) {
out += "… (cut)\n";
break;
}
out += chunk;
}
@@ -170,16 +198,14 @@ async function codeContext(diff, src) {
let entries = "";
for (const name of components.slice(0, 6)) {
const kebab = name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
const re = new RegExp(
"import\\s+" + name + "\\b" +
"|from\\s+[\"'][^\"']*/" + name + "(\\.vue)?[\"']" +
"|import\\(\\s*[\"'][^\"']*/" + name + "(\\.vue)?[\"']\\s*\\)" +
"|<" + kebab + "[\\s>/]|<" + name + "[\\s>/]");
const re = new RegExp("import\\s+" + name + "\\b" + "|from\\s+[\"'][^\"']*/" + name + "(\\.vue)?[\"']" + "|import\\(\\s*[\"'][^\"']*/" + name + "(\\.vue)?[\"']\\s*\\)" + "|<" + kebab + "[\\s>/]|<" + name + "[\\s>/]");
const flags = new Map(); // caller file -> Set of line numbers
for (const p of files) {
const t = await read(p);
if (!t || rel(p).endsWith(`/${name}.vue`)) continue;
t.split("\n").forEach((l, i) => { if (re.test(l)) (flags.get(p) ?? flags.set(p, new Set()).get(p)).add(i); });
t.split("\n").forEach((l, i) => {
if (re.test(l)) (flags.get(p) ?? flags.set(p, new Set()).get(p)).add(i);
});
}
for (const [p, lines] of flags) {
// a dynamic import renames the component (const X = defineAsyncComponent):
@@ -194,14 +220,19 @@ async function codeContext(diff, src) {
}
if (aliases.size > 1) {
const tagRe = new RegExp("<(?:" + [...aliases].join("|") + "|" + [...aliases].map((a) => a.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()).join("|") + ")[\\s>/]");
linesArr.forEach((l, i) => { if (tagRe.test(l)) lines.add(i); });
linesArr.forEach((l, i) => {
if (tagRe.test(l)) lines.add(i);
});
}
// the usage with its v-if/v-show/:open condition, and identifiers in it
const span = [];
for (const i of lines) span.push(linesArr.slice(Math.max(0, i - 6), i + 14).join("\n"));
const cond = span.join("\n");
const chunk = `--- ${rel(p)} renders ${name} (how it is shown):\n${cond.slice(0, 2500)}\n`;
if (entries.length + chunk.length > 9e3) { entries += "… (cut)\n"; break; }
if (entries.length + chunk.length > 9e3) {
entries += "… (cut)\n";
break;
}
entries += chunk;
// who flips those conditions: identifiers in v-if / :open / v-model near the usage
const ids = [...new Set([...cond.matchAll(/(?:v-if|v-show|:open|v-model:open|:model-value)="\s*([A-Za-z_$][\w$.]*)/g)].map((m) => m[1]))].slice(0, 4);
@@ -270,9 +301,10 @@ async function glide(page, x, y) {
const n = Math.max(8, Math.round(ms / 16));
const from = { ...mouse };
for (let i = 1; i <= n; i++) {
const t = i / n, e = t < .5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
const t = i / n,
e = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
// a slight arc, not a ruler line
const bend = Math.sin(Math.PI * t) * Math.min(40, d * .08);
const bend = Math.sin(Math.PI * t) * Math.min(40, d * 0.08);
const nx = from.x + (x - from.x) * e - ((y - from.y) / (d || 1)) * bend;
const ny = from.y + (y - from.y) * e + ((x - from.x) / (d || 1)) * bend;
await page.mouse.move(nx, ny);
@@ -319,11 +351,14 @@ async function locate(page, step) {
const el = await hit.evaluateHandle((n, text) => {
for (;;) {
const kids = [...n.children].filter((c) => (c.textContent || "").includes(text) && c.getBoundingClientRect().width > 0);
if (kids.length === 1) n = kids[0]; else return n;
if (kids.length === 1) n = kids[0];
else return n;
}
}, text);
return el.asElement() || hit;
} catch { return hit; }
} catch {
return hit;
}
};
let loc = text ? all.filter({ hasText: text }) : all;
let hit = await visible(loc);
@@ -356,7 +391,6 @@ async function locate(page, step) {
throw new Error(`nothing visible matches ${step.target}${step.text ? ` with "${step.text}"` : ""} (${await loc.count()} in the page)`);
}
// Out of sight is scrolled to as a reader would, with the wheel over what
// scrolls. Each view that hides it, the innermost first, is wheeled in small
// steps until the element is in the middle of it, or at its top when it's
@@ -378,19 +412,22 @@ async function bringIntoView(page, loc) {
const root = s === document.scrollingElement;
const b = root ? clip : s.getBoundingClientRect();
// what of it shows: inside the window and every view around it
let top = Math.max(b.top, 0), bottom = Math.min(b.bottom, innerHeight);
let left = Math.max(b.left, 0), right = Math.min(b.right, innerWidth);
let top = Math.max(b.top, 0),
bottom = Math.min(b.bottom, innerHeight);
let left = Math.max(b.left, 0),
right = Math.min(b.right, innerWidth);
for (let p = s.parentElement; p && !root; p = p.parentElement) {
if (!scrollers.includes(p) || p === document.scrollingElement) continue;
const pb = p.getBoundingClientRect();
top = Math.max(top, pb.top); bottom = Math.min(bottom, pb.bottom);
left = Math.max(left, pb.left); right = Math.min(right, pb.right);
top = Math.max(top, pb.top);
bottom = Math.min(bottom, pb.bottom);
left = Math.max(left, pb.left);
right = Math.min(right, pb.right);
}
const room = bottom - top - 2 * PAD;
const want = r.height <= room ? (r.top + r.bottom) / 2 - (top + bottom) / 2 : r.top - (top + PAD);
const shown = r.top >= top + Math.min(PAD, Math.max(0, room - r.height) / 2) && (r.height <= room ? r.bottom <= bottom - PAD : r.top <= top + PAD * 2);
out.push({ shown, dy: Math.max(-s.scrollTop, Math.min(s.scrollHeight - s.clientHeight - s.scrollTop, want)),
x: (left + right) / 2, y: (top + bottom) / 2, seen: bottom - top > 20 && right - left > 20 });
out.push({ shown, dy: Math.max(-s.scrollTop, Math.min(s.scrollHeight - s.clientHeight - s.scrollTop, want)), x: (left + right) / 2, y: (top + bottom) / 2, seen: bottom - top > 20 && right - left > 20 });
}
return out;
}, PAD);
@@ -416,7 +453,8 @@ async function pointAt(page, loc) {
await bringIntoView(page, loc);
const b = await loc.boundingBox();
if (!b) throw new Error("the element has no box");
const x = b.x + Math.min(b.width / 2, 60), y = b.y + b.height / 2;
const x = b.x + Math.min(b.width / 2, 60),
y = b.y + b.height / 2;
await glide(page, x, y);
return b;
}
@@ -425,10 +463,16 @@ async function pointAt(page, loc) {
// the seeded connections painted in, is ready
async function settle(page) {
await page.waitForLoadState("load").catch(() => {});
await page.waitForFunction(() => {
const txt = document.body.innerText || "";
return txt.includes("新建连接") || txt.includes("New Connection") || document.querySelectorAll("button").length >= 6;
}, null, { timeout: 20000 }).catch(() => {});
await page
.waitForFunction(
() => {
const txt = document.body.innerText || "";
return txt.includes("新建连接") || txt.includes("New Connection") || document.querySelectorAll("button").length >= 6;
},
null,
{ timeout: 20000 },
)
.catch(() => {});
await sleep(1500);
}
@@ -440,13 +484,18 @@ async function runStep(page, step, scene) {
switch (step.do) {
case "click": {
const loc = await locate(page, step);
const label = (await loc.innerText().catch(() => "")) + " " + (await loc.getAttribute("title").catch(() => "") || "");
const label = (await loc.innerText().catch(() => "")) + " " + ((await loc.getAttribute("title").catch(() => "")) || "");
if (FORBIDDEN.test(label)) throw new Error(`won't press "${label.trim().slice(0, 40)}"`);
await pointAt(page, loc);
await sleep(180);
if (step.dblclick) {
await page.mouse.down(); await sleep(60); await page.mouse.up();
await sleep(120); await page.mouse.down(); await sleep(60); await page.mouse.up();
await page.mouse.down();
await sleep(60);
await page.mouse.up();
await sleep(120);
await page.mouse.down();
await sleep(60);
await page.mouse.up();
} else {
await page.mouse.down();
await sleep(90);
@@ -465,7 +514,9 @@ async function runStep(page, step, scene) {
case "type": {
const loc = await locate(page, step);
await pointAt(page, loc);
await page.mouse.down(); await sleep(80); await page.mouse.up();
await page.mouse.down();
await sleep(80);
await page.mouse.up();
await loc.fill("");
await page.keyboard.type(String(step.value ?? ""), { delay: 70 });
await sleep(600);
@@ -517,7 +568,8 @@ async function runStep(page, step, scene) {
if (b) {
// the target with room around it, never so small that a button
// is shown without where it is
const w = Math.min(VIEW.width, Math.max(560, b.width + 48)), h = Math.min(VIEW.height, Math.max(340, b.height + 48));
const w = Math.min(VIEW.width, Math.max(560, b.width + 48)),
h = Math.min(VIEW.height, Math.max(340, b.height + 48));
const x = Math.min(VIEW.width - w, Math.max(0, b.x + b.width / 2 - w / 2));
const y = Math.min(VIEW.height - h, Math.max(0, b.y + b.height / 2 - h / 2));
clip = { x, y, width: w, height: h };
@@ -553,7 +605,9 @@ async function main() {
await lp.goto(BASE);
await settle(lp);
outlines.app = await lp.evaluate(outline);
} catch (e) { outlines.app = `(failed to load: ${e.message})`; }
} catch (e) {
outlines.app = `(failed to load: ${e.message})`;
}
await look.close();
const MAXDIFF = 150e3;
@@ -561,13 +615,18 @@ async function main() {
log(`code context: ${code.length} characters`);
const ask = [
{ role: "system", content: GUIDE },
{ role: "user", content: `PR title: ${env("PR_TITLE", "")}\n\nPR description:\n${body.slice(0, 6000)}\n\nDiff${diff.length > MAXDIFF ? " (cut)" : ""}:\n${diff.slice(0, MAXDIFF)}\n\n` +
`=== outline: app ===\n${outlines.app}` +
(code ? `\n\n=== the changed frontend files (the PR's version) ===\n${code}` : "") },
{
role: "user",
content: `PR title: ${env("PR_TITLE", "")}\n\nPR description:\n${body.slice(0, 6000)}\n\nDiff${diff.length > MAXDIFF ? " (cut)" : ""}:\n${diff.slice(0, MAXDIFF)}\n\n` + `=== outline: app ===\n${outlines.app}` + (code ? `\n\n=== the changed frontend files (the PR's version) ===\n${code}` : ""),
},
];
let plan;
try { plan = env("PLAN_FILE") ? JSON.parse(await fs.readFile(env("PLAN_FILE"), "utf8")) : await askPlanner(ask); }
catch (e) { plan = null; manifest.errors.push(`plan: ${e.message}`); }
try {
plan = env("PLAN_FILE") ? JSON.parse(await fs.readFile(env("PLAN_FILE"), "utf8")) : await askPlanner(ask);
} catch (e) {
plan = null;
manifest.errors.push(`plan: ${e.message}`);
}
if (!plan || plan.ui_change === false || !plan.scenes?.length) {
manifest.summary = plan?.summary || "";
manifest.skipped = plan ? "no visible UI change found in the diff" : "no plan";
@@ -582,7 +641,8 @@ async function main() {
if (plan.scenes.length) {
const rec = await browser.newContext({ ...ctxOpts, recordVideo: { dir: path.join(OUT, "raw"), size: VIEW } });
await rec.addInitScript({ path: path.join(here, "cursor.js") });
let repairs = 0, interactive = false;
let repairs = 0,
interactive = false;
// one take per scene: every scene starts from the app as it opens, and
// its video keeps only what happens once the app has settled — the
// load never reaches the final cut, so a scene change reads as a cut,
@@ -613,8 +673,9 @@ async function main() {
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
if (["click", "hover", "type", "press", "scroll", "upload"].includes(step.do)) interactive = true;
try { await runStep(page, step, scene); }
catch (e) {
try {
await runStep(page, step, scene);
} catch (e) {
if (manifest.leak) break;
manifest.errors.push(`${scene.title} · ${step.do} ${step.target || step.name || ""}: ${e.message.split("\n")[0]}`);
log("step failed:", e.message);
@@ -640,13 +701,27 @@ async function main() {
repairs++;
// the page as it is now, and what went wrong: the rest of the scene again
try {
const fix = await askPlanner([...ask, { role: "assistant", content: JSON.stringify(plan) },
{ role: "user", content: `Step ${i + 1} of scene "${scene.title}" failed: ${e.message.split("\n")[0]}\nThe page now:\n${await page.evaluate(outline)}\n\nIf the failed selector came from the diff, remember the changed-file paths above say which dialog, panel or component holds it — a different one may have to be opened (or closed) first, not just a different selector for this page. Reply with JSON {"steps": [...]}: the steps to do instead of that one and the ones after it in this scene.` }]);
if (Array.isArray(fix.steps)) { steps = [...steps.slice(0, i + 1), ...fix.steps.slice(0, 20)]; log("repaired:", JSON.stringify(fix.steps)); }
} catch (e2) { manifest.errors.push(`repair: ${e2.message}`); }
const fix = await askPlanner([
...ask,
{ role: "assistant", content: JSON.stringify(plan) },
{
role: "user",
content: `Step ${i + 1} of scene "${scene.title}" failed: ${e.message.split("\n")[0]}\nThe page now:\n${await page.evaluate(outline)}\n\nIf the failed selector came from the diff, remember the changed-file paths above say which dialog, panel or component holds it — a different one may have to be opened (or closed) first, not just a different selector for this page. Reply with JSON {"steps": [...]}: the steps to do instead of that one and the ones after it in this scene.`,
},
]);
if (Array.isArray(fix.steps)) {
steps = [...steps.slice(0, i + 1), ...fix.steps.slice(0, 20)];
log("repaired:", JSON.stringify(fix.steps));
}
} catch (e2) {
manifest.errors.push(`repair: ${e2.message}`);
}
}
}
if (manifest.leak) { await page.close().catch(() => {}); break; }
if (manifest.leak) {
await page.close().catch(() => {});
break;
}
await caption(page, "");
await sleep(600);
takes.push({ video: await page.video().path(), from: Math.max(0, (ready - began) / 1000 - 0.2) });
@@ -659,9 +734,7 @@ async function main() {
// the takes joined, each already trimmed of its load
const args = ["-y", "-loglevel", "error"];
const pins = takes.map((t, i) => (args.push("-ss", t.from.toFixed(2), "-i", t.video), `[${i}:v]`));
args.push("-filter_complex", `${pins.join("")}concat=n=${takes.length}:v=1:a=0[v]`, "-map", "[v]",
"-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-r", "30",
path.join(OUT, "preview.mp4"));
args.push("-filter_complex", `${pins.join("")}concat=n=${takes.length}:v=1:a=0[v]`, "-map", "[v]", "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-r", "30", path.join(OUT, "preview.mp4"));
execFileSync("ffmpeg", args);
manifest.video = "preview.mp4";
manifest.poster = await poster(browser);
@@ -671,7 +744,8 @@ async function main() {
await browser.close();
if (manifest.leak) {
for (const f of await fs.readdir(OUT)) await fs.rm(path.join(OUT, f), { recursive: true, force: true });
manifest.scenes = []; manifest.video = manifest.poster = null;
manifest.scenes = [];
manifest.video = manifest.poster = null;
}
await fs.writeFile(path.join(OUT, "manifest.json"), JSON.stringify(manifest, null, 2));
log("done:", JSON.stringify({ shots: manifest.scenes.reduce((n, s) => n + s.shots.length, 0), video: manifest.video, errors: manifest.errors.length, leak: manifest.leak }));
+39 -12
View File
@@ -31,14 +31,22 @@ const DBS = [
name: "商店 Shop (SQLite)",
note: "订单、商品与客户示例库",
tables: "customers · products · orders · audit_log(含视图 v_daily_revenue、外键与触发器)",
warm: ["SELECT * FROM orders ORDER BY created_at DESC LIMIT 50", "SELECT c.city, COUNT(*) AS orders, SUM(o.amount) AS revenue\nFROM orders o JOIN customers c ON c.id = o.customer_id\nGROUP BY c.city ORDER BY revenue DESC", "SELECT category, COUNT(*) AS n, ROUND(AVG(price), 2) AS avg_price\nFROM products WHERE listed = 1 GROUP BY category ORDER BY n DESC"],
warm: [
"SELECT * FROM orders ORDER BY created_at DESC LIMIT 50",
"SELECT c.city, COUNT(*) AS orders, SUM(o.amount) AS revenue\nFROM orders o JOIN customers c ON c.id = o.customer_id\nGROUP BY c.city ORDER BY revenue DESC",
"SELECT category, COUNT(*) AS n, ROUND(AVG(price), 2) AS avg_price\nFROM products WHERE listed = 1 GROUP BY category ORDER BY n DESC",
],
},
{
file: "dbx-analytics.sqlite",
name: "分析 Analytics (SQLite)",
note: "事件流与每日指标",
tables: "events(5000 行,看分页)· daily_stats(90 天)",
warm: ["SELECT event_type, COUNT(*) AS n, ROUND(AVG(duration_ms)) AS avg_ms\nFROM events GROUP BY event_type ORDER BY n DESC", "SELECT date(ts) AS day, COUNT(DISTINCT user_id) AS dau\nFROM events GROUP BY day ORDER BY day DESC LIMIT 14", "SELECT * FROM daily_stats ORDER BY stat_date DESC LIMIT 30"],
warm: [
"SELECT event_type, COUNT(*) AS n, ROUND(AVG(duration_ms)) AS avg_ms\nFROM events GROUP BY event_type ORDER BY n DESC",
"SELECT date(ts) AS day, COUNT(DISTINCT user_id) AS dau\nFROM events GROUP BY day ORDER BY day DESC LIMIT 14",
"SELECT * FROM daily_stats ORDER BY stat_date DESC LIMIT 30",
],
},
];
@@ -112,7 +120,11 @@ async function api(base, p, init = {}) {
});
const text = await res.text();
if (!res.ok) throw new Error(`${init.method || "GET"} ${p}: ${res.status} ${text.slice(0, 300)}`);
try { return JSON.parse(text); } catch { return text; }
try {
return JSON.parse(text);
} catch {
return text;
}
}
async function seedApi() {
@@ -166,8 +178,14 @@ async function seedApi() {
await api(base, "/api/saved-sql", {
method: "POST",
body: JSON.stringify({
...s, connectionId: conns[i % conns.length].id, folderId: folder?.id || "seed-folder-daily",
database: "main", orderIndex: i, openCount: 3 + i, createdAt: iso(36e5 * (70 - i)), updatedAt: iso(36e5 * (24 - i * 6)),
...s,
connectionId: conns[i % conns.length].id,
folderId: folder?.id || "seed-folder-daily",
database: "main",
orderIndex: i,
openCount: 3 + i,
createdAt: iso(36e5 * (70 - i)),
updatedAt: iso(36e5 * (24 - i * 6)),
}),
}).catch((e) => console.log("[ui-preview] saved-sql:", e.message));
}
@@ -188,9 +206,17 @@ async function seedApi() {
method: "POST",
body: JSON.stringify({
entry: {
id: `seed-h-${Math.random().toString(36).slice(2, 10)}`, connection_id: c.id, connection_name: c.name,
database: "main", sql, executed_at: iso(36e5 * (2 + Math.random() * 60)), execution_time_ms: ms, success: ok,
error: ok ? null : "demo entry: would have modified rows (not really run)", activity_kind: "query", source: "editor",
id: `seed-h-${Math.random().toString(36).slice(2, 10)}`,
connection_id: c.id,
connection_name: c.name,
database: "main",
sql,
executed_at: iso(36e5 * (2 + Math.random() * 60)),
execution_time_ms: ms,
success: ok,
error: ok ? null : "demo entry: would have modified rows (not really run)",
activity_kind: "query",
source: "editor",
},
}),
}).catch((e) => console.log("[ui-preview] history:", e.message));
@@ -205,12 +231,13 @@ if (cmd === "dbs") {
python(dir);
// a small importable file, for import/mapping dialogs (record.mjs's
// "upload" step resolves names inside this directory)
fs.writeFileSync(path.join(dir, "sample.csv"),
"name,category,price,stock\n" +
Array.from({ length: 8 }, (_, i) => `键盘 K${i + 100},键盘,${(199 + i * 23).toFixed(2)},${40 - i * 3}`).join("\n") + "\n");
fs.writeFileSync(path.join(dir, "sample.csv"), "name,category,price,stock\n" + Array.from({ length: 8 }, (_, i) => `键盘 K${i + 100},键盘,${(199 + i * 23).toFixed(2)},${40 - i * 3}`).join("\n") + "\n");
console.log("[ui-preview] databases written to", dir);
} else if (cmd === "api") {
seedApi().catch((e) => { console.error(e); process.exit(1); });
seedApi().catch((e) => {
console.error(e);
process.exit(1);
});
} else {
console.error("usage: seed.mjs dbs <dir> | seed.mjs api <base-url> <dir> [--as <server path>]");
process.exit(1);