From 224131c7b96b0eef04cfdc52f37ed14ad59889af Mon Sep 17 00:00:00 2001 From: jordonpeterson Date: Mon, 27 Jul 2026 17:13:38 +0000 Subject: [PATCH] feat(custom_properties): support include/exclude to preserve unmanaged property values Custom properties cannot be deleted through the API, so Diffable's removal path sets `value: null`. Any property present on a repository but absent from config was therefore cleared on every sync, including values written by other automation. Safe-settings effectively claimed exclusive ownership of the whole custom-properties surface on any repo with a `custom_properties` section. Adds the `include`/`exclude` config shape already used by the Labels plugin (#408). Properties matching an `exclude` regex are never cleared; properties listed in `include` are enforced exactly as before, even if they also match an exclude pattern. The plain-array config shape is unchanged. Both config-error paths are designed to fail safe, because the cost of misreading an exclude pattern is cleared property values: * Exclude patterns are lowercased when compiled. Property names are lowercased before matching, so an uppercase pattern would otherwise be a valid regex that silently matches nothing and clears the properties it was written to protect. * An unparseable pattern is recorded as a per-repo config error and fails closed - every property on that repo is treated as excluded, so a typo protects values rather than clearing them. This matters because these are regexes, not globs: the intuitive `*` is not a valid pattern. Properties in `include` are still applied. The error is not thrown, because child plugins are constructed outside any try/catch in Settings.updateRepos, so a throw there rejects the org-wide Promise.all and aborts the sync for every other repo. * The object form requires `include`, `exclude`, or both. A config that is neither the array shape nor the include/exclude shape (`custom_properties: {}`, say) is reported and also fails closed. The schemas reject that shape too, but they are authoring aids rather than a runtime gate - nothing in lib/ validates config against them - so the plugin does not rely on them. Without this the empty object reached `normalizeEntries` and threw a TypeError out of the constructor, which is the org-wide abort described above. Co-Authored-By: Claude Opus 5 --- docs/sample-settings/settings.yml | 45 +++ lib/plugins/custom_properties.js | 98 ++++- schema/dereferenced/repos.json | 72 +++- schema/dereferenced/settings.json | 72 +++- schema/dereferenced/suborgs.json | 72 +++- schema/repos.json | 44 ++- schema/settings.json | 44 ++- schema/suborgs.json | 44 ++- .../lib/plugins/custom_properties.test.js | 353 +++++++++++++++++- 9 files changed, 794 insertions(+), 50 deletions(-) diff --git a/docs/sample-settings/settings.yml b/docs/sample-settings/settings.yml index 1ede6a079..ac7b19f15 100644 --- a/docs/sample-settings/settings.yml +++ b/docs/sample-settings/settings.yml @@ -203,10 +203,55 @@ branches: # Custom properties # See https://docs.github.com/en/rest/repos/custom-properties?apiVersion=2026-03-10 +# +# Custom properties cannot be deleted through the API, so a property that exists +# on the repository but is absent from this config has its value set to `null`. +# That means safe-settings assumes ownership of every custom property on the +# repository. If other automation (a GitHub App, a workflow) sets property values +# you want left alone, list them under `exclude` using the object form below. custom_properties: - name: test value: test +# The object form additionally protects properties from being cleared: +# +# custom_properties: +# include: +# - name: test +# value: test +# exclude: +# # Regexes - NOT globs - matched against the property name. Casing does not +# # matter; patterns are lowercased before matching. +# # Never clear the value of any property starting with "app-": +# - name: ^app- +# # Never clear the value of the "deploy-status" property: +# - name: ^deploy-status$ +# +# To manage only the properties you declare and leave every other property on the +# repository untouched, exclude everything with `.*`: +# +# custom_properties: +# include: +# - name: test +# value: test +# exclude: +# - name: .* +# +# Notes: +# * These are regexes, so "match everything" is `.*`. A bare `*` is not a valid +# regex; see the invalid-pattern note below for what happens if you use one. +# * `exclude` only prevents removal. A property listed in `include` is always +# created/updated, even if it also matches an `exclude` pattern. +# * `exclude` on its own (no `include`) still means "safe-settings manages the +# custom properties on this repo" - every property not matching a pattern is +# cleared. To manage nothing, omit the `custom_properties` section entirely. +# * An invalid regex fails closed: the error is reported and every property on +# that repo is excluded, so a typo protects values rather than clearing them. +# Properties in `include` are still applied. +# * The object form must contain `include`, `exclude`, or both. A malformed +# `custom_properties` (`{}`, say) is reported and also fails closed, leaving +# every property on the repo untouched. + # See the docs (https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/managing-repository-settings/configuring-autolinks-to-reference-external-resources) for a description of autolinks and replacement values. autolinks: - key_prefix: "JIRA-" diff --git a/lib/plugins/custom_properties.js b/lib/plugins/custom_properties.js index 35f0144da..355c4557e 100644 --- a/lib/plugins/custom_properties.js +++ b/lib/plugins/custom_properties.js @@ -1,15 +1,105 @@ const Diffable = require('./diffable') const NopCommand = require('../nopcommand') +// Detects the `{ include: [...], exclude: [...] }` config shape. Either key on +// its own is enough; a missing `include` is treated as an empty include list. +function isExcludeAwareConfig (entries) { + return !!entries && + typeof entries === 'object' && + !Array.isArray(entries) && + (Array.isArray(entries.include) || Array.isArray(entries.exclude)) +} + module.exports = class CustomProperties extends Diffable { - constructor (...args) { - super(...args) + constructor (nop, github, repo, entries, log, errors) { + // Two config shapes are supported: + // + // custom_properties: | custom_properties: + // - name: jira-team | include: + // value: Platform | - name: jira-team + // | value: Platform + // | exclude: + // | - name: ^app- + // + // The object shape lets a config declare the properties it manages + // (`include`) while protecting properties owned by other automation + // (`exclude`) from being nulled out. The plain array shape is unchanged. + let include = entries + let exclude = [] + let malformed = false + + if (isExcludeAwareConfig(entries)) { + include = Array.isArray(entries.include) ? entries.include : [] + exclude = Array.isArray(entries.exclude) ? entries.exclude : [] + } else if (entries !== null && entries !== undefined && !Array.isArray(entries)) { + // Neither the array shape nor the include/exclude shape - for example + // `custom_properties: {}`. The schemas reject this, but they are authoring + // aids rather than a runtime gate, so the plugin cannot rely on them. Treat + // it as "manage nothing and clear nothing" instead of letting a downstream + // TypeError escape the constructor, which would reject the org-wide + // Promise.all in Settings.updateRepos and abort the sync for every repo. + include = [] + malformed = true + } + + super(nop, github, repo, include, log, errors) + + const { patterns, excludeAll } = this.compileExcludePatterns(exclude) + this.exclude = patterns + this.excludeAll = excludeAll || malformed + + if (malformed) { + this.logError('`custom_properties` must be a list of properties or an object with `include` and/or `exclude` keys. Ignoring it and excluding all custom properties for this repo so no values are cleared.') + } if (this.entries) { this.normalizeEntries() } } + // Compile the `exclude[].name` regex strings, skipping entries without a + // string name. An unparseable pattern is recorded as a config error for this + // repo rather than thrown: child plugins are constructed outside of any + // try/catch in `Settings.updateRepos`, so throwing here would reject the + // org-wide `Promise.all` and abort the sync for every other repo too. + // + // An invalid pattern fails closed - every property on the repo is treated as + // excluded. A typo in an exclude pattern is a request to protect something, + // so the safe reading is "protect everything until the config is fixed" + // rather than "protect nothing", which would clear the very values the + // pattern was written to defend. Properties in `include` are still enforced. + compileExcludePatterns (exclude) { + return exclude.reduce((state, item) => { + if (!item || typeof item.name !== 'string') { + return state + } + + try { + // Property names are lowercased before matching, so lowercase the + // pattern too - an uppercase pattern would otherwise be a valid regex + // that silently matches nothing and clears the properties it names. + state.patterns.push(new RegExp(item.name.toLowerCase())) + } catch (e) { + this.logError(`Invalid custom property exclude pattern "${item.name}": ${e.message || e}. Excluding all custom properties for this repo so no values are cleared.`) + state.excludeAll = true + } + + return state + }, { patterns: [], excludeAll: false }) + } + + // Exclude patterns are matched against the normalized (lowercased) property + // name. Patterns are lowercased when compiled, so config casing does not + // matter. Note these are regexes, not globs: `.*` matches everything, `*` is + // not a valid pattern. + isExcluded (name) { + if (this.excludeAll) { + return true + } + + return typeof name === 'string' && this.exclude.some(rx => rx.test(name)) + } + // Force all names to lowercase to avoid comparison issues. normalizeEntries () { this.entries = this.entries.reduce((normalizedEntries, entry) => { @@ -90,6 +180,10 @@ module.exports = class CustomProperties extends Diffable { // Custom Properties on repository does not support deletion, so we set the value to null async remove ({ name }) { + if (this.isExcluded(name)) { + this.log.debug(`Custom Property "${name}" matches an exclude pattern; leaving its value untouched`) + return Promise.resolve([]) + } return this.modifyProperty('Delete', { name, value: null }) } diff --git a/schema/dereferenced/repos.json b/schema/dereferenced/repos.json index 9213456a0..4814fb41c 100644 --- a/schema/dereferenced/repos.json +++ b/schema/dereferenced/repos.json @@ -778,19 +778,69 @@ }, "custom_properties": { "description": "Custom properties", - "type": "array", - "items": { - "description": "A custom property entry", - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "value": { - "type": "string" + "oneOf": [ + { + "type": "array", + "items": { + "description": "A custom property entry", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } } + }, + { + "type": "object", + "properties": { + "include": { + "description": "Custom properties managed by safe-settings", + "type": "array", + "items": { + "description": "A custom property entry", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + }, + "exclude": { + "description": "Never clear the value of any custom property whose name matches one of these regexes", + "type": "array", + "items": { + "description": "A regex, matched against the lowercased custom property name, identifying properties safe-settings must not clear", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + }, + "anyOf": [ + { + "required": [ + "include" + ] + }, + { + "required": [ + "exclude" + ] + } + ] } - } + ] }, "variables": { "description": "Repository or org-level Actions variables", diff --git a/schema/dereferenced/settings.json b/schema/dereferenced/settings.json index 4dcdf0eb6..25b483109 100644 --- a/schema/dereferenced/settings.json +++ b/schema/dereferenced/settings.json @@ -1956,19 +1956,69 @@ }, "custom_properties": { "description": "Custom properties", - "type": "array", - "items": { - "description": "A custom property entry", - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "value": { - "type": "string" + "oneOf": [ + { + "type": "array", + "items": { + "description": "A custom property entry", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } } + }, + { + "type": "object", + "properties": { + "include": { + "description": "Custom properties managed by safe-settings", + "type": "array", + "items": { + "description": "A custom property entry", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + }, + "exclude": { + "description": "Never clear the value of any custom property whose name matches one of these regexes", + "type": "array", + "items": { + "description": "A regex, matched against the lowercased custom property name, identifying properties safe-settings must not clear", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + }, + "anyOf": [ + { + "required": [ + "include" + ] + }, + { + "required": [ + "exclude" + ] + } + ] } - } + ] }, "variables": { "description": "Repository or org-level Actions variables", diff --git a/schema/dereferenced/suborgs.json b/schema/dereferenced/suborgs.json index 0267bf7a8..56545572f 100644 --- a/schema/dereferenced/suborgs.json +++ b/schema/dereferenced/suborgs.json @@ -812,19 +812,69 @@ }, "custom_properties": { "description": "Custom properties", - "type": "array", - "items": { - "description": "A custom property entry", - "type": "object", - "properties": { - "name": { - "type": "string" - }, - "value": { - "type": "string" + "oneOf": [ + { + "type": "array", + "items": { + "description": "A custom property entry", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } } + }, + { + "type": "object", + "properties": { + "include": { + "description": "Custom properties managed by safe-settings", + "type": "array", + "items": { + "description": "A custom property entry", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + } + }, + "exclude": { + "description": "Never clear the value of any custom property whose name matches one of these regexes", + "type": "array", + "items": { + "description": "A regex, matched against the lowercased custom property name, identifying properties safe-settings must not clear", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + }, + "anyOf": [ + { + "required": [ + "include" + ] + }, + { + "required": [ + "exclude" + ] + } + ] } - } + ] }, "variables": { "description": "Repository or org-level Actions variables", diff --git a/schema/repos.json b/schema/repos.json index 3a7c51301..159ffb096 100644 --- a/schema/repos.json +++ b/schema/repos.json @@ -57,10 +57,37 @@ }, "custom_properties": { "description": "Custom properties", - "type": "array", - "items": { - "$ref": "#/$defs/CustomPropertiesSettings" - } + "oneOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/CustomPropertiesSettings" + } + }, + { + "type": "object", + "properties": { + "include": { + "description": "Custom properties managed by safe-settings", + "type": "array", + "items": { + "$ref": "#/$defs/CustomPropertiesSettings" + } + }, + "exclude": { + "description": "Never clear the value of any custom property whose name matches one of these regexes", + "type": "array", + "items": { + "$ref": "#/$defs/CustomPropertiesExcludeSettings" + } + } + }, + "anyOf": [ + { "required": ["include"] }, + { "required": ["exclude"] } + ] + } + ] }, "variables": { "description": "Repository or org-level Actions variables", @@ -300,6 +327,15 @@ } } }, + "CustomPropertiesExcludeSettings": { + "description": "A regex, matched against the lowercased custom property name, identifying properties safe-settings must not clear", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, "VariablesSettings": { "description": "An Actions variable entry", "type": "object", diff --git a/schema/settings.json b/schema/settings.json index 59d662d50..8df78bb59 100644 --- a/schema/settings.json +++ b/schema/settings.json @@ -64,10 +64,37 @@ }, "custom_properties": { "description": "Custom properties", - "type": "array", - "items": { - "$ref": "#/$defs/CustomPropertiesSettings" - } + "oneOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/CustomPropertiesSettings" + } + }, + { + "type": "object", + "properties": { + "include": { + "description": "Custom properties managed by safe-settings", + "type": "array", + "items": { + "$ref": "#/$defs/CustomPropertiesSettings" + } + }, + "exclude": { + "description": "Never clear the value of any custom property whose name matches one of these regexes", + "type": "array", + "items": { + "$ref": "#/$defs/CustomPropertiesExcludeSettings" + } + } + }, + "anyOf": [ + { "required": ["include"] }, + { "required": ["exclude"] } + ] + } + ] }, "variables": { "description": "Repository or org-level Actions variables", @@ -307,6 +334,15 @@ } } }, + "CustomPropertiesExcludeSettings": { + "description": "A regex, matched against the lowercased custom property name, identifying properties safe-settings must not clear", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, "VariablesSettings": { "description": "An Actions variable entry", "type": "object", diff --git a/schema/suborgs.json b/schema/suborgs.json index 3a3c79def..4d7a90f7c 100644 --- a/schema/suborgs.json +++ b/schema/suborgs.json @@ -91,10 +91,37 @@ }, "custom_properties": { "description": "Custom properties", - "type": "array", - "items": { - "$ref": "#/$defs/CustomPropertiesSettings" - } + "oneOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/CustomPropertiesSettings" + } + }, + { + "type": "object", + "properties": { + "include": { + "description": "Custom properties managed by safe-settings", + "type": "array", + "items": { + "$ref": "#/$defs/CustomPropertiesSettings" + } + }, + "exclude": { + "description": "Never clear the value of any custom property whose name matches one of these regexes", + "type": "array", + "items": { + "$ref": "#/$defs/CustomPropertiesExcludeSettings" + } + } + }, + "anyOf": [ + { "required": ["include"] }, + { "required": ["exclude"] } + ] + } + ] }, "variables": { "description": "Repository or org-level Actions variables", @@ -334,6 +361,15 @@ } } }, + "CustomPropertiesExcludeSettings": { + "description": "A regex, matched against the lowercased custom property name, identifying properties safe-settings must not clear", + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, "VariablesSettings": { "description": "An Actions variable entry", "type": "object", diff --git a/test/unit/lib/plugins/custom_properties.test.js b/test/unit/lib/plugins/custom_properties.test.js index a9e878d58..0bc8c3ffb 100644 --- a/test/unit/lib/plugins/custom_properties.test.js +++ b/test/unit/lib/plugins/custom_properties.test.js @@ -4,26 +4,38 @@ describe('CustomProperties', () => { const nop = false let github let log + let errors const owner = 'test-owner' const repo = 'test-repo' function configure (config) { - return new CustomProperties(nop, github, { owner, repo }, config, log, []) + return new CustomProperties(nop, github, { owner, repo }, config, log, errors) + } + + function configureNop (config) { + return new CustomProperties(true, github, { owner, repo }, config, log, errors) } beforeEach(() => { + const createOrUpdateCustomPropertiesValues = jest.fn() + createOrUpdateCustomPropertiesValues.endpoint = jest.fn(params => ({ + url: `/repos/${params.owner}/${params.repo}/properties/values`, + body: params + })) + github = { paginate: jest.fn(), rest: { repos: { getCustomPropertiesValues: jest.fn(), - createOrUpdateCustomPropertiesValues: jest.fn() + createOrUpdateCustomPropertiesValues } } } - log = { debug: jest.fn(), error: console.error } + log = { debug: jest.fn(), error: jest.fn() } + errors = [] }) describe('Custom Properties plugin', () => { @@ -164,4 +176,339 @@ describe('CustomProperties', () => { // }) }) }) + + describe('include/exclude config shape', () => { + // Existing repo state shared by most of the exclude tests: one property + // managed by safe-settings, one owned by another app, one abandoned. + function mockExistingProperties (properties) { + github.paginate.mockResolvedValue(properties) + } + + function propertyUpdate (name, value) { + return { + owner, + repo, + properties: [{ property_name: name, value }] + } + } + + it('keeps the plain array shape working unchanged', () => { + mockExistingProperties([ + { property_name: 'jira-team', value: 'Search' }, + { property_name: 'stale-prop', value: 'whatever' } + ]) + + const plugin = configure([ + { name: 'jira-team', value: 'Platform' }, + { name: 'jira-project', value: 'ARCH' } + ]) + + expect(plugin.exclude).toEqual([]) + + return plugin.sync().then(() => { + // update + expect(github.rest.repos.createOrUpdateCustomPropertiesValues) + .toHaveBeenCalledWith(propertyUpdate('jira-team', 'Platform')) + // add + expect(github.rest.repos.createOrUpdateCustomPropertiesValues) + .toHaveBeenCalledWith(propertyUpdate('jira-project', 'ARCH')) + // remove + expect(github.rest.repos.createOrUpdateCustomPropertiesValues) + .toHaveBeenCalledWith(propertyUpdate('stale-prop', null)) + expect(github.rest.repos.createOrUpdateCustomPropertiesValues).toHaveBeenCalledTimes(3) + }) + }) + + it('does not null out an unmanaged property matching an exclude pattern', () => { + mockExistingProperties([ + { property_name: 'jira-team', value: 'Search' }, + { property_name: 'app-deploy-ring', value: 'canary' } + ]) + + const plugin = configure({ + include: [ + { name: 'jira-team', value: 'Platform' }, + { name: 'jira-project', value: 'ARCH' } + ], + exclude: [ + { name: '^app-.*' } + ] + }) + + return plugin.sync().then(() => { + expect(github.rest.repos.createOrUpdateCustomPropertiesValues) + .not.toHaveBeenCalledWith(propertyUpdate('app-deploy-ring', null)) + // Managed properties are still enforced. + expect(github.rest.repos.createOrUpdateCustomPropertiesValues) + .toHaveBeenCalledWith(propertyUpdate('jira-team', 'Platform')) + expect(github.rest.repos.createOrUpdateCustomPropertiesValues) + .toHaveBeenCalledWith(propertyUpdate('jira-project', 'ARCH')) + expect(github.rest.repos.createOrUpdateCustomPropertiesValues).toHaveBeenCalledTimes(2) + }) + }) + + it('enforces a property that is in include even when it matches exclude', () => { + mockExistingProperties([ + { property_name: 'app-owner', value: 'unknown' } + ]) + + const plugin = configure({ + include: [ + { name: 'app-owner', value: 'platform-team' } + ], + exclude: [ + { name: '^app-.*' } + ] + }) + + return plugin.sync().then(() => { + expect(github.rest.repos.createOrUpdateCustomPropertiesValues) + .toHaveBeenCalledWith(propertyUpdate('app-owner', 'platform-team')) + expect(github.rest.repos.createOrUpdateCustomPropertiesValues).toHaveBeenCalledTimes(1) + }) + }) + + it('still removes an unmanaged property that matches no exclude pattern', () => { + mockExistingProperties([ + { property_name: 'app-deploy-ring', value: 'canary' }, + { property_name: 'stale-prop', value: 'leftover' } + ]) + + const plugin = configure({ + include: [ + { name: 'jira-team', value: 'Platform' } + ], + exclude: [ + { name: '^app-.*' } + ] + }) + + return plugin.sync().then(() => { + expect(github.rest.repos.createOrUpdateCustomPropertiesValues) + .toHaveBeenCalledWith(propertyUpdate('stale-prop', null)) + expect(github.rest.repos.createOrUpdateCustomPropertiesValues) + .not.toHaveBeenCalledWith(propertyUpdate('app-deploy-ring', null)) + }) + }) + + it('protects properties whose API casing differs from the pattern', () => { + // The API may return any casing; the plugin normalizes to lowercase, so + // exclude patterns are matched against the lowercased name. + mockExistingProperties([ + { property_name: 'APP-Thing', value: 'set-by-an-app' } + ]) + + const plugin = configure({ + include: [], + exclude: [{ name: '^app-.*' }] + }) + + return plugin.sync().then(() => { + expect(github.rest.repos.createOrUpdateCustomPropertiesValues).not.toHaveBeenCalled() + }) + }) + + it('produces no Delete NopCommand for an excluded property in nop mode', () => { + mockExistingProperties([ + { property_name: 'app-deploy-ring', value: 'canary' }, + { property_name: 'stale-prop', value: 'leftover' } + ]) + + const plugin = configureNop({ + include: [{ name: 'jira-team', value: 'Platform' }], + exclude: [{ name: '^app-.*' }] + }) + + return plugin.sync().then(res => { + const commands = res.flat().filter(c => c && c.action) + const deletes = commands.filter(c => c.action.msg === 'Delete Custom Property') + + expect(github.rest.repos.createOrUpdateCustomPropertiesValues).not.toHaveBeenCalled() + expect(deletes).toHaveLength(1) + expect(deletes[0].body.properties).toEqual([ + { property_name: 'stale-prop', value: null } + ]) + }) + }) + + describe('degenerate shapes', () => { + it('accepts include without exclude', () => { + const plugin = configure({ + include: [{ name: 'Jira-Team', value: 'Platform' }] + }) + + expect(plugin.entries).toEqual([{ name: 'jira-team', value: 'Platform' }]) + expect(plugin.exclude).toEqual([]) + }) + + it('accepts exclude without include, managing nothing', () => { + mockExistingProperties([ + { property_name: 'app-thing', value: 'a' }, + { property_name: 'other-thing', value: 'b' } + ]) + + const plugin = configure({ + exclude: [{ name: '^app-.*' }] + }) + + expect(plugin.entries).toEqual([]) + + // An empty include list means safe-settings owns the whole surface, so + // everything not protected by `exclude` is still nulled out. + return plugin.sync().then(() => { + expect(github.rest.repos.createOrUpdateCustomPropertiesValues) + .toHaveBeenCalledWith(propertyUpdate('other-thing', null)) + expect(github.rest.repos.createOrUpdateCustomPropertiesValues).toHaveBeenCalledTimes(1) + }) + }) + + it('does not throw on a null config', () => { + let plugin + expect(() => { plugin = configure(null) }).not.toThrow() + expect(plugin.entries).toBeNull() + expect(plugin.exclude).toEqual([]) + expect(plugin.sync()).toBeUndefined() + }) + + it('does not throw on an undefined config', () => { + let plugin + expect(() => { plugin = configure(undefined) }).not.toThrow() + expect(plugin.exclude).toEqual([]) + }) + + it('ignores exclude entries without a name string', () => { + const plugin = configure({ + include: [{ name: 'jira-team', value: 'Platform' }], + exclude: [{ name: '^app-.*' }, {}, null, { value: 'nope' }, { name: 42 }] + }) + + expect(plugin.exclude).toHaveLength(1) + expect(plugin.isExcluded('app-thing')).toBe(true) + }) + + it('ignores a non-array exclude', () => { + const plugin = configure({ + include: [{ name: 'jira-team', value: 'Platform' }], + exclude: 'app-' + }) + + expect(plugin.exclude).toEqual([]) + expect(plugin.entries).toEqual([{ name: 'jira-team', value: 'Platform' }]) + }) + }) + + describe('malformed object config', () => { + it('does not throw on an empty object', () => { + expect(() => configure({})).not.toThrow() + }) + + it('fails closed and reports a config error for an empty object', () => { + const plugin = configure({}) + + expect(plugin.entries).toEqual([]) + expect(plugin.excludeAll).toBe(true) + expect(errors).toHaveLength(1) + expect(errors[0].msg).toMatch(/must be a list of properties or an object with `include`/) + }) + + it('clears nothing when the config is an empty object', async () => { + github.paginate.mockResolvedValue([{ property_name: 'deploy-status', value: 'green' }]) + + const plugin = configure({}) + await plugin.sync() + + expect(github.rest.repos.createOrUpdateCustomPropertiesValues).not.toHaveBeenCalled() + }) + + it('fails closed when include is present but not an array', async () => { + github.paginate.mockResolvedValue([{ property_name: 'deploy-status', value: 'green' }]) + + const plugin = configure({ include: 'not-a-list' }) + await plugin.sync() + + expect(plugin.excludeAll).toBe(true) + expect(github.rest.repos.createOrUpdateCustomPropertiesValues).not.toHaveBeenCalled() + }) + }) + + describe('pattern casing', () => { + it('matches an uppercase pattern against the lowercased property name', () => { + const plugin = configure({ include: [], exclude: [{ name: '^Deploy-Status$' }] }) + + expect(plugin.isExcluded('deploy-status')).toBe(true) + }) + + it('does not clear a property whose exclude pattern was written in uppercase', async () => { + github.paginate.mockResolvedValue([{ property_name: 'Deploy-Status', value: 'green' }]) + + const plugin = configure({ include: [], exclude: [{ name: '^Deploy-' }] }) + await plugin.sync() + + expect(github.rest.repos.createOrUpdateCustomPropertiesValues).not.toHaveBeenCalled() + }) + }) + + describe('invalid exclude patterns', () => { + it('records a config error instead of throwing', () => { + let plugin + expect(() => { + plugin = configure({ + include: [{ name: 'jira-team', value: 'Platform' }], + exclude: [{ name: '^app-.*' }, { name: '[unterminated' }] + }) + }).not.toThrow() + + expect(plugin.exclude).toHaveLength(1) + expect(plugin.isExcluded('app-thing')).toBe(true) + + expect(errors).toHaveLength(1) + expect(errors[0]).toMatchObject({ + owner, + repo, + plugin: 'CustomProperties' + }) + expect(errors[0].msg).toMatch(/Invalid custom property exclude pattern "\[unterminated"/) + }) + + it('fails closed - an invalid pattern excludes every property', () => { + const plugin = configure({ include: [], exclude: [{ name: '[unterminated' }] }) + + expect(plugin.excludeAll).toBe(true) + expect(plugin.isExcluded('anything-at-all')).toBe(true) + }) + + it('clears nothing when a glob "*" is used instead of the regex ".*"', async () => { + github.paginate.mockResolvedValue([ + { property_name: 'deploy-status', value: 'green' }, + { property_name: 'cost-center', value: '4417' } + ]) + + const plugin = configure({ include: [], exclude: [{ name: '*' }] }) + await plugin.sync() + + expect(github.rest.repos.createOrUpdateCustomPropertiesValues).not.toHaveBeenCalled() + expect(errors).toHaveLength(1) + }) + + it('still enforces included properties when an invalid pattern fails closed', async () => { + github.paginate.mockResolvedValue([ + { property_name: 'jira-team', value: 'OldTeam' }, + { property_name: 'deploy-status', value: 'green' } + ]) + + const plugin = configure({ + include: [{ name: 'jira-team', value: 'Platform' }], + exclude: [{ name: '*' }] + }) + await plugin.sync() + + expect(github.rest.repos.createOrUpdateCustomPropertiesValues).toHaveBeenCalledTimes(1) + expect(github.rest.repos.createOrUpdateCustomPropertiesValues).toHaveBeenCalledWith( + expect.objectContaining({ + properties: [{ property_name: 'jira-team', value: 'Platform' }] + }) + ) + }) + }) + }) })