Skip to content

feat(custom_properties): support include/exclude to preserve unmanaged property values - #1036

Draft
jordonpeterson wants to merge 1 commit into
github-community-projects:main-enterprisefrom
jordonpeterson:feat/custom-properties-exclude
Draft

feat(custom_properties): support include/exclude to preserve unmanaged property values#1036
jordonpeterson wants to merge 1 commit into
github-community-projects:main-enterprisefrom
jordonpeterson:feat/custom-properties-exclude

Conversation

@jordonpeterson

@jordonpeterson jordonpeterson commented Jul 27, 2026

Copy link
Copy Markdown

Lets a custom_properties config declare the properties it manages and leave
properties owned by other automation untouched, instead of clearing every property
it does not list.

Closes #986 — implements the include/exclude shape requested there, mirroring
the Labels plugin as the issue suggests.

What

custom_properties:
  include:
    - name: ruleset-tier
      value: strict
  exclude:
    # Regexes, matched against the property name
    - name: ^team-

To manage only what you declare and leave every other property alone, exclude
everything with .*:

custom_properties:
  include:
    - name: ruleset-tier
      value: strict
  exclude:
    - name: .*

The existing plain-array shape is unchanged.

Why

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. In
effect safe-settings claimed exclusive ownership of the entire custom-properties
surface of any repo with a custom_properties section, with no way to opt out short
of dropping the section altogether. That is exactly the case #986 describes:
org-wide properties should be enforced while per-repo ones are left alone.

Behavior

  • A property matching an exclude regex is never cleared.
  • A property in include is created/updated as before, even if it also matches an
    exclude pattern. include wins.
  • exclude on its own still means safe-settings manages this repo's properties —
    everything not matching a pattern is cleared. To manage nothing, omit the section.

Config mistakes fail closed rather than clearing values — see the design notes below.

Changes

  • lib/plugins/custom_properties.js — config-shape detection, exclude-pattern
    compilation, exclude check in remove()
  • test/unit/lib/plugins/custom_properties.test.js — 22 new tests
  • docs/sample-settings/settings.yml — worked example and the ownership caveat
  • schema/{repos,settings,suborgs}.json — accept both shapes; require the object
    form to carry include, exclude, or both
  • schema/dereferenced/*.json — 6 of the 9 changed files are these generated
    schemas. Their custom_properties blocks are verified byte-equal to
    npm run build:schema output. They are spliced in rather than committed wholesale
    because a full regeneration also pulls in unrelated repositories/teams/rulesets
    churn from the live GitHub API description, which drifts on main-enterprise
    independently of this change.

Testing

New behaviortest/unit/lib/plugins/custom_properties.test.js passes 27/27,
covering exclude protection, include-wins precedence, casing on both the API
response and the pattern, nop mode, degenerate configs, invalid regexes, and
malformed object configs.

Existing behavior is provably unchanged. Since this touches a path that nulls out
property values, the old behavior was verified directly rather than by inspection: a
15-scenario matrix (× apply and nop modes = 30 runs) was run against the plugin as it
exists on main-enterprise and against this branch, capturing API writes,
NopCommands, normalized entries, and recorded errors for each. The two transcripts
are byte-identical.
The matrix uses only pre-change config shapes — plain arrays,
null, undefined — and covers update, create, no-op, clearing unmanaged properties,
the property_name key variant, mixed casing on both sides, nameless and non-object
entries, and multi-property syncs. 11 of the 30 runs perform API writes and 11 emit
NopCommands, so the comparison is exercising real output.

The schemas were checked the same way: every config shape that validated against the
schema on main-enterprise still validates here — empty array, {name,value},
{property_name,value}, multiple entries, name-only, and no custom_properties key.
Zero regressions.

npm run test:unit (what CI runs) passes 156 tests across 16 suites. standard is
clean on both changed files, and eslint reports the same 145 pre-existing problems
as the base commit — no new lint.

Design notes: why config errors fail closed, and where this diverges from Labels

The include/exclude vocabulary and the unanchored-regex matching follow
lib/plugins/labels.js. Two deliberate divergences, both because the cost of
misreading an exclude pattern here is cleared property values rather than a stray
label:

  • 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 invalid pattern fails closed. It is recorded as a per-repo config error and
    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, and under a fail-open reading it would clear
    every unmanaged property. Properties in include are still applied.

A malformed object config fails closed the same way. The object form requires
include, exclude, or both; the schemas reject anything else, but the plugin does
not rely on them — they are authoring aids, not a runtime gate, since nothing in
lib/ validates config against them. Without the runtime check
custom_properties: {} reached normalizeEntries and threw a TypeError out of the
constructor.

Errors are recorded rather than thrown throughout: child plugins are constructed
outside any try/catch in Settings.updateRepos, so throwing would reject the org-wide
Promise.all and abort the sync for every other repo. A bad config in one repo should
not take down the org's sync.

Happy to rebase if #1032 lands first — it touches the same plugin.

Copilot AI review requested due to automatic review settings July 27, 2026 18:56

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR extends the custom_properties plugin to support an { include, exclude } configuration shape, allowing safe-settings to manage only declared properties while preserving values owned by other automation via exclude regexes. It also updates schemas, docs, and unit tests to cover the new behavior while keeping the existing plain-array config shape unchanged.

Changes:

  • Add { include, exclude } config-shape support to custom_properties, including exclude-regex compilation and delete short-circuiting.
  • Update JSON schemas (and dereferenced copies) to accept both the legacy array shape and the new object shape.
  • Document the ownership/clearing caveat and add unit tests for include/exclude behavior, casing, nop-mode, and invalid regex handling.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
lib/plugins/custom_properties.js Implements include/exclude shape detection, exclude regex compilation, and protected remove behavior.
test/unit/lib/plugins/custom_properties.test.js Adds unit tests validating new include/exclude behavior and regressions for the legacy array shape.
schema/repos.json Updates repo override schema to accept both array and object forms for custom_properties.
schema/settings.json Updates org-level schema to accept both array and object forms for custom_properties.
schema/suborgs.json Updates suborg-level schema to accept both array and object forms for custom_properties.
schema/dereferenced/repos.json Mirrors the schema change in the dereferenced repo schema.
schema/dereferenced/settings.json Mirrors the schema change in the dereferenced settings schema.
schema/dereferenced/suborgs.json Mirrors the schema change in the dereferenced suborg schema.
docs/sample-settings/settings.yml Documents the new object form and the “clearing sets value to null” ownership caveat.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/plugins/custom_properties.js Outdated
Comment on lines +27 to +37
let include = entries
let exclude = []

if (isExcludeAwareConfig(entries)) {
include = Array.isArray(entries.include) ? entries.include : []
exclude = Array.isArray(entries.exclude) ? entries.exclude : []
}

super(nop, github, repo, include, log, errors)

this.exclude = this.compileExcludePatterns(exclude)
Comment thread schema/repos.json
},
{
"type": "object",
"properties": {
Comment thread schema/settings.json
},
{
"type": "object",
"properties": {
Comment thread schema/suborgs.json
},
{
"type": "object",
"properties": {
},
{
"type": "object",
"properties": {
},
{
"type": "object",
"properties": {
},
{
"type": "object",
"properties": {
@jordonpeterson
jordonpeterson marked this pull request as draft July 27, 2026 19:04
@jordonpeterson
jordonpeterson force-pushed the feat/custom-properties-exclude branch from ec6a24d to 5da417f Compare July 27, 2026 19:28
…d 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
(github-community-projects#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 <noreply@anthropic.com>
@jordonpeterson
jordonpeterson force-pushed the feat/custom-properties-exclude branch from 5da417f to 224131c Compare July 27, 2026 19:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Include/Exclude for custom properties

2 participants