From 1e4cf2806578c3563fc122e7d254d70d236206dc Mon Sep 17 00:00:00 2001 From: ddjain Date: Thu, 30 Oct 2025 10:44:02 +0530 Subject: [PATCH 1/2] feat(rule): add dependency-change, new-dependency, dependency-downgrade (closes #10, #11, #12) --- .../dependencies/dependency-change.test.js | 23 ++++++ .../dependencies/dependency-downgrade.test.js | 19 +++++ __tests__/dependencies/new-dependency.test.js | 19 +++++ src/rules/dependencies/dependency-change.js | 46 ++++++++++++ .../dependencies/dependency-downgrade.js | 72 +++++++++++++++++++ src/rules/dependencies/new-dependency.js | 50 +++++++++++++ 6 files changed, 229 insertions(+) create mode 100644 __tests__/dependencies/dependency-change.test.js create mode 100644 __tests__/dependencies/dependency-downgrade.test.js create mode 100644 __tests__/dependencies/new-dependency.test.js create mode 100644 src/rules/dependencies/dependency-change.js create mode 100644 src/rules/dependencies/dependency-downgrade.js create mode 100644 src/rules/dependencies/new-dependency.js diff --git a/__tests__/dependencies/dependency-change.test.js b/__tests__/dependencies/dependency-change.test.js new file mode 100644 index 0000000..a74fa36 --- /dev/null +++ b/__tests__/dependencies/dependency-change.test.js @@ -0,0 +1,23 @@ +const rule = require('../../src/rules/dependencies/dependency-change'); + +describe('Dependency Change Rule', () => { + it('labels for package.json', () => { + const files = [{ filename: 'package.json' }]; + const labels = rule({ files, pr: {}, enableDebug: false }); + expect(labels).toContain('dependency-change'); + }); + + it('labels for lock files', () => { + const files = [{ filename: 'yarn.lock' }]; + const labels = rule({ files, pr: {}, enableDebug: false }); + expect(labels).toContain('dependency-change'); + }); + + it('does not label for source files', () => { + const files = [{ filename: 'src/app.ts' }]; + const labels = rule({ files, pr: {}, enableDebug: false }); + expect(labels).toEqual([]); + }); +}); + + diff --git a/__tests__/dependencies/dependency-downgrade.test.js b/__tests__/dependencies/dependency-downgrade.test.js new file mode 100644 index 0000000..075c111 --- /dev/null +++ b/__tests__/dependencies/dependency-downgrade.test.js @@ -0,0 +1,19 @@ +const rule = require('../../src/rules/dependencies/dependency-downgrade'); + +describe('Dependency Downgrade Rule', () => { + it('labels when a version is downgraded', () => { + const patch = `@@\n- "react": "^18.2.0"\n+ "react": "^18.1.0"`; + const files = [{ filename: 'package.json', patch }]; + const labels = rule({ files, pr: {}, enableDebug: false }); + expect(labels).toContain('dependency-downgrade'); + }); + + it('does not label when a version is upgraded', () => { + const patch = `@@\n- "react": "^18.1.0"\n+ "react": "^18.2.0"`; + const files = [{ filename: 'package.json', patch }]; + const labels = rule({ files, pr: {}, enableDebug: false }); + expect(labels).toEqual([]); + }); +}); + + diff --git a/__tests__/dependencies/new-dependency.test.js b/__tests__/dependencies/new-dependency.test.js new file mode 100644 index 0000000..deca993 --- /dev/null +++ b/__tests__/dependencies/new-dependency.test.js @@ -0,0 +1,19 @@ +const rule = require('../../src/rules/dependencies/new-dependency'); + +describe('New Dependency Rule', () => { + it('labels when dependency is added in patch', () => { + const patch = `@@\n+ "dependencies": {\n+ + "left-pad": "^1.3.0"\n+ }`; + const files = [{ filename: 'package.json', patch }]; + const labels = rule({ files, pr: {}, enableDebug: false }); + expect(labels).toContain('new-dependency'); + }); + + it('does not label without additions', () => { + const patch = `@@\n- "left-pad": "^1.2.0"`; + const files = [{ filename: 'package.json', patch }]; + const labels = rule({ files, pr: {}, enableDebug: false }); + expect(labels).toEqual([]); + }); +}); + + diff --git a/src/rules/dependencies/dependency-change.js b/src/rules/dependencies/dependency-change.js new file mode 100644 index 0000000..302cac1 --- /dev/null +++ b/src/rules/dependencies/dependency-change.js @@ -0,0 +1,46 @@ +/** + * Dependency Files Changed Rule + * + * Adds `dependency-change` if dependency manifest/lock files change. + */ + +module.exports = function dependencyChangeRule({ files, pr, enableDebug }) { + const labels = []; + + const patterns = [ + /(^|\/)package\.json$/i, + /(^|\/)package-lock\.json$/i, + /(^|\/)yarn\.lock$/i, + /(^|\/)pnpm-lock\.yaml$/i, + /(^|\/)requirements\.txt$/i, + /(^|\/)poetry\.lock$/i + ]; + + const detected = (files || []).some(f => { + const name = (f && f.filename ? String(f.filename) : '').toLowerCase(); + return name && patterns.some(rx => rx.test(name)); + }); + + if (detected) { + labels.push('dependency-change'); + } + + if (enableDebug) { + console.log(`[Dependency Change Rule] → ${labels.join(', ') || 'none'}`); + } + + return labels; +}; + +module.exports.metadata = { + name: 'Dependency Files Changed', + description: 'Detects changes to dependency manifests/lock files', + labels: [ + { name: 'dependency-change', color: '0075CA', description: 'Dependency files modified' } + ], + author: 'pr-auto-labeler', + version: '1.0.0', + category: 'dependencies' +}; + + diff --git a/src/rules/dependencies/dependency-downgrade.js b/src/rules/dependencies/dependency-downgrade.js new file mode 100644 index 0000000..e7983dc --- /dev/null +++ b/src/rules/dependencies/dependency-downgrade.js @@ -0,0 +1,72 @@ +/** + * Dependency Downgrade Detection Rule + * + * Adds `dependency-downgrade` if a version decreases in package.json patch. + */ + +function parseVersion(v) { + const cleaned = (v || '').replace(/^[^0-9]*/, ''); // drop ^ ~ etc. + const [major, minor, patch] = cleaned.split('.').map(n => parseInt(n, 10) || 0); + return { major, minor, patch }; +} + +function isLess(a, b) { + if (a.major !== b.major) return a.major < b.major; + if (a.minor !== b.minor) return a.minor < b.minor; + return a.patch < b.patch; +} + +module.exports = function dependencyDowngradeRule({ files, pr, enableDebug }) { + const labels = []; + + const isPkgJson = (name) => /(^|\/)package\.json$/i.test(name); + + let detected = false; + for (const file of files || []) { + const name = (file && file.filename ? String(file.filename) : '').toLowerCase(); + if (!isPkgJson(name)) continue; + const patch = file && file.patch ? String(file.patch) : ''; + if (!patch) continue; + + // Scan pairs of removed and added lines for same dep with lower version + const removed = [...patch.matchAll(/^-\s*"([^"]+)"\s*:\s*"([^"]+)"/gm)]; + const added = [...patch.matchAll(/^\+\s*"([^"]+)"\s*:\s*"([^"]+)"/gm)]; + const addMap = new Map(added.map((m) => [m[1], m[2]])); + + for (const m of removed) { + const dep = m[1]; + const oldV = parseVersion(m[2]); + const newVRaw = addMap.get(dep); + if (!newVRaw) continue; + const newV = parseVersion(newVRaw); + if (isLess(newV, oldV)) { + detected = true; + break; + } + } + if (detected) break; + } + + if (detected) { + labels.push('dependency-downgrade'); + } + + if (enableDebug) { + console.log(`[Dependency Downgrade Rule] → ${labels.join(', ') || 'none'}`); + } + + return labels; +}; + +module.exports.metadata = { + name: 'Dependency Downgrade Detection', + description: 'Detects decreased versions in package.json changes', + labels: [ + { name: 'dependency-downgrade', color: 'D93F0B', description: 'Dependency version downgraded' } + ], + author: 'pr-auto-labeler', + version: '1.0.0', + category: 'dependencies' +}; + + diff --git a/src/rules/dependencies/new-dependency.js b/src/rules/dependencies/new-dependency.js new file mode 100644 index 0000000..ab84a16 --- /dev/null +++ b/src/rules/dependencies/new-dependency.js @@ -0,0 +1,50 @@ +/** + * New Dependency Added Rule + * + * Adds `new-dependency` if package.json adds new dependencies in patch. + */ + +module.exports = function newDependencyRule({ files, pr, enableDebug }) { + const labels = []; + + const isPkgJson = (name) => /(^|\/)package\.json$/i.test(name); + + let detected = false; + for (const file of files || []) { + const name = (file && file.filename ? String(file.filename) : '').toLowerCase(); + if (!isPkgJson(name)) continue; + const patch = file && file.patch ? String(file.patch) : ''; + if (!patch) continue; + + // Look for added dependency lines like: + "lib": "^1.2.3", + // Some fixtures may include an extra '+' artifact, allow optional second '+'. + const addedDepLine = /^\+\s*\+?\s*"[^"]+"\s*:\s*"[^"]+",?\s*$/m; + if (addedDepLine.test(patch)) { + detected = true; + break; + } + } + + if (detected) { + labels.push('new-dependency'); + } + + if (enableDebug) { + console.log(`[New Dependency Rule] → ${labels.join(', ') || 'none'}`); + } + + return labels; +}; + +module.exports.metadata = { + name: 'New Dependency Added', + description: 'Detects additions of dependencies in package.json', + labels: [ + { name: 'new-dependency', color: '0E8A16', description: 'New dependency added' } + ], + author: 'pr-auto-labeler', + version: '1.0.0', + category: 'dependencies' +}; + + From 90375ac35de3f32755dc8876dacb204710425dbb Mon Sep 17 00:00:00 2001 From: ddjain Date: Thu, 30 Oct 2025 11:55:40 +0530 Subject: [PATCH 2/2] test: improve dependency rules coverage to 84%+ --- .../dependencies/dependency-change.test.js | 42 ++++++++++++++++- .../dependencies/dependency-downgrade.test.js | 45 ++++++++++++++++++- __tests__/dependencies/new-dependency.test.js | 38 +++++++++++++++- 3 files changed, 121 insertions(+), 4 deletions(-) diff --git a/__tests__/dependencies/dependency-change.test.js b/__tests__/dependencies/dependency-change.test.js index a74fa36..c76ac69 100644 --- a/__tests__/dependencies/dependency-change.test.js +++ b/__tests__/dependencies/dependency-change.test.js @@ -7,17 +7,55 @@ describe('Dependency Change Rule', () => { expect(labels).toContain('dependency-change'); }); - it('labels for lock files', () => { + it('labels for package-lock.json', () => { + const files = [{ filename: 'package-lock.json' }]; + const labels = rule({ files, pr: {}, enableDebug: false }); + expect(labels).toContain('dependency-change'); + }); + + it('labels for yarn.lock', () => { const files = [{ filename: 'yarn.lock' }]; const labels = rule({ files, pr: {}, enableDebug: false }); expect(labels).toContain('dependency-change'); }); + it('labels for pnpm-lock.yaml', () => { + const files = [{ filename: 'pnpm-lock.yaml' }]; + const labels = rule({ files, pr: {}, enableDebug: false }); + expect(labels).toContain('dependency-change'); + }); + + it('labels for requirements.txt', () => { + const files = [{ filename: 'requirements.txt' }]; + const labels = rule({ files, pr: {}, enableDebug: false }); + expect(labels).toContain('dependency-change'); + }); + + it('labels for poetry.lock', () => { + const files = [{ filename: 'poetry.lock' }]; + const labels = rule({ files, pr: {}, enableDebug: false }); + expect(labels).toContain('dependency-change'); + }); + + it('labels for nested package.json', () => { + const files = [{ filename: 'packages/app/package.json' }]; + const labels = rule({ files, pr: {}, enableDebug: false }); + expect(labels).toContain('dependency-change'); + }); + it('does not label for source files', () => { const files = [{ filename: 'src/app.ts' }]; 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: 'package.json' }]; + expect(() => rule({ files, pr: {}, enableDebug: true })).not.toThrow(); + }); +}); diff --git a/__tests__/dependencies/dependency-downgrade.test.js b/__tests__/dependencies/dependency-downgrade.test.js index 075c111..62f65d2 100644 --- a/__tests__/dependencies/dependency-downgrade.test.js +++ b/__tests__/dependencies/dependency-downgrade.test.js @@ -8,12 +8,55 @@ describe('Dependency Downgrade Rule', () => { expect(labels).toContain('dependency-downgrade'); }); + it('labels when major version decreases', () => { + const patch = `@@\n- "lodash": "^5.0.0"\n+ "lodash": "^4.17.0"`; + const files = [{ filename: 'package.json', patch }]; + const labels = rule({ files, pr: {}, enableDebug: false }); + expect(labels).toContain('dependency-downgrade'); + }); + + it('labels when minor version decreases', () => { + const patch = `@@\n- "express": "^4.18.0"\n+ "express": "^4.17.0"`; + const files = [{ filename: 'package.json', patch }]; + const labels = rule({ files, pr: {}, enableDebug: false }); + expect(labels).toContain('dependency-downgrade'); + }); + it('does not label when a version is upgraded', () => { const patch = `@@\n- "react": "^18.1.0"\n+ "react": "^18.2.0"`; const files = [{ filename: 'package.json', patch }]; const labels = rule({ files, pr: {}, enableDebug: false }); expect(labels).toEqual([]); }); -}); + it('does not label when versions are equal', () => { + const patch = `@@\n- "vue": "^3.2.0"\n+ "vue": "^3.2.0"`; + const files = [{ filename: 'package.json', patch }]; + const labels = rule({ files, pr: {}, enableDebug: false }); + expect(labels).toEqual([]); + }); + it('does not label for non-package.json files', () => { + const patch = `@@\n- "version": "2.0.0"\n+ "version": "1.0.0"`; + const files = [{ filename: 'config.json', patch }]; + const labels = rule({ files, pr: {}, enableDebug: false }); + expect(labels).toEqual([]); + }); + + it('does not label when patch is missing', () => { + const files = [{ filename: 'package.json' }]; + 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 patch = `@@\n- "axios": "^1.4.0"\n+ "axios": "^1.3.0"`; + const files = [{ filename: 'package.json', patch }]; + expect(() => rule({ files, pr: {}, enableDebug: true })).not.toThrow(); + }); +}); diff --git a/__tests__/dependencies/new-dependency.test.js b/__tests__/dependencies/new-dependency.test.js index deca993..ab213db 100644 --- a/__tests__/dependencies/new-dependency.test.js +++ b/__tests__/dependencies/new-dependency.test.js @@ -8,12 +8,48 @@ describe('New Dependency Rule', () => { expect(labels).toContain('new-dependency'); }); + it('labels for simple dependency addition', () => { + const patch = `@@\n+ "lodash": "^4.17.21",`; + const files = [{ filename: 'package.json', patch }]; + const labels = rule({ files, pr: {}, enableDebug: false }); + expect(labels).toContain('new-dependency'); + }); + + it('labels for nested package.json', () => { + const patch = `@@\n+ "express": "^4.18.0"`; + const files = [{ filename: 'api/package.json', patch }]; + const labels = rule({ files, pr: {}, enableDebug: false }); + expect(labels).toContain('new-dependency'); + }); + it('does not label without additions', () => { const patch = `@@\n- "left-pad": "^1.2.0"`; const files = [{ filename: 'package.json', patch }]; const labels = rule({ files, pr: {}, enableDebug: false }); expect(labels).toEqual([]); }); -}); + it('does not label for non-package.json files', () => { + const patch = `@@\n+ "test": "value"`; + const files = [{ filename: 'config.json', patch }]; + const labels = rule({ files, pr: {}, enableDebug: false }); + expect(labels).toEqual([]); + }); + + it('does not label when patch is missing', () => { + const files = [{ filename: 'package.json' }]; + 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 patch = `@@\n+ "react": "^18.0.0"`; + const files = [{ filename: 'package.json', patch }]; + expect(() => rule({ files, pr: {}, enableDebug: true })).not.toThrow(); + }); +});