mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
style: apply oxfmt across drifted files
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
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() {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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"] = [];
|
||||
|
||||
@@ -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
@@ -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\)/);
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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] || [])])],
|
||||
}));
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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]);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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 || "" };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user