chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
export function augmentLatestJsonWithJdbcPlugin({ latestJson, jdbcVersion, protocolVersion, url }) {
|
||||
const data = JSON.parse(latestJson);
|
||||
data.jdbc_plugin = {
|
||||
version: jdbcVersion,
|
||||
protocol_version: Number(protocolVersion),
|
||||
url,
|
||||
};
|
||||
return `${JSON.stringify(data, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function main() {
|
||||
const [latestJsonPath, jdbcVersion, protocolVersion, url] = process.argv.slice(2);
|
||||
if (!latestJsonPath || !jdbcVersion || !protocolVersion || !url) {
|
||||
console.error("Usage: augment-latest-json-jdbc-plugin.mjs <latest.json> <jdbc-version> <protocol-version> <url>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const updated = augmentLatestJsonWithJdbcPlugin({
|
||||
latestJson: readFileSync(latestJsonPath, "utf8"),
|
||||
jdbcVersion,
|
||||
protocolVersion,
|
||||
url,
|
||||
});
|
||||
writeFileSync(latestJsonPath, updated);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const VERSIONS_PATH = "agents/versions.json";
|
||||
|
||||
function bumpPatchVersion(version) {
|
||||
const match = /^(\d+)\.(\d+)\.(\d+)(.*)$/.exec(version);
|
||||
if (!match) {
|
||||
throw new Error(`Agent version '${version}' is not a patchable semver version.`);
|
||||
}
|
||||
return `${match[1]}.${match[2]}.${Number(match[3]) + 1}${match[4]}`;
|
||||
}
|
||||
|
||||
function pathChanged(changedFiles, pathPrefix) {
|
||||
const normalized = pathPrefix.endsWith("/") ? pathPrefix : `${pathPrefix}/`;
|
||||
return changedFiles.some((file) => file === pathPrefix || file.startsWith(normalized));
|
||||
}
|
||||
|
||||
function isCommonRuntimeChange(file) {
|
||||
return file === "agents/common/build.gradle" || file.startsWith("agents/common/src/main/");
|
||||
}
|
||||
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
function fileContainsCommonDependency(path, moduleExists, readModuleFile) {
|
||||
if (!moduleExists(path)) return false;
|
||||
const source = readModuleFile(path);
|
||||
return /project\(\s*['"]:common['"]\s*\)/.test(source);
|
||||
}
|
||||
|
||||
function resolveAgentModule(moduleName, { legacyStandaloneModules, moduleExists, readModuleFile }) {
|
||||
let checkDir = null;
|
||||
if (moduleName === "oracle" && moduleExists("agents/drivers/oracle-go")) {
|
||||
checkDir = "drivers/oracle-go";
|
||||
} else if (moduleExists(`agents/drivers/${moduleName}`)) {
|
||||
checkDir = `drivers/${moduleName}`;
|
||||
} else if (moduleExists(`agents/${moduleName}`) || moduleName === "common") {
|
||||
checkDir = moduleName;
|
||||
}
|
||||
|
||||
if (!checkDir) return null;
|
||||
|
||||
const modulePath = `agents/${checkDir}`;
|
||||
const buildGradlePath = `${modulePath}/build.gradle`;
|
||||
const hasBuildGradle = moduleExists(buildGradlePath);
|
||||
const explicitlyDependsOnCommon = fileContainsCommonDependency(buildGradlePath, moduleExists, readModuleFile);
|
||||
|
||||
return {
|
||||
checkDir,
|
||||
modulePath,
|
||||
commonDependent: hasBuildGradle && (explicitlyDependsOnCommon || !legacyStandaloneModules.has(moduleName)),
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
if (manualVersionsChanged && !skipBump) {
|
||||
logs.push("Manual agents/versions.json changes detected; preserving manually changed module versions and auto-bumping the rest.");
|
||||
}
|
||||
|
||||
if (skipBump) {
|
||||
logs.push("Skipping automatic module version bump for migrated first release; versions.json was carried over from dbx-agents.");
|
||||
return { changed, versions: nextVersions, prevVersions, logs };
|
||||
}
|
||||
|
||||
const commonChanged = changedFiles.some(isCommonRuntimeChange);
|
||||
if (commonChanged) {
|
||||
logs.push("Common agent runtime changes detected; common-triggered bumps are limited to modules that package agents/common.");
|
||||
}
|
||||
|
||||
for (const moduleName of Object.keys(versions)) {
|
||||
const module = resolveAgentModule(moduleName, { legacyStandaloneModules, moduleExists, readModuleFile });
|
||||
if (!module) continue;
|
||||
|
||||
const moduleChanged = pathChanged(changedFiles, module.modulePath);
|
||||
// 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;
|
||||
const oldVersion = nextVersions[moduleName] ?? "0.1.0";
|
||||
const prevVersion = prevVersions[moduleName] ?? "";
|
||||
const manuallyVersioned = manualVersionsChanged && (!prevVersion || prevVersion !== oldVersion);
|
||||
|
||||
if (!moduleChanged && !commonAffectsModule) {
|
||||
logs.push(` ${moduleName}: no changes`);
|
||||
} else if (manuallyVersioned) {
|
||||
if (!prevVersion) {
|
||||
logs.push(` ${moduleName}: CHANGED, new module version kept at ${oldVersion}`);
|
||||
} else {
|
||||
logs.push(` ${moduleName}: CHANGED, manual version ${prevVersion} -> ${oldVersion}`);
|
||||
}
|
||||
} else {
|
||||
const newVersion = bumpPatchVersion(oldVersion);
|
||||
nextVersions[moduleName] = newVersion;
|
||||
changed = true;
|
||||
logs.push(` ${moduleName}: CHANGED`);
|
||||
logs.push(` ${moduleName}: ${oldVersion} -> ${newVersion}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { changed, versions: nextVersions, prevVersions, logs };
|
||||
}
|
||||
|
||||
function git(args) {
|
||||
return execFileSync("git", args, { encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
migratedFirstRelease: false,
|
||||
prevTag: "",
|
||||
prevVersionsFile: "",
|
||||
skipBump: false,
|
||||
write: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--write") {
|
||||
options.write = true;
|
||||
} else if (arg === "--skip-bump") {
|
||||
options.skipBump = true;
|
||||
} else if (arg === "--prev-tag") {
|
||||
options.prevTag = argv[++index] ?? "";
|
||||
} else if (arg === "--prev-versions-file") {
|
||||
options.prevVersionsFile = argv[++index] ?? "";
|
||||
} else if (arg === "--migrated-first-release") {
|
||||
options.migratedFirstRelease = (argv[++index] ?? "") === "true";
|
||||
} else {
|
||||
throw new Error(`Unexpected argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.prevTag) {
|
||||
throw new Error("--prev-tag is required.");
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function outputStepValues(result, prevTag, migratedFirstRelease) {
|
||||
const outputPath = process.env.GITHUB_OUTPUT;
|
||||
if (!outputPath) return;
|
||||
|
||||
appendFileSync(
|
||||
outputPath,
|
||||
[
|
||||
`versions=${JSON.stringify(result.versions)}`,
|
||||
`prev_versions=${JSON.stringify(result.prevVersions)}`,
|
||||
`prev_tag=${prevTag}`,
|
||||
`migrated_first_release=${migratedFirstRelease}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const versions = JSON.parse(readFileSync(VERSIONS_PATH, "utf8"));
|
||||
const legacyStandaloneModules = parseLegacyStandaloneProjects(readFileSync("agents/build.gradle", "utf8"));
|
||||
const changedFiles = options.skipBump ? [] : git(["diff", "--name-only", `${options.prevTag}..HEAD`]).split("\n").filter(Boolean);
|
||||
const manualVersionsChanged = changedFiles.includes(VERSIONS_PATH);
|
||||
const prevVersions = options.prevVersionsFile
|
||||
? JSON.parse(readFileSync(options.prevVersionsFile, "utf8"))
|
||||
: manualVersionsChanged
|
||||
? JSON.parse(git(["show", `${options.prevTag}:${VERSIONS_PATH}`]))
|
||||
: versions;
|
||||
|
||||
const result = evaluateAgentVersionBump({
|
||||
versions,
|
||||
prevVersions,
|
||||
changedFiles,
|
||||
legacyStandaloneModules,
|
||||
skipBump: options.skipBump,
|
||||
manualVersionsChanged,
|
||||
});
|
||||
|
||||
for (const line of result.logs) {
|
||||
console.log(line);
|
||||
}
|
||||
|
||||
const versionsJson = `${JSON.stringify(result.versions, null, 2)}\n`;
|
||||
if (options.write) {
|
||||
writeFileSync(VERSIONS_PATH, versionsJson);
|
||||
}
|
||||
console.log(versionsJson);
|
||||
outputStepValues(result, options.prevTag, options.migratedFirstRelease);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const POM_PATH = "plugins/jdbc/pom.xml";
|
||||
const MANIFEST_PATH = "plugins/jdbc/manifest.json";
|
||||
|
||||
function firstProjectVersion(pomXml) {
|
||||
const match = pomXml.match(/<project[\s\S]*?<version>([^<]+)<\/version>/);
|
||||
return match?.[1]?.trim() ?? "";
|
||||
}
|
||||
|
||||
function manifestVersion(manifestJson) {
|
||||
return JSON.parse(manifestJson).version ?? "";
|
||||
}
|
||||
|
||||
function bumpPatchVersion(version) {
|
||||
const match = version.match(/^(\d+)\.(\d+)\.(\d+)(.*)$/);
|
||||
if (!match) {
|
||||
throw new Error(`JDBC plugin version '${version}' is not a patchable semver version.`);
|
||||
}
|
||||
return `${match[1]}.${match[2]}.${Number(match[3]) + 1}${match[4]}`;
|
||||
}
|
||||
|
||||
function isReleaseBumpRelevantJdbcPluginChange(file) {
|
||||
if (file.startsWith("plugins/jdbc/src/") || file.startsWith("plugins/jdbc/bin/")) return true;
|
||||
if (!file.startsWith("plugins/jdbc/")) return false;
|
||||
if (file.startsWith("plugins/jdbc/dist/") || file.startsWith("plugins/jdbc/target/")) return false;
|
||||
if (file === "plugins/jdbc/README.md" || file === "plugins/jdbc/package.sh") return false;
|
||||
if (file === POM_PATH || file === MANIFEST_PATH) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function hasJdbcPluginVersionChange(file) {
|
||||
return file === POM_PATH || file === MANIFEST_PATH;
|
||||
}
|
||||
|
||||
function updatePomVersion(pomXml, version) {
|
||||
return pomXml.replace(/(<project[\s\S]*?<version>)([^<]+)(<\/version>)/, `$1${version}$3`);
|
||||
}
|
||||
|
||||
function updateManifestVersion(manifestJson, version) {
|
||||
const manifest = JSON.parse(manifestJson);
|
||||
manifest.version = version;
|
||||
return `${JSON.stringify(manifest, null, 2)}\n`;
|
||||
}
|
||||
|
||||
export function evaluateJdbcPluginReleaseBump({ changedFiles, pomXml, manifestJson }) {
|
||||
const pomVersion = firstProjectVersion(pomXml);
|
||||
const currentManifestVersion = manifestVersion(manifestJson);
|
||||
if (pomVersion !== currentManifestVersion) {
|
||||
throw new Error(`JDBC plugin version mismatch: pom.xml is ${pomVersion} but manifest.json is ${currentManifestVersion}.`);
|
||||
}
|
||||
|
||||
const shouldBump = changedFiles.some(isReleaseBumpRelevantJdbcPluginChange) && !changedFiles.some(hasJdbcPluginVersionChange);
|
||||
const newVersion = shouldBump ? bumpPatchVersion(pomVersion) : pomVersion;
|
||||
return {
|
||||
changed: shouldBump,
|
||||
oldVersion: pomVersion,
|
||||
newVersion,
|
||||
pomXml: shouldBump ? updatePomVersion(pomXml, newVersion) : pomXml,
|
||||
manifestJson: shouldBump ? updateManifestVersion(manifestJson, newVersion) : manifestJson,
|
||||
};
|
||||
}
|
||||
|
||||
function git(args) {
|
||||
return execFileSync("git", args, { encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
function main() {
|
||||
const [baseRef = "HEAD~1", headRef = "HEAD", ...flags] = process.argv.slice(2);
|
||||
const write = flags.includes("--write");
|
||||
const changedFiles = git(["diff", "--name-only", baseRef, headRef]).split("\n").filter(Boolean);
|
||||
const result = evaluateJdbcPluginReleaseBump({
|
||||
changedFiles,
|
||||
pomXml: readFileSync(POM_PATH, "utf8"),
|
||||
manifestJson: readFileSync(MANIFEST_PATH, "utf8"),
|
||||
});
|
||||
|
||||
if (write && result.changed) {
|
||||
writeFileSync(POM_PATH, result.pomXml);
|
||||
writeFileSync(MANIFEST_PATH, result.manifestJson);
|
||||
}
|
||||
|
||||
console.log(`changed=${result.changed}`);
|
||||
console.log(`old_version=${result.oldVersion}`);
|
||||
console.log(`new_version=${result.newVersion}`);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const POM_PATH = "plugins/jdbc/pom.xml";
|
||||
const MANIFEST_PATH = "plugins/jdbc/manifest.json";
|
||||
|
||||
function firstProjectVersion(pomXml) {
|
||||
const match = pomXml.match(/<project[\s\S]*?<version>([^<]+)<\/version>/);
|
||||
return match?.[1]?.trim() ?? "";
|
||||
}
|
||||
|
||||
function manifestVersion(manifestJson) {
|
||||
return JSON.parse(manifestJson).version ?? "";
|
||||
}
|
||||
|
||||
export function evaluateJdbcPluginVersionChange({ headPomVersion, headManifestVersion }) {
|
||||
const errors = [];
|
||||
if (headPomVersion !== headManifestVersion) {
|
||||
errors.push(`JDBC plugin version mismatch: pom.xml is ${headPomVersion} but manifest.json is ${headManifestVersion}.`);
|
||||
return errors;
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
function git(args) {
|
||||
return execFileSync("git", args, { encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
function readFileAt(ref, path) {
|
||||
return git(["show", `${ref}:${path}`]);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const [, headRef = "HEAD"] = process.argv.slice(2);
|
||||
const headPomVersion = firstProjectVersion(readFileAt(headRef, POM_PATH));
|
||||
const headManifestVersion = manifestVersion(readFileAt(headRef, MANIFEST_PATH));
|
||||
const errors = evaluateJdbcPluginVersionChange({
|
||||
headPomVersion,
|
||||
headManifestVersion,
|
||||
});
|
||||
|
||||
if (errors.length) {
|
||||
for (const error of errors) {
|
||||
console.error(`::error::${error}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`JDBC plugin version check passed (${headPomVersion}).`);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,544 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const LOCALES_DIR = "apps/desktop/src/i18n/locales";
|
||||
const SOURCE_LOCALE = "zh-CN";
|
||||
const TARGET_LOCALES = ["en", "es", "it", "ja", "pt-BR", "zh-TW"];
|
||||
const TARGET_LABELS = {
|
||||
en: "English",
|
||||
es: "Spanish",
|
||||
it: "Italian",
|
||||
ja: "Japanese",
|
||||
"pt-BR": "Brazilian Portuguese",
|
||||
"zh-TW": "Traditional Chinese used in Taiwan",
|
||||
};
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const dryRun = args.has("--dry-run") || !args.has("--write");
|
||||
const mockTranslations = args.has("--mock-translations") || process.env.I18N_MOCK_TRANSLATIONS === "1";
|
||||
const baseRef = valueArg("--base-ref") || process.env.I18N_BASE_REF || "origin/main";
|
||||
const apiKey = process.env.DEEPSEEK_API_KEY || "";
|
||||
const apiUrl = process.env.DEEPSEEK_API_URL || "https://api.deepseek.com/chat/completions";
|
||||
const model = process.env.DEEPSEEK_MODEL || "deepseek-v4-pro";
|
||||
|
||||
async function main() {
|
||||
const sourcePath = localePath(SOURCE_LOCALE);
|
||||
const baseSourceText = readBaseFile(sourcePath);
|
||||
const headSource = parseLocaleFile(readFileSync(sourcePath, "utf8"), sourcePath);
|
||||
const baseSource = parseLocaleFile(baseSourceText, `${baseRef}:${sourcePath}`);
|
||||
|
||||
// Only PR-new Chinese source keys are eligible for auto-fill; historical
|
||||
// locale gaps intentionally stay untouched to keep bot patches scoped.
|
||||
const baseLeaves = flattenLeaves(baseSource.root);
|
||||
const headLeaves = flattenLeaves(headSource.root);
|
||||
const newSourceKeys = [...headLeaves.keys()].filter((key) => !baseLeaves.has(key));
|
||||
|
||||
if (newSourceKeys.length === 0) {
|
||||
console.log(`No new ${SOURCE_LOCALE} i18n keys compared with ${baseRef}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${newSourceKeys.length} new ${SOURCE_LOCALE} i18n key(s):`);
|
||||
for (const key of newSourceKeys) console.log(`- ${key}`);
|
||||
|
||||
let patchedFiles = 0;
|
||||
const summary = {
|
||||
sourceLocale: SOURCE_LOCALE,
|
||||
newKeys: newSourceKeys,
|
||||
updatedLocales: [],
|
||||
};
|
||||
|
||||
for (const locale of TARGET_LOCALES) {
|
||||
const path = localePath(locale);
|
||||
if (!existsSync(path)) throw new Error(`Missing locale file: ${path}`);
|
||||
|
||||
const text = readFileSync(path, "utf8");
|
||||
const parsed = parseLocaleFile(text, path);
|
||||
const targetLeaves = flattenLeaves(parsed.root);
|
||||
const missingKeys = newSourceKeys.filter((key) => !targetLeaves.has(key));
|
||||
|
||||
if (missingKeys.length === 0) {
|
||||
console.log(`${locale}: all new keys already present`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`${locale}: missing ${missingKeys.length} new key(s)`);
|
||||
for (const key of missingKeys) console.log(` - ${key}`);
|
||||
|
||||
const translations = await translateMissing(locale, missingKeys, headLeaves);
|
||||
const updated = patchLocaleText(text, parsed, missingKeys, translations, headSource.root);
|
||||
|
||||
if (updated === text) {
|
||||
throw new Error(`${path}: expected changes for missing keys, but patch was empty`);
|
||||
}
|
||||
|
||||
parseLocaleFile(updated, path);
|
||||
if (!dryRun) writeFileSync(path, updated);
|
||||
patchedFiles += 1;
|
||||
summary.updatedLocales.push({ locale, count: missingKeys.length });
|
||||
}
|
||||
|
||||
if (patchedFiles === 0) {
|
||||
console.log("No locale files needed changes");
|
||||
} else if (dryRun) {
|
||||
console.log(`Dry run completed; ${patchedFiles} locale file(s) would be patched`);
|
||||
} else {
|
||||
console.log(`Patched ${patchedFiles} locale file(s)`);
|
||||
}
|
||||
|
||||
writeSummary(summary);
|
||||
}
|
||||
|
||||
function valueArg(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
if (index === -1) return null;
|
||||
const value = process.argv[index + 1];
|
||||
if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function localePath(locale) {
|
||||
return join(LOCALES_DIR, `${locale}.ts`);
|
||||
}
|
||||
|
||||
function readBaseFile(path) {
|
||||
try {
|
||||
return execFileSync("git", ["show", `${baseRef}:${path}`], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
|
||||
} catch (error) {
|
||||
throw new Error(`Unable to read ${path} from ${baseRef}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function writeSummary(summary) {
|
||||
const path = process.env.I18N_SUMMARY_FILE;
|
||||
if (!path) return;
|
||||
writeFileSync(path, `${JSON.stringify(summary, null, 2)}\n`);
|
||||
}
|
||||
|
||||
async function translateMissing(locale, keys, sourceLeaves) {
|
||||
if (mockTranslations) {
|
||||
return Object.fromEntries(keys.map((key) => [key, `[${locale}] ${sourceLeaves.get(key)}`]));
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error(`DEEPSEEK_API_KEY is required to translate ${keys.length} missing ${locale} i18n key(s)`);
|
||||
}
|
||||
|
||||
const items = keys.map((key) => ({ key, source: sourceLeaves.get(key) }));
|
||||
const response = await fetch(apiUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
response_format: { type: "json_object" },
|
||||
temperature: 0.1,
|
||||
max_tokens: Math.max(1024, keys.length * 120),
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: [
|
||||
`Translate DBX UI i18n strings from Simplified Chinese to ${TARGET_LABELS[locale]}.`,
|
||||
'Return strict JSON only in this exact shape: {"translations":{"path.to.key":"translated text"}}.',
|
||||
"Keep i18n placeholders such as {count}, {message}, and {name} exactly unchanged.",
|
||||
"Keep product names, database names, SQL keywords, file extensions, shortcuts, and code-like terms unchanged unless the target language convention clearly translates them.",
|
||||
"Do not add keys, remove keys, or translate key names.",
|
||||
].join(" "),
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: JSON.stringify({ targetLocale: locale, items }),
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`DeepSeek API failed for ${locale}: HTTP ${response.status} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data?.choices?.[0]?.message?.content;
|
||||
if (typeof content !== "string" || !content.trim()) {
|
||||
throw new Error(`DeepSeek API returned no translation content for ${locale}`);
|
||||
}
|
||||
|
||||
const parsed = parseJsonContent(content);
|
||||
const translations = parsed.translations;
|
||||
if (!translations || typeof translations !== "object" || Array.isArray(translations)) {
|
||||
throw new Error(`DeepSeek response for ${locale} must contain a translations object`);
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
if (typeof translations[key] !== "string" || !translations[key].trim()) {
|
||||
throw new Error(`DeepSeek response for ${locale} is missing translation for ${key}`);
|
||||
}
|
||||
assertSamePlaceholders(key, sourceLeaves.get(key), translations[key], locale);
|
||||
}
|
||||
|
||||
return translations;
|
||||
}
|
||||
|
||||
function parseJsonContent(content) {
|
||||
const trimmed = content.trim();
|
||||
const withoutFence = trimmed
|
||||
.replace(/^```(?:json)?\s*/i, "")
|
||||
.replace(/\s*```$/i, "")
|
||||
.trim();
|
||||
try {
|
||||
return JSON.parse(withoutFence);
|
||||
} catch (error) {
|
||||
throw new Error(`Unable to parse DeepSeek JSON response: ${error.message}\n${content}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSamePlaceholders(key, source, translated, locale) {
|
||||
const sourcePlaceholders = placeholders(source);
|
||||
const translatedPlaceholders = placeholders(translated);
|
||||
if (sourcePlaceholders.join("\0") !== translatedPlaceholders.join("\0")) {
|
||||
throw new Error(
|
||||
`${locale}:${key} placeholder mismatch: expected [${sourcePlaceholders.join(", ")}], got [${translatedPlaceholders.join(", ")}]`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function placeholders(value) {
|
||||
return [...String(value).matchAll(/\{[^{}]+\}/g)].map((match) => match[0]).sort();
|
||||
}
|
||||
|
||||
function patchLocaleText(text, parsed, missingKeys, translations, sourceRoot) {
|
||||
const groups = groupMissingKeys(parsed.root, missingKeys, translations, sourceRoot);
|
||||
const edits = [];
|
||||
|
||||
for (const group of groups) {
|
||||
const insertText = buildInsertion(text, group.objectNode, group.entries, group.sourceObjectNode);
|
||||
edits.push({ index: lineStartBefore(text, group.objectNode.closeBrace), text: insertText });
|
||||
}
|
||||
|
||||
return applyEdits(text, edits);
|
||||
}
|
||||
|
||||
function groupMissingKeys(targetRoot, missingKeys, translations, sourceRoot) {
|
||||
const byParent = new Map();
|
||||
|
||||
for (const key of missingKeys) {
|
||||
const segments = key.split(".");
|
||||
const { node: parentNode, path: parentPath, remaining } = findInsertionParent(targetRoot, segments);
|
||||
const parentKey = parentPath.join(".");
|
||||
const sourceParent = findObjectNode(sourceRoot, parentPath);
|
||||
if (!sourceParent) throw new Error(`${key}: insertion parent is missing in ${SOURCE_LOCALE}`);
|
||||
|
||||
const sourceOrder = sourceParent.properties.map((property) => property.key);
|
||||
const entry = {
|
||||
path: remaining,
|
||||
value: translations[key],
|
||||
order: sourceOrder.indexOf(remaining[0]),
|
||||
};
|
||||
|
||||
if (!byParent.has(parentKey)) byParent.set(parentKey, { objectNode: parentNode, sourceObjectNode: sourceParent, entries: [] });
|
||||
byParent.get(parentKey).entries.push(entry);
|
||||
}
|
||||
|
||||
return [...byParent.values()].map((group) => ({
|
||||
...group,
|
||||
entries: group.entries.sort((left, right) => left.order - right.order || left.path.join(".").localeCompare(right.path.join("."))),
|
||||
}));
|
||||
}
|
||||
|
||||
function findInsertionParent(root, fullPath) {
|
||||
let current = root;
|
||||
const existingPath = [];
|
||||
|
||||
for (const segment of fullPath.slice(0, -1)) {
|
||||
const property = current.properties.find((candidate) => candidate.key === segment);
|
||||
if (!property) break;
|
||||
if (property.value.type !== "object") {
|
||||
throw new Error(`${fullPath.join(".")}: cannot insert under non-object locale key ${[...existingPath, segment].join(".")}`);
|
||||
}
|
||||
current = property.value;
|
||||
existingPath.push(segment);
|
||||
}
|
||||
|
||||
return { node: current, path: existingPath, remaining: fullPath.slice(existingPath.length) };
|
||||
}
|
||||
|
||||
function findObjectNode(root, path) {
|
||||
let current = root;
|
||||
for (const segment of path) {
|
||||
const property = current.properties.find((candidate) => candidate.key === segment);
|
||||
if (!property || property.value.type !== "object") return null;
|
||||
current = property.value;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function buildInsertion(text, objectNode, entries, sourceObjectNode) {
|
||||
const parentIndent = lineIndentBefore(text, objectNode.closeBrace);
|
||||
const childIndent = objectNode.properties.length > 0 ? lineIndentBefore(text, objectNode.properties[0].start) : `${parentIndent} `;
|
||||
const lines = renderEntryTree(buildEntryTree(entries), sourceObjectNode, childIndent);
|
||||
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function buildEntryTree(entries) {
|
||||
const root = { children: new Map(), value: undefined };
|
||||
for (const entry of entries) {
|
||||
let current = root;
|
||||
for (const segment of entry.path) {
|
||||
if (!current.children.has(segment)) current.children.set(segment, { children: new Map(), value: undefined });
|
||||
current = current.children.get(segment);
|
||||
}
|
||||
current.value = entry.value;
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function renderEntryTree(tree, sourceObjectNode, indent) {
|
||||
const lines = [];
|
||||
const entries = [...tree.children.entries()].sort(([left], [right]) => compareSourceOrder(sourceObjectNode, left, right));
|
||||
|
||||
for (const [key, child] of entries) {
|
||||
if (child.children.size === 0) {
|
||||
lines.push(`${indent}${formatKey(key)}: ${JSON.stringify(child.value)},`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const childSourceObject = sourceObjectNode?.properties.find((property) => property.key === key && property.value.type === "object")?.value;
|
||||
lines.push(`${indent}${formatKey(key)}: {`);
|
||||
lines.push(...renderEntryTree(child, childSourceObject, `${indent} `));
|
||||
lines.push(`${indent}},`);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function compareSourceOrder(sourceObjectNode, left, right) {
|
||||
const order = sourceObjectNode?.properties.map((property) => property.key) || [];
|
||||
const leftIndex = order.indexOf(left);
|
||||
const rightIndex = order.indexOf(right);
|
||||
if (leftIndex !== -1 || rightIndex !== -1) {
|
||||
if (leftIndex === -1) return 1;
|
||||
if (rightIndex === -1) return -1;
|
||||
return leftIndex - rightIndex;
|
||||
}
|
||||
return left.localeCompare(right);
|
||||
}
|
||||
|
||||
function formatKey(key) {
|
||||
return /^[A-Za-z_$][\w$]*$/.test(key) ? key : JSON.stringify(key);
|
||||
}
|
||||
|
||||
function lineIndentBefore(text, index) {
|
||||
const lineStart = lineStartBefore(text, index);
|
||||
const match = text.slice(lineStart, index).match(/^[ \t]*/);
|
||||
return match ? match[0] : "";
|
||||
}
|
||||
|
||||
function lineStartBefore(text, index) {
|
||||
return text.lastIndexOf("\n", Math.max(0, index - 1)) + 1;
|
||||
}
|
||||
|
||||
function applyEdits(text, edits) {
|
||||
let updated = text;
|
||||
for (const edit of edits.sort((left, right) => right.index - left.index)) {
|
||||
updated = `${updated.slice(0, edit.index)}${edit.text}${updated.slice(edit.index)}`;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
function flattenLeaves(root) {
|
||||
const result = new Map();
|
||||
flattenNode(root, [], result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function flattenNode(node, path, result) {
|
||||
for (const property of node.properties) {
|
||||
const nextPath = [...path, property.key];
|
||||
if (property.value.type === "object") {
|
||||
flattenNode(property.value, nextPath, result);
|
||||
} else {
|
||||
result.set(nextPath.join("."), property.value.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseLocaleFile(text, file) {
|
||||
const parser = new Parser(text, file);
|
||||
const root = parser.parseLocaleRoot();
|
||||
return { root };
|
||||
}
|
||||
|
||||
class Parser {
|
||||
constructor(text, file) {
|
||||
this.text = text;
|
||||
this.file = file;
|
||||
this.index = 0;
|
||||
}
|
||||
|
||||
parseLocaleRoot() {
|
||||
const exportIndex = this.text.indexOf("export default");
|
||||
if (exportIndex === -1) this.fail("Missing export default");
|
||||
this.index = exportIndex + "export default".length;
|
||||
this.skipSpace();
|
||||
|
||||
if (this.peek() === "{") return this.parseObject();
|
||||
|
||||
const callee = this.parseIdentifier();
|
||||
this.skipSpace();
|
||||
if (!callee || this.peek() !== "(") this.fail("Expected object literal or wrapper call after export default");
|
||||
this.index += 1;
|
||||
this.skipSpace();
|
||||
const object = this.parseObject();
|
||||
this.skipSpace();
|
||||
if (this.peek() !== ")") this.fail("Expected closing wrapper call parenthesis");
|
||||
return object;
|
||||
}
|
||||
|
||||
parseObject() {
|
||||
this.expect("{");
|
||||
const openBrace = this.index - 1;
|
||||
const properties = [];
|
||||
|
||||
while (true) {
|
||||
this.skipSpace();
|
||||
if (this.peek() === "}") {
|
||||
const closeBrace = this.index;
|
||||
this.index += 1;
|
||||
return { type: "object", openBrace, closeBrace, end: this.index, properties };
|
||||
}
|
||||
|
||||
const start = this.index;
|
||||
const key = this.parseKey();
|
||||
this.skipSpace();
|
||||
this.expect(":");
|
||||
this.skipSpace();
|
||||
const value = this.parseValue();
|
||||
this.skipSpace();
|
||||
|
||||
let hasComma = false;
|
||||
if (this.peek() === ",") {
|
||||
hasComma = true;
|
||||
this.index += 1;
|
||||
}
|
||||
|
||||
properties.push({ key, start, end: this.index, hasComma, value });
|
||||
}
|
||||
}
|
||||
|
||||
parseValue() {
|
||||
const char = this.peek();
|
||||
if (char === "{") return this.parseObject();
|
||||
if (char === '"' || char === "'") {
|
||||
const start = this.index;
|
||||
const value = this.parseString();
|
||||
return { type: "string", start, end: this.index, value };
|
||||
}
|
||||
this.fail(`Unsupported locale value starting with ${JSON.stringify(char)}`);
|
||||
}
|
||||
|
||||
parseKey() {
|
||||
const char = this.peek();
|
||||
if (char === '"' || char === "'") return this.parseString();
|
||||
const key = this.parseIdentifier();
|
||||
if (!key) this.fail("Expected property key");
|
||||
return key;
|
||||
}
|
||||
|
||||
parseIdentifier() {
|
||||
const match = /^[A-Za-z_$][\w$-]*/.exec(this.text.slice(this.index));
|
||||
if (!match) return "";
|
||||
this.index += match[0].length;
|
||||
return match[0];
|
||||
}
|
||||
|
||||
parseString() {
|
||||
const quote = this.peek();
|
||||
this.index += 1;
|
||||
let value = "";
|
||||
while (this.index < this.text.length) {
|
||||
const char = this.text[this.index];
|
||||
if (char === "\\") {
|
||||
const escaped = this.text[this.index + 1];
|
||||
if (!escaped) this.fail("Unterminated string literal");
|
||||
this.index += 2;
|
||||
if (escaped === "n") value += "\n";
|
||||
else if (escaped === "r") value += "\r";
|
||||
else if (escaped === "t") value += "\t";
|
||||
else if (escaped === "b") value += "\b";
|
||||
else if (escaped === "f") value += "\f";
|
||||
else if (escaped === "v") value += "\v";
|
||||
else if (escaped === "0") value += "\0";
|
||||
else if (escaped === "u") {
|
||||
const hex = this.text.slice(this.index, this.index + 4);
|
||||
if (!/^[0-9a-fA-F]{4}$/.test(hex)) this.fail("Invalid unicode escape");
|
||||
value += String.fromCharCode(Number.parseInt(hex, 16));
|
||||
this.index += 4;
|
||||
} else {
|
||||
value += escaped;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === quote) {
|
||||
this.index += 1;
|
||||
return value;
|
||||
}
|
||||
if (char === "\n" || char === "\r") this.fail("Unterminated string literal");
|
||||
value += char;
|
||||
this.index += 1;
|
||||
}
|
||||
this.fail("Unterminated string literal");
|
||||
}
|
||||
|
||||
skipSpace() {
|
||||
while (this.index < this.text.length) {
|
||||
const char = this.text[this.index];
|
||||
if (/\s/.test(char)) {
|
||||
this.index += 1;
|
||||
continue;
|
||||
}
|
||||
if (this.text.startsWith("//", this.index)) {
|
||||
const next = this.text.indexOf("\n", this.index + 2);
|
||||
this.index = next === -1 ? this.text.length : next + 1;
|
||||
continue;
|
||||
}
|
||||
if (this.text.startsWith("/*", this.index)) {
|
||||
const next = this.text.indexOf("*/", this.index + 2);
|
||||
if (next === -1) this.fail("Unterminated block comment");
|
||||
this.index = next + 2;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(expected) {
|
||||
if (this.peek() !== expected) this.fail(`Expected ${expected}`);
|
||||
this.index += expected.length;
|
||||
}
|
||||
|
||||
peek() {
|
||||
return this.text[this.index] || "";
|
||||
}
|
||||
|
||||
fail(message) {
|
||||
const location = this.location();
|
||||
throw new Error(`${this.file}:${location.line}:${location.column}: ${message}`);
|
||||
}
|
||||
|
||||
location() {
|
||||
const before = this.text.slice(0, this.index);
|
||||
const lines = before.split(/\r?\n/);
|
||||
return { line: lines.length, column: lines[lines.length - 1].length + 1 };
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env node
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const issueNumber = process.env.ISSUE_NUMBER;
|
||||
const commentBody = process.env.COMMENT_BODY || "";
|
||||
const commentUser = process.env.COMMENT_USER || "";
|
||||
const commentUserType = process.env.COMMENT_USER_TYPE || "";
|
||||
|
||||
// Only handle /claim as a standalone word
|
||||
if (!/(?:^|\s)\/claim(?:\s|$)/.test(commentBody)) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Don't let bots claim
|
||||
if (commentUserType === "Bot") {
|
||||
console.log("Commenter is a bot, skipping");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`/claim from @${commentUser} on #${issueNumber}`);
|
||||
|
||||
async function gh(args) {
|
||||
const { stdout } = await execFileAsync("gh", args, { maxBuffer: 1024 * 1024 });
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
async function ghJson(args) {
|
||||
const out = await gh(args);
|
||||
return JSON.parse(out);
|
||||
}
|
||||
|
||||
// Check current assignees
|
||||
const assignees = await ghJson([
|
||||
"issue", "view", issueNumber,
|
||||
"--json", "assignees",
|
||||
"-q", ".assignees",
|
||||
]);
|
||||
|
||||
if (assignees.length > 0) {
|
||||
const names = assignees.map((a) => `@${a.login}`).join(", ");
|
||||
await gh([
|
||||
"issue", "comment", issueNumber,
|
||||
"--body", `❌ @${commentUser} 这个 issue 已经有人认领了:${names}`,
|
||||
]);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Assign
|
||||
await gh(["issue", "edit", issueNumber, "--add-assignee", commentUser]);
|
||||
|
||||
// Confirm
|
||||
await gh([
|
||||
"issue", "comment", issueNumber,
|
||||
"--body", `✅ @${commentUser} 已认领 #${issueNumber},开始处理吧!`,
|
||||
]);
|
||||
|
||||
console.log(`Assigned @${commentUser} to #${issueNumber}`);
|
||||
@@ -0,0 +1,570 @@
|
||||
#!/usr/bin/env node
|
||||
import fs from "node:fs";
|
||||
|
||||
const LABEL_PREFIX = "db/";
|
||||
const USER_PRIORITY_PREFIX = "user-priority/";
|
||||
const API_VERSION = "2022-11-28";
|
||||
const dryRun = process.env.DRY_RUN === "1" || process.env.DRY_RUN === "true";
|
||||
|
||||
const USER_PRIORITY_LABELS = {
|
||||
P0: { color: "b60205", description: "User-reported priority: urgent" },
|
||||
P1: { color: "d93f0b", description: "User-reported priority: important" },
|
||||
P2: { color: "fbca04", description: "User-reported priority: normal" },
|
||||
P3: { color: "bfd4f2", description: "User-reported priority: low" },
|
||||
};
|
||||
|
||||
const LABEL_PALETTE = [
|
||||
"0e8a16",
|
||||
"1d76db",
|
||||
"5319e7",
|
||||
"c2e0c6",
|
||||
"bfd4f2",
|
||||
"d4c5f9",
|
||||
"fef2c0",
|
||||
"fbca04",
|
||||
"f9d0c4",
|
||||
"e99695",
|
||||
"f29513",
|
||||
"c5def5",
|
||||
];
|
||||
|
||||
const extraAliases = {
|
||||
mysql: ["mariadb", "percona", "tidb"],
|
||||
postgres: ["postgresql", "postgres", "pgsql", "pg", "hologres"],
|
||||
sqlite: ["sqlite3", "sql lite"],
|
||||
clickhouse: ["click house", "ch"],
|
||||
sqlserver: ["sql server", "mssql", "microsoft sql server", "sqlservice"],
|
||||
mongodb: ["mongo", "mongodb"],
|
||||
oracle: ["oracle database"],
|
||||
elasticsearch: ["elastic search"],
|
||||
chromadb: ["chroma"],
|
||||
manticoresearch: ["manticore"],
|
||||
dameng: ["dm8", "dameng", "达梦"],
|
||||
kingbase: ["kingbasees", "kingbase", "人大金仓", "金仓"],
|
||||
highgo: ["瀚高"],
|
||||
yashandb: ["yashan", "崖山"],
|
||||
saphana: ["hana", "sap hana"],
|
||||
opengauss: ["open gauss"],
|
||||
"oceanbase-oracle": ["oceanbase", "oceanbase oracle"],
|
||||
gbase: ["gbase 8a", "gbase8a", "gbase 8s", "gbase8s"],
|
||||
access: ["microsoft access", "ms access"],
|
||||
vastbase: ["vastbase g", "vastbaseg"],
|
||||
prestosql: ["presto", "presto sql"],
|
||||
hive: ["apache hive"],
|
||||
db2: ["ibm db2"],
|
||||
informix: ["ibm informix"],
|
||||
bigquery: ["google bigquery"],
|
||||
kylin: ["apache kylin"],
|
||||
oscar: ["shentong", "oscar", "神通"],
|
||||
xugu: ["xugudb", "xugu", "虚谷"],
|
||||
zookeeper: ["zoo keeper", "apache zookeeper"],
|
||||
mq: ["message queue", "kafka", "rabbitmq", "rocketmq"],
|
||||
iotdb: ["apache iotdb"],
|
||||
iris: ["intersystems iris", "intersystems cache", "intersystems caché", "cache", "caché", "ensemble"],
|
||||
spark: ["apache 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 manifestUrl = new URL("../../crates/dbx-core/assets/database-drivers.manifest.json", import.meta.url);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestUrl, "utf8"));
|
||||
const drivers = manifest.drivers.map((driver) => ({
|
||||
dbType: driver.dbType,
|
||||
label: driver.label,
|
||||
aliases: [...new Set([driver.dbType, driver.label, ...(extraAliases[driver.dbType] || [])])],
|
||||
}));
|
||||
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)) {
|
||||
const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8"));
|
||||
return event.issue || {};
|
||||
}
|
||||
|
||||
return {
|
||||
number: process.env.ISSUE_NUMBER,
|
||||
title: process.env.ISSUE_TITLE || "",
|
||||
body: process.env.ISSUE_BODY || "",
|
||||
labels: process.env.ISSUE_LABELS ? JSON.parse(process.env.ISSUE_LABELS) : [],
|
||||
};
|
||||
}
|
||||
|
||||
function labelNames(labels) {
|
||||
return (labels || []).map((label) => (typeof label === "string" ? label : label.name)).filter(Boolean);
|
||||
}
|
||||
|
||||
function stablePaletteColor(value) {
|
||||
let hash = 0;
|
||||
for (const char of value) {
|
||||
hash = (hash * 31 + char.codePointAt(0)) >>> 0;
|
||||
}
|
||||
return LABEL_PALETTE[hash % LABEL_PALETTE.length];
|
||||
}
|
||||
|
||||
function extractIssueFormSection(body, predicate) {
|
||||
const matches = [...String(body || "").matchAll(/^###\s+(.+?)\s*$/gm)];
|
||||
for (const [index, match] of matches.entries()) {
|
||||
const heading = match[1].trim();
|
||||
if (!predicate(heading)) continue;
|
||||
|
||||
const start = match.index + match[0].length;
|
||||
const end = matches[index + 1]?.index ?? body.length;
|
||||
return body.slice(start, end).trim();
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function extractDatabaseField(body) {
|
||||
return extractIssueFormSection(body, (heading) => (
|
||||
heading.includes("数据库类型") ||
|
||||
heading.toLowerCase().includes("database type") ||
|
||||
/^database$/i.test(heading)
|
||||
));
|
||||
}
|
||||
|
||||
function extractPriorityField(body) {
|
||||
return extractIssueFormSection(body, (heading) => (
|
||||
heading.includes("优先级") ||
|
||||
heading.toLowerCase().includes("priority")
|
||||
));
|
||||
}
|
||||
|
||||
function extractTitleSummaryField(body) {
|
||||
const headingPredicates = [
|
||||
(heading) => heading.includes("问题描述") || heading.toLowerCase().includes("description"),
|
||||
(heading) => heading.includes("当前痛点") || heading.toLowerCase().includes("problem"),
|
||||
(heading) => heading.includes("期望方案") || heading.toLowerCase().includes("proposal"),
|
||||
(heading) => heading === "描述" || heading.toLowerCase() === "description",
|
||||
];
|
||||
|
||||
for (const predicate of headingPredicates) {
|
||||
const section = extractIssueFormSection(body, predicate);
|
||||
if (section) return section;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function matchPriorityLabel(value) {
|
||||
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 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()
|
||||
);
|
||||
return `[${normalizedPrefix}]`;
|
||||
}
|
||||
|
||||
function cleanTitleSummaryLine(line) {
|
||||
return String(line || "")
|
||||
.replace(/<img\b[^>]*>/gi, "")
|
||||
.replace(/<[^>]+>/g, "")
|
||||
.replace(/^>\s*/, "")
|
||||
.replace(/^[-*]\s+/, "")
|
||||
.replace(/^\d+[.)、]\s*/, "")
|
||||
.replace(/!\[[^\]]*?\]\([^)]*?\)/g, "")
|
||||
.replace(/\[[^\]]*?\]\([^)]*?\)/g, "")
|
||||
.replace(/[`*_~]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
function firstSentence(value) {
|
||||
const withoutCodeBlocks = String(value || "").replace(/```[\s\S]*?```/g, "\n");
|
||||
const lines = withoutCodeBlocks
|
||||
.split(/\r?\n/)
|
||||
.map(cleanTitleSummaryLine)
|
||||
.filter((line) => !isSkippableTitleSummaryLine(line))
|
||||
.filter((line) => /[\p{L}\p{N}\u3400-\u9fff]/u.test(line));
|
||||
|
||||
for (const line of lines) {
|
||||
const sentenceMatch = line.match(/^(.{4,120}?[。!?!?]|.{12,120}?\.(?=\s|$))/u);
|
||||
const sentence = (sentenceMatch ? sentenceMatch[1] : line)
|
||||
.replace(/[。!?!?.]+$/u, "")
|
||||
.trim();
|
||||
if (sentence.length >= 4) {
|
||||
return sentence.length > 90 ? `${sentence.slice(0, 89)}…` : sentence;
|
||||
}
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function inferIssueTitle(title, body) {
|
||||
const prefix = genericIssueTitlePrefix(title);
|
||||
if (!prefix) return null;
|
||||
|
||||
const summary = firstSentence(extractTitleSummaryField(body));
|
||||
if (!summary) return null;
|
||||
|
||||
return `${prefix} ${summary}`;
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || "")
|
||||
.normalize("NFKC")
|
||||
.toLowerCase()
|
||||
.replace(/[·,。;:、]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function containsCjk(value) {
|
||||
return /[\u3400-\u9fff]/u.test(value);
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function compactAscii(value) {
|
||||
return normalizeText(value).replace(/[^a-z0-9]/g, "");
|
||||
}
|
||||
|
||||
function levenshteinDistance(left, right) {
|
||||
if (left === right) return 0;
|
||||
if (!left) return right.length;
|
||||
if (!right) return left.length;
|
||||
|
||||
let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
|
||||
for (let leftIndex = 0; leftIndex < left.length; leftIndex += 1) {
|
||||
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,
|
||||
);
|
||||
}
|
||||
previous = current;
|
||||
}
|
||||
|
||||
return previous[right.length];
|
||||
}
|
||||
|
||||
function fuzzyThreshold(length) {
|
||||
if (length < 6) return 0;
|
||||
if (length <= 8) return 1;
|
||||
return 2;
|
||||
}
|
||||
|
||||
function candidateTokens(text) {
|
||||
const normalized = normalizeText(text);
|
||||
const tokens = new Set();
|
||||
|
||||
for (const match of normalized.matchAll(/[a-z][a-z0-9._-]*/g)) {
|
||||
const compact = compactAscii(match[0]);
|
||||
if (!compact) continue;
|
||||
|
||||
tokens.add(compact);
|
||||
|
||||
// User reports often write database names directly followed by versions,
|
||||
// for example mysql5.7, Oracle19c, or postgresql18.
|
||||
const withoutVersion = compact.replace(/(?:v?\d[\da-z.]*)$/i, "");
|
||||
if (withoutVersion && withoutVersion !== compact) tokens.add(withoutVersion);
|
||||
}
|
||||
|
||||
return [...tokens].filter((token) => token.length >= 5);
|
||||
}
|
||||
|
||||
function fuzzyAliasMatches(text, alias) {
|
||||
const normalizedAlias = normalizeText(alias);
|
||||
if (containsCjk(normalizedAlias)) return false;
|
||||
if (globallyAmbiguousAliases.has(normalizedAlias)) return false;
|
||||
|
||||
const compactAlias = compactAscii(normalizedAlias);
|
||||
const threshold = fuzzyThreshold(compactAlias.length);
|
||||
if (!threshold) return false;
|
||||
|
||||
return candidateTokens(text).some((token) => {
|
||||
if (token !== compactAlias && exactAsciiAliases.has(token)) return false;
|
||||
if (Math.abs(token.length - compactAlias.length) > threshold) return false;
|
||||
return levenshteinDistance(token, compactAlias) <= threshold;
|
||||
});
|
||||
}
|
||||
|
||||
function aliasMatches(text, alias) {
|
||||
const normalizedText = normalizeText(text);
|
||||
const normalizedAlias = normalizeText(alias);
|
||||
if (!normalizedAlias) return false;
|
||||
|
||||
if (containsCjk(normalizedAlias)) {
|
||||
return normalizedText.includes(normalizedAlias);
|
||||
}
|
||||
|
||||
const flexibleAlias = normalizedAlias
|
||||
.split(/[\s._/-]+/)
|
||||
.filter(Boolean)
|
||||
.map(escapeRegExp)
|
||||
.join("[\\s._/-]*");
|
||||
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);
|
||||
}
|
||||
|
||||
function isGenericDatabaseValue(value) {
|
||||
const normalized = normalizeText(value);
|
||||
const parts = normalized
|
||||
.split(/[,\n/|;,、]+/)
|
||||
.map((part) => part.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
parts.length > 0 &&
|
||||
parts.every((part) => genericDatabaseValues.some((generic) => aliasMatches(part, generic)))
|
||||
);
|
||||
}
|
||||
|
||||
function matchDrivers(text, { allowAmbiguous }) {
|
||||
const matches = [];
|
||||
|
||||
for (const driver of drivers) {
|
||||
const matchedAlias = driver.aliases.find((alias) => {
|
||||
const normalizedAlias = normalizeText(alias);
|
||||
if (!allowAmbiguous && globallyAmbiguousAliases.has(normalizedAlias)) return false;
|
||||
return aliasMatches(text, alias) || fuzzyAliasMatches(text, alias);
|
||||
});
|
||||
|
||||
if (matchedAlias) {
|
||||
matches.push({ ...driver, matchedAlias });
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
function uniqueByDbType(matches) {
|
||||
const seen = new Set();
|
||||
return matches.filter((match) => {
|
||||
if (seen.has(match.dbType)) return false;
|
||||
seen.add(match.dbType);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function githubRequest(method, path, body) {
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const repository = process.env.GITHUB_REPOSITORY;
|
||||
if (!token) throw new Error("GITHUB_TOKEN is required");
|
||||
if (!repository) throw new Error("GITHUB_REPOSITORY is required");
|
||||
|
||||
const response = await fetch(`https://api.github.com/repos/${repository}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-GitHub-Api-Version": API_VERSION,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const text = await response.text();
|
||||
const payload = text ? JSON.parse(text) : null;
|
||||
if (!response.ok) {
|
||||
const message = payload?.message || response.statusText;
|
||||
const error = new Error(`${method} ${path} failed: ${response.status} ${message}`);
|
||||
error.status = response.status;
|
||||
error.payload = payload;
|
||||
throw error;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function ensureLabel(name, description, color) {
|
||||
try {
|
||||
const dbType = name.startsWith(LABEL_PREFIX) ? name.slice(LABEL_PREFIX.length) : name;
|
||||
await githubRequest("POST", "/labels", { name, color: color || stablePaletteColor(dbType), description });
|
||||
console.log(`Created label ${name}`);
|
||||
} catch (error) {
|
||||
// GitHub returns 422 when the label already exists; that is the common path
|
||||
// after the first issue for a database type has been triaged.
|
||||
if (error.status === 422) {
|
||||
console.log(`Label ${name} already exists`);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function addLabels(issueNumber, names) {
|
||||
await githubRequest("POST", `/issues/${issueNumber}/labels`, { labels: names });
|
||||
}
|
||||
|
||||
async function removeLabel(issueNumber, name) {
|
||||
try {
|
||||
await githubRequest("DELETE", `/issues/${issueNumber}/labels/${encodeURIComponent(name)}`);
|
||||
console.log(`Removed stale label ${name}`);
|
||||
} catch (error) {
|
||||
if (error.status === 404) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateIssueTitle(issueNumber, title) {
|
||||
await githubRequest("PATCH", `/issues/${issueNumber}`, { title });
|
||||
}
|
||||
|
||||
const issue = loadIssue();
|
||||
if (issue.pull_request) {
|
||||
console.log("Skipping pull request event");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (!issue.number) {
|
||||
throw new Error("Issue number is required");
|
||||
}
|
||||
|
||||
const databaseField = extractDatabaseField(issue.body || "");
|
||||
const hasDatabaseField = databaseField.length > 0;
|
||||
const fieldIsGeneric = hasDatabaseField && isGenericDatabaseValue(databaseField);
|
||||
// Free-form bodies often mention comparison databases or examples; the title
|
||||
// and issue-form database field are the reliable labeling sources.
|
||||
const sourceText = hasDatabaseField ? databaseField : issue.title || "";
|
||||
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) {
|
||||
// Some issue forms contain only a version in the database field and put the
|
||||
// actual database name in the title; keep that fallback conservative.
|
||||
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 priorityField = extractPriorityField(issue.body || "");
|
||||
const targetPriorityValue = matchPriorityLabel(priorityField);
|
||||
const targetPriorityLabel = targetPriorityValue ? `${USER_PRIORITY_PREFIX}${targetPriorityValue}` : null;
|
||||
if (targetPriorityValue) {
|
||||
targetLabelColors[targetPriorityLabel] = USER_PRIORITY_LABELS[targetPriorityValue].color;
|
||||
}
|
||||
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 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((driver) => ({
|
||||
name: `${LABEL_PREFIX}${driver.dbType}`,
|
||||
description: `Database: ${driver.label}`,
|
||||
color: stablePaletteColor(driver.dbType),
|
||||
})),
|
||||
...(targetPriorityValue
|
||||
? [{ name: targetPriorityLabel, ...USER_PRIORITY_LABELS[targetPriorityValue] }]
|
||||
: []),
|
||||
];
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
issue: issue.number,
|
||||
databaseField: hasDatabaseField ? databaseField : null,
|
||||
titleMatched: titleMatchedDrivers.map(({ dbType, label, matchedAlias }) => ({ dbType, label, matchedAlias })),
|
||||
matched: uniqueMatchedDrivers.map(({ dbType, label, matchedAlias }) => ({ dbType, label, matchedAlias })),
|
||||
priorityField: priorityField || null,
|
||||
priorityLabel: targetPriorityLabel,
|
||||
priorityValue: targetPriorityValue,
|
||||
titleToUpdate,
|
||||
labelsToAdd,
|
||||
targetLabelColors,
|
||||
staleDatabaseLabels,
|
||||
stalePriorityLabels,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
if (dryRun) {
|
||||
console.log("Dry run enabled; no GitHub API calls were made");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
for (const labelSpec of labelSpecs) {
|
||||
await ensureLabel(labelSpec.name, labelSpec.description, labelSpec.color);
|
||||
}
|
||||
|
||||
if (labelsToAdd.length > 0) {
|
||||
await addLabels(issue.number, labelsToAdd);
|
||||
console.log(`Added labels: ${labelsToAdd.join(", ")}`);
|
||||
} else {
|
||||
console.log("No database labels to add");
|
||||
}
|
||||
|
||||
for (const label of staleDatabaseLabels) {
|
||||
await removeLabel(issue.number, label);
|
||||
}
|
||||
|
||||
for (const label of stalePriorityLabels) {
|
||||
await removeLabel(issue.number, label);
|
||||
}
|
||||
|
||||
if (titleToUpdate) {
|
||||
await updateIssueTitle(issue.number, titleToUpdate);
|
||||
console.log(`Updated title: ${titleToUpdate}`);
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { basename, join } from "node:path";
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const DEFAULT_API_BASE = "https://api.atomgit.com/api/v5";
|
||||
const DEFAULT_REPOSITORY = "t8y2/dbx";
|
||||
const DEFAULT_CONCURRENCY = 3;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
apiBase: process.env.ATOMGIT_API_BASE || DEFAULT_API_BASE,
|
||||
repository: process.env.ATOMGIT_REPOSITORY || DEFAULT_REPOSITORY,
|
||||
token: process.env.ATOMGIT_TOKEN || "",
|
||||
githubReleasePath: "",
|
||||
assetsDir: "",
|
||||
skipExistingAssets: true,
|
||||
concurrency: Number.parseInt(process.env.ATOMGIT_UPLOAD_CONCURRENCY || `${DEFAULT_CONCURRENCY}`, 10),
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--github-release") {
|
||||
args.githubReleasePath = argv[++i];
|
||||
} else if (arg === "--assets-dir") {
|
||||
args.assetsDir = argv[++i];
|
||||
} else if (arg === "--repository") {
|
||||
args.repository = argv[++i];
|
||||
} else if (arg === "--api-base") {
|
||||
args.apiBase = argv[++i];
|
||||
} else if (arg === "--replace-assets") {
|
||||
args.skipExistingAssets = false;
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!args.token) {
|
||||
throw new Error("ATOMGIT_TOKEN is required.");
|
||||
}
|
||||
if (!args.githubReleasePath || !args.assetsDir) {
|
||||
throw new Error("Usage: sync-atomgit-release.mjs --github-release <release.json> --assets-dir <dir>");
|
||||
}
|
||||
if (!args.repository.includes("/")) {
|
||||
throw new Error(`Invalid AtomGit repository: ${args.repository}`);
|
||||
}
|
||||
if (!Number.isInteger(args.concurrency) || args.concurrency < 1) {
|
||||
throw new Error("ATOMGIT_UPLOAD_CONCURRENCY must be a positive integer.");
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const [owner, repo] = args.repository.split("/", 2);
|
||||
const release = JSON.parse(readFileSync(args.githubReleasePath, "utf8"));
|
||||
const tag = release.tagName || release.tag_name;
|
||||
if (!tag) {
|
||||
throw new Error("GitHub release JSON is missing tagName.");
|
||||
}
|
||||
|
||||
const client = new AtomGitClient({ apiBase: args.apiBase, token: args.token, owner, repo });
|
||||
const atomRelease = await ensureRelease(client, release);
|
||||
const existingAssets = atomGitAssetNames(atomRelease);
|
||||
const assets = localAssets(args.assetsDir);
|
||||
if (assets.length === 0) {
|
||||
console.log("No release assets found to sync.");
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingAssets = assets.filter((assetPath) => {
|
||||
const name = basename(assetPath);
|
||||
if (args.skipExistingAssets && existingAssets.has(name)) {
|
||||
console.log(`Skipping existing AtomGit asset: ${name}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
console.log(`Uploading ${pendingAssets.length} asset(s) with concurrency ${args.concurrency}.`);
|
||||
await mapWithConcurrency(pendingAssets, args.concurrency, (assetPath) => uploadAsset(client, tag, assetPath));
|
||||
}
|
||||
|
||||
async function ensureRelease(client, githubRelease) {
|
||||
const tag = githubRelease.tagName || githubRelease.tag_name;
|
||||
const payload = {
|
||||
tag_name: tag,
|
||||
name: githubRelease.name || tag,
|
||||
body: githubRelease.body || "",
|
||||
target_commitish: githubRelease.targetCommitish || githubRelease.target_commitish || "",
|
||||
prerelease: Boolean(githubRelease.isPrerelease || githubRelease.prerelease),
|
||||
};
|
||||
|
||||
const existing = await client.getRelease(tag);
|
||||
if (existing) {
|
||||
console.log(`Updating existing AtomGit release: ${tag}`);
|
||||
return client.updateRelease(tag, payload);
|
||||
}
|
||||
|
||||
console.log(`Creating AtomGit release: ${tag}`);
|
||||
return client.createRelease(payload);
|
||||
}
|
||||
|
||||
async function uploadAsset(client, tag, filePath) {
|
||||
const name = basename(filePath);
|
||||
const size = statSync(filePath).size;
|
||||
console.log(`Uploading ${name} (${size} bytes)`);
|
||||
const uploadTarget = await client.getUploadTarget(tag, name);
|
||||
const contentType = contentTypeFor(name);
|
||||
const uploadHeaders = uploadTarget.headers.length > 0
|
||||
? uploadTarget.headers
|
||||
: [
|
||||
["Authorization", `Bearer ${client.token}`],
|
||||
["X-Api-Version", "2023-02-21"],
|
||||
];
|
||||
const contentTypeHeader = headerValue(uploadHeaders, "Content-Type") || contentType;
|
||||
const curlHeaders = uploadHeaders
|
||||
.filter(([key]) => key.toLowerCase() !== "content-type")
|
||||
.flatMap(([key, value]) => ["--header", `${key}: ${value}`]);
|
||||
|
||||
// AtomGit returns a per-file upload URL plus required upload headers.
|
||||
// Try raw PUT first and fall back to multipart POST for API-compatible deployments.
|
||||
const putStatus = await runCurl([
|
||||
"--fail-with-body",
|
||||
"--location",
|
||||
"--show-error",
|
||||
"--retry",
|
||||
"3",
|
||||
"--retry-delay",
|
||||
"5",
|
||||
"--request",
|
||||
"PUT",
|
||||
...curlHeaders,
|
||||
"--header",
|
||||
`Content-Type: ${contentTypeHeader}`,
|
||||
"--upload-file",
|
||||
filePath,
|
||||
uploadTarget.url,
|
||||
]);
|
||||
if (putStatus === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(`PUT upload failed for ${name}; retrying as multipart POST.`);
|
||||
const postStatus = await runCurl([
|
||||
"--fail-with-body",
|
||||
"--location",
|
||||
"--show-error",
|
||||
"--retry",
|
||||
"3",
|
||||
"--retry-delay",
|
||||
"5",
|
||||
"--request",
|
||||
"POST",
|
||||
...curlHeaders,
|
||||
"--form",
|
||||
`file=@${filePath};filename=${name};type=${contentTypeHeader}`,
|
||||
uploadTarget.url,
|
||||
]);
|
||||
if (postStatus !== 0) {
|
||||
throw new Error(`Failed to upload ${name} to AtomGit.`);
|
||||
}
|
||||
}
|
||||
|
||||
function runCurl(args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("curl", args, { stdio: "inherit" });
|
||||
child.once("error", reject);
|
||||
child.once("close", (code) => resolve(code ?? 1));
|
||||
});
|
||||
}
|
||||
|
||||
async function mapWithConcurrency(items, concurrency, worker) {
|
||||
let nextIndex = 0;
|
||||
async function runWorker() {
|
||||
while (nextIndex < items.length) {
|
||||
const index = nextIndex++;
|
||||
await worker(items[index]);
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, runWorker));
|
||||
}
|
||||
|
||||
function localAssets(dir) {
|
||||
return readdirSync(dir)
|
||||
.map((name) => join(dir, name))
|
||||
.filter((path) => statSync(path).isFile())
|
||||
.sort((a, b) => statSync(a).size - statSync(b).size || basename(a).localeCompare(basename(b)));
|
||||
}
|
||||
|
||||
function atomGitAssetNames(release) {
|
||||
const names = new Set();
|
||||
const groups = [
|
||||
release?.assets,
|
||||
release?.attach_files,
|
||||
release?.attachFiles,
|
||||
release?.files,
|
||||
release?.attachments,
|
||||
];
|
||||
for (const group of groups) {
|
||||
if (!Array.isArray(group)) {
|
||||
continue;
|
||||
}
|
||||
for (const item of group) {
|
||||
const name = item?.name || item?.file_name || item?.filename || item?.title;
|
||||
if (name) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function contentTypeFor(name) {
|
||||
if (name.endsWith(".json")) return "application/json";
|
||||
if (name.endsWith(".zip")) return "application/zip";
|
||||
if (name.endsWith(".gz") || name.endsWith(".tgz")) return "application/gzip";
|
||||
if (name.endsWith(".dmg")) return "application/x-apple-diskimage";
|
||||
if (name.endsWith(".exe") || name.endsWith(".msi")) return "application/octet-stream";
|
||||
if (name.endsWith(".deb")) return "application/vnd.debian.binary-package";
|
||||
if (name.endsWith(".rpm")) return "application/x-rpm";
|
||||
if (name.endsWith(".AppImage")) return "application/octet-stream";
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
function headerValue(headers, name) {
|
||||
const normalizedName = name.toLowerCase();
|
||||
const entry = headers.find(([key]) => key.toLowerCase() === normalizedName);
|
||||
return entry ? entry[1] : "";
|
||||
}
|
||||
|
||||
class AtomGitClient {
|
||||
constructor({ apiBase, token, owner, repo }) {
|
||||
this.apiBase = apiBase.replace(/\/+$/, "");
|
||||
this.token = token;
|
||||
this.owner = owner;
|
||||
this.repo = repo;
|
||||
}
|
||||
|
||||
async getRelease(tag) {
|
||||
const res = await this.request("GET", `/repos/${this.owner}/${this.repo}/releases/${encodeURIComponent(tag)}`, null, {
|
||||
allow404: true,
|
||||
});
|
||||
if (res.status === 404) {
|
||||
return null;
|
||||
}
|
||||
return parseJsonResponse(res);
|
||||
}
|
||||
|
||||
async createRelease(payload) {
|
||||
const res = await this.request("POST", `/repos/${this.owner}/${this.repo}/releases`, payload, {
|
||||
acceptStatuses: [409, 422],
|
||||
});
|
||||
if (res.status === 409 || res.status === 422) {
|
||||
return this.updateRelease(payload.tag_name, payload);
|
||||
}
|
||||
return parseJsonResponse(res);
|
||||
}
|
||||
|
||||
async updateRelease(tag, payload) {
|
||||
const res = await this.request("PATCH", `/repos/${this.owner}/${this.repo}/releases/${encodeURIComponent(tag)}`, payload);
|
||||
return parseJsonResponse(res);
|
||||
}
|
||||
|
||||
async getUploadTarget(tag, fileName) {
|
||||
const params = new URLSearchParams({ file_name: fileName });
|
||||
const res = await this.request("GET", `/repos/${this.owner}/${this.repo}/releases/${encodeURIComponent(tag)}/upload_url?${params}`);
|
||||
const body = await parseJsonResponse(res);
|
||||
if (body && typeof body === "object" && !Array.isArray(body)) {
|
||||
console.log(`AtomGit upload_url response keys: ${Object.keys(body).sort().join(", ")}`);
|
||||
if (body.headers && typeof body.headers === "object") {
|
||||
console.log(`AtomGit upload header keys: ${Object.keys(body.headers).sort().join(", ")}`);
|
||||
}
|
||||
}
|
||||
const uploadUrl = typeof body === "string" ? body : body.url || body.upload_url || body.uploadUrl;
|
||||
if (!uploadUrl) {
|
||||
throw new Error(`AtomGit upload_url response did not include an upload URL: ${JSON.stringify(body)}`);
|
||||
}
|
||||
console.log(`AtomGit upload target: ${sanitizeUrlForLog(uploadUrl)}`);
|
||||
return {
|
||||
url: uploadUrl,
|
||||
headers: normalizeUploadHeaders(typeof body === "string" ? null : body.headers),
|
||||
};
|
||||
}
|
||||
|
||||
async request(method, path, body = null, requestOptions = {}) {
|
||||
const headers = {
|
||||
Accept: "application/json",
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
"X-Api-Version": "2023-02-21",
|
||||
};
|
||||
const fetchOptions = { method, headers };
|
||||
if (body) {
|
||||
const cleanBody = Object.fromEntries(Object.entries(body).filter(([, value]) => value !== ""));
|
||||
headers["Content-Type"] = "application/json";
|
||||
fetchOptions.body = JSON.stringify(cleanBody);
|
||||
}
|
||||
|
||||
const res = await fetch(`${this.apiBase}${path}`, fetchOptions);
|
||||
const acceptedStatus = requestOptions.acceptStatuses?.includes(res.status);
|
||||
if (!res.ok && !(requestOptions.allow404 && res.status === 404) && !acceptedStatus) {
|
||||
const text = await res.text();
|
||||
throw new Error(`AtomGit API ${method} ${path} failed with ${res.status}: ${text}`);
|
||||
}
|
||||
return res;
|
||||
}
|
||||
}
|
||||
|
||||
async function parseJsonResponse(res) {
|
||||
const text = await res.text();
|
||||
if (!text.trim()) {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeUploadHeaders(headers) {
|
||||
if (!headers || typeof headers !== "object" || Array.isArray(headers)) {
|
||||
return [];
|
||||
}
|
||||
return Object.entries(headers)
|
||||
.filter(([key, value]) => key && value !== null && value !== undefined && value !== "")
|
||||
.map(([key, value]) => [key, String(value)]);
|
||||
}
|
||||
|
||||
function sanitizeUrlForLog(value) {
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return `${url.origin}${url.pathname}`;
|
||||
} catch {
|
||||
return "<invalid upload URL>";
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const REPO = "t8y2/dbx";
|
||||
const GITHUB_TOKEN = process.env.GITHUB_TOKEN || "";
|
||||
const DEEPSEEK_API_KEY = process.env.DEEPSEEK_API_KEY || "";
|
||||
const OUT_CN = "releases-cn.json";
|
||||
const OUT_EN = "releases-en.json";
|
||||
const LATEST_EN_OUT = "latest-en.json";
|
||||
const EN_CACHE_URL = process.env.CHANGELOG_EN_CACHE_URL || "https://dl.dbxio.com/changelog/releases-en.json";
|
||||
|
||||
const SECTION_MAP = {
|
||||
新功能: "added",
|
||||
Added: "added",
|
||||
改进: "improved",
|
||||
Improved: "improved",
|
||||
修复: "fixed",
|
||||
Fixed: "fixed",
|
||||
变更: "changed",
|
||||
Changed: "changed",
|
||||
移除: "removed",
|
||||
Removed: "removed",
|
||||
};
|
||||
|
||||
export async function fetchAllReleases() {
|
||||
const releases = [];
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const res = await fetch(`https://api.github.com/repos/${REPO}/releases?per_page=100&page=${page}`, { headers: { Authorization: `token ${GITHUB_TOKEN}`, Accept: "application/vnd.github+json" } });
|
||||
if (!res.ok) throw new Error(`GitHub API ${res.status}: ${await res.text()}`);
|
||||
const data = await res.json();
|
||||
if (data.length === 0) break;
|
||||
releases.push(...data);
|
||||
page++;
|
||||
}
|
||||
return releases;
|
||||
}
|
||||
|
||||
export function stripDownloadSection(body) {
|
||||
const markers = ["### 下载安装", "### Download", "### 系统要求", "### System Requirements"];
|
||||
let idx = body.length;
|
||||
for (const m of markers) {
|
||||
const i = body.indexOf(m);
|
||||
if (i !== -1 && i < idx) idx = i;
|
||||
}
|
||||
return body.slice(0, idx).trim();
|
||||
}
|
||||
|
||||
// 剥离 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, "");
|
||||
}
|
||||
|
||||
// 对整份 releases JSON 统一剥离 closes # 引用。cache 复用的英文翻译来自 R2,
|
||||
// 可能是旧版脚本生成的(desc 仍含 closes #),在写文件前统一清理一次。
|
||||
function stripIssueRefsInReleases(json) {
|
||||
for (const release of json.releases || []) {
|
||||
for (const section of release.sections || []) {
|
||||
for (const item of section.items || []) {
|
||||
if (item.desc) item.desc = stripIssueRefs(item.desc);
|
||||
}
|
||||
}
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
export function parseBody(body) {
|
||||
const cleaned = stripDownloadSection(body);
|
||||
const sections = [];
|
||||
let current = null;
|
||||
|
||||
for (const line of cleaned.split("\n")) {
|
||||
const headerMatch = line.match(/^###\s+(.+)/);
|
||||
if (headerMatch) {
|
||||
const title = headerMatch[1].trim();
|
||||
const type = SECTION_MAP[title] || "other";
|
||||
current = { type, title, items: [] };
|
||||
sections.push(current);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!current) continue;
|
||||
|
||||
const itemMatch = line.match(/^-\s+\*\*(.+?)\*\*\s*[—–-]\s*(.+)/);
|
||||
if (itemMatch) {
|
||||
current.items.push({ title: itemMatch[1].trim(), desc: stripIssueRefs(itemMatch[2].trim()) });
|
||||
continue;
|
||||
}
|
||||
|
||||
const plainMatch = line.match(/^-\s+(.+)/);
|
||||
if (plainMatch) {
|
||||
current.items.push({ title: plainMatch[1].trim(), desc: "" });
|
||||
}
|
||||
}
|
||||
|
||||
return sections.filter((s) => s.items.length > 0);
|
||||
}
|
||||
|
||||
export function buildReleaseSourceHash(release) {
|
||||
return createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify({
|
||||
tag: release.tag_name,
|
||||
name: release.name || release.tag_name,
|
||||
publishedAt: release.published_at || "",
|
||||
body: release.body || "",
|
||||
}),
|
||||
)
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
export function buildReleasesJson(releases, now = new Date()) {
|
||||
return {
|
||||
updatedAt: now.toISOString(),
|
||||
releases: releases
|
||||
.filter((r) => !r.draft && !r.prerelease && !r.tag_name.startsWith("agents-"))
|
||||
.sort((a, b) => new Date(b.published_at) - new Date(a.published_at))
|
||||
.map((r) => ({
|
||||
tag: r.tag_name,
|
||||
name: r.name || r.tag_name,
|
||||
date: r.published_at.slice(0, 10),
|
||||
_sourceHash: buildReleaseSourceHash(r),
|
||||
sections: parseBody(r.body || ""),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function releaseToMarkdown(release) {
|
||||
return release.sections
|
||||
.map((s) => {
|
||||
const items = s.items.map((i) => (i.desc ? `- **${i.title}** — ${i.desc}` : `- ${i.title}`)).join("\n");
|
||||
return `### ${s.title}\n${items}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
// 给应用内更新提示用的英文 notes:只取最新一条版本(releases 已按 published_at 降序),
|
||||
// 转成 md。version 用 tag(如 v0.5.47),应用端 normalize_version 后与 latest.json 的 version 校验。
|
||||
function buildLatestEnNotes(enReleasesJson) {
|
||||
const latest = enReleasesJson.releases?.[0];
|
||||
if (!latest) return null;
|
||||
return {
|
||||
version: latest.tag,
|
||||
notes: releaseToMarkdown(latest),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchCachedEnglish({ cacheUrl = EN_CACHE_URL, fetchImpl = fetch } = {}) {
|
||||
try {
|
||||
const res = await fetchImpl(cacheUrl, { headers: { Accept: "application/json" } });
|
||||
if (!res.ok) {
|
||||
console.warn(`English changelog cache unavailable: ${res.status}`);
|
||||
return null;
|
||||
}
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
console.warn(`English changelog cache unavailable: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function translateToEnglish(cnJson, { cachedEnJson = null, deepseekApiKey = DEEPSEEK_API_KEY, fetchImpl = fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)) } = {}) {
|
||||
const cachedByTag = new Map((cachedEnJson?.releases || []).map((release) => [release.tag, release]));
|
||||
const enReleases = [];
|
||||
let reusedCount = 0;
|
||||
let translatedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
// 无 API key:仅复用 R2 上的英文缓存,未命中的条目回退中文以保证文件完整。
|
||||
// 这样本地(无 key)只要 R2 缓存新鲜也能产出英文 CHANGELOG;CI 有 key 时走正常翻译流程。
|
||||
if (!deepseekApiKey) {
|
||||
console.warn("DEEPSEEK_API_KEY not set, falling back to cached English translations only");
|
||||
for (const release of cnJson.releases) {
|
||||
const cachedRelease = cachedByTag.get(release.tag);
|
||||
if (cachedRelease?._sourceHash === release._sourceHash) {
|
||||
enReleases.push({ ...cachedRelease, name: release.name, date: release.date, _sourceHash: release._sourceHash });
|
||||
reusedCount++;
|
||||
} else {
|
||||
enReleases.push(release);
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
console.log(`English changelog cache reused ${reusedCount}, skipped ${skippedCount} (no API key)`);
|
||||
return { updatedAt: cnJson.updatedAt, releases: enReleases };
|
||||
}
|
||||
|
||||
for (const release of cnJson.releases) {
|
||||
const cachedRelease = cachedByTag.get(release.tag);
|
||||
if (cachedRelease?._sourceHash === release._sourceHash) {
|
||||
enReleases.push({
|
||||
...cachedRelease,
|
||||
name: release.name,
|
||||
date: release.date,
|
||||
_sourceHash: release._sourceHash,
|
||||
});
|
||||
reusedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const sectionsText = releaseToMarkdown(release);
|
||||
|
||||
if (!sectionsText.trim()) {
|
||||
enReleases.push({ ...release, sections: [] });
|
||||
continue;
|
||||
}
|
||||
|
||||
const res = await fetchImpl("https://api.deepseek.com/chat/completions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${deepseekApiKey}` },
|
||||
body: JSON.stringify({
|
||||
model: "deepseek-chat",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content:
|
||||
"You are a technical translator. Translate the following Chinese software changelog to English. Keep the exact markdown format (### headers, - bullet points, **bold** titles, — dashes). Only translate, do not add or remove content. Keep technical terms, product names, and contributor names unchanged.",
|
||||
},
|
||||
{ role: "user", content: sectionsText },
|
||||
],
|
||||
temperature: 0.1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
console.error(`DeepSeek API error for ${release.tag}: ${res.status}`);
|
||||
enReleases.push(release);
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const translated = data.choices?.[0]?.message?.content || "";
|
||||
const enSections = parseBody(translated);
|
||||
enReleases.push({ ...release, sections: enSections.length > 0 ? enSections : release.sections });
|
||||
translatedCount++;
|
||||
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
console.log(`English changelog cache reused ${reusedCount} release(s), translated ${translatedCount} release(s)`);
|
||||
return { updatedAt: cnJson.updatedAt, releases: enReleases };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("Fetching releases from GitHub...");
|
||||
const releases = await fetchAllReleases();
|
||||
console.log(`Found ${releases.length} releases`);
|
||||
|
||||
const cnJson = buildReleasesJson(releases);
|
||||
console.log(`Processed ${cnJson.releases.length} non-draft releases`);
|
||||
|
||||
writeFileSync(OUT_CN, JSON.stringify(cnJson, null, 2));
|
||||
console.log(`Wrote ${OUT_CN}`);
|
||||
|
||||
console.log("Fetching cached English changelog...");
|
||||
const cachedEnJson = await fetchCachedEnglish();
|
||||
|
||||
console.log("Translating to English...");
|
||||
const enJson = await translateToEnglish(cnJson, { cachedEnJson });
|
||||
if (enJson) {
|
||||
// cache 复用的英文翻译可能来自旧版 R2 数据(desc 含 closes # 引用),统一剥离一次
|
||||
stripIssueRefsInReleases(enJson);
|
||||
writeFileSync(OUT_EN, JSON.stringify(enJson, null, 2));
|
||||
console.log(`Wrote ${OUT_EN}`);
|
||||
|
||||
// 应用内更新提示用的英文 notes(单条最新版本)
|
||||
const latestEn = buildLatestEnNotes(enJson);
|
||||
if (latestEn) {
|
||||
writeFileSync(LATEST_EN_OUT, JSON.stringify(latestEn, null, 2));
|
||||
console.log(`Wrote ${LATEST_EN_OUT}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("Done!");
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createReadStream, readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { basename, join } from "node:path";
|
||||
|
||||
const DEFAULT_API_BASE = "https://api.cnb.cool";
|
||||
const DEFAULT_REPOSITORY = "dbxio.com/dbx";
|
||||
const DEFAULT_CONCURRENCY = 3;
|
||||
const MAX_ATTEMPTS = 4;
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const githubRelease = JSON.parse(readFileSync(args.githubReleasePath, "utf8"));
|
||||
const tag = githubRelease.tagName || githubRelease.tag_name;
|
||||
if (!tag) throw new Error("GitHub release JSON is missing tagName.");
|
||||
|
||||
const client = new CnbClient(args);
|
||||
const release = await client.ensureRelease(tag, githubRelease);
|
||||
const existingAssets = new Set((release.assets || []).map((asset) => asset.name));
|
||||
const assets = localAssets(args.assetsDir).filter((assetPath) => {
|
||||
const name = basename(assetPath);
|
||||
if (existingAssets.has(name)) {
|
||||
console.log(`Skipping existing CNB asset: ${name}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
console.log(`Uploading ${assets.length} CNB asset(s) with concurrency ${args.concurrency}.`);
|
||||
await mapWithConcurrency(assets, args.concurrency, (assetPath) => uploadWithRetry(client, release.id, assetPath));
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
apiBase: process.env.CNB_API_BASE || DEFAULT_API_BASE,
|
||||
repository: process.env.CNB_REPOSITORY || DEFAULT_REPOSITORY,
|
||||
token: process.env.CNB_TOKEN || "",
|
||||
concurrency: Number.parseInt(process.env.CNB_UPLOAD_CONCURRENCY || `${DEFAULT_CONCURRENCY}`, 10),
|
||||
githubReleasePath: "",
|
||||
assetsDir: "",
|
||||
};
|
||||
for (let index = 0; index < argv.length; index++) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--github-release") args.githubReleasePath = argv[++index];
|
||||
else if (arg === "--assets-dir") args.assetsDir = argv[++index];
|
||||
else throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
if (!args.token) throw new Error("CNB_TOKEN is required.");
|
||||
if (!args.githubReleasePath || !args.assetsDir) {
|
||||
throw new Error("Usage: sync-cnb-release.mjs --github-release <release.json> --assets-dir <dir>");
|
||||
}
|
||||
if (!Number.isInteger(args.concurrency) || args.concurrency < 1) {
|
||||
throw new Error("CNB_UPLOAD_CONCURRENCY must be a positive integer.");
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
async function uploadWithRetry(client, releaseId, filePath) {
|
||||
const name = basename(filePath);
|
||||
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
console.log(`Uploading ${name}, attempt ${attempt}/${MAX_ATTEMPTS}.`);
|
||||
await client.uploadAsset(releaseId, filePath);
|
||||
console.log(`Uploaded ${name}.`);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (attempt === MAX_ATTEMPTS) throw error;
|
||||
const delayMs = 2 ** (attempt - 1) * 5000;
|
||||
console.warn(`Upload failed for ${name}: ${error.message}; retrying in ${delayMs / 1000}s.`);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CnbClient {
|
||||
constructor({ apiBase, repository, token }) {
|
||||
this.apiBase = apiBase.replace(/\/+$/, "");
|
||||
this.repository = repository;
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
async ensureRelease(tag, githubRelease) {
|
||||
const payload = {
|
||||
tag_name: tag,
|
||||
name: githubRelease.name || tag,
|
||||
body: githubRelease.body || "",
|
||||
prerelease: Boolean(githubRelease.isPrerelease || githubRelease.prerelease),
|
||||
target_commitish: githubRelease.targetCommitish || githubRelease.target_commitish || "",
|
||||
};
|
||||
const existing = await this.request("GET", `/${this.repository}/-/releases/tags/${encodeURIComponent(tag)}`, null, true);
|
||||
if (!existing) return this.request("POST", `/${this.repository}/-/releases`, payload);
|
||||
|
||||
// Keep release metadata aligned while preserving the existing asset list used for resumable uploads.
|
||||
await this.request("PATCH", `/${this.repository}/-/releases/${existing.id}`, {
|
||||
name: payload.name,
|
||||
body: payload.body,
|
||||
});
|
||||
return existing;
|
||||
}
|
||||
|
||||
async uploadAsset(releaseId, filePath) {
|
||||
const size = statSync(filePath).size;
|
||||
const target = await this.request("POST", `/${this.repository}/-/releases/${releaseId}/asset-upload-url`, {
|
||||
asset_name: basename(filePath),
|
||||
size,
|
||||
overwrite: false,
|
||||
});
|
||||
const uploadResponse = await fetch(target.upload_url, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/octet-stream", "Content-Length": `${size}` },
|
||||
body: createReadStream(filePath),
|
||||
duplex: "half",
|
||||
});
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error(`CNB object upload failed with ${uploadResponse.status}: ${await uploadResponse.text()}`);
|
||||
}
|
||||
const verifyResponse = await fetch(decodeURIComponent(target.verify_url), {
|
||||
method: "POST",
|
||||
headers: this.headers(),
|
||||
});
|
||||
if (!verifyResponse.ok) {
|
||||
throw new Error(`CNB upload confirmation failed with ${verifyResponse.status}: ${await verifyResponse.text()}`);
|
||||
}
|
||||
}
|
||||
|
||||
async request(method, path, body = null, allow404 = false) {
|
||||
const response = await fetch(`${this.apiBase}${path}`, {
|
||||
method,
|
||||
headers: this.headers(body !== null),
|
||||
body: body === null ? undefined : JSON.stringify(body),
|
||||
});
|
||||
if (allow404 && response.status === 404) return null;
|
||||
if (!response.ok) throw new Error(`CNB API ${method} ${path} failed with ${response.status}: ${await response.text()}`);
|
||||
return response.status === 204 ? null : response.json();
|
||||
}
|
||||
|
||||
headers(json = false) {
|
||||
return {
|
||||
Accept: "application/vnd.cnb.api+json",
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
...(json ? { "Content-Type": "application/json" } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function localAssets(dir) {
|
||||
return readdirSync(dir)
|
||||
.map((name) => join(dir, name))
|
||||
.filter((path) => statSync(path).isFile())
|
||||
.sort((left, right) => statSync(right).size - statSync(left).size || basename(left).localeCompare(basename(right)));
|
||||
}
|
||||
|
||||
async function mapWithConcurrency(items, concurrency, worker) {
|
||||
let nextIndex = 0;
|
||||
async function runWorker() {
|
||||
while (nextIndex < items.length) {
|
||||
const index = nextIndex++;
|
||||
await worker(items[index]);
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, runWorker));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,389 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const DEFAULT_PROJECT_OWNER = "t8y2";
|
||||
const DEFAULT_PROJECT_NUMBER = 1;
|
||||
const DEFAULT_REPO = "t8y2/dbx";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (!arg.startsWith("--")) continue;
|
||||
const key = arg.slice(2);
|
||||
const next = argv[i + 1];
|
||||
if (!next || next.startsWith("--")) {
|
||||
args[key] = "true";
|
||||
continue;
|
||||
}
|
||||
args[key] = next;
|
||||
i++;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
// PROJECT_TOKEN is a PAT with project scope; GH_TOKEN / GITHUB_TOKEN (auto-generated) handles repo access
|
||||
const projectToken = process.env.PROJECT_TOKEN || process.env.GH_TOKEN || process.env.GITHUB_TOKEN || "";
|
||||
const repoTokens = [process.env.GH_TOKEN, process.env.GITHUB_TOKEN, process.env.PROJECT_TOKEN].filter(Boolean);
|
||||
const repoToken = repoTokens[0] || "";
|
||||
const projectOwner = args["project-owner"] || process.env.PROJECT_OWNER || DEFAULT_PROJECT_OWNER;
|
||||
const projectNumber = Number(args["project-number"] || process.env.PROJECT_NUMBER || DEFAULT_PROJECT_NUMBER);
|
||||
const repo = args.repo || process.env.GITHUB_REPOSITORY || DEFAULT_REPO;
|
||||
const [repoOwner, repoName] = repo.split("/");
|
||||
const issueNumber = args["issue-number"] ? Number(args["issue-number"]) : null;
|
||||
const mode = args.backfill === "true" ? "backfill" : "issue";
|
||||
const eventAction = args["event-action"] || process.env.ISSUE_EVENT_ACTION || "";
|
||||
|
||||
if (!projectToken) {
|
||||
throw new Error("PROJECT_TOKEN, GH_TOKEN, or GITHUB_TOKEN is required");
|
||||
}
|
||||
if (!repoToken) {
|
||||
throw new Error("GH_TOKEN or GITHUB_TOKEN is required for repository access");
|
||||
}
|
||||
|
||||
if (!repoOwner || !repoName) {
|
||||
throw new Error(`Invalid repo: ${repo}`);
|
||||
}
|
||||
|
||||
if (mode === "issue" && !issueNumber) {
|
||||
throw new Error("--issue-number is required unless --backfill true is set");
|
||||
}
|
||||
|
||||
function gqlString(value) {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function gqlNumber(value) {
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function gqlNullableString(value) {
|
||||
return value == null ? "null" : JSON.stringify(value);
|
||||
}
|
||||
|
||||
async function graphql(query, token) {
|
||||
const t = token || repoTokens[0];
|
||||
const resp = await fetch("https://api.github.com/graphql", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `bearer ${t}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "dbx-project-triage/1.0",
|
||||
},
|
||||
body: JSON.stringify({ query }),
|
||||
});
|
||||
|
||||
const payload = await resp.json();
|
||||
if (payload.errors) {
|
||||
throw new Error(`GraphQL request failed: ${JSON.stringify(payload.errors)}`);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
throw new Error(`GraphQL request failed: HTTP ${resp.status} ${resp.statusText}`);
|
||||
}
|
||||
return payload.data;
|
||||
}
|
||||
|
||||
function triageName(issue) {
|
||||
const labels = new Set(issue.labels.nodes.map((label) => label.name));
|
||||
if (labels.has("question")) return "Needs Info";
|
||||
if (issue.assignees.nodes.length > 0) return "Ready";
|
||||
return "Inbox";
|
||||
}
|
||||
|
||||
function singleSelectOptionsByName(field) {
|
||||
return Object.fromEntries((field?.options || []).map((option) => [option.name, option.id]));
|
||||
}
|
||||
|
||||
function singleSelectOptionId(options, name) {
|
||||
const exact = options[name];
|
||||
if (exact) return exact;
|
||||
const target = name.toLowerCase();
|
||||
const entry = Object.entries(options).find(([optionName]) => optionName.toLowerCase() === target);
|
||||
return entry?.[1];
|
||||
}
|
||||
|
||||
async function getProjectConfig() {
|
||||
const query = `
|
||||
query {
|
||||
user(login: ${gqlString(projectOwner)}) {
|
||||
projectV2(number: ${gqlNumber(projectNumber)}) {
|
||||
id
|
||||
title
|
||||
fields(first: 50) {
|
||||
nodes {
|
||||
... on ProjectV2Field {
|
||||
id
|
||||
name
|
||||
dataType
|
||||
}
|
||||
... on ProjectV2SingleSelectField {
|
||||
id
|
||||
name
|
||||
options {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await graphql(query, projectToken);
|
||||
const project = data.user?.projectV2;
|
||||
if (!project) {
|
||||
throw new Error(`Project ${projectOwner}#${projectNumber} not found`);
|
||||
}
|
||||
|
||||
const triageField = project.fields.nodes.find((field) => field.name === "Triage");
|
||||
if (!triageField) {
|
||||
throw new Error(`Project ${project.title} is missing a Triage field`);
|
||||
}
|
||||
const statusField = project.fields.nodes.find((field) => field.name === "Status");
|
||||
|
||||
return {
|
||||
id: project.id,
|
||||
title: project.title,
|
||||
triageFieldId: triageField.id,
|
||||
triageOptions: singleSelectOptionsByName(triageField),
|
||||
statusFieldId: statusField?.id || "",
|
||||
statusOptions: singleSelectOptionsByName(statusField),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchIssue(number) {
|
||||
const query = `
|
||||
query {
|
||||
repository(owner: ${gqlString(repoOwner)}, name: ${gqlString(repoName)}) {
|
||||
issue(number: ${gqlNumber(number)}) {
|
||||
id
|
||||
number
|
||||
title
|
||||
url
|
||||
state
|
||||
assignees(first: 20) {
|
||||
nodes {
|
||||
login
|
||||
}
|
||||
}
|
||||
labels(first: 50) {
|
||||
nodes {
|
||||
name
|
||||
}
|
||||
}
|
||||
projectItems(first: 50) {
|
||||
nodes {
|
||||
id
|
||||
project {
|
||||
id
|
||||
number
|
||||
title
|
||||
owner {
|
||||
__typename
|
||||
... on User {
|
||||
login
|
||||
}
|
||||
... on Organization {
|
||||
login
|
||||
}
|
||||
}
|
||||
}
|
||||
fieldValueByName(name: "Triage") {
|
||||
... on ProjectV2ItemFieldSingleSelectValue {
|
||||
name
|
||||
optionId
|
||||
}
|
||||
}
|
||||
statusValue: fieldValueByName(name: "Status") {
|
||||
... on ProjectV2ItemFieldSingleSelectValue {
|
||||
name
|
||||
optionId
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await graphql(query, repoToken);
|
||||
const issue = data.repository?.issue;
|
||||
if (!issue) {
|
||||
throw new Error(`Issue #${number} not found in ${repo}`);
|
||||
}
|
||||
return issue;
|
||||
}
|
||||
|
||||
async function addItemToProject(projectId, contentId) {
|
||||
const mutation = `
|
||||
mutation {
|
||||
addProjectV2ItemById(input: { projectId: ${gqlString(projectId)}, contentId: ${gqlString(contentId)} }) {
|
||||
item {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const data = await graphql(mutation, projectToken);
|
||||
|
||||
return data.addProjectV2ItemById.item.id;
|
||||
}
|
||||
|
||||
async function updateTriage({ projectId, itemId, fieldId, optionId }) {
|
||||
const mutation = `
|
||||
mutation {
|
||||
updateProjectV2ItemFieldValue(
|
||||
input: {
|
||||
projectId: ${gqlString(projectId)}
|
||||
itemId: ${gqlString(itemId)}
|
||||
fieldId: ${gqlString(fieldId)}
|
||||
value: { singleSelectOptionId: ${gqlString(optionId)} }
|
||||
}
|
||||
) {
|
||||
projectV2Item {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
await graphql(mutation, projectToken);
|
||||
}
|
||||
|
||||
async function updateSingleSelectField({ projectId, itemId, fieldId, optionId }) {
|
||||
const mutation = `
|
||||
mutation {
|
||||
updateProjectV2ItemFieldValue(
|
||||
input: {
|
||||
projectId: ${gqlString(projectId)}
|
||||
itemId: ${gqlString(itemId)}
|
||||
fieldId: ${gqlString(fieldId)}
|
||||
value: { singleSelectOptionId: ${gqlString(optionId)} }
|
||||
}
|
||||
) {
|
||||
projectV2Item {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
await graphql(mutation, projectToken);
|
||||
}
|
||||
|
||||
async function syncIssue(projectConfig, number) {
|
||||
const issue = await fetchIssue(number);
|
||||
const targetTriage = triageName(issue);
|
||||
const optionId = projectConfig.triageOptions[targetTriage];
|
||||
if (!optionId) {
|
||||
throw new Error(`Missing Triage option: ${targetTriage}`);
|
||||
}
|
||||
|
||||
let projectItem = issue.projectItems.nodes.find(
|
||||
(item) =>
|
||||
item.project.id === projectConfig.id ||
|
||||
(item.project.number === projectNumber && item.project.owner?.login === projectOwner),
|
||||
);
|
||||
|
||||
if (!projectItem) {
|
||||
// 只有被 assign 的 issue 才进入 project 看板;未分配的留在 issue 列表,
|
||||
// 等 assigned 事件触发后再纳入。已在 project 里的 issue 仍随 label/assign 变化同步。
|
||||
if (issue.assignees.nodes.length === 0) {
|
||||
console.log(`Issue #${issue.number} has no assignee, skip adding to project`);
|
||||
return;
|
||||
}
|
||||
const itemId = await addItemToProject(projectConfig.id, issue.id);
|
||||
projectItem = {
|
||||
id: itemId,
|
||||
fieldValueByName: null,
|
||||
statusValue: null,
|
||||
};
|
||||
console.log(`Added issue #${issue.number} to ${projectConfig.title}`);
|
||||
}
|
||||
|
||||
const currentOptionId = projectItem.fieldValueByName?.optionId || "";
|
||||
if (currentOptionId !== optionId) {
|
||||
await updateTriage({
|
||||
projectId: projectConfig.id,
|
||||
itemId: projectItem.id,
|
||||
fieldId: projectConfig.triageFieldId,
|
||||
optionId,
|
||||
});
|
||||
console.log(`Set issue #${issue.number} triage to ${targetTriage}`);
|
||||
} else {
|
||||
console.log(`Issue #${issue.number} triage already ${targetTriage}`);
|
||||
}
|
||||
|
||||
if (eventAction === "assigned" && issue.state === "OPEN") {
|
||||
const targetStatus = "In Progress";
|
||||
const statusOptionId = singleSelectOptionId(projectConfig.statusOptions, targetStatus);
|
||||
if (!projectConfig.statusFieldId) {
|
||||
throw new Error(`Project ${projectConfig.title} is missing a Status field`);
|
||||
}
|
||||
if (!statusOptionId) {
|
||||
throw new Error(`Missing Status option: ${targetStatus}`);
|
||||
}
|
||||
|
||||
const currentStatusOptionId = projectItem.statusValue?.optionId || "";
|
||||
if (currentStatusOptionId !== statusOptionId) {
|
||||
await updateSingleSelectField({
|
||||
projectId: projectConfig.id,
|
||||
itemId: projectItem.id,
|
||||
fieldId: projectConfig.statusFieldId,
|
||||
optionId: statusOptionId,
|
||||
});
|
||||
console.log(`Set issue #${issue.number} status to ${targetStatus}`);
|
||||
} else {
|
||||
console.log(`Issue #${issue.number} status already ${targetStatus}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchOpenIssueNumbers() {
|
||||
const numbers = [];
|
||||
let cursor = null;
|
||||
while (true) {
|
||||
const query = `
|
||||
query {
|
||||
repository(owner: ${gqlString(repoOwner)}, name: ${gqlString(repoName)}) {
|
||||
issues(first: 100, after: ${gqlNullableString(cursor)}, states: OPEN, orderBy: { field: CREATED_AT, direction: ASC }) {
|
||||
nodes {
|
||||
number
|
||||
}
|
||||
pageInfo {
|
||||
endCursor
|
||||
hasNextPage
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
const data = await graphql(query, repoToken);
|
||||
const issues = data.repository?.issues;
|
||||
if (!issues) break;
|
||||
numbers.push(...issues.nodes.map((issue) => issue.number));
|
||||
if (!issues.pageInfo.hasNextPage) break;
|
||||
cursor = issues.pageInfo.endCursor;
|
||||
}
|
||||
return numbers;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const projectConfig = await getProjectConfig();
|
||||
if (mode === "backfill") {
|
||||
const numbers = await fetchOpenIssueNumbers();
|
||||
console.log(`Backfilling ${numbers.length} open issues into ${projectConfig.title}`);
|
||||
for (const number of numbers) {
|
||||
await syncIssue(projectConfig, number);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await syncIssue(projectConfig, issueNumber);
|
||||
}
|
||||
|
||||
await main();
|
||||
Reference in New Issue
Block a user