diff --git a/.github/scripts/label-pull-request.mjs b/.github/scripts/label-pull-request.mjs new file mode 100644 index 0000000000..22041164d3 --- /dev/null +++ b/.github/scripts/label-pull-request.mjs @@ -0,0 +1,566 @@ +#!/usr/bin/env node +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import { pathToFileURL } from "node:url"; + +const API_VERSION = "2022-11-28"; +const DATABASE_LABEL_PREFIX = "db/"; +const dryRun = process.env.DRY_RUN === "1" || process.env.DRY_RUN === "true"; + +export const STATIC_LABEL_SPECS = { + bug: { color: "d73a4a", description: "Fixes a bug" }, + enhancement: { color: "a2eeef", description: "Adds or improves functionality" }, + documentation: { color: "0075ca", description: "Documentation-only change" }, + maintenance: { color: "ededed", description: "Maintenance, refactoring, build, CI, or test change" }, + "area/desktop": { color: "1d76db", description: "Desktop application or Tauri shell" }, + "area/core": { color: "5319e7", description: "Shared DBX core runtime" }, + "area/web": { color: "0e8a16", description: "Web backend or web API" }, + "area/agents": { color: "f9d0c4", description: "Database agents and agent runtime" }, + "area/mcp": { color: "c2e0c6", description: "MCP server or packages" }, + "area/cli": { color: "bfd4f2", description: "DBX command-line interface" }, + "area/plugins": { color: "d4c5f9", description: "Dialect, JDBC, or mapping plugins" }, + "area/docs": { color: "0075ca", description: "Documentation site or repository documentation" }, + "area/ci": { color: "e99695", description: "GitHub Actions or repository automation" }, + "area/deploy": { color: "fef2c0", description: "Deployment, packaging, or distribution" }, + "ui-change": { color: "c5def5", description: "Changes user-visible interface, text, or visual assets" }, + "dependencies/frontend": { color: "0366d6", description: "Adds a frontend dependency" }, + "dependencies/backend": { color: "5319e7", description: "Adds a backend dependency" }, + "tests-only": { color: "bfd4f2", description: "Changes only tests, fixtures, or snapshots" }, +}; + +const TYPE_LABELS = new Set(["bug", "enhancement", "documentation", "maintenance"]); +const LABEL_PALETTE = [ + "0e8a16", + "1d76db", + "5319e7", + "c2e0c6", + "bfd4f2", + "d4c5f9", + "fef2c0", + "fbca04", + "f9d0c4", + "e99695", + "f29513", + "c5def5", +]; + +const DRIVER_DATABASE_ALIASES = { + gbase8a: "gbase", + gbase8s: "gbase", + "h2-legacy": "h2", + kafka: "mq", + "kingbase-go": "kingbase", + "oracle-10g": "oracle", + "oracle-go": "oracle", + "oracle-legacy": "oracle", + rabbitmq: "mq", + rocketmq: "mq", + "sqlserver-legacy": "sqlserver", +}; + +const DIALECT_DATABASE_ALIASES = { + oceanbase: "oceanbase-oracle", + postgresql: "postgres", + turso: "sqlite", +}; + +const DATABASE_PATH_TOKENS = [ + [/clickhouse/i, "clickhouse"], + [/doris/i, "doris"], + [/duckdb/i, "duckdb"], + [/jdbc[x]?/i, "jdbc"], + [/mongo(?:db)?/i, "mongodb"], + [/mysql/i, "mysql"], + [/oracle/i, "oracle"], + [/postgres(?:ql)?/i, "postgres"], + [/redis/i, "redis"], + [/sqlite/i, "sqlite"], + [/sqlserver/i, "sqlserver"], +]; + +const PACKAGE_DEPENDENCY_SECTIONS = [ + "dependencies", + "devDependencies", + "optionalDependencies", + "peerDependencies", +]; + +function stablePaletteColor(value) { + let hash = 0; + for (const character of value) { + hash = (hash * 31 + character.codePointAt(0)) >>> 0; + } + return LABEL_PALETTE[hash % LABEL_PALETTE.length]; +} + +function normalizeChangedFiles(changedFiles) { + return [...new Set(changedFiles.map((file) => String(file).replaceAll("\\", "/")).filter(Boolean))].sort(); +} + +function conventionalType(title) { + const normalized = String(title || "").trim().replace(/^[^\p{L}\p{N}]+/u, ""); + return /^([a-z]+)(?:\([^)]*\))?!?:/i.exec(normalized)?.[1]?.toLowerCase() || null; +} + +export function isDocumentationFile(file) { + return file.startsWith("docs/") + || /(^|\/)README(?:\.[^/]+)?\.md$/i.test(file) + || /(^|\/)(SECURITY|CONTRIBUTING|CODE_OF_CONDUCT)\.md$/i.test(file); +} + +export function inferTypeLabel(title, changedFiles) { + const type = conventionalType(title); + if (type === "fix") return "bug"; + if (type === "feat") return "enhancement"; + if (type === "docs") return "documentation"; + if (["build", "chore", "ci", "perf", "refactor", "style", "test"].includes(type)) return "maintenance"; + if (changedFiles.length > 0 && changedFiles.every(isDocumentationFile)) return "documentation"; + return null; +} + +export function inferAreaLabels(changedFiles) { + const labels = new Set(); + const has = (predicate) => changedFiles.some(predicate); + + if (has((file) => file === "package.json" + || file.startsWith("apps/desktop/") + || file.startsWith("packages/app-tests/") + || file.startsWith("src-tauri/"))) { + labels.add("area/desktop"); + } + if (has((file) => file.startsWith("crates/dbx-core/") || file.startsWith("packages/node-core/"))) { + labels.add("area/core"); + } + if (has((file) => file.startsWith("crates/dbx-web/") || file.startsWith("examples/web-api/"))) { + labels.add("area/web"); + } + if (has((file) => file.startsWith("agents/"))) labels.add("area/agents"); + if (has((file) => file.startsWith("crates/dbx-mcp/") + || file.startsWith("packages/mcp-") + || file.startsWith("packages/mcp-server/") + || file.startsWith("examples/mcp/"))) { + labels.add("area/mcp"); + } + if (has((file) => file.startsWith("crates/dbx-cli/") + || file.startsWith("packages/cli-") + || file.startsWith("packages/cli/") + || file.startsWith("examples/cli/"))) { + labels.add("area/cli"); + } + if (has((file) => file.startsWith("plugins/"))) labels.add("area/plugins"); + if (has(isDocumentationFile)) labels.add("area/docs"); + if (has((file) => file.startsWith(".github/"))) labels.add("area/ci"); + if (has((file) => file.startsWith("deploy/") + || file === "flake.nix" + || file === "flake.lock" + || file === "Makefile" + || /^packages\/(?:cli|mcp)-(?:darwin|linux|win32)-/.test(file))) { + labels.add("area/deploy"); + } + + return [...labels].sort(); +} + +function isVisualAsset(file) { + return /\.(?:avif|bmp|gif|ico|jpe?g|png|svg|webp|woff2?|ttf|otf)$/i.test(file); +} + +export function isUiChangeFile(file) { + if (isTestFile(file)) return false; + + if (file.startsWith("apps/desktop/")) { + return /\.(?:vue|css|less|sass|scss|styl)$/i.test(file) + || file.startsWith("apps/desktop/src/components/") + || file.startsWith("apps/desktop/src/i18n/") + || file.startsWith("apps/desktop/src/styles/") + || file.startsWith("apps/desktop/public/") + || isVisualAsset(file); + } + + if (file.startsWith("src-tauri/icons/")) return true; + + if (file.startsWith("docs/")) { + return /\.(?:css|jsx|tsx)$/i.test(file) + || file.startsWith("docs/components/") + || (file.startsWith("docs/public/") && isVisualAsset(file)); + } + + return false; +} + +export function isTestFile(file) { + return /(^|\/)(?:__tests__|fixtures|snapshots|test|tests|test-support)(\/|$)/.test(file) + || /\.(?:spec|test)\.[^/]+$/i.test(file) + || /(^|\/)packages\/app-tests\//.test(file); +} + +function normalizeDatabaseType(value, aliases) { + const normalized = String(value || "").toLowerCase(); + return aliases[normalized] || normalized; +} + +export function inferDatabaseTypes(changedFiles, knownDatabaseTypes) { + const databaseTypes = new Set(); + + const add = (value) => { + if (knownDatabaseTypes.has(value)) databaseTypes.add(value); + }; + + for (const file of changedFiles) { + const driverMatch = /^agents\/drivers\/([^/]+)\//.exec(file); + if (driverMatch) add(normalizeDatabaseType(driverMatch[1], DRIVER_DATABASE_ALIASES)); + + const dialectMatch = /^plugins\/dialects\/([^/]+)\.ya?ml$/i.exec(file); + if (dialectMatch) add(normalizeDatabaseType(dialectMatch[1], DIALECT_DATABASE_ALIASES)); + + if (file.startsWith("plugins/jdbc/")) add("jdbc"); + + if (file.startsWith("apps/desktop/src/") || file.startsWith("crates/dbx-core/src/")) { + for (const [pattern, databaseType] of DATABASE_PATH_TOKENS) { + if (pattern.test(file)) add(databaseType); + } + } + } + + return [...databaseTypes].sort(); +} + +export function parsePackageDependencyNames(source) { + if (!source) return new Set(); + const manifest = JSON.parse(source); + const names = new Set(); + for (const section of PACKAGE_DEPENDENCY_SECTIONS) { + const dependencies = manifest[section]; + if (!dependencies || typeof dependencies !== "object" || Array.isArray(dependencies)) continue; + for (const name of Object.keys(dependencies)) names.add(name); + } + return names; +} + +function unquoteTomlKey(value) { + const trimmed = String(value || "").trim(); + if ((trimmed.startsWith('"') && trimmed.endsWith('"')) + || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +export function parseCargoDependencyNames(source) { + const names = new Set(); + let dependencySection = false; + + for (const line of String(source || "").split(/\r?\n/)) { + const sectionMatch = /^\s*\[([^\]]+)]\s*(?:#.*)?$/.exec(line); + if (sectionMatch) { + const section = sectionMatch[1].trim(); + const dependencyMatch = /(?:^|\.)(?:dev-|build-)?dependencies(?:\.(.+))?$/.exec(section); + dependencySection = Boolean(dependencyMatch && !dependencyMatch[1]); + if (dependencyMatch?.[1]) names.add(unquoteTomlKey(dependencyMatch[1].split(".")[0])); + continue; + } + + if (!dependencySection || /^\s*(?:#|$)/.test(line)) continue; + const keyMatch = /^\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_.-]+))\s*=/.exec(line); + const name = keyMatch?.[1] || keyMatch?.[2] || keyMatch?.[3]; + if (name) names.add(name); + } + + return names; +} + +function normalizeGradleDependency(value) { + const coordinate = String(value || "").trim(); + if (!coordinate) return null; + if (coordinate.startsWith(":")) return `project:${coordinate}`; + const parts = coordinate.split(":"); + return parts.length >= 2 ? `${parts[0]}:${parts[1]}` : coordinate; +} + +export function parseGradleDependencyNames(source) { + const names = new Set(); + const configuration = /^(?:api|annotationProcessor|classpath|compileOnly|implementation|kapt|ksp|runtimeOnly|testCompileOnly|testImplementation|testRuntimeOnly)$/; + + for (const line of String(source || "").split(/\r?\n/)) { + const declaration = /^\s*([A-Za-z][A-Za-z0-9]*)\b(.*)$/.exec(line); + if (!declaration || !configuration.test(declaration[1])) continue; + const rest = declaration[2].replace(/\/\/.*$/, ""); + const quoted = /['"]([^'"]+)['"]/.exec(rest)?.[1]; + const catalog = /\b(libs\.[A-Za-z0-9_.-]+)/.exec(rest)?.[1]; + const name = catalog ? `catalog:${catalog}` : normalizeGradleDependency(quoted); + if (name) names.add(name); + } + + return names; +} + +export function parseGoDependencyNames(source) { + const names = new Set(); + let inRequireBlock = false; + + for (const line of String(source || "").split(/\r?\n/)) { + const trimmed = line.replace(/\/\/.*$/, "").trim(); + if (trimmed === "require (") { + inRequireBlock = true; + continue; + } + if (inRequireBlock && trimmed === ")") { + inRequireBlock = false; + continue; + } + + const value = inRequireBlock ? trimmed : /^require\s+(.+)$/.exec(trimmed)?.[1]; + const moduleName = value?.split(/\s+/)[0]; + if (moduleName) names.add(moduleName); + } + + return names; +} + +export function parseMavenDependencyNames(source) { + const names = new Set(); + for (const match of String(source || "").matchAll(/\s*([\s\S]*?)<\/dependency>/g)) { + const groupId = /\s*([^<]+?)\s*<\/groupId>/.exec(match[1])?.[1]; + const artifactId = /\s*([^<]+?)\s*<\/artifactId>/.exec(match[1])?.[1]; + if (groupId && artifactId) names.add(`${groupId}:${artifactId}`); + } + return names; +} + +function dependencySide(file) { + if (file === "package.json" || file === "docs/package.json" || file === "packages/mongo-shell/package.json") { + return "frontend"; + } + if (/^packages\/(?:cli(?:-|\/)|mcp(?:-|server\/)).*\/package\.json$/.test(file) + || file === "packages/cli/package.json" + || file === "packages/mcp-server/package.json") { + return "backend"; + } + if (file === "Cargo.toml" + || file.endsWith("/Cargo.toml") + || file.endsWith("/build.gradle") + || file.endsWith("/build.gradle.kts") + || file.endsWith("/go.mod") + || file.endsWith("/pom.xml")) { + return "backend"; + } + return null; +} + +function parseDependencyNames(file, source) { + if (file.endsWith("package.json")) return parsePackageDependencyNames(source); + if (file === "Cargo.toml" || file.endsWith("/Cargo.toml")) return parseCargoDependencyNames(source); + if (file.endsWith("/build.gradle") || file.endsWith("/build.gradle.kts")) return parseGradleDependencyNames(source); + if (file.endsWith("/go.mod")) return parseGoDependencyNames(source); + if (file.endsWith("/pom.xml")) return parseMavenDependencyNames(source); + return new Set(); +} + +export function inferAddedDependencies(changedFiles, readBaseFile, readHeadFile) { + const added = { frontend: new Set(), backend: new Set() }; + + for (const file of changedFiles) { + const side = dependencySide(file); + if (!side) continue; + + const baseNames = parseDependencyNames(file, readBaseFile(file) || ""); + const headNames = parseDependencyNames(file, readHeadFile(file) || ""); + for (const name of headNames) { + if (!baseNames.has(name)) added[side].add(`${file}:${name}`); + } + } + + return added; +} + +export function evaluatePullRequestLabels({ + title, + changedFiles, + knownDatabaseTypes, + readBaseFile = () => null, + readHeadFile = () => null, +}) { + const files = normalizeChangedFiles(changedFiles); + const labels = new Set(inferAreaLabels(files)); + const typeLabel = inferTypeLabel(title, files); + if (typeLabel) labels.add(typeLabel); + if (files.some(isUiChangeFile)) labels.add("ui-change"); + if (files.length > 0 && files.every(isTestFile)) labels.add("tests-only"); + + const databaseTypes = inferDatabaseTypes(files, knownDatabaseTypes); + for (const databaseType of databaseTypes) labels.add(`${DATABASE_LABEL_PREFIX}${databaseType}`); + + const addedDependencies = inferAddedDependencies(files, readBaseFile, readHeadFile); + if (addedDependencies.frontend.size > 0) labels.add("dependencies/frontend"); + if (addedDependencies.backend.size > 0) labels.add("dependencies/backend"); + + return { + addedDependencies: { + frontend: [...addedDependencies.frontend].sort(), + backend: [...addedDependencies.backend].sort(), + }, + databaseTypes, + files, + labels: [...labels].sort(), + typeLabel, + }; +} + +function labelNames(labels) { + return (labels || []).map((label) => (typeof label === "string" ? label : label.name)).filter(Boolean); +} + +function loadEvent() { + if (!process.env.GITHUB_EVENT_PATH || !fs.existsSync(process.env.GITHUB_EVENT_PATH)) { + throw new Error("GITHUB_EVENT_PATH is required"); + } + return JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); +} + +function loadDatabaseCatalog() { + const manifestUrl = new URL("../../crates/dbx-core/assets/database-drivers.manifest.json", import.meta.url); + const manifest = JSON.parse(fs.readFileSync(manifestUrl, "utf8")); + return new Map(manifest.drivers.map((driver) => [driver.dbType, driver.label])); +} + +function listChangedFiles(baseSha, headSha) { + const output = execFileSync("git", ["diff", "--name-only", "-z", `${baseSha}...${headSha}`], { + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + }); + return output.split("\0").filter(Boolean); +} + +function readFileAtRef(ref, file) { + try { + return execFileSync("git", ["show", `${ref}:${file}`], { + encoding: "utf8", + maxBuffer: 16 * 1024 * 1024, + stdio: ["ignore", "pipe", "ignore"], + }); + } catch { + return null; + } +} + +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, spec) { + try { + await githubRequest("POST", "/labels", { name, color: spec.color, description: spec.description }); + console.log(`Created label ${name}`); + } catch (error) { + if (error.status === 422) return; + throw error; + } +} + +async function addLabels(pullRequestNumber, names) { + await githubRequest("POST", `/issues/${pullRequestNumber}/labels`, { labels: names }); +} + +async function removeLabel(pullRequestNumber, name) { + try { + await githubRequest("DELETE", `/issues/${pullRequestNumber}/labels/${encodeURIComponent(name)}`); + } catch (error) { + if (error.status === 404) return; + throw error; + } +} + +function isManagedLabel(name) { + return TYPE_LABELS.has(name) + || name.startsWith("area/") + || name.startsWith(DATABASE_LABEL_PREFIX) + || name.startsWith("dependencies/") + || name === "tests-only" + || name === "ui-change"; +} + +async function main() { + const event = loadEvent(); + const pullRequest = event.pull_request; + if (!pullRequest?.number) throw new Error("Pull request event is required"); + + const baseSha = pullRequest.base?.sha; + const headSha = pullRequest.head?.sha; + if (!baseSha || !headSha) throw new Error("Pull request base and head SHAs are required"); + + const databaseCatalog = loadDatabaseCatalog(); + const result = evaluatePullRequestLabels({ + title: pullRequest.title || "", + changedFiles: listChangedFiles(baseSha, headSha), + knownDatabaseTypes: new Set(databaseCatalog.keys()), + readBaseFile: (file) => readFileAtRef(baseSha, file), + readHeadFile: (file) => readFileAtRef(headSha, file), + }); + const existingLabels = labelNames(pullRequest.labels); + const labelsToAdd = result.labels.filter((name) => !existingLabels.includes(name)); + const labelsToRemove = existingLabels.filter((name) => isManagedLabel(name) && !result.labels.includes(name)); + + const labelSpecs = { ...STATIC_LABEL_SPECS }; + for (const databaseType of result.databaseTypes) { + labelSpecs[`${DATABASE_LABEL_PREFIX}${databaseType}`] = { + color: stablePaletteColor(databaseType), + description: `Database: ${databaseCatalog.get(databaseType) || databaseType}`, + }; + } + + console.log(JSON.stringify({ + pullRequest: pullRequest.number, + title: pullRequest.title, + ...result, + existingLabels, + labelsToAdd, + labelsToRemove, + }, null, 2)); + + if (dryRun) { + console.log("Dry run enabled; no GitHub API calls were made"); + return; + } + + for (const name of labelsToAdd) { + await ensureLabel(name, labelSpecs[name]); + } + + if (labelsToAdd.length > 0) { + await addLabels(pullRequest.number, labelsToAdd); + console.log(`Added labels: ${labelsToAdd.join(", ")}`); + } + + for (const name of labelsToRemove) { + await removeLabel(pullRequest.number, name); + console.log(`Removed stale label ${name}`); + } +} + +const entryPoint = process.argv[1] ? pathToFileURL(process.argv[1]).href : null; +if (entryPoint === import.meta.url) await main(); diff --git a/.github/scripts/label-pull-request.test.mjs b/.github/scripts/label-pull-request.test.mjs new file mode 100644 index 0000000000..da63ac3d07 --- /dev/null +++ b/.github/scripts/label-pull-request.test.mjs @@ -0,0 +1,186 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + evaluatePullRequestLabels, + inferAddedDependencies, + inferDatabaseTypes, + parseCargoDependencyNames, + parseGoDependencyNames, + parseGradleDependencyNames, + parseMavenDependencyNames, + parsePackageDependencyNames, +} from "./label-pull-request.mjs"; + +const knownDatabaseTypes = new Set([ + "access", + "doris", + "jdbc", + "mongodb", + "mq", + "mysql", + "oceanbase-oracle", + "oracle", + "postgres", + "redis", + "sqlite", + "sqlserver", +]); + +test("labels a desktop MySQL UI fix", () => { + const result = evaluatePullRequestLabels({ + title: "fix(mysql): improve connection dialog", + changedFiles: [ + "apps/desktop/src/components/connection/MySqlConnectionDialog.vue", + "apps/desktop/src/i18n/en.ts", + ], + knownDatabaseTypes, + }); + + assert.deepEqual(result.labels, ["area/desktop", "bug", "db/mysql", "ui-change"]); +}); + +test("labels documentation-only changes without a conventional title", () => { + const result = evaluatePullRequestLabels({ + title: "Improve installation notes", + changedFiles: ["README.md", "docs/content/docs/install.mdx"], + knownDatabaseTypes, + }); + + assert.deepEqual(result.labels, ["area/docs", "documentation"]); +}); + +test("maps agent and dialect paths to existing database types", () => { + assert.deepEqual( + inferDatabaseTypes([ + "agents/drivers/oracle-go/go.mod", + "agents/drivers/kafka/build.gradle", + "plugins/dialects/postgresql.yaml", + "plugins/dialects/oceanbase.yaml", + ], knownDatabaseTypes), + ["mq", "oceanbase-oracle", "oracle", "postgres"], + ); +}); + +test("labels tests-only changes", () => { + const result = evaluatePullRequestLabels({ + title: "test(core): cover transaction recovery", + changedFiles: [ + "crates/dbx-core/tests/transaction_recovery.rs", + "apps/desktop/src/lib/__tests__/transactionRecovery.spec.ts", + ], + knownDatabaseTypes, + }); + + assert.deepEqual(result.labels, ["area/core", "area/desktop", "maintenance", "tests-only"]); +}); + +test("does not treat component tests or MCP server code as UI or deploy changes", () => { + const result = evaluatePullRequestLabels({ + title: "🐛 fix(mcp): cover server routing", + changedFiles: [ + "apps/desktop/src/components/mcp/McpPanel.spec.ts", + "packages/mcp-server/src/index.ts", + ], + knownDatabaseTypes, + }); + + assert.deepEqual(result.labels, ["area/desktop", "area/mcp", "bug"]); +}); + +test("detects only newly added package dependencies", () => { + const result = inferAddedDependencies( + ["package.json"], + () => JSON.stringify({ dependencies: { vue: "3.5.0", pinia: "2.0.0" } }), + () => JSON.stringify({ dependencies: { vue: "3.6.0", pinia: "2.0.0", zod: "4.0.0" } }), + ); + + assert.deepEqual([...result.frontend], ["package.json:zod"]); + assert.equal(result.backend.size, 0); +}); + +test("detects frontend and backend additions in the same pull request", () => { + const manifests = { + base: { + "package.json": JSON.stringify({ dependencies: { vue: "3.5.0" } }), + "crates/dbx-core/Cargo.toml": "[dependencies]\ntokio = \"1\"\n", + }, + head: { + "package.json": JSON.stringify({ dependencies: { vue: "3.5.0", zod: "4" } }), + "crates/dbx-core/Cargo.toml": "[dependencies]\ntokio = \"1\"\nserde = \"1\"\n", + }, + }; + const result = evaluatePullRequestLabels({ + title: "feat(core): add validated settings", + changedFiles: ["package.json", "crates/dbx-core/Cargo.toml", "pnpm-lock.yaml", "Cargo.lock"], + knownDatabaseTypes, + readBaseFile: (file) => manifests.base[file], + readHeadFile: (file) => manifests.head[file], + }); + + assert.deepEqual(result.labels, [ + "area/core", + "area/desktop", + "dependencies/backend", + "dependencies/frontend", + "enhancement", + ]); +}); + +test("ignores lockfile-only dependency changes", () => { + const result = evaluatePullRequestLabels({ + title: "chore: refresh lockfiles", + changedFiles: ["pnpm-lock.yaml", "Cargo.lock", "agents/drivers/xugu/go.sum"], + knownDatabaseTypes, + }); + + assert.deepEqual(result.labels, ["area/agents", "maintenance"]); +}); + +test("parses dependency names from supported backend manifests", () => { + assert.deepEqual( + [...parseCargoDependencyNames(` +[workspace.dependencies] +serde = "1" + +[target.'cfg(windows)'.dependencies] +windows-sys = "0.61" + +[dependencies.mysql_async] +git = "https://example.com/mysql_async" +`)].sort(), + ["mysql_async", "serde", "windows-sys"], + ); + assert.deepEqual( + [...parseGradleDependencyNames(` +implementation 'com.zaxxer:HikariCP:7.0.2' +testImplementation project(':test-support') +runtimeOnly libs.slf4j.nop +`)].sort(), + ["catalog:libs.slf4j.nop", "com.zaxxer:HikariCP", "project::test-support"], + ); + assert.deepEqual( + [...parseGoDependencyNames(` +require github.com/example/direct v1.0.0 +require ( + github.com/example/block v2.0.0 +) +`)].sort(), + ["github.com/example/block", "github.com/example/direct"], + ); + assert.deepEqual( + [...parseMavenDependencyNames(` + + org.exampleclient1 + +`)], + ["org.example:client"], + ); + assert.deepEqual( + [...parsePackageDependencyNames(JSON.stringify({ + dependencies: { vue: "3" }, + devDependencies: { vitest: "4" }, + }))].sort(), + ["vitest", "vue"], + ); +}); diff --git a/.github/workflows/pull-request-labels.yml b/.github/workflows/pull-request-labels.yml new file mode 100644 index 0000000000..c148ff8268 --- /dev/null +++ b/.github/workflows/pull-request-labels.yml @@ -0,0 +1,43 @@ +name: Pull Request Labels + +on: + pull_request_target: + types: [opened, edited, synchronize, reopened, ready_for_review] + +concurrency: + group: pull-request-labels-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + label: + runs-on: ubuntu-latest + steps: + - name: Check out trusted base revision + uses: actions/checkout@v5 + with: + ref: ${{ github.event.pull_request.base.sha }} + fetch-depth: 0 + persist-credentials: false + + - name: Fetch pull request head without checkout + env: + EXPECTED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} + PULL_REQUEST_NUMBER: ${{ github.event.pull_request.number }} + run: | + git fetch --no-tags origin "pull/${PULL_REQUEST_NUMBER}/head" + test "$(git rev-parse FETCH_HEAD)" = "${EXPECTED_HEAD_SHA}" + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22.13.0 + + - name: Synchronize pull request labels + run: node .github/scripts/label-pull-request.mjs + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}