From 0a155851474337e24a2a084172413edeea7cea43 Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Wed, 20 May 2026 17:04:58 -0500 Subject: [PATCH 1/3] Add custom property value sync Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 134 +++- __tests__/index.test.js | 187 ++++++ action.yml | 3 + badges/coverage.svg | 2 +- package-lock.json | 4 +- package.json | 2 +- .../custom-property-values.yml | 24 + sample-configuration/orgs.yml | 8 + sample-configuration/teams/platform-repos.yml | 6 + src/index.js | 571 +++++++++++++++++- 10 files changed, 916 insertions(+), 25 deletions(-) create mode 100644 sample-configuration/custom-property-values.yml create mode 100644 sample-configuration/teams/platform-repos.yml diff --git a/README.md b/README.md index 1aa6692..fb64d6d 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Please refer to the [release page](https://github.com/joshjohanning/bulk-github- ## Features - 🏷️ Sync custom property definitions across organizations +- 🏷️ Sync custom property values onto selected organization repositories - 📋 Sync organization-level rulesets across organizations - 🏷️ Sync issue type definitions across organizations - 🧩 Sync issue field definitions across organizations @@ -73,7 +74,7 @@ For stronger security and higher rate limits, use a GitHub App: **Organization permissions:** - **Administration**: Read and write (required for managing organization settings, rulesets, and organization role team assignments) - - **Custom properties**: Admin (required for managing custom property definitions) + - **Custom properties**: Admin (required for managing custom property definitions) or Write (required for managing repository custom property values) - **Custom organization roles**: Write (required for managing custom organization roles) - **Custom repository roles**: Write (required for managing custom repository roles) - **Issue types**: Write (required for managing issue type definitions) @@ -191,7 +192,7 @@ orgs: **Optional: `base-path`** -Use the `base-path` top-level property to avoid repeating a common directory prefix for all file-path settings (`custom-properties-file`, `issue-types-file`, `issue-fields-file`, `rulesets-file`). Relative paths in per-org overrides are resolved relative to `base-path`. Absolute paths are left unchanged. +Use the `base-path` top-level property to avoid repeating a common directory prefix for all file-path settings (`custom-properties-file`, `custom-property-values-file`, `issue-types-file`, `issue-fields-file`, `rulesets-file`). Relative paths in per-org overrides are resolved relative to `base-path`. Absolute paths are left unchanged. ```yaml base-path: './config/' @@ -263,20 +264,21 @@ The file-based form lets you keep per-org config in separate files while still u ### Supported features -| Feature | Inline key | File-path key | Per-org semantics | -| ---------------------------------- | ------------------------------------ | ----------------------------------------- | -------------------------- | -| Custom properties | `custom-properties` | `custom-properties-file` | Merge by `name` with base | -| Issue types | `issue-types` | `issue-types-file` | Merge by `name` with base | -| Issue fields | `issue-fields` | `issue-fields-file` | Merge by `name` with base | -| Custom organization roles | `custom-org-roles` | `custom-org-roles-file` | Merge by `name` with base | -| Custom repository roles | `custom-repo-roles` | `custom-repo-roles-file` | Merge by `name` with base | -| Code security configurations | `code-security-configurations` | `code-security-configurations-file` | Merge by `name` with base | -| Organization role team assignments | `organization-role-team-assignments` | `organization-role-team-assignments-file` | Replaces base for that org | -| Member privileges | `member-privileges` | _(direct action inputs serve as base)_ | Per-key override of base | -| Organization profile | `org-profile` | _(direct action inputs serve as base)_ | Per-key override of base | -| Actions policy | `actions-policy` | _(direct action inputs serve as base)_ | Per-key override of base | -| Rulesets | _(file only — no inline form)_ | `rulesets-file` (string or YAML array) | Replaces base for that org | -| Actions allow list | _(file only — no inline form)_ | `actions-allow-list-file` | Replaces base for that org | +| Feature | Inline key | File-path key | Per-org semantics | +| ---------------------------------- | ------------------------------------ | ----------------------------------------- | ---------------------------------- | +| Custom properties | `custom-properties` | `custom-properties-file` | Merge by `name` with base | +| Custom property values | `custom-property-values` | `custom-property-values-file` | File replaces base; inline appends | +| Issue types | `issue-types` | `issue-types-file` | Merge by `name` with base | +| Issue fields | `issue-fields` | `issue-fields-file` | Merge by `name` with base | +| Custom organization roles | `custom-org-roles` | `custom-org-roles-file` | Merge by `name` with base | +| Custom repository roles | `custom-repo-roles` | `custom-repo-roles-file` | Merge by `name` with base | +| Code security configurations | `code-security-configurations` | `code-security-configurations-file` | Merge by `name` with base | +| Organization role team assignments | `organization-role-team-assignments` | `organization-role-team-assignments-file` | Replaces base for that org | +| Member privileges | `member-privileges` | _(direct action inputs serve as base)_ | Per-key override of base | +| Organization profile | `org-profile` | _(direct action inputs serve as base)_ | Per-key override of base | +| Actions policy | `actions-policy` | _(direct action inputs serve as base)_ | Per-key override of base | +| Rulesets | _(file only — no inline form)_ | `rulesets-file` (string or YAML array) | Replaces base for that org | +| Actions allow list | _(file only — no inline form)_ | `actions-allow-list-file` | Replaces base for that org | ### Precedence @@ -290,10 +292,12 @@ When both inline and file-based per-org overrides are set for the same org: - For **replace-semantics** features (currently `organization-role-team-assignments`), inline takes precedence and the per-org file is ignored. - For **merge-by-name** features (`custom-properties`, `issue-types`, `issue-fields`, `custom-org-roles`, `custom-repo-roles`, `code-security-configurations`), the per-org file becomes that org's base and inline entries then merge on top by `name`. +- For **custom property values**, the per-org file replaces the base values file for that org, and inline `custom-property-values` rules append after file rules. Later rules win if they set the same property on the same repository. ### Merge vs replace - **Merge by `name`** — per-org list items with the same `name` override the base item; other base items are preserved. +- **File replaces base; inline appends** — the per-org file replaces the base file, then inline per-org rules are appended and can override earlier rules by order. - **Replaces base for that org** — the entire per-org value replaces the base (no merge). - **Per-key override of base** — keys present in the per-org block override base values for those keys only; other base keys are preserved. @@ -429,6 +433,101 @@ By default, syncing custom properties will create or update the specified proper --- +## Syncing Custom Property Values + +Sync custom property values onto repositories in each organization. This assigns values for properties that already exist in the org schema, such as setting `team=platform` on selected repositories. + +> [!TIP] +> 📄 **See full example:** [sample-configuration/custom-property-values.yml](sample-configuration/custom-property-values.yml) + +Create a `custom-property-values.yml` file: + +```yaml +- repositories: + names: + - api + - web + names-file: teams/platform-repos.yml + query: 'topic:platform archived:false' + properties: + team: platform + environment: + - production + - staging + +- repositories: + names-file: teams/infra-repos.yml + properties: + team: infrastructure +``` + +Use it in a workflow: + +```yml +- name: Sync Organization Settings + uses: joshjohanning/bulk-github-org-settings-sync-action@v1 + with: + github-token: ${{ secrets.ORG_ADMIN_TOKEN }} + organizations: 'my-org' + custom-properties-file: './custom-properties.yml' + custom-property-values-file: './custom-property-values.yml' + dry-run: ${{ github.event_name == 'pull_request' }} +``` + +**Repository selectors:** + +| Selector | Description | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `repositories.names` | Explicit bare repository names in the current org. `org/repo` is rejected because the action already runs per org and the API accepts bare names. | +| `repositories.names-file` | YAML file containing a list of bare repository names. Paths are resolved relative to the custom property values file that references them. Inline `orgs.yml` rules resolve relative to `base-path`, or the `orgs.yml` directory when `base-path` is not set. | +| `repositories.query` | GitHub repository query passed to `GET /orgs/{org}/properties/values` as `repository_query`. | + +Selectors in one rule are unioned. Missing repositories from `names` or `names-file` warn and are skipped. Query selectors that match zero repositories warn and continue. + +`names-file` is useful when a team owns a repo list through CODEOWNERS and normal pull request review: + +```yaml +# teams/platform-repos.yml +- api +- web +- worker +``` + +> [!IMPORTANT] +> CODEOWNERS provides review and audit workflow, not an authorization boundary. The token running this action can still apply values to any repository it can manage. + +**Conflict and update behavior:** + +- Rules are resolved before any writes happen. +- If multiple rules set the same property on the same repo, later rules win and a warning is logged. +- If multiple rules set different properties on the same repo, the values are merged. +- Existing unmanaged values are left alone. To unset a value, set that property to `null` explicitly. +- `multi_select` values are compared order-insensitively to avoid unnecessary updates. +- Updates are batched in groups of up to 30 repositories, matching GitHub's API limit. + +> [!NOTE] +> Query selectors depend on GitHub repository search behavior and can be affected by indexing latency or result limits. Prefer `names` or `names-file` for large or freshness-sensitive selections. + +### Per-Org Custom Property Value Overrides + +In `orgs.yml`, use `custom-property-values-file` to replace the base values file for a specific org, and `custom-property-values` to append org-specific rules after file rules: + +```yaml +orgs: + - org: my-org + # inherits base custom-property-values-file as-is + + - org: my-other-org + custom-property-values-file: './config/custom-property-values/other-org.yml' + custom-property-values: + - repositories: + names: [special-service] + properties: + team: platform +``` + +--- + ## Syncing Organization Rulesets Sync organization-level rulesets across organizations. Rulesets define rules that apply to repositories within the organization (e.g., branch protection rules, tag rules). Each ruleset is defined in its own JSON file, and `rulesets-file` accepts comma-separated paths to sync multiple rulesets. @@ -1354,6 +1453,7 @@ orgs: | `organizations` | Comma-separated list of organization names | No | | | `organizations-file` | Path to YAML file containing organization settings configuration | No | | | `custom-properties-file` | Path to a YAML file defining custom property schemas | No | | +| `custom-property-values-file` | Path to a YAML file defining custom property values for selected organization repositories | No | | | `delete-unmanaged-properties` | Delete custom properties not defined in the configuration file | No | `false` | | `issue-types-file` | Path to a YAML file defining issue type definitions | No | | | `delete-unmanaged-issue-types` | Delete issue types not defined in the configuration file | No | `false` | @@ -1410,7 +1510,7 @@ orgs: | `dry-run` | Preview changes without applying them | No | `false` | > [!NOTE] -> You must provide either `organizations` or `organizations-file`. The `custom-properties-file`, `issue-types-file`, `issue-fields-file`, `rulesets-file`, `custom-org-roles-file`, `custom-repo-roles-file`, `actions-allow-list-file`, `code-security-configurations-file`, and `organization-role-team-assignments-file` inputs provide base settings for all orgs and can be combined with either approach. Member privilege settings can be provided as individual inputs (e.g., `default-repository-permission`). Actions policy settings can be provided as individual inputs (e.g., `actions-policy-allowed-actions`). Org profile settings can be provided as individual inputs (e.g., `org-name`). The `dot-github-source-dir` and `dot-github-private-source-dir` inputs sync a local directory to the respective special repositories via PR, and `create-missing-dot-github-repos` plus the repo-visibility inputs control optional repo bootstrapping before sync. Per-org overrides in `organizations-file` layer on top of the base unless otherwise noted — see [Per-Org Overrides: Inline vs File-Based](#per-org-overrides-inline-vs-file-based) for which features support inline vs file-path overrides and their precedence and merge semantics. +> You must provide either `organizations` or `organizations-file`. The `custom-properties-file`, `custom-property-values-file`, `issue-types-file`, `issue-fields-file`, `rulesets-file`, `custom-org-roles-file`, `custom-repo-roles-file`, `actions-allow-list-file`, `code-security-configurations-file`, and `organization-role-team-assignments-file` inputs provide base settings for all orgs and can be combined with either approach. Member privilege settings can be provided as individual inputs (e.g., `default-repository-permission`). Actions policy settings can be provided as individual inputs (e.g., `actions-policy-allowed-actions`). Org profile settings can be provided as individual inputs (e.g., `org-name`). The `dot-github-source-dir` and `dot-github-private-source-dir` inputs sync a local directory to the respective special repositories via PR, and `create-missing-dot-github-repos` plus the repo-visibility inputs control optional repo bootstrapping before sync. Per-org overrides in `organizations-file` layer on top of the base unless otherwise noted — see [Per-Org Overrides: Inline vs File-Based](#per-org-overrides-inline-vs-file-based) for which features support inline vs file-path overrides and their precedence and merge semantics. ## Action Outputs diff --git a/__tests__/index.test.js b/__tests__/index.test.js index 247d795..65ced7f 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -37,6 +37,8 @@ inputs: description: 'Path to YAML file' custom-properties-file: description: 'Custom properties file' + custom-property-values-file: + description: 'Custom property values file' delete-unmanaged-properties: description: 'Delete unmanaged properties' issue-types-file: @@ -207,9 +209,12 @@ const { parseOrganizations, parseOrganizationsFile, parseCustomPropertiesFile, + parseCustomPropertyValuesFile, + normalizeCustomPropertyValueRules, normalizeCustomProperties, compareCustomProperty, syncCustomProperties, + syncCustomPropertyValues, parseIssueTypesFile, normalizeIssueTypes, compareIssueType, @@ -1508,6 +1513,54 @@ orgs: }); }); + // ─── parseCustomPropertyValuesFile ─────────────────────────────────────── + + describe('parseCustomPropertyValuesFile', () => { + test('should parse custom property value rules and resolve names-file relative to values file', () => { + const valuesYaml = `- repositories: + names: [api, web] + names-file: teams/platform.yml + query: 'topic:platform archived:false' + properties: + team: platform + environments: [production, staging] + owner: null +`; + setMockFileContent(valuesYaml, '/mock/config/custom-property-values.yml'); + + const result = parseCustomPropertyValuesFile('/mock/config/custom-property-values.yml'); + + expect(result).toEqual([ + { + repositories: { + names: ['api', 'web'], + namesFile: '/mock/config/teams/platform.yml', + query: 'topic:platform archived:false' + }, + properties: [ + { property_name: 'team', value: 'platform' }, + { property_name: 'environments', value: ['production', 'staging'] }, + { property_name: 'owner', value: null } + ] + } + ]); + }); + + test('should reject owner/name repository identifiers', () => { + expect(() => + normalizeCustomPropertyValueRules( + [ + { + repositories: { names: ['my-org/api'] }, + properties: { team: 'platform' } + } + ], + 'custom property values' + ) + ).toThrow('bare repository name'); + }); + }); + // ─── mergeCustomProperties ─────────────────────────────────────────── describe('mergeCustomProperties', () => { @@ -2484,6 +2537,140 @@ orgs: }); }); + // ─── syncCustomPropertyValues ──────────────────────────────────────────── + + describe('syncCustomPropertyValues', () => { + const valueRules = [ + { + repositories: { + names: ['api', 'missing'], + query: 'topic:platform archived:false' + }, + properties: [ + { property_name: 'team', value: 'platform' }, + { property_name: 'environments', value: ['production', 'staging'] } + ] + } + ]; + + function mockCustomPropertyValueFetches() { + mockPaginate.mockImplementation((route, params) => { + if (route === 'GET /orgs/{org}/repos') { + return Promise.resolve([{ name: 'api' }, { name: 'web' }]); + } + if (route === 'GET /orgs/{org}/properties/values' && params.repository_query) { + return Promise.resolve([{ repository_name: 'web', properties: [] }]); + } + if (route === 'GET /orgs/{org}/properties/values') { + return Promise.resolve([ + { + repository_name: 'api', + properties: [ + { property_name: 'team', value: 'frontend' }, + { property_name: 'environments', value: ['staging', 'production'] } + ] + }, + { repository_name: 'web', properties: [] } + ]); + } + return Promise.resolve([]); + }); + + mockRequest.mockResolvedValueOnce({ + data: [ + { + property_name: 'team', + value_type: 'single_select', + required: false, + allowed_values: ['platform', 'frontend'] + }, + { + property_name: 'environments', + value_type: 'multi_select', + required: false, + allowed_values: ['production', 'staging'] + } + ] + }); + } + + test('should diff values, warn on missing hard-selected repos, and patch changed repos', async () => { + mockCustomPropertyValueFetches(); + mockRequest.mockResolvedValueOnce({}); + + const result = await syncCustomPropertyValues(mockOctokit, 'my-org', valueRules, false); + + expect(result.failed).toBe(false); + expect(result.subResults.some(r => r.kind === 'custom-property-value-select')).toBe(true); + expect(result.subResults.filter(r => r.kind === 'custom-property-value-update')).toHaveLength(2); + expect(mockRequest).toHaveBeenCalledWith('PATCH /orgs/{org}/properties/values', { + org: 'my-org', + repository_names: ['api'], + properties: [{ property_name: 'team', value: 'platform' }] + }); + expect(mockRequest).toHaveBeenCalledWith('PATCH /orgs/{org}/properties/values', { + org: 'my-org', + repository_names: ['web'], + properties: [ + { property_name: 'environments', value: ['production', 'staging'] }, + { property_name: 'team', value: 'platform' } + ] + }); + }); + + test('should not patch in dry-run mode', async () => { + mockCustomPropertyValueFetches(); + + const result = await syncCustomPropertyValues(mockOctokit, 'my-org', valueRules, true); + + expect(result.subResults.some(r => r.kind === 'custom-property-value-update')).toBe(true); + expect(mockRequest).toHaveBeenCalledTimes(1); + }); + + test('should warn on conflicting rule values and let later rules win', async () => { + mockPaginate.mockImplementation(route => { + if (route === 'GET /orgs/{org}/repos') { + return Promise.resolve([{ name: 'api' }]); + } + if (route === 'GET /orgs/{org}/properties/values') { + return Promise.resolve([ + { repository_name: 'api', properties: [{ property_name: 'team', value: 'frontend' }] } + ]); + } + return Promise.resolve([]); + }); + mockRequest + .mockResolvedValueOnce({ + data: [ + { + property_name: 'team', + value_type: 'single_select', + required: false, + allowed_values: ['platform', 'frontend'] + } + ] + }) + .mockResolvedValueOnce({}); + + const result = await syncCustomPropertyValues( + mockOctokit, + 'my-org', + [ + { repositories: { names: ['api'] }, properties: [{ property_name: 'team', value: 'frontend' }] }, + { repositories: { names: ['api'] }, properties: [{ property_name: 'team', value: 'platform' }] } + ], + false + ); + + expect(result.subResults.some(r => r.kind === 'custom-property-value-conflict')).toBe(true); + expect(mockRequest).toHaveBeenCalledWith('PATCH /orgs/{org}/properties/values', { + org: 'my-org', + repository_names: ['api'], + properties: [{ property_name: 'team', value: 'platform' }] + }); + }); + }); + // ─── run (integration) ───────────────────────────────────────────────── describe('Action execution', () => { diff --git a/action.yml b/action.yml index 071e79b..64d47bb 100644 --- a/action.yml +++ b/action.yml @@ -27,6 +27,9 @@ inputs: custom-properties-file: description: 'Path to a YAML file defining custom property schemas to sync to target organizations' required: false + custom-property-values-file: + description: 'Path to a YAML file defining custom property values to sync to target organization repositories' + required: false delete-unmanaged-properties: description: 'Delete custom properties not defined in the configuration file' required: false diff --git a/badges/coverage.svg b/badges/coverage.svg index c9dff47..8e459e8 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -Coverage: 85.84%Coverage85.84% \ No newline at end of file +Coverage: 84.08%Coverage84.08% \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ea9cd17..10db6ff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bulk-github-org-settings-sync-action", - "version": "1.12.0", + "version": "1.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bulk-github-org-settings-sync-action", - "version": "1.12.0", + "version": "1.13.0", "license": "MIT", "dependencies": { "@actions/core": "^3.0.1", diff --git a/package.json b/package.json index 838e835..6afd8a3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "bulk-github-org-settings-sync-action", "description": "🏢 Bulk configure GitHub organization settings across multiple orgs using a declarative YAML config", - "version": "1.12.0", + "version": "1.13.0", "type": "module", "author": { "name": "Josh Johanning", diff --git a/sample-configuration/custom-property-values.yml b/sample-configuration/custom-property-values.yml new file mode 100644 index 0000000..e602e82 --- /dev/null +++ b/sample-configuration/custom-property-values.yml @@ -0,0 +1,24 @@ +# Standalone custom property values file for use with custom-property-values-file input +# Use with: custom-property-values-file: './custom-property-values.yml' +# +# Properties must already exist in the organization custom property schema. +# Pair this with custom-properties-file when you want this action to manage both +# the schema and selected repository values. + +- repositories: + names: + - api + - web + names-file: teams/platform-repos.yml + query: 'topic:platform archived:false' + properties: + team: platform + environment: + - production + - staging + +- repositories: + names-file: teams/infra-repos.yml + properties: + team: devops + cost-center: CC-1234 diff --git a/sample-configuration/orgs.yml b/sample-configuration/orgs.yml index 4486dab..be75499 100644 --- a/sample-configuration/orgs.yml +++ b/sample-configuration/orgs.yml @@ -13,6 +13,8 @@ # merge with the base by `name`; some replace the base for that org. Two # features are file-only (no inline form): `rulesets-file` and # `actions-allow-list-file`. +# Custom property values are ordered rules: per-org files replace the base file, +# and inline rules append after file rules so later rules can override earlier ones. # # See the README "Per-Org Overrides: Inline vs File-Based" section for the # full feature matrix, precedence rules, and merge vs replace semantics: @@ -48,6 +50,11 @@ orgs: - backend - data-science values-editable-by: org_actors + custom-property-values: + - repositories: + names: [special-service] + properties: + team: platform issue-types: - name: Bug description: 'Something is broken' @@ -121,6 +128,7 @@ orgs: # Org with per-org FILE-BASED overrides (point to a different config file for this org) - org: my-file-based-org custom-properties-file: './config/custom-properties/file-based-org.yml' + custom-property-values-file: './config/custom-property-values/file-based-org.yml' issue-types-file: './config/issue-types/file-based-org.yml' issue-fields-file: './config/issue-fields/file-based-org.yml' custom-org-roles-file: './config/custom-org-roles/file-based-org.yml' diff --git a/sample-configuration/teams/platform-repos.yml b/sample-configuration/teams/platform-repos.yml new file mode 100644 index 0000000..1f8c303 --- /dev/null +++ b/sample-configuration/teams/platform-repos.yml @@ -0,0 +1,6 @@ +# Repo list that can be owned by a team via CODEOWNERS / PR review. +# CODEOWNERS is a review workflow, not an authorization boundary. + +- api +- web +- worker diff --git a/src/index.js b/src/index.js index b35c82a..2cc03d9 100644 --- a/src/index.js +++ b/src/index.js @@ -125,6 +125,13 @@ const KNOWN_CUSTOM_PROPERTY_KEYS = new Set([ 'values_editable_by' ]); +/** + * Known keys for custom property value rules in the YAML file. + * Used to warn about typos or unknown keys. + */ +const KNOWN_CUSTOM_PROPERTY_VALUE_RULE_KEYS = new Set(['repositories', 'properties']); +const KNOWN_CUSTOM_PROPERTY_VALUE_REPOSITORIES_KEYS = new Set(['names', 'names-file', 'query']); + /** * Known keys for issue type definitions in the YAML file. * Used to warn about typos or unknown keys. @@ -253,6 +260,8 @@ export const ACTIONS_POLICY_SETTINGS = new Map([ const ORG_CONFIG_TOP_LEVEL_KEYS = new Set([ 'org', 'custom-properties', + 'custom-property-values', + 'custom-property-values-file', 'custom-properties-file', 'delete-unmanaged-properties', 'issue-types', @@ -350,6 +359,31 @@ export function validateOrgConfig(orgConfig, orgName) { } } + if (Array.isArray(orgConfig['custom-property-values'])) { + for (const [index, rule] of orgConfig['custom-property-values'].entries()) { + if (typeof rule !== 'object' || rule === null) continue; + for (const key of Object.keys(rule)) { + if (!KNOWN_CUSTOM_PROPERTY_VALUE_RULE_KEYS.has(key)) { + core.warning( + `⚠️ Unknown custom property value rule key "${key}" found for rule ${index + 1} in organization "${orgName}". ` + + `This key may not exist or may have a typo.` + ); + } + } + + if (typeof rule.repositories === 'object' && rule.repositories !== null) { + for (const key of Object.keys(rule.repositories)) { + if (!KNOWN_CUSTOM_PROPERTY_VALUE_REPOSITORIES_KEYS.has(key)) { + core.warning( + `⚠️ Unknown custom property value repository selector key "${key}" found for rule ${index + 1} in organization "${orgName}". ` + + `This key may not exist or may have a typo.` + ); + } + } + } + } + } + // Validate issue type keys if present if (Array.isArray(orgConfig['issue-types'])) { for (const issueType of orgConfig['issue-types']) { @@ -487,6 +521,7 @@ export function validateOrgConfig(orgConfig, orgName) { */ const FILE_PATH_CONFIG_KEYS = [ 'custom-properties-file', + 'custom-property-values-file', 'issue-types-file', 'issue-fields-file', 'rulesets-file', @@ -575,6 +610,10 @@ const SYNC_KIND_LABELS = Object.freeze({ 'custom-property-update': 'custom property (updated)', 'custom-property-delete': 'custom property (deleted)', 'custom-property-fetch': 'custom property (fetch failed)', + 'custom-property-value-update': 'custom property value (updated)', + 'custom-property-value-select': 'custom property value (selection warning)', + 'custom-property-value-conflict': 'custom property value (conflict)', + 'custom-property-value-fetch': 'custom property value (fetch failed)', 'issue-type-create': 'issue type (created)', 'issue-type-update': 'issue type (updated)', 'issue-type-delete': 'issue type (deleted)', @@ -794,7 +833,8 @@ function formatSubResultStatus(status) { * @param {string} [dotGithubRepoVisibility] - Visibility used when creating the .github repo * @param {string} [dotGithubPrivateRepoVisibility] - Visibility used when creating the .github-private repo * @param {string} [issueFieldsFile] - Path to issue fields YAML file (base for all orgs) - * @returns {Array<{ org: string, customProperties?: Array, rulesetsFiles?: string[], deleteUnmanagedRulesets?: boolean, issueTypes?: Array, issueFields?: Array, memberPrivileges?: Object, customOrgRoles?: Array, customRepoRoles?: Array, organizationRoleTeamAssignments?: Array, orgProfile?: Object, codeSecurityConfigurations?: Array, deleteUnmanagedCodeSecurityConfigurations?: boolean, actionsPolicy?: Object, actionsAllowList?: string[], dotGithubSourceDir?: string, dotGithubPrivateSourceDir?: string, createMissingDotGithubRepos?: boolean, dotGithubRepoVisibility?: string, dotGithubPrivateRepoVisibility?: string }>} Parsed org configs + * @param {string} [customPropertyValuesFile] - Path to custom property values YAML file (base for all orgs) + * @returns {Array<{ org: string, customProperties?: Array, customPropertyValues?: Array, rulesetsFiles?: string[], deleteUnmanagedRulesets?: boolean, issueTypes?: Array, issueFields?: Array, memberPrivileges?: Object, customOrgRoles?: Array, customRepoRoles?: Array, organizationRoleTeamAssignments?: Array, orgProfile?: Object, codeSecurityConfigurations?: Array, deleteUnmanagedCodeSecurityConfigurations?: boolean, actionsPolicy?: Object, actionsAllowList?: string[], dotGithubSourceDir?: string, dotGithubPrivateSourceDir?: string, createMissingDotGithubRepos?: boolean, dotGithubRepoVisibility?: string, dotGithubPrivateRepoVisibility?: string }>} Parsed org configs */ export function parseOrganizations( organizationsInput, @@ -816,7 +856,8 @@ export function parseOrganizations( createMissingDotGithubRepos, dotGithubRepoVisibility, dotGithubPrivateRepoVisibility, - issueFieldsFile + issueFieldsFile, + customPropertyValuesFile ) { if ( issueFieldsFile === undefined && @@ -851,6 +892,11 @@ export function parseOrganizations( baseCustomProperties = parseCustomPropertiesFile(customPropertiesFile); } + let baseCustomPropertyValues = null; + if (customPropertyValuesFile) { + baseCustomPropertyValues = parseCustomPropertyValuesFile(customPropertyValuesFile); + } + // Load base issue types from separate file (applies to all orgs) let baseIssueTypes = null; if (issueTypesFile) { @@ -935,6 +981,26 @@ export function parseOrganizations( // Clean up the intermediate field delete orgConfig.customPropertiesFile; + let orgCustomPropertyValuesBase = baseCustomPropertyValues; + if (orgConfig.customPropertyValuesFile) { + try { + orgCustomPropertyValuesBase = parseCustomPropertyValuesFile(orgConfig.customPropertyValuesFile); + } catch (error) { + throw new Error( + `Failed to parse custom property values file "${orgConfig.customPropertyValuesFile}" for organization "${orgConfig.org}": ${error.message}`, + { cause: error } + ); + } + } + + if (orgCustomPropertyValuesBase || orgConfig.customPropertyValues) { + orgConfig.customPropertyValues = [ + ...(orgCustomPropertyValuesBase || []), + ...(orgConfig.customPropertyValues || []) + ]; + } + delete orgConfig.customPropertyValuesFile; + // Per-org issue-types-file overrides the base for this org let orgIssueTypesBase = baseIssueTypes; if (orgConfig.issueTypesFile) { @@ -1155,6 +1221,7 @@ export function parseOrganizations( return orgs.map(org => ({ org, ...(baseCustomProperties ? { customProperties: baseCustomProperties } : {}), + ...(baseCustomPropertyValues ? { customPropertyValues: baseCustomPropertyValues } : {}), ...(baseIssueTypes ? { issueTypes: baseIssueTypes } : {}), ...(baseIssueFields ? { issueFields: baseIssueFields } : {}), ...(rulesetsFiles && rulesetsFiles.length > 0 ? { rulesetsFiles } : {}), @@ -1731,7 +1798,7 @@ export function mergeOrgProfile(baseProfile, orgProfile) { /** * Parse the organizations YAML config file. * @param {string} filePath - Path to the YAML file - * @returns {Array<{ org: string, customPropertiesFile?: string, customProperties?: Array, issueTypesFile?: string, issueTypes?: Array, issueFieldsFile?: string, issueFields?: Array, rulesetsFiles?: string[], deleteUnmanagedRulesets?: boolean, deleteUnmanagedProperties?: boolean, deleteUnmanagedIssueTypes?: boolean, deleteUnmanagedIssueFields?: boolean, memberPrivileges?: Object, orgProfile?: Object, codeSecurityConfigurationsFile?: string, codeSecurityConfigurations?: Array, deleteUnmanagedCodeSecurityConfigurations?: boolean, actionsPolicy?: Object, actionsAllowListFile?: string, organizationRoleTeamAssignments?: Array, organizationRoleTeamAssignmentsFile?: string }>} + * @returns {Array<{ org: string, customPropertiesFile?: string, customProperties?: Array, customPropertyValuesFile?: string, customPropertyValues?: Array, issueTypesFile?: string, issueTypes?: Array, issueFieldsFile?: string, issueFields?: Array, rulesetsFiles?: string[], deleteUnmanagedRulesets?: boolean, deleteUnmanagedProperties?: boolean, deleteUnmanagedIssueTypes?: boolean, deleteUnmanagedIssueFields?: boolean, memberPrivileges?: Object, orgProfile?: Object, codeSecurityConfigurationsFile?: string, codeSecurityConfigurations?: Array, deleteUnmanagedCodeSecurityConfigurations?: boolean, actionsPolicy?: Object, actionsAllowListFile?: string, organizationRoleTeamAssignments?: Array, organizationRoleTeamAssignmentsFile?: string }>} */ export function parseOrganizationsFile(filePath) { if (!fs.existsSync(filePath)) { @@ -1778,6 +1845,25 @@ export function parseOrganizationsFile(filePath) { result.customProperties = normalizeCustomProperties(orgConfig['custom-properties']); } + if (Object.prototype.hasOwnProperty.call(orgConfig, 'custom-property-values-file')) { + const cpvFile = orgConfig['custom-property-values-file']; + if (typeof cpvFile !== 'string' || cpvFile.trim() === '') { + throw new Error( + `Invalid "custom-property-values-file" for org "${orgConfig.org}": expected a non-empty string` + ); + } + result.customPropertyValuesFile = cpvFile.trim(); + } + + if (Object.prototype.hasOwnProperty.call(orgConfig, 'custom-property-values')) { + const inlineBaseDir = basePath ? basePath : path.dirname(filePath); + result.customPropertyValues = normalizeCustomPropertyValueRules( + orgConfig['custom-property-values'], + `custom-property-values for org "${orgConfig.org}"`, + inlineBaseDir + ); + } + if (Object.prototype.hasOwnProperty.call(orgConfig, 'issue-types-file')) { const itFile = orgConfig['issue-types-file']; if (typeof itFile !== 'string' || itFile.trim() === '') { @@ -2067,6 +2153,165 @@ export function parseCustomPropertiesFile(filePath) { return normalizeCustomProperties(properties); } +/** + * Parse a standalone custom property values YAML file. + * @param {string} filePath - Path to the YAML file + * @returns {Array} Normalized custom property value rules + */ +export function parseCustomPropertyValuesFile(filePath) { + if (!fs.existsSync(filePath)) { + throw new Error(`Custom property values file not found: ${filePath}`); + } + + const content = fs.readFileSync(filePath, 'utf8'); + const rules = yaml.load(content); + + return normalizeCustomPropertyValueRules(rules, `custom property values file "${filePath}"`, path.dirname(filePath)); +} + +/** + * Normalize custom property value assignment rules from YAML format. + * @param {*} rules - Raw custom property value rules + * @param {string} context - Human-readable context for errors + * @param {string} [baseDir] - Base directory for nested names-file paths + * @returns {Array} Normalized rules + */ +export function normalizeCustomPropertyValueRules(rules, context = 'custom property values', baseDir = '') { + if (!Array.isArray(rules)) { + throw new Error(`Invalid ${context}: expected an array of rules`); + } + + return rules.map((rule, index) => normalizeCustomPropertyValueRule(rule, `${context} rule ${index + 1}`, baseDir)); +} + +function normalizeCustomPropertyValueRule(rule, context, baseDir) { + if (typeof rule !== 'object' || rule === null || Array.isArray(rule)) { + throw new Error(`Invalid ${context}: expected an object`); + } + + if (typeof rule.repositories !== 'object' || rule.repositories === null || Array.isArray(rule.repositories)) { + throw new Error(`Invalid ${context}: expected "repositories" to be an object`); + } + + const repositories = normalizeCustomPropertyValueRepositories(rule.repositories, context, baseDir); + const properties = normalizeCustomPropertyValueProperties(rule.properties, context); + + if (repositories.names.length === 0 && !repositories.namesFile && !repositories.query) { + throw new Error(`Invalid ${context}: at least one repository selector is required`); + } + + return { repositories, properties }; +} + +function normalizeCustomPropertyValueRepositories(repositories, context, baseDir) { + const names = normalizeRepositoryNames(repositories.names, `${context} repositories.names`); + let namesFile; + if (Object.prototype.hasOwnProperty.call(repositories, 'names-file')) { + const value = repositories['names-file']; + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`Invalid ${context} repositories.names-file: expected a non-empty string`); + } + namesFile = baseDir ? resolveFilePath(baseDir, value) : value.trim(); + } + + let query; + if (Object.prototype.hasOwnProperty.call(repositories, 'query')) { + if (typeof repositories.query !== 'string' || repositories.query.trim() === '') { + throw new Error(`Invalid ${context} repositories.query: expected a non-empty string`); + } + query = repositories.query.trim(); + } + + return { names, ...(namesFile ? { namesFile } : {}), ...(query ? { query } : {}) }; +} + +function normalizeRepositoryNames(value, context) { + if (value === undefined || value === null) { + return []; + } + + const rawNames = + typeof value === 'string' + ? value + .split(',') + .map(v => v.trim()) + .filter(v => v.length > 0) + : value; + + if (!Array.isArray(rawNames)) { + throw new Error(`Invalid ${context}: expected a comma-separated string or array of repository names`); + } + + const names = []; + const seen = new Set(); + for (const name of rawNames) { + if (typeof name !== 'string' || name.trim() === '') { + throw new Error(`Invalid ${context}: expected repository names to be non-empty strings`); + } + const trimmed = name.trim(); + if (trimmed.includes('/')) { + throw new Error(`Invalid ${context}: repository "${trimmed}" must be a bare repository name without "org/"`); + } + const key = trimmed.toLowerCase(); + if (!seen.has(key)) { + names.push(trimmed); + seen.add(key); + } + } + + return names; +} + +function normalizeCustomPropertyValueProperties(properties, context) { + if (typeof properties !== 'object' || properties === null || Array.isArray(properties)) { + throw new Error(`Invalid ${context}: expected "properties" to be an object mapping property names to values`); + } + + const normalized = []; + for (const [propertyName, value] of Object.entries(properties)) { + if (!propertyName.trim()) { + throw new Error(`Invalid ${context}: property names must be non-empty strings`); + } + + normalized.push({ + property_name: propertyName.trim(), + value: normalizeCustomPropertyValue(value) + }); + } + + if (normalized.length === 0) { + throw new Error(`Invalid ${context}: at least one property value is required`); + } + + return normalized; +} + +function normalizeCustomPropertyValue(value) { + if (value === null) { + return null; + } + + if (Array.isArray(value)) { + return value.map(v => String(v)); + } + + return String(value); +} + +function parseRepositoryNamesFile(filePath) { + if (!fs.existsSync(filePath)) { + throw new Error(`Repository names file not found: ${filePath}`); + } + + const content = fs.readFileSync(filePath, 'utf8'); + const names = yaml.load(content); + if (names === null || names === undefined) { + return []; + } + + return normalizeRepositoryNames(names, `repository names file "${filePath}"`); +} + /** * Normalize custom property definitions from YAML format to API format. * @param {Array} properties - Custom property definitions from YAML @@ -3553,6 +3798,305 @@ export async function syncCustomProperties(octokit, org, desiredProperties, dele return { subResults, failed: false }; } +const CUSTOM_PROPERTY_VALUES_BATCH_SIZE = 30; + +/** + * Sync custom property values for repositories in an organization. + * @param {Octokit} octokit - Octokit instance + * @param {string} org - Organization name + * @param {Array} rules - Custom property value rules + * @param {boolean} dryRun - Preview mode + * @returns {Promise} Result object with subResults + */ +export async function syncCustomPropertyValues(octokit, org, rules, dryRun) { + const subResults = []; + const wouldPrefix = dryRun ? 'Would ' : ''; + + let repositories; + let currentEntries; + let schema; + try { + [repositories, currentEntries, schema] = await Promise.all([ + octokit.paginate('GET /orgs/{org}/repos', { org, type: 'all', per_page: 100 }), + octokit.paginate('GET /orgs/{org}/properties/values', { org, per_page: 100 }), + (async () => { + const { data } = await octokit.request('GET /orgs/{org}/properties/schema', { org }); + return data; + })() + ]); + } catch (error) { + if (isPermissionLikeFetchError(error)) { + const message = formatPermissionFetchWarning('custom property values', org, error); + core.warning(` ⚠️ ${message}`); + subResults.push(createSubResult('custom-property-value-fetch', SubResultStatus.WARNING, message)); + return { subResults, failed: false }; + } + throw error; + } + + const repoNameMap = new Map(repositories.map(repo => [repo.name.toLowerCase(), repo.name])); + const currentValueMap = buildCurrentPropertyValueMap(currentEntries); + const schemaMap = new Map(schema.map(prop => [prop.property_name, prop])); + const desiredByRepo = new Map(); + + for (const [index, rule] of rules.entries()) { + validateCustomPropertyValueRuleAgainstSchema(rule, schemaMap, `rule ${index + 1}`); + + const selectedRepos = await resolveCustomPropertyValueRuleRepositories( + octokit, + org, + rule, + index + 1, + repoNameMap, + subResults + ); + + if (selectedRepos.length === 0) { + const message = `Rule ${index + 1} did not match any repositories`; + core.warning(` ⚠️ ${message}`); + subResults.push(createSubResult('custom-property-value-select', SubResultStatus.WARNING, message)); + continue; + } + + for (const repoName of selectedRepos) { + if (!desiredByRepo.has(repoName)) { + desiredByRepo.set(repoName, new Map()); + } + const repoProperties = desiredByRepo.get(repoName); + for (const property of rule.properties) { + const existing = repoProperties.get(property.property_name); + if (existing !== undefined && !customPropertyValuesEqual(existing, property.value)) { + const message = + `Repository "${repoName}" property "${property.property_name}" is set by multiple rules; ` + + `using value from rule ${index + 1}`; + core.warning(` ⚠️ ${message}`); + subResults.push(createSubResult('custom-property-value-conflict', SubResultStatus.WARNING, message)); + } + repoProperties.set(property.property_name, property.value); + } + } + } + + const changes = []; + for (const [repoName, desiredProperties] of desiredByRepo.entries()) { + const currentProperties = currentValueMap.get(repoName.toLowerCase()) || new Map(); + const changedProperties = []; + + for (const [propertyName, desiredValue] of desiredProperties.entries()) { + const currentValue = currentProperties.get(propertyName); + if (!customPropertyValuesEqual(currentValue, desiredValue)) { + changedProperties.push({ + property_name: propertyName, + value: desiredValue, + currentValue + }); + } + } + + if (changedProperties.length > 0) { + const diff = changedProperties + .map( + p => + `${p.property_name}: ${formatCustomPropertyValue(p.currentValue)} -> ${formatCustomPropertyValue(p.value)}` + ) + .join(', '); + core.info(` 📝 ${wouldPrefix}Update custom property values for ${repoName}: ${diff}`); + subResults.push( + createSubResult( + 'custom-property-value-update', + SubResultStatus.CHANGED, + `${wouldPrefix}update "${repoName}" (${diff})` + ) + ); + changes.push({ + repoName, + properties: changedProperties.map(({ property_name, value }) => ({ property_name, value })) + }); + } else { + core.info(` ✅ Custom property values unchanged for ${repoName}`); + } + } + + if (dryRun || changes.length === 0) { + return { subResults, failed: false }; + } + + let failed = false; + for (const group of groupCustomPropertyValueChanges(changes)) { + for (const repoChunk of chunkArray(group.repositoryNames, CUSTOM_PROPERTY_VALUES_BATCH_SIZE)) { + try { + await octokit.request('PATCH /orgs/{org}/properties/values', { + org, + repository_names: repoChunk, + properties: group.properties + }); + } catch (error) { + failed = true; + const message = `Failed to update custom property values for ${repoChunk.join(', ')}: ${error.message}`; + core.warning(` ⚠️ ${message}`); + subResults.push(createSubResult('custom-property-value-update', SubResultStatus.WARNING, message)); + } + } + } + + return { subResults, failed }; +} + +async function resolveCustomPropertyValueRuleRepositories(octokit, org, rule, ruleNumber, repoNameMap, subResults) { + const selected = new Map(); + const addHardSelectedName = name => { + const repoName = repoNameMap.get(name.toLowerCase()); + if (!repoName) { + const message = `Repository "${name}" from custom property value rule ${ruleNumber} was not found in organization "${org}"`; + core.warning(` ⚠️ ${message}`); + subResults.push(createSubResult('custom-property-value-select', SubResultStatus.WARNING, message)); + return; + } + selected.set(repoName.toLowerCase(), repoName); + }; + + for (const name of rule.repositories.names) { + addHardSelectedName(name); + } + + if (rule.repositories.namesFile) { + const fileNames = parseRepositoryNamesFile(rule.repositories.namesFile); + if (fileNames.length === 0) { + const message = `Repository names file "${rule.repositories.namesFile}" is empty`; + core.warning(` ⚠️ ${message}`); + subResults.push(createSubResult('custom-property-value-select', SubResultStatus.WARNING, message)); + } + for (const name of fileNames) { + addHardSelectedName(name); + } + } + + if (rule.repositories.query) { + const matchingEntries = await octokit.paginate('GET /orgs/{org}/properties/values', { + org, + repository_query: rule.repositories.query, + per_page: 100 + }); + + if (matchingEntries.length === 0) { + const message = `Repository query "${rule.repositories.query}" in custom property value rule ${ruleNumber} matched no repositories`; + core.warning(` ⚠️ ${message}`); + subResults.push(createSubResult('custom-property-value-select', SubResultStatus.WARNING, message)); + } + + for (const entry of matchingEntries) { + if (entry.repository_name) { + selected.set(entry.repository_name.toLowerCase(), entry.repository_name); + } + } + } + + return Array.from(selected.values()).sort((a, b) => a.localeCompare(b)); +} + +function validateCustomPropertyValueRuleAgainstSchema(rule, schemaMap, context) { + for (const property of rule.properties) { + const schema = schemaMap.get(property.property_name); + if (!schema) { + throw new Error(`Custom property value ${context} references unknown property "${property.property_name}"`); + } + + if (property.value === null) { + if (schema.required) { + throw new Error(`Custom property value ${context} cannot unset required property "${property.property_name}"`); + } + continue; + } + + if (schema.value_type === 'multi_select') { + if (!Array.isArray(property.value)) { + throw new Error(`Custom property value ${context} property "${property.property_name}" must be an array`); + } + validateAllowedCustomPropertyValues(property, schema, context); + } else { + if (Array.isArray(property.value)) { + throw new Error(`Custom property value ${context} property "${property.property_name}" must not be an array`); + } + validateAllowedCustomPropertyValues(property, schema, context); + } + } +} + +function validateAllowedCustomPropertyValues(property, schema, context) { + if (!Array.isArray(schema.allowed_values) || schema.allowed_values.length === 0) { + return; + } + + const values = Array.isArray(property.value) ? property.value : [property.value]; + const invalid = values.filter(value => !schema.allowed_values.includes(value)); + if (invalid.length > 0) { + throw new Error( + `Custom property value ${context} property "${property.property_name}" has value(s) not in allowed_values: ${invalid.join(', ')}` + ); + } +} + +function buildCurrentPropertyValueMap(currentEntries) { + const result = new Map(); + for (const entry of currentEntries) { + const repoName = entry.repository_name; + if (!repoName) continue; + const properties = new Map(); + for (const property of entry.properties || []) { + properties.set(property.property_name, normalizeCustomPropertyValue(property.value)); + } + result.set(repoName.toLowerCase(), properties); + } + return result; +} + +function customPropertyValuesEqual(a, b) { + if (a === undefined || a === null) { + return b === undefined || b === null; + } + if (b === undefined || b === null) { + return false; + } + if (Array.isArray(a) || Array.isArray(b)) { + if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { + return false; + } + const sortedA = [...a].sort(); + const sortedB = [...b].sort(); + return sortedA.every((value, index) => value === sortedB[index]); + } + return String(a) === String(b); +} + +function groupCustomPropertyValueChanges(changes) { + const groups = new Map(); + for (const change of changes) { + const properties = [...change.properties].sort((a, b) => a.property_name.localeCompare(b.property_name)); + const key = JSON.stringify(properties); + if (!groups.has(key)) { + groups.set(key, { properties, repositoryNames: [] }); + } + groups.get(key).repositoryNames.push(change.repoName); + } + + return Array.from(groups.values()); +} + +function chunkArray(values, size) { + const chunks = []; + for (let i = 0; i < values.length; i += size) { + chunks.push(values.slice(i, i + size)); + } + return chunks; +} + +function formatCustomPropertyValue(value) { + if (value === undefined) return '(unset)'; + if (value === null) return 'null'; + if (Array.isArray(value)) return `[${value.join(', ')}]`; + return String(value); +} + // ─── Member Privileges Sync ───────────────────────────────────────────────────── /** @@ -5757,6 +6301,7 @@ export async function run() { const organizationsInput = core.getInput('organizations'); const organizationsFile = core.getInput('organizations-file'); const customPropertiesFile = core.getInput('custom-properties-file'); + const customPropertyValuesFile = core.getInput('custom-property-values-file'); const deleteUnmanagedProperties = getBooleanInput('delete-unmanaged-properties') ?? false; const rulesetsFileInput = core.getInput('rulesets-file'); const rulesetsFiles = parseRulesetsFileValue(rulesetsFileInput); @@ -5821,11 +6366,13 @@ export async function run() { createMissingDotGithubRepos, dotGithubRepoVisibility, dotGithubPrivateRepoVisibility, - issueFieldsFile + issueFieldsFile, + customPropertyValuesFile ); // Check that at least one setting type is specified const hasCustomProperties = orgList.some(o => o.customProperties && o.customProperties.length > 0); + const hasCustomPropertyValues = orgList.some(o => o.customPropertyValues && o.customPropertyValues.length > 0); const hasRulesets = orgList.some(o => o.rulesetsFiles && o.rulesetsFiles.length > 0); const hasIssueTypes = orgList.some(o => o.issueTypes && o.issueTypes.length > 0); const hasIssueFields = orgList.some(o => o.issueFields && o.issueFields.length > 0); @@ -5853,6 +6400,7 @@ export async function run() { ); if ( !hasCustomProperties && + !hasCustomPropertyValues && !hasRulesets && !hasIssueTypes && !hasIssueFields && @@ -5869,6 +6417,7 @@ export async function run() { throw new Error( 'At least one setting must be specified. Provide custom properties via ' + '"organizations-file" or via "organizations" + "custom-properties-file" inputs, ' + + 'custom property values via "custom-property-values-file", ' + 'provide issue types via "issue-types-file", issue fields via "issue-fields-file", rulesets via "rulesets-file", ' + 'member privileges via individual inputs (e.g., "default-repository-permission"), ' + 'organization role team assignments via "organization-role-team-assignments-file", ' + @@ -5957,6 +6506,20 @@ export async function run() { } } + // Sync custom property values after schema sync so newly-defined properties can be assigned. + if (orgConfig.customPropertyValues && orgConfig.customPropertyValues.length > 0) { + core.info(` 🏷️ Syncing custom property values (${orgConfig.customPropertyValues.length} rule(s))...`); + const cpvResult = await syncCustomPropertyValues(octokit, org, orgConfig.customPropertyValues, dryRun); + result.subResults.push(...cpvResult.subResults); + + if (cpvResult.failed) { + result.success = false; + result.error = result.error + ? `${result.error}; Custom property values sync failed` + : 'Custom property values sync failed'; + } + } + // Sync issue types if (orgConfig.issueTypes && orgConfig.issueTypes.length > 0) { core.info(` 🏷️ Syncing issue types (${orgConfig.issueTypes.length} defined)...`); From 54edfc812855f7b78d3efff5806ff69badefe39a Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Wed, 20 May 2026 20:19:15 -0500 Subject: [PATCH 2/3] fix: address CCR comments for custom property values - Preserve boolean values in normalizeCustomPropertyValue so true_false custom properties are not coerced to strings before API writes - Remove redundant GET /orgs/{org}/repos call; build repoNameMap from currentEntries which already contains all org repositories - Add true_false type validation in validateCustomPropertyValueRuleAgainstSchema so non-boolean values (e.g. strings) are caught before hitting the API - Update 'at least one setting' error message to mention both organizations-file and custom-property-values-file config paths - Add tests for boolean value preservation and true_false schema validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- __tests__/index.test.js | 38 ++++++++++++++++++++++++++++++++------ badges/coverage.svg | 2 +- src/index.js | 20 +++++++++++++++----- 3 files changed, 48 insertions(+), 12 deletions(-) diff --git a/__tests__/index.test.js b/__tests__/index.test.js index 65ced7f..0d88744 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -1559,6 +1559,17 @@ orgs: ) ).toThrow('bare repository name'); }); + + test('should preserve boolean values for true_false properties', () => { + const result = normalizeCustomPropertyValueRules( + [{ repositories: { names: ['api'] }, properties: { 'is-public': true, 'is-archived': false } }], + 'custom property values' + ); + const prop1 = result[0].properties.find(p => p.property_name === 'is-public'); + const prop2 = result[0].properties.find(p => p.property_name === 'is-archived'); + expect(prop1.value).toBe(true); + expect(prop2.value).toBe(false); + }); }); // ─── mergeCustomProperties ─────────────────────────────────────────── @@ -2555,9 +2566,6 @@ orgs: function mockCustomPropertyValueFetches() { mockPaginate.mockImplementation((route, params) => { - if (route === 'GET /orgs/{org}/repos') { - return Promise.resolve([{ name: 'api' }, { name: 'web' }]); - } if (route === 'GET /orgs/{org}/properties/values' && params.repository_query) { return Promise.resolve([{ repository_name: 'web', properties: [] }]); } @@ -2629,9 +2637,6 @@ orgs: test('should warn on conflicting rule values and let later rules win', async () => { mockPaginate.mockImplementation(route => { - if (route === 'GET /orgs/{org}/repos') { - return Promise.resolve([{ name: 'api' }]); - } if (route === 'GET /orgs/{org}/properties/values') { return Promise.resolve([ { repository_name: 'api', properties: [{ property_name: 'team', value: 'frontend' }] } @@ -2669,6 +2674,27 @@ orgs: properties: [{ property_name: 'team', value: 'platform' }] }); }); + + test('should throw on schema validation failure when true_false property has non-boolean value', async () => { + mockPaginate.mockImplementation(route => { + if (route === 'GET /orgs/{org}/properties/values') { + return Promise.resolve([{ repository_name: 'api', properties: [] }]); + } + return Promise.resolve([]); + }); + mockRequest.mockResolvedValueOnce({ + data: [{ property_name: 'is-public', value_type: 'true_false', required: false }] + }); + + await expect( + syncCustomPropertyValues( + mockOctokit, + 'my-org', + [{ repositories: { names: ['api'] }, properties: [{ property_name: 'is-public', value: 'yes' }] }], + false + ) + ).rejects.toThrow('must be a boolean'); + }); }); // ─── run (integration) ───────────────────────────────────────────────── diff --git a/badges/coverage.svg b/badges/coverage.svg index 8e459e8..15b8547 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -Coverage: 84.08%Coverage84.08% \ No newline at end of file +Coverage: 84.11%Coverage84.11% \ No newline at end of file diff --git a/src/index.js b/src/index.js index 2cc03d9..cf49826 100644 --- a/src/index.js +++ b/src/index.js @@ -2291,6 +2291,10 @@ function normalizeCustomPropertyValue(value) { return null; } + if (typeof value === 'boolean') { + return value; + } + if (Array.isArray(value)) { return value.map(v => String(v)); } @@ -3812,12 +3816,10 @@ export async function syncCustomPropertyValues(octokit, org, rules, dryRun) { const subResults = []; const wouldPrefix = dryRun ? 'Would ' : ''; - let repositories; let currentEntries; let schema; try { - [repositories, currentEntries, schema] = await Promise.all([ - octokit.paginate('GET /orgs/{org}/repos', { org, type: 'all', per_page: 100 }), + [currentEntries, schema] = await Promise.all([ octokit.paginate('GET /orgs/{org}/properties/values', { org, per_page: 100 }), (async () => { const { data } = await octokit.request('GET /orgs/{org}/properties/schema', { org }); @@ -3834,7 +3836,9 @@ export async function syncCustomPropertyValues(octokit, org, rules, dryRun) { throw error; } - const repoNameMap = new Map(repositories.map(repo => [repo.name.toLowerCase(), repo.name])); + const repoNameMap = new Map( + currentEntries.map(entry => [entry.repository_name.toLowerCase(), entry.repository_name]) + ); const currentValueMap = buildCurrentPropertyValueMap(currentEntries); const schemaMap = new Map(schema.map(prop => [prop.property_name, prop])); const desiredByRepo = new Map(); @@ -4013,6 +4017,12 @@ function validateCustomPropertyValueRuleAgainstSchema(rule, schemaMap, context) throw new Error(`Custom property value ${context} property "${property.property_name}" must be an array`); } validateAllowedCustomPropertyValues(property, schema, context); + } else if (schema.value_type === 'true_false') { + if (typeof property.value !== 'boolean') { + throw new Error( + `Custom property value ${context} property "${property.property_name}" must be a boolean (true or false)` + ); + } } else { if (Array.isArray(property.value)) { throw new Error(`Custom property value ${context} property "${property.property_name}" must not be an array`); @@ -6417,7 +6427,7 @@ export async function run() { throw new Error( 'At least one setting must be specified. Provide custom properties via ' + '"organizations-file" or via "organizations" + "custom-properties-file" inputs, ' + - 'custom property values via "custom-property-values-file", ' + + 'custom property values via "organizations-file" or via "organizations" + "custom-property-values-file" inputs, ' + 'provide issue types via "issue-types-file", issue fields via "issue-fields-file", rulesets via "rulesets-file", ' + 'member privileges via individual inputs (e.g., "default-repository-permission"), ' + 'organization role team assignments via "organization-role-team-assignments-file", ' + From 59723f2462484e03e6f592fcc60eedc74fcb373a Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Wed, 20 May 2026 21:15:17 -0500 Subject: [PATCH 3/3] fix: robustness improvements for custom property values sync - Fix 1: fall back to GET /repos/{org}/{name} when a hard-selected repo is not in the /properties/values response, avoiding false 'not found' warnings for repos that have never had any property values set - Fix 2: merge desired custom-properties schema into schemaMap in dry-run mode so newly-defined properties don't cause false unknown-property validation failures when schema and values are introduced together - Fix 3: treat null/undefined and [] as equal in customPropertyValuesEqual to prevent infinite re-patching when the API returns null for an empty multi_select instead of [] - Fix 4: conflict warning now names both rule numbers and their values to make debugging multi-rule conflicts easier - Fix 5: cache /properties/values query results per-run so repeated rules sharing the same query string don't re-paginate the whole org - Bonus: aggregate all schema validation errors per rule before throwing instead of stopping at the first invalid property - Bonus: sort multi_select array values in batch grouping key so repos with semantically identical values group into the same PATCH call Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- __tests__/index.test.js | 63 +++++++++++++++++- badges/coverage.svg | 2 +- src/index.js | 139 +++++++++++++++++++++++++++++----------- 3 files changed, 163 insertions(+), 41 deletions(-) diff --git a/__tests__/index.test.js b/__tests__/index.test.js index 0d88744..ecbd55e 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -2604,6 +2604,8 @@ orgs: test('should diff values, warn on missing hard-selected repos, and patch changed repos', async () => { mockCustomPropertyValueFetches(); + // fallback GET /repos/{owner}/{repo} for 'missing' (not in /properties/values) + mockRequest.mockRejectedValueOnce(Object.assign(new Error('Not Found'), { status: 404 })); mockRequest.mockResolvedValueOnce({}); const result = await syncCustomPropertyValues(mockOctokit, 'my-org', valueRules, false); @@ -2628,11 +2630,13 @@ orgs: test('should not patch in dry-run mode', async () => { mockCustomPropertyValueFetches(); + // fallback GET /repos/{owner}/{repo} for 'missing' (not in /properties/values) + mockRequest.mockRejectedValueOnce(Object.assign(new Error('Not Found'), { status: 404 })); const result = await syncCustomPropertyValues(mockOctokit, 'my-org', valueRules, true); expect(result.subResults.some(r => r.kind === 'custom-property-value-update')).toBe(true); - expect(mockRequest).toHaveBeenCalledTimes(1); + expect(mockRequest).toHaveBeenCalledTimes(2); // schema fetch + fallback GET }); test('should warn on conflicting rule values and let later rules win', async () => { @@ -2668,6 +2672,10 @@ orgs: ); expect(result.subResults.some(r => r.kind === 'custom-property-value-conflict')).toBe(true); + // conflict warning should name both rule numbers and values + const conflictResult = result.subResults.find(r => r.kind === 'custom-property-value-conflict'); + expect(conflictResult.message).toMatch(/rule 1/); + expect(conflictResult.message).toMatch(/rule 2/); expect(mockRequest).toHaveBeenCalledWith('PATCH /orgs/{org}/properties/values', { org: 'my-org', repository_names: ['api'], @@ -2675,6 +2683,59 @@ orgs: }); }); + test('should resolve empty array as equal to null to prevent infinite diff', async () => { + mockPaginate.mockImplementation(route => { + if (route === 'GET /orgs/{org}/properties/values') { + return Promise.resolve([ + { + repository_name: 'api', + properties: [{ property_name: 'environments', value: null }] + } + ]); + } + return Promise.resolve([]); + }); + mockRequest.mockResolvedValueOnce({ + data: [{ property_name: 'environments', value_type: 'multi_select', required: false, allowed_values: [] }] + }); + + // desired = [] (empty array), current = null — should be treated as equal + const result = await syncCustomPropertyValues( + mockOctokit, + 'my-org', + [{ repositories: { names: ['api'] }, properties: [{ property_name: 'environments', value: [] }] }], + false + ); + + expect(result.failed).toBe(false); + expect(result.subResults.filter(r => r.kind === 'custom-property-value-update')).toHaveLength(0); + // PATCH should NOT be called since [] == null + expect(mockRequest).toHaveBeenCalledTimes(1); // schema only + }); + + test('should merge desired schema in dry-run to avoid false unknown-property errors', async () => { + mockPaginate.mockImplementation(route => { + if (route === 'GET /orgs/{org}/properties/values') { + return Promise.resolve([{ repository_name: 'api', properties: [] }]); + } + return Promise.resolve([]); + }); + // schema does NOT contain 'new-prop' yet + mockRequest.mockResolvedValueOnce({ data: [] }); + + const desiredProperties = [{ property_name: 'new-prop', value_type: 'string', required: false }]; + + await expect( + syncCustomPropertyValues( + mockOctokit, + 'my-org', + [{ repositories: { names: ['api'] }, properties: [{ property_name: 'new-prop', value: 'hello' }] }], + true, // dry-run + desiredProperties + ) + ).resolves.not.toThrow(); + }); + test('should throw on schema validation failure when true_false property has non-boolean value', async () => { mockPaginate.mockImplementation(route => { if (route === 'GET /orgs/{org}/properties/values') { diff --git a/badges/coverage.svg b/badges/coverage.svg index 15b8547..76b59bb 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -Coverage: 84.11%Coverage84.11% \ No newline at end of file +Coverage: 84.12%Coverage84.12% \ No newline at end of file diff --git a/src/index.js b/src/index.js index cf49826..eeedc16 100644 --- a/src/index.js +++ b/src/index.js @@ -3810,9 +3810,11 @@ const CUSTOM_PROPERTY_VALUES_BATCH_SIZE = 30; * @param {string} org - Organization name * @param {Array} rules - Custom property value rules * @param {boolean} dryRun - Preview mode + * @param {Array} [desiredProperties] - Custom property schema definitions from config; used in + * dry-run to avoid false validation failures when properties are newly defined in the same run. * @returns {Promise} Result object with subResults */ -export async function syncCustomPropertyValues(octokit, org, rules, dryRun) { +export async function syncCustomPropertyValues(octokit, org, rules, dryRun, desiredProperties = []) { const subResults = []; const wouldPrefix = dryRun ? 'Would ' : ''; @@ -3841,7 +3843,20 @@ export async function syncCustomPropertyValues(octokit, org, rules, dryRun) { ); const currentValueMap = buildCurrentPropertyValueMap(currentEntries); const schemaMap = new Map(schema.map(prop => [prop.property_name, prop])); + + // In dry-run, the schema PATCH was also skipped, so merge the desired property definitions + // into the schema map to avoid false "unknown property" validation errors when new properties + // and their values are introduced in the same run. + if (dryRun && desiredProperties.length > 0) { + for (const prop of desiredProperties) { + if (!schemaMap.has(prop.property_name)) { + schemaMap.set(prop.property_name, prop); + } + } + } + const desiredByRepo = new Map(); + const queryCache = new Map(); for (const [index, rule] of rules.entries()) { validateCustomPropertyValueRuleAgainstSchema(rule, schemaMap, `rule ${index + 1}`); @@ -3852,7 +3867,8 @@ export async function syncCustomPropertyValues(octokit, org, rules, dryRun) { rule, index + 1, repoNameMap, - subResults + subResults, + queryCache ); if (selectedRepos.length === 0) { @@ -3869,24 +3885,25 @@ export async function syncCustomPropertyValues(octokit, org, rules, dryRun) { const repoProperties = desiredByRepo.get(repoName); for (const property of rule.properties) { const existing = repoProperties.get(property.property_name); - if (existing !== undefined && !customPropertyValuesEqual(existing, property.value)) { + if (existing !== undefined && !customPropertyValuesEqual(existing.value, property.value)) { const message = - `Repository "${repoName}" property "${property.property_name}" is set by multiple rules; ` + - `using value from rule ${index + 1}`; + `Repository "${repoName}" property "${property.property_name}" is set by rule ${existing.ruleNumber} ` + + `(${formatCustomPropertyValue(existing.value)}) and rule ${index + 1} ` + + `(${formatCustomPropertyValue(property.value)}); using value from rule ${index + 1}`; core.warning(` ⚠️ ${message}`); subResults.push(createSubResult('custom-property-value-conflict', SubResultStatus.WARNING, message)); } - repoProperties.set(property.property_name, property.value); + repoProperties.set(property.property_name, { value: property.value, ruleNumber: index + 1 }); } } } const changes = []; - for (const [repoName, desiredProperties] of desiredByRepo.entries()) { + for (const [repoName, repoDesiredProperties] of desiredByRepo.entries()) { const currentProperties = currentValueMap.get(repoName.toLowerCase()) || new Map(); const changedProperties = []; - for (const [propertyName, desiredValue] of desiredProperties.entries()) { + for (const [propertyName, { value: desiredValue }] of repoDesiredProperties.entries()) { const currentValue = currentProperties.get(propertyName); if (!customPropertyValuesEqual(currentValue, desiredValue)) { changedProperties.push({ @@ -3946,10 +3963,33 @@ export async function syncCustomPropertyValues(octokit, org, rules, dryRun) { return { subResults, failed }; } -async function resolveCustomPropertyValueRuleRepositories(octokit, org, rule, ruleNumber, repoNameMap, subResults) { +async function resolveCustomPropertyValueRuleRepositories( + octokit, + org, + rule, + ruleNumber, + repoNameMap, + subResults, + queryCache = new Map() +) { const selected = new Map(); - const addHardSelectedName = name => { - const repoName = repoNameMap.get(name.toLowerCase()); + const addHardSelectedName = async name => { + let repoName = repoNameMap.get(name.toLowerCase()); + if (!repoName) { + // The /properties/values endpoint normally returns all org repos, but repos the token + // cannot see or repos that were very recently created may be absent. Do a single fallback + // lookup before warning so we don't produce false "not found" warnings. + try { + const { data: repo } = await octokit.request('GET /repos/{owner}/{repo}', { + owner: org, + repo: name + }); + repoName = repo.name; + repoNameMap.set(repoName.toLowerCase(), repoName); + } catch (lookupError) { + if (lookupError.status !== 404) throw lookupError; + } + } if (!repoName) { const message = `Repository "${name}" from custom property value rule ${ruleNumber} was not found in organization "${org}"`; core.warning(` ⚠️ ${message}`); @@ -3960,7 +4000,7 @@ async function resolveCustomPropertyValueRuleRepositories(octokit, org, rule, ru }; for (const name of rule.repositories.names) { - addHardSelectedName(name); + await addHardSelectedName(name); } if (rule.repositories.namesFile) { @@ -3971,16 +4011,22 @@ async function resolveCustomPropertyValueRuleRepositories(octokit, org, rule, ru subResults.push(createSubResult('custom-property-value-select', SubResultStatus.WARNING, message)); } for (const name of fileNames) { - addHardSelectedName(name); + await addHardSelectedName(name); } } if (rule.repositories.query) { - const matchingEntries = await octokit.paginate('GET /orgs/{org}/properties/values', { - org, - repository_query: rule.repositories.query, - per_page: 100 - }); + let matchingEntries; + if (queryCache.has(rule.repositories.query)) { + matchingEntries = queryCache.get(rule.repositories.query); + } else { + matchingEntries = await octokit.paginate('GET /orgs/{org}/properties/values', { + org, + repository_query: rule.repositories.query, + per_page: 100 + }); + queryCache.set(rule.repositories.query, matchingEntries); + } if (matchingEntries.length === 0) { const message = `Repository query "${rule.repositories.query}" in custom property value rule ${ruleNumber} matched no repositories`; @@ -3999,51 +4045,58 @@ async function resolveCustomPropertyValueRuleRepositories(octokit, org, rule, ru } function validateCustomPropertyValueRuleAgainstSchema(rule, schemaMap, context) { + const errors = []; for (const property of rule.properties) { const schema = schemaMap.get(property.property_name); if (!schema) { - throw new Error(`Custom property value ${context} references unknown property "${property.property_name}"`); + errors.push(`Custom property value ${context} references unknown property "${property.property_name}"`); + continue; } if (property.value === null) { if (schema.required) { - throw new Error(`Custom property value ${context} cannot unset required property "${property.property_name}"`); + errors.push(`Custom property value ${context} cannot unset required property "${property.property_name}"`); } continue; } if (schema.value_type === 'multi_select') { if (!Array.isArray(property.value)) { - throw new Error(`Custom property value ${context} property "${property.property_name}" must be an array`); + errors.push(`Custom property value ${context} property "${property.property_name}" must be an array`); + } else { + const err = getCustomPropertyAllowedValuesError(property, schema, context); + if (err) errors.push(err); } - validateAllowedCustomPropertyValues(property, schema, context); } else if (schema.value_type === 'true_false') { if (typeof property.value !== 'boolean') { - throw new Error( + errors.push( `Custom property value ${context} property "${property.property_name}" must be a boolean (true or false)` ); } } else { if (Array.isArray(property.value)) { - throw new Error(`Custom property value ${context} property "${property.property_name}" must not be an array`); + errors.push(`Custom property value ${context} property "${property.property_name}" must not be an array`); + } else { + const err = getCustomPropertyAllowedValuesError(property, schema, context); + if (err) errors.push(err); } - validateAllowedCustomPropertyValues(property, schema, context); } } + if (errors.length > 0) { + throw new Error(errors.join('; ')); + } } -function validateAllowedCustomPropertyValues(property, schema, context) { +function getCustomPropertyAllowedValuesError(property, schema, context) { if (!Array.isArray(schema.allowed_values) || schema.allowed_values.length === 0) { - return; + return null; } - const values = Array.isArray(property.value) ? property.value : [property.value]; const invalid = values.filter(value => !schema.allowed_values.includes(value)); if (invalid.length > 0) { - throw new Error( - `Custom property value ${context} property "${property.property_name}" has value(s) not in allowed_values: ${invalid.join(', ')}` - ); + return `Custom property value ${context} property "${property.property_name}" has value(s) not in allowed_values: ${invalid.join(', ')}`; } + return null; } function buildCurrentPropertyValueMap(currentEntries) { @@ -4061,12 +4114,9 @@ function buildCurrentPropertyValueMap(currentEntries) { } function customPropertyValuesEqual(a, b) { - if (a === undefined || a === null) { - return b === undefined || b === null; - } - if (b === undefined || b === null) { - return false; - } + const isEmpty = v => v === undefined || v === null || (Array.isArray(v) && v.length === 0); + if (isEmpty(a) && isEmpty(b)) return true; + if (isEmpty(a) || isEmpty(b)) return false; if (Array.isArray(a) || Array.isArray(b)) { if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) { return false; @@ -4081,7 +4131,12 @@ function customPropertyValuesEqual(a, b) { function groupCustomPropertyValueChanges(changes) { const groups = new Map(); for (const change of changes) { - const properties = [...change.properties].sort((a, b) => a.property_name.localeCompare(b.property_name)); + const properties = [...change.properties] + .sort((a, b) => a.property_name.localeCompare(b.property_name)) + .map(p => ({ + property_name: p.property_name, + value: Array.isArray(p.value) ? [...p.value].sort() : p.value + })); const key = JSON.stringify(properties); if (!groups.has(key)) { groups.set(key, { properties, repositoryNames: [] }); @@ -6519,7 +6574,13 @@ export async function run() { // Sync custom property values after schema sync so newly-defined properties can be assigned. if (orgConfig.customPropertyValues && orgConfig.customPropertyValues.length > 0) { core.info(` 🏷️ Syncing custom property values (${orgConfig.customPropertyValues.length} rule(s))...`); - const cpvResult = await syncCustomPropertyValues(octokit, org, orgConfig.customPropertyValues, dryRun); + const cpvResult = await syncCustomPropertyValues( + octokit, + org, + orgConfig.customPropertyValues, + dryRun, + orgConfig.customProperties || [] + ); result.subResults.push(...cpvResult.subResults); if (cpvResult.failed) {