import {jest, describe, it, expect, beforeEach, beforeAll} from '@jest/globals'; import * as yaml from 'js-yaml'; import * as fs from 'fs'; import type { MatchConfig, BaseMatchConfig } from '../src/api/get-label-configs.js'; // Define API mock functions at module level const getPullRequestsMock = jest.fn(); const getLabelConfigsMock = jest.fn(); const addLabelsMock = jest.fn(); const removeLabelsMock = jest.fn(); const getChangedFilesMock = jest.fn(); const getContentMock = jest.fn(); jest.unstable_mockModule('@actions/core', () => ({ getInput: jest.fn(), getMultilineInput: jest.fn(), getBooleanInput: jest.fn(), setOutput: jest.fn(), setFailed: jest.fn(), error: jest.fn(), warning: jest.fn(), info: jest.fn(), debug: jest.fn() })); jest.unstable_mockModule('@actions/github', () => ({ context: { payload: { pull_request: { number: 123, head: {ref: 'head-branch'}, base: {ref: 'base-branch'} } }, repo: {owner: 'monalisa', repo: 'helloworld'} }, getOctokit: jest.fn() })); jest.unstable_mockModule('../src/api/index.js', () => ({ getPullRequests: getPullRequestsMock, getLabelConfigs: getLabelConfigsMock, addLabels: addLabelsMock, removeLabels: removeLabelsMock, getChangedFiles: getChangedFilesMock, getContent: getContentMock })); const core = await import('@actions/core'); const github = await import('@actions/github'); const api = await import('../src/api/index.js'); const {labeler, checkMatchConfigs} = await import('../src/labeler.js'); const { toMatchConfig, getLabelConfigMapFromObject, getLabelConfigResultFromObject, configUsesChangedFiles } = await import('../src/api/get-label-configs.js'); beforeAll(() => { (core.getInput as jest.Mock).mockImplementation(() => undefined); }); const loadYaml = (filepath: string) => { const loadedFile = fs.readFileSync(filepath); const content = Buffer.from(loadedFile).toString(); return yaml.load(content); }; describe('getLabelConfigMapFromObject', () => { const yamlObject = loadYaml('__tests__/fixtures/all_options.yml'); const expected = new Map(); expected.set('label1', [ { any: [ {changedFiles: [{anyGlobToAnyFile: ['glob']}]}, {baseBranch: undefined, headBranch: ['regexp']}, {baseBranch: ['regexp'], headBranch: undefined} ] }, { all: [ {changedFiles: [{allGlobsToAllFiles: ['glob']}]}, {baseBranch: undefined, headBranch: ['regexp']}, {baseBranch: ['regexp'], headBranch: undefined} ] } ]); expected.set('label2', [ { any: [ {changedFiles: [{anyGlobToAnyFile: ['glob']}]}, {baseBranch: undefined, headBranch: ['regexp']}, {baseBranch: ['regexp'], headBranch: undefined} ] } ]); it('returns a MatchConfig', () => { const result = getLabelConfigMapFromObject(yamlObject); expect(result).toEqual(expected); }); it('ignores top-level options like changed-files-labels-limit and max-files-changed', () => { const configWithLimit = { 'changed-files-labels-limit': 5, 'max-files-changed': 100, label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}] }; const result = getLabelConfigMapFromObject(configWithLimit); expect(result.has('changed-files-labels-limit')).toBe(false); expect(result.has('max-files-changed')).toBe(false); expect(result.has('label1')).toBe(true); }); }); describe('getLabelConfigResultFromObject', () => { it('extracts changed-files-labels-limit as a number', () => { const config = { 'changed-files-labels-limit': 5, label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}] }; const result = getLabelConfigResultFromObject(config); expect(result.changedFilesLimit).toBe(5); expect(result.labelConfigs.has('label1')).toBe(true); }); it('parses changed-files-labels-limit from string', () => { const config = { 'changed-files-labels-limit': '10', label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}] }; const result = getLabelConfigResultFromObject(config); expect(result.changedFilesLimit).toBe(10); }); it('trims whitespace when parsing string values', () => { const config = { 'changed-files-labels-limit': ' 5 ', label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}] }; const result = getLabelConfigResultFromObject(config); expect(result.changedFilesLimit).toBe(5); }); it('returns undefined changedFilesLimit when not set', () => { const config = { label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}] }; const result = getLabelConfigResultFromObject(config); expect(result.changedFilesLimit).toBeUndefined(); }); it('throws error for invalid changed-files-labels-limit value', () => { const config = { 'changed-files-labels-limit': 'invalid', label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}] }; expect(() => getLabelConfigResultFromObject(config)).toThrow( /Invalid value for 'changed-files-labels-limit'/ ); }); it('throws error for negative changed-files-labels-limit value', () => { const config = { 'changed-files-labels-limit': -1, label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}] }; expect(() => getLabelConfigResultFromObject(config)).toThrow( /must be a non-negative integer/ ); }); it('throws error for string with trailing characters', () => { const config = { 'changed-files-labels-limit': '10abc', label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}] }; expect(() => getLabelConfigResultFromObject(config)).toThrow( /must be a non-negative integer/ ); }); it('throws error for decimal string', () => { const config = { 'changed-files-labels-limit': '3.2', label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}] }; expect(() => getLabelConfigResultFromObject(config)).toThrow( /must be a non-negative integer/ ); }); it('throws error for float number', () => { const config = { 'changed-files-labels-limit': 3.2, label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}] }; expect(() => getLabelConfigResultFromObject(config)).toThrow( /must be a non-negative integer/ ); }); it('accepts zero as a valid changed-files-labels-limit', () => { const config = { 'changed-files-labels-limit': 0, label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}] }; const result = getLabelConfigResultFromObject(config); expect(result.changedFilesLimit).toBe(0); }); it('extracts max-files-changed as a number', () => { const config = { 'max-files-changed': 100, label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}] }; const result = getLabelConfigResultFromObject(config); expect(result.maxFilesChanged).toBe(100); expect(result.labelConfigs.has('label1')).toBe(true); }); it('parses max-files-changed from string', () => { const config = { 'max-files-changed': '50', label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}] }; const result = getLabelConfigResultFromObject(config); expect(result.maxFilesChanged).toBe(50); }); it('returns undefined maxFilesChanged when not set', () => { const config = { label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}] }; const result = getLabelConfigResultFromObject(config); expect(result.maxFilesChanged).toBeUndefined(); }); it('throws error for invalid max-files-changed value', () => { const config = { 'max-files-changed': 'invalid', label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}] }; expect(() => getLabelConfigResultFromObject(config)).toThrow( /Invalid value for 'max-files-changed'/ ); }); it('throws error for negative max-files-changed value', () => { const config = { 'max-files-changed': -1, label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}] }; expect(() => getLabelConfigResultFromObject(config)).toThrow( /must be a non-negative integer/ ); }); it('accepts zero as a valid max-files-changed', () => { const config = { 'max-files-changed': 0, label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}] }; const result = getLabelConfigResultFromObject(config); expect(result.maxFilesChanged).toBe(0); }); it('supports both options together', () => { const config = { 'changed-files-labels-limit': 5, 'max-files-changed': 100, label1: [{'changed-files': [{'any-glob-to-any-file': ['*.txt']}]}] }; const result = getLabelConfigResultFromObject(config); expect(result.changedFilesLimit).toBe(5); expect(result.maxFilesChanged).toBe(100); }); it('throws a clear error when max-files-changed is used as a label', () => { const config = { 'max-files-changed': [ {'changed-files': [{'any-glob-to-any-file': ['*.txt']}]} ] }; expect(() => getLabelConfigResultFromObject(config)).toThrow( /reserved top-level option and cannot be used as a label name/ ); }); it('throws a clear error when changed-files-labels-limit is used as a label', () => { const config = { 'changed-files-labels-limit': [ {'changed-files': [{'any-glob-to-any-file': ['*.txt']}]} ] }; expect(() => getLabelConfigResultFromObject(config)).toThrow( /reserved top-level option and cannot be used as a label name/ ); }); }); describe('toMatchConfig', () => { describe('when all expected config options are present', () => { const config = { 'changed-files': [{'any-glob-to-any-file': ['testing-files']}], 'head-branch': ['testing-head'], 'base-branch': ['testing-base'] }; const expected: BaseMatchConfig = { changedFiles: [{anyGlobToAnyFile: ['testing-files']}], headBranch: ['testing-head'], baseBranch: ['testing-base'] }; it('returns a MatchConfig object with all options', () => { const result = toMatchConfig(config); expect(result).toEqual(expected); }); describe('and there are also unexpected options present', () => { config['test-test'] = 'testing'; it('does not include the unexpected items in the returned MatchConfig object', () => { const result = toMatchConfig(config); expect(result).toEqual(expected); }); }); }); }); describe('checkMatchConfigs', () => { describe('when a single match config is provided', () => { const matchConfig: MatchConfig[] = [ {any: [{changedFiles: [{anyGlobToAnyFile: ['*.txt']}]}]} ]; it('returns true when our pattern does match changed files', () => { const changedFiles = ['foo.txt', 'bar.txt']; const result = checkMatchConfigs(changedFiles, matchConfig, false); expect(result).toBeTruthy(); }); it('returns false when our pattern does not match changed files', () => { const changedFiles = ['foo.docx']; const result = checkMatchConfigs(changedFiles, matchConfig, false); expect(result).toBeFalsy(); }); it('returns true when either the branch or changed files patter matches', () => { const matchConfig: MatchConfig[] = [ { any: [ {changedFiles: [{anyGlobToAnyFile: ['*.txt']}]}, {headBranch: ['some-branch']} ] } ]; const changedFiles = ['foo.txt', 'bar.txt']; const result = checkMatchConfigs(changedFiles, matchConfig, false); expect(result).toBe(true); }); it('returns false for a file starting with dot if `dot` option is false', () => { const changedFiles = ['.foo.txt']; const result = checkMatchConfigs(changedFiles, matchConfig, false); expect(result).toBeFalsy(); }); it('returns true for a file starting with dot if `dot` option is true', () => { const changedFiles = ['.foo.txt']; const result = checkMatchConfigs(changedFiles, matchConfig, true); expect(result).toBeTruthy(); }); }); describe('when multiple MatchConfigs are supplied', () => { const matchConfig: MatchConfig[] = [ {any: [{changedFiles: [{anyGlobToAnyFile: ['*.txt']}]}]}, {any: [{headBranch: ['some-branch']}]} ]; const changedFiles = ['foo.txt', 'bar.md']; it('returns false when only one config matches', () => { const result = checkMatchConfigs(changedFiles, matchConfig, false); expect(result).toBe(false); }); it('returns true when only both config matches', () => { const matchConfig: MatchConfig[] = [ {any: [{changedFiles: [{anyGlobToAnyFile: ['*.txt']}]}]}, {any: [{headBranch: ['head-branch']}]} ]; const result = checkMatchConfigs(changedFiles, matchConfig, false); expect(result).toBe(true); }); }); }); describe('configUsesChangedFiles', () => { it('returns true when config has changed-files in any block', () => { const matchConfig: MatchConfig[] = [ {any: [{changedFiles: [{anyGlobToAnyFile: ['*.txt']}]}]} ]; expect(configUsesChangedFiles(matchConfig)).toBe(true); }); it('returns true when config has changed-files in all block', () => { const matchConfig: MatchConfig[] = [ {all: [{changedFiles: [{allGlobsToAllFiles: ['*.txt']}]}]} ]; expect(configUsesChangedFiles(matchConfig)).toBe(true); }); it('returns false when config only has branch patterns', () => { const matchConfig: MatchConfig[] = [ {any: [{headBranch: ['^test/']}]}, {any: [{baseBranch: ['main']}]} ]; expect(configUsesChangedFiles(matchConfig)).toBe(false); }); it('returns false when config has empty changed-files array', () => { const matchConfig: MatchConfig[] = [{any: [{changedFiles: []}]}]; expect(configUsesChangedFiles(matchConfig)).toBe(false); }); it('returns false when config has changed-files with empty objects', () => { const matchConfig: MatchConfig[] = [{any: [{changedFiles: [{}]}]}]; expect(configUsesChangedFiles(matchConfig)).toBe(false); }); it('returns true when config has mixed branch and changed-files patterns', () => { const matchConfig: MatchConfig[] = [ { any: [ {changedFiles: [{anyGlobToAnyFile: ['*.txt']}]}, {headBranch: ['^feature/']} ] } ]; expect(configUsesChangedFiles(matchConfig)).toBe(true); }); }); describe('labeler error handling', () => { const mockClient = {} as any; const mockPullRequest = { number: 123, data: {labels: []}, changedFiles: [] }; beforeEach(() => { jest.resetAllMocks(); (github.getOctokit as jest.Mock).mockReturnValue(mockClient); getPullRequestsMock.mockReturnValue([ { ...mockPullRequest, data: {labels: [{name: 'old-label'}]} } ]); getLabelConfigsMock.mockResolvedValue({ labelConfigs: new Map([['new-label', ['dummy-config']]]), changedFilesLimit: undefined }); // Force match so "new-label" is always added jest.spyOn({checkMatchConfigs}, 'checkMatchConfigs').mockReturnValue(true); }); it('throws a custom error for HttpError 403 with "unauthorized" message', async () => { addLabelsMock.mockRejectedValue({ name: 'HttpError', status: 403, message: 'Request failed with status code 403: Unauthorized' }); await expect(labeler()).rejects.toThrow( /does not have permission to create labels/ ); }); it('rethrows unexpected HttpError', async () => { const unexpectedError = { name: 'HttpError', status: 404, message: 'Not Found' }; addLabelsMock.mockRejectedValue(unexpectedError); // NOTE: In the current implementation, labeler rethrows the raw error object (not an Error instance). // `rejects.toThrow` only works with real Error objects, so here we must use `rejects.toEqual`. // If labeler is updated to always wrap errors in `Error`, this test can be changed to use `rejects.toThrow`. await expect(labeler()).rejects.toEqual(unexpectedError); }); it('handles "Resource not accessible by integration" gracefully', async () => { const error = { name: 'HttpError', message: 'Resource not accessible by integration' }; addLabelsMock.mockRejectedValue(error); await labeler(); expect(core.warning).toHaveBeenCalledWith( expect.stringContaining("requires 'issues: write'"), expect.any(Object) ); expect(core.setFailed).toHaveBeenCalledWith(error.message); }); it('reports the configured labels when a bulk removal fails', async () => { (core.getBooleanInput as jest.Mock).mockReturnValue(true); getPullRequestsMock.mockReturnValue([ { number: 123, data: { node_id: 'PR_node_id', labels: [{name: 'stale-label', node_id: 'label_node_id'}] }, changedFiles: ['file.txt'] } ]); getLabelConfigsMock.mockResolvedValue({ labelConfigs: new Map([ [ 'stale-label', [{any: [{changedFiles: [{anyGlobToAnyFile: ['*.pdf']}]}]}] ] ]), changedFilesLimit: undefined }); removeLabelsMock.mockRejectedValue(new Error('GraphQL request failed')); await expect(labeler()).rejects.toThrow( "Failed to remove configured labels 'stale-label' from PR #123" ); expect(addLabelsMock).not.toHaveBeenCalled(); }); });