From 961bdec26be833784a98d8f689622f626675fa41 Mon Sep 17 00:00:00 2001 From: ddjain Date: Thu, 30 Oct 2025 10:43:20 +0530 Subject: [PATCH 1/2] feat(rule): add safe-migration detection (closes #6) --- __tests__/database/safe-migration.test.js | 23 ++++++++++ src/rules/database/safe-migration.js | 52 +++++++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 __tests__/database/safe-migration.test.js create mode 100644 src/rules/database/safe-migration.js diff --git a/__tests__/database/safe-migration.test.js b/__tests__/database/safe-migration.test.js new file mode 100644 index 0000000..0bebff9 --- /dev/null +++ b/__tests__/database/safe-migration.test.js @@ -0,0 +1,23 @@ +const rule = require('../../src/rules/database/safe-migration'); + +describe('Safe Migration Rule', () => { + it('labels for additive migrations without risky ops', () => { + const files = [{ filename: 'migrations/001_add_users.sql', patch: '+ ALTER TABLE users ADD COLUMN age INT;' }]; + const labels = rule({ files, pr: {}, enableDebug: false }); + expect(labels).toContain('safe-migration'); + }); + + it('does not label when risky ops are present', () => { + const files = [{ filename: 'migrations/002_drop_table.sql', patch: '+ DROP TABLE users;' }]; + const labels = rule({ files, pr: {}, enableDebug: false }); + expect(labels).toEqual([]); + }); + + it('does not label when no migrations found', () => { + const files = [{ filename: 'src/app.ts', patch: '+ const x = 1;' }]; + const labels = rule({ files, pr: {}, enableDebug: false }); + expect(labels).toEqual([]); + }); +}); + + diff --git a/src/rules/database/safe-migration.js b/src/rules/database/safe-migration.js new file mode 100644 index 0000000..6fef06f --- /dev/null +++ b/src/rules/database/safe-migration.js @@ -0,0 +1,52 @@ +/** + * Safe Migration Detection Rule + * + * Adds `safe-migration` when migration files are present and no risky/schema + * keywords are detected (i.e., only additive changes like ADD COLUMN/INDEX). + */ + +module.exports = function safeMigrationRule({ files, pr, enableDebug }) { + const labels = []; + + const isMigration = (name) => /(^|\/)(migrations|db\/migrations|database\/migrations)\//i.test(name) || /\.sql$/i.test(name); + const risky = /(DROP\s+TABLE|TRUNCATE\s+TABLE|ALTER\s+TABLE\s+[^;]*\s+DROP\b|RENAME\s+COLUMN|MODIFY\s+COLUMN)/i; + const additive = /(CREATE\s+INDEX|ADD\s+COLUMN|CREATE\s+TABLE|ADD\s+CONSTRAINT)/i; + + let hasMigration = false; + let hasRisky = false; + let hasAdditive = false; + + for (const file of files || []) { + const name = (file && file.filename ? String(file.filename) : '').toLowerCase(); + if (!name || !isMigration(name)) continue; + hasMigration = true; + const patch = file && file.patch ? String(file.patch) : ''; + if (patch) { + if (risky.test(patch)) hasRisky = true; + if (additive.test(patch)) hasAdditive = true; + } + } + + if (hasMigration && !hasRisky && hasAdditive) { + labels.push('safe-migration'); + } + + if (enableDebug) { + console.log(`[Safe Migration Rule] hasMigration=${hasMigration} risky=${hasRisky} additive=${hasAdditive} → ${labels.join(', ') || 'none'}`); + } + + return labels; +}; + +module.exports.metadata = { + name: 'Safe Migration Detection', + description: 'Detects additive-only DB migrations (safe to apply)', + labels: [ + { name: 'safe-migration', color: '0E8A16', description: 'Additive database migration detected' } + ], + author: 'pr-auto-labeler', + version: '1.0.0', + category: 'database' +}; + + From 6d5ffb13d18a1305da2c5bf50c1b59f6255e6f28 Mon Sep 17 00:00:00 2001 From: ddjain Date: Thu, 30 Oct 2025 10:52:56 +0530 Subject: [PATCH 2/2] test: improve safe-migration test coverage to 90%+ --- __tests__/database/safe-migration.test.js | 40 +++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/__tests__/database/safe-migration.test.js b/__tests__/database/safe-migration.test.js index 0bebff9..a4b9a3e 100644 --- a/__tests__/database/safe-migration.test.js +++ b/__tests__/database/safe-migration.test.js @@ -7,17 +7,57 @@ describe('Safe Migration Rule', () => { expect(labels).toContain('safe-migration'); }); + it('labels for CREATE INDEX operations', () => { + const files = [{ filename: 'db/migrations/002_add_index.sql', patch: '+ CREATE INDEX idx_email ON users(email);' }]; + const labels = rule({ files, pr: {}, enableDebug: false }); + expect(labels).toContain('safe-migration'); + }); + + it('labels for CREATE TABLE operations', () => { + const files = [{ filename: 'database/migrations/003_create.sql', patch: '+ CREATE TABLE posts (id INT);' }]; + const labels = rule({ files, pr: {}, enableDebug: false }); + expect(labels).toContain('safe-migration'); + }); + it('does not label when risky ops are present', () => { const files = [{ filename: 'migrations/002_drop_table.sql', patch: '+ DROP TABLE users;' }]; const labels = rule({ files, pr: {}, enableDebug: false }); expect(labels).toEqual([]); }); + it('does not label when ALTER DROP is present', () => { + const files = [{ filename: 'migrations/003_alter.sql', patch: '+ ALTER TABLE users DROP COLUMN age;' }]; + const labels = rule({ files, pr: {}, enableDebug: false }); + expect(labels).toEqual([]); + }); + it('does not label when no migrations found', () => { const files = [{ filename: 'src/app.ts', patch: '+ const x = 1;' }]; const labels = rule({ files, pr: {}, enableDebug: false }); expect(labels).toEqual([]); }); + + it('does not label when migration has no patch', () => { + const files = [{ filename: 'migrations/004_test.sql' }]; + const labels = rule({ files, pr: {}, enableDebug: false }); + expect(labels).toEqual([]); + }); + + it('does not label when only risky ops without additive', () => { + const files = [{ filename: 'migrations/005_truncate.sql', patch: '+ TRUNCATE TABLE users;' }]; + const labels = rule({ files, pr: {}, enableDebug: false }); + expect(labels).toEqual([]); + }); + + it('handles empty files array', () => { + const labels = rule({ files: [], pr: {}, enableDebug: false }); + expect(labels).toEqual([]); + }); + + it('handles debug mode', () => { + const files = [{ filename: 'migrations/006_add.sql', patch: '+ ADD COLUMN name VARCHAR(255);' }]; + expect(() => rule({ files, pr: {}, enableDebug: true })).not.toThrow(); + }); });