diff --git a/lib/plugins/variables.js b/lib/plugins/variables.js index 25795c40..a8e65b91 100644 --- a/lib/plugins/variables.js +++ b/lib/plugins/variables.js @@ -1,196 +1,71 @@ -const _ = require('lodash') const Diffable = require('./diffable') +const NopCommand = require('../nopcommand') module.exports = class Variables extends Diffable { constructor (...args) { super(...args) if (this.entries) { - // Force all names to uppercase to avoid comparison issues. this.entries.forEach((variable) => { variable.name = variable.name.toUpperCase() }) } } - /** - * Look-up existing variables for a given repository - * - * @see {@link https://docs.github.com/en/rest/actions/variables?apiVersion=2022-11-28#list-repository-variables} list repository variables - * @returns {Array.} Returns a list of variables that exist in a repository - */ - async find () { + find () { this.log.debug(`Finding repo vars for ${this.repo.owner}/${this.repo.repo}`) - const { data: { variables } } = await this.github.request('GET /repos/:org/:repo/actions/variables', { + return this.github.request('GET /repos/:org/:repo/actions/variables', { org: this.repo.owner, repo: this.repo.repo - }) - return variables - } - - /** - * Compare the existing variables with what we've defined as code - * - * @param {Array.} existing Existing variables defined in the repository - * @param {Array.} variables Variables that we have defined as code - * - * @returns {object} The results of a list comparison - */ - getChanged (existing, variables = []) { - const result = - JSON.stringify( - existing.sort((x1, x2) => { - return x1.name.toUpperCase().localeCompare(x2.name.toUpperCase()) - }) - ) !== - JSON.stringify( - variables.sort((x1, x2) => { - return x1.name.toUpperCase().localeCompare(x2.name.toUpperCase()) - }) - ) - return result + }).then(({ data: { variables } }) => variables.map(({ name, value }) => ({ name, value }))) } - /** - * Compare existing variables with what's defined - * - * @param {Object} existing The existing entries in GitHub - * @param {Object} attrs The entries defined as code - * - * @returns - */ comparator (existing, attrs) { return existing.name === attrs.name } - /** - * Return a list of changed entries - * - * @param {Object} existing The existing entries in GitHub - * @param {Object} attrs The entries defined as code - * - * @returns - */ changed (existing, attrs) { - return this.getChanged(_.castArray(existing), _.castArray(attrs)) + return existing.value !== attrs.value } - /** - * Update an existing variable if the value has changed - * - * @param {Array.} existing Existing variables defined in the repository - * @param {Array.} variables Variables that we have defined as code - * - * @see {@link https://docs.github.com/en/rest/actions/variables?apiVersion=2022-11-28#update-a-repository-variable} update a repository variable - * @returns - */ - async update (existing, variables = []) { - this.log.debug(`Updating a repo var existing params ${JSON.stringify(existing)} and new ${JSON.stringify(variables)}`) - existing = _.castArray(existing) - variables = _.castArray(variables) - const changed = this.getChanged(existing, variables) - - if (changed) { - let existingVariables = [...existing] - for (const variable of variables) { - const existingVariable = existingVariables.find((_var) => _var.name === variable.name) - if (existingVariable) { - existingVariables = existingVariables.filter((_var) => _var.name !== variable.name) - if (existingVariable.value !== variable.value) { - await this.github - .request('PATCH /repos/:org/:repo/actions/variables/:variable_name', { - org: this.repo.owner, - repo: this.repo.repo, - variable_name: variable.name.toUpperCase(), - value: variable.value.toString() - }) - .then((res) => { - return res - }) - .catch((e) => { - this.logError(e) - }) - } - } else { - await this.github - .request('POST /repos/:org/:repo/actions/variables', { - org: this.repo.owner, - repo: this.repo.repo, - name: variable.name.toUpperCase(), - value: variable.value.toString() - }) - .then((res) => { - return res - }) - .catch((e) => { - this.logError(e) - }) - } - } - - for (const variable of existingVariables) { - await this.github - .request('DELETE /repos/:org/:repo/actions/variables/:variable_name', { - org: this.repo.owner, - repo: this.repo.repo, - variable_name: variable.name.toUpperCase() - }) - .then((res) => { - return res - }) - .catch((e) => { - this.logError(e) - }) - } + update (existing, attrs) { + if (this.nop) { + return Promise.resolve([ + new NopCommand(this.constructor.name, this.repo, null, `Update variable ${attrs.name}`) + ]) } + return this.github.request('PATCH /repos/:org/:repo/actions/variables/:variable_name', { + org: this.repo.owner, + repo: this.repo.repo, + variable_name: attrs.name.toUpperCase(), + value: attrs.value.toString() + }) } - /** - * Add a new variable to a given repository - * - * @param {object} variable The variable to add, with name and value - * - * @see {@link https://docs.github.com/en/rest/actions/variables?apiVersion=2022-11-28#create-a-repository-variable} create a repository variable - * @returns - */ - async add (variable) { - this.log.debug(`Adding a repo var with the params ${JSON.stringify(variable)}`) - await this.github - .request('POST /repos/:org/:repo/actions/variables', { - org: this.repo.owner, - repo: this.repo.repo, - name: variable.name, - value: variable.value.toString() - }) - .then((res) => { - return res - }) - .catch((e) => { - this.logError(e) - }) + add (attrs) { + if (this.nop) { + return Promise.resolve([ + new NopCommand(this.constructor.name, this.repo, null, `Add variable ${attrs.name}`) + ]) + } + return this.github.request('POST /repos/:org/:repo/actions/variables', { + org: this.repo.owner, + repo: this.repo.repo, + name: attrs.name.toUpperCase(), + value: attrs.value.toString() + }) } - /** - * Remove variables that aren't defined as code - * - * @param {String} existing Name of the existing variable to remove - * - * @see {@link https://docs.github.com/en/rest/actions/variables?apiVersion=2022-11-28#delete-a-repository-variable} delete a repository variable - * @returns - */ - async remove (existing) { - this.log.debug(`Removing a repo var with the params ${JSON.stringify(existing)}`) - await this.github - .request('DELETE /repos/:org/:repo/actions/variables/:variable_name', { - org: this.repo.owner, - repo: this.repo.repo, - variable_name: existing.name - }) - .then((res) => { - return res - }) - .catch((e) => { - this.logError(e) - }) + remove (existing) { + if (this.nop) { + return Promise.resolve([ + new NopCommand(this.constructor.name, this.repo, null, `Remove variable ${existing.name}`) + ]) + } + return this.github.request('DELETE /repos/:org/:repo/actions/variables/:variable_name', { + org: this.repo.owner, + repo: this.repo.repo, + variable_name: existing.name.toUpperCase() + }) } } diff --git a/test/unit/lib/plugins/variables.test.js b/test/unit/lib/plugins/variables.test.js index 2784d7af..72ae6293 100644 --- a/test/unit/lib/plugins/variables.test.js +++ b/test/unit/lib/plugins/variables.test.js @@ -1,78 +1,203 @@ const { when } = require('jest-when') const Variables = require('../../../../lib/plugins/variables') +const NopCommand = require('../../../../lib/nopcommand') describe('Variables', () => { let github const org = 'bkeepers' const repo = 'test' - function fillVariables (variables = []) { - return variables - } - - function configure () { - const log = { debug: console.debug, error: console.error } + function configure (nop = false, entries = [{ name: 'test', value: 'test' }]) { + const log = { debug: jest.fn(), error: console.error } const errors = [] - return new Variables(undefined, github, { owner: org, repo }, [{ name: 'test', value: 'test' }], log, errors) + return new Variables(nop, github, { owner: org, repo }, entries, log, errors) } - beforeAll(() => { + beforeEach(() => { github = { request: jest.fn().mockReturnValue(Promise.resolve(true)) } }) - it('sync', () => { - const plugin = configure() - - when(github.request) - .calledWith('GET /repos/:org/:repo/actions/variables', { org, repo }) - .mockResolvedValue({ - data: { - variables: [ - fillVariables({ - variables: [] - }) - ] - } - }); - - ['variables'].forEach(() => { + describe('constructor', () => { + it('should uppercase entry names', () => { + const plugin = configure(false, [{ name: 'lower_case', value: 'val' }]) + expect(plugin.entries[0].name).toBe('LOWER_CASE') + }) + }) + + describe('find', () => { + it('should return only name and value fields', async () => { when(github.request) .calledWith('GET /repos/:org/:repo/actions/variables', { org, repo }) .mockResolvedValue({ data: { - variables: [{ name: 'DELETE_me', value: 'test' }] + variables: [{ name: 'VAR1', value: 'val1', created_at: '2024-01-01', updated_at: '2024-01-02' }] } }) + + const plugin = configure() + const result = await plugin.find() + + expect(result).toEqual([{ name: 'VAR1', value: 'val1' }]) }) + }) - when(github.request).calledWith('POST /repos/:org/:repo/actions/variables').mockResolvedValue({}) + describe('changed', () => { + it('should return true when values differ', () => { + const plugin = configure() + expect(plugin.changed({ name: 'X', value: 'old' }, { name: 'X', value: 'new' })).toBe(true) + }) - return plugin.sync().then(() => { - expect(github.request).toHaveBeenCalledWith('GET /repos/:org/:repo/actions/variables', { org, repo }); + it('should return false when values match', () => { + const plugin = configure() + expect(plugin.changed({ name: 'X', value: 'same' }, { name: 'X', value: 'same' })).toBe(false) + }) + }) + + describe('sync', () => { + it('should add new and remove stale variables', () => { + const plugin = configure() + + when(github.request) + .calledWith('GET /repos/:org/:repo/actions/variables', { org, repo }) + .mockResolvedValue({ + data: { + variables: [{ name: 'DELETE_ME', value: 'test' }] + } + }) - ['variables'].forEach(() => { - expect(github.request).toHaveBeenCalledWith('GET /repos/:org/:repo/actions/variables', { org, repo }) + return plugin.sync().then(() => { + expect(github.request).toHaveBeenCalledWith( + 'DELETE /repos/:org/:repo/actions/variables/:variable_name', + expect.objectContaining({ org, repo, variable_name: 'DELETE_ME' }) + ) + + expect(github.request).toHaveBeenCalledWith( + 'POST /repos/:org/:repo/actions/variables', + expect.objectContaining({ org, repo, name: 'TEST', value: 'test' }) + ) }) + }) - expect(github.request).toHaveBeenCalledWith( - 'DELETE /repos/:org/:repo/actions/variables/:variable_name', - expect.objectContaining({ - org, - repo, - variable_name: 'DELETE_me' + it('should return NopCommands and not mutate when nop is true', async () => { + const plugin = configure(true) + + when(github.request) + .calledWith('GET /repos/:org/:repo/actions/variables', { org, repo }) + .mockResolvedValue({ + data: { + variables: [{ name: 'EXISTING_VAR', value: 'existing-value' }] + } }) + + const result = await plugin.sync() + + expect(github.request).toHaveBeenCalledWith('GET /repos/:org/:repo/actions/variables', { org, repo }) + expect(github.request).not.toHaveBeenCalledWith( + expect.stringMatching(/^(POST|PATCH|DELETE)/), + expect.anything() ) + expect(Array.isArray(result)).toBe(true) + expect(result.length).toBeGreaterThan(0) + // resArray contains: INFO NopCommand (flat), then [NopCommand] arrays from add/remove/update + const flat = result.flat() + flat.forEach(cmd => expect(cmd).toBeInstanceOf(NopCommand)) + }) + + it('should return NopCommand results when updating via sync', async () => { + const plugin = configure(true, [{ name: 'TEST', value: 'new-value' }]) + + when(github.request) + .calledWith('GET /repos/:org/:repo/actions/variables', { org, repo }) + .mockResolvedValue({ + data: { + variables: [{ name: 'TEST', value: 'old-value' }] + } + }) + + const result = await plugin.sync() + + expect(github.request).not.toHaveBeenCalledWith( + expect.stringMatching(/^(POST|PATCH|DELETE)/), + expect.anything() + ) + + expect(Array.isArray(result)).toBe(true) + const flat = result.flat() + flat.forEach(cmd => expect(cmd).toBeInstanceOf(NopCommand)) + }) + }) + + describe('add', () => { + it('should return NopCommand array when nop is true', async () => { + const plugin = configure(true) + const result = await plugin.add({ name: 'NEW_VAR', value: 'new-value' }) + + expect(Array.isArray(result)).toBe(true) + expect(result[0]).toBeInstanceOf(NopCommand) + expect(result[0].plugin).toBe('Variables') + expect(github.request).not.toHaveBeenCalled() + }) + + it('should make POST request when nop is false', async () => { + const plugin = configure(false) + await plugin.add({ name: 'NEW_VAR', value: 'new-value' }) + expect(github.request).toHaveBeenCalledWith( 'POST /repos/:org/:repo/actions/variables', - expect.objectContaining({ - org, - repo, - name: 'TEST', - value: 'test' - }) + expect.objectContaining({ org, repo, name: 'NEW_VAR', value: 'new-value' }) + ) + }) + }) + + describe('remove', () => { + it('should return NopCommand array when nop is true', async () => { + const plugin = configure(true) + const result = await plugin.remove({ name: 'EXISTING_VAR', value: 'existing-value' }) + + expect(Array.isArray(result)).toBe(true) + expect(result[0]).toBeInstanceOf(NopCommand) + expect(result[0].plugin).toBe('Variables') + expect(github.request).not.toHaveBeenCalled() + }) + + it('should make DELETE request when nop is false', async () => { + const plugin = configure(false) + await plugin.remove({ name: 'EXISTING_VAR', value: 'existing-value' }) + + expect(github.request).toHaveBeenCalledWith( + 'DELETE /repos/:org/:repo/actions/variables/:variable_name', + expect.objectContaining({ org, repo, variable_name: 'EXISTING_VAR' }) + ) + }) + }) + + describe('update', () => { + it('should return NopCommand array when nop is true', async () => { + const plugin = configure(true) + const result = await plugin.update( + { name: 'VAR1', value: 'old-value' }, + { name: 'VAR1', value: 'new-value' } + ) + + expect(Array.isArray(result)).toBe(true) + expect(result[0]).toBeInstanceOf(NopCommand) + expect(result[0].plugin).toBe('Variables') + expect(github.request).not.toHaveBeenCalled() + }) + + it('should make PATCH request when nop is false', async () => { + const plugin = configure(false) + await plugin.update( + { name: 'VAR1', value: 'old-value' }, + { name: 'VAR1', value: 'new-value' } + ) + + expect(github.request).toHaveBeenCalledWith( + 'PATCH /repos/:org/:repo/actions/variables/:variable_name', + expect.objectContaining({ org, repo, variable_name: 'VAR1', value: 'new-value' }) ) }) })