Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 117 additions & 17 deletions README.md

Large diffs are not rendered by default.

274 changes: 274 additions & 0 deletions __tests__/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -207,9 +209,12 @@ const {
parseOrganizations,
parseOrganizationsFile,
parseCustomPropertiesFile,
parseCustomPropertyValuesFile,
normalizeCustomPropertyValueRules,
normalizeCustomProperties,
compareCustomProperty,
syncCustomProperties,
syncCustomPropertyValues,
parseIssueTypesFile,
normalizeIssueTypes,
compareIssueType,
Expand Down Expand Up @@ -1508,6 +1513,65 @@ 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');
});

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 ───────────────────────────────────────────

describe('mergeCustomProperties', () => {
Expand Down Expand Up @@ -2484,6 +2548,216 @@ 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}/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();
// 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);

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();
// 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(2); // schema fetch + fallback GET
});

test('should warn on conflicting rule values and let later rules win', async () => {
mockPaginate.mockImplementation(route => {
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);
// 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'],
properties: [{ property_name: 'team', value: 'platform' }]
});
});

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') {
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) ─────────────────────────────────────────────────

describe('Action execution', () => {
Expand Down
3 changes: 3 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion badges/coverage.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
24 changes: 24 additions & 0 deletions sample-configuration/custom-property-values.yml
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions sample-configuration/orgs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down
Loading