diff --git a/README.md b/README.md
index 8ee99b4..1aa6692 100644
--- a/README.md
+++ b/README.md
@@ -18,6 +18,7 @@ Please refer to the [release page](https://github.com/joshjohanning/bulk-github-
- 🏷️ Sync custom property definitions across organizations
- 📋 Sync organization-level rulesets across organizations
- 🏷️ Sync issue type definitions across organizations
+- 🧩 Sync issue field definitions across organizations
- 🔧 Sync member privileges and repository policies across organizations
- 📁 Sync `.github` and `.github-private` repository files across organizations (via PR)
- 🔒 Sync code security configurations across organizations
@@ -76,6 +77,7 @@ For stronger security and higher rate limits, use a GitHub App:
- **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)
+ - **Issue fields**: Write (required for managing issue field definitions)
**Repository permissions** _(only required for `.github`/`.github-private` repo sync)_:
- **Contents**: Read and write
@@ -189,7 +191,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`, `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`, `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/'
@@ -265,6 +267,7 @@ The file-based form lets you keep per-org config in separate files while still u
| ---------------------------------- | ------------------------------------ | ----------------------------------------- | -------------------------- |
| 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 |
@@ -286,7 +289,7 @@ For features that support both forms, precedence is:
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`, `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 **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`.
### Merge vs replace
@@ -664,6 +667,111 @@ By default, syncing issue types will create or update the specified types, but w
---
+## Syncing Issue Fields
+
+Sync organization-level issue field definitions across organizations. Issue fields let you add structured metadata (for example priority, effort, target date) to issues.
+
+> [!TIP]
+> 📄 **See full example:** [sample-configuration/issue-fields.yml](sample-configuration/issue-fields.yml)
+
+Create an `issue-fields.yml` file:
+
+```yaml
+- name: Priority
+ description: 'Issue priority'
+ data-type: single_select
+ visibility: organization_members_only
+ options:
+ - name: Urgent
+ color: red
+ priority: 1
+ - name: High
+ color: orange
+ priority: 2
+ - name: Medium
+ color: yellow
+ priority: 3
+ - name: Low
+ color: green
+ priority: 4
+
+- name: Target date
+ description: 'Target completion date'
+ data-type: date
+```
+
+Use in 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'
+ issue-fields-file: './issue-fields.yml'
+```
+
+**Behavior:**
+
+- If an issue field with the same name doesn't exist, it is created
+- If it exists but differs from the config, it is updated
+- If content is identical, no changes are made
+- With `delete-unmanaged-issue-fields: true`, issue fields not in the config are deleted
+
+### Issue Field Fields
+
+Each issue field supports these fields:
+
+| Field | Description | Required | Default |
+| ------------- | ---------------------------------------------------------------------------------- | ----------- | ----------- |
+| `name` | Issue field name | Yes | |
+| `description` | Human-readable description | No | |
+| `data-type` | Field type: `text`, `date`, `single_select`, `number` | Yes | |
+| `visibility` | Field visibility: `organization_members_only` or `all` | No | API default |
+| `options` | Required for `single_select`; list of options with `name`, `color`, and `priority` | Conditional | |
+
+### Per-Org Issue Fields Override
+
+In `orgs.yml`, override issue fields per org either inline or by pointing at a different file. Per-org issue fields are merged with the base by `name`; see [Per-Org Overrides: Inline vs File-Based](#per-org-overrides-inline-vs-file-based) for precedence and merge rules.
+
+```yaml
+orgs:
+ - org: my-org
+ # inherits base issue-fields-file from action input
+
+ - org: inline-org
+ issue-fields: # inline override (merges with base by name)
+ - name: Priority
+ data-type: single_select
+ options:
+ - name: Critical
+ color: red
+ priority: 1
+ - name: Normal
+ color: yellow
+ priority: 2
+ delete-unmanaged-issue-fields: true
+
+ - org: file-based-org
+ issue-fields-file: './config/issue-fields/file-based-org.yml' # file-based override
+```
+
+### Delete Unmanaged Issue Fields
+
+By default, syncing issue fields will create or update the specified fields, but will not delete other issue fields that may exist in the organization. To delete all other issue fields not defined in the config, use `delete-unmanaged-issue-fields`:
+
+```yml
+- name: Sync Organization Settings
+ uses: joshjohanning/bulk-github-org-settings-sync-action@v1
+ with:
+ github-token: ${{ secrets.ORG_ADMIN_TOKEN }}
+ organizations: 'my-org'
+ issue-fields-file: './issue-fields.yml'
+ delete-unmanaged-issue-fields: true
+```
+
+---
+
## Syncing Custom Organization Roles
> [!IMPORTANT]
@@ -1249,6 +1357,8 @@ orgs:
| `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` |
+| `issue-fields-file` | Path to a YAML file defining issue field definitions | No | |
+| `delete-unmanaged-issue-fields` | Delete issue fields not defined in the configuration file | No | `false` |
| `default-repository-permission` | Default permission for org members: `read`, `write`, `admin`, `none` | No | |
| `members-can-create-repositories` | Whether members can create repositories | No | |
| `members-can-create-public-repositories` | Whether members can create public repositories | No | |
@@ -1300,7 +1410,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`, `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. 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`, `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 7cb3f44..247d795 100644
--- a/__tests__/index.test.js
+++ b/__tests__/index.test.js
@@ -43,6 +43,10 @@ inputs:
description: 'Issue types file'
delete-unmanaged-issue-types:
description: 'Delete unmanaged issue types'
+ issue-fields-file:
+ description: 'Issue fields file'
+ delete-unmanaged-issue-fields:
+ description: 'Delete unmanaged issue fields'
default-repository-permission:
description: 'Default permission level'
members-can-create-repositories:
@@ -211,6 +215,11 @@ const {
compareIssueType,
syncIssueTypes,
mergeIssueTypes,
+ parseIssueFieldsFile,
+ normalizeIssueFields,
+ compareIssueField,
+ syncIssueFields,
+ mergeIssueFields,
syncOrgRulesets,
mergeCustomProperties,
mergeMemberPrivileges,
@@ -410,6 +419,34 @@ describe('Bulk GitHub Organization Settings Sync Action', () => {
expect(mockCore.warning).not.toHaveBeenCalled();
});
+ test('should warn for unknown issue field key', () => {
+ validateOrgConfig(
+ {
+ org: 'my-org',
+ 'issue-fields': [{ name: 'Priority', 'data-type': 'single_select', optins: [] }]
+ },
+ 'my-org'
+ );
+ expect(mockCore.warning).toHaveBeenCalledWith(expect.stringContaining('Unknown issue field key "optins"'));
+ });
+
+ test('should not warn for valid issue field keys', () => {
+ validateOrgConfig(
+ {
+ org: 'my-org',
+ 'issue-fields': [
+ {
+ name: 'Priority',
+ 'data-type': 'single_select',
+ options: [{ name: 'High', color: 'red', priority: 1 }]
+ }
+ ]
+ },
+ 'my-org'
+ );
+ expect(mockCore.warning).not.toHaveBeenCalled();
+ });
+
test('should warn when action.yml cannot be read', () => {
resetKnownOrgConfigKeysCache();
mockFs.readFileSync.mockImplementation(filePath => {
@@ -1023,6 +1060,101 @@ describe('Bulk GitHub Organization Settings Sync Action', () => {
expect(bugType.description).toBe('A serious bug');
expect(bugType.color).toBe('0000ff');
});
+
+ test('should parse organizations with issue-fields-file', () => {
+ const ifYaml = `- name: Priority
+ data-type: single_select
+ options:
+ - name: High
+ color: red
+ priority: 1
+ - name: Medium
+ color: yellow
+ priority: 2
+`;
+ setMockFileContent(ifYaml, '/mock/issue-fields.yml');
+ const result = parseOrganizations(
+ 'my-org',
+ '',
+ '',
+ [],
+ false,
+ '',
+ null,
+ '',
+ '',
+ null,
+ '',
+ null,
+ '',
+ '',
+ '',
+ '',
+ '/mock/issue-fields.yml'
+ );
+
+ expect(result).toHaveLength(1);
+ expect(result[0].org).toBe('my-org');
+ expect(result[0].issueFields).toBeDefined();
+ expect(result[0].issueFields.length).toBe(1);
+ expect(result[0].issueFields[0].name).toBe('Priority');
+ });
+
+ test('should merge base issue-fields-file with per-org overrides in organizations-file', () => {
+ const orgsYaml = `orgs:
+ - org: my-org
+ - org: my-other-org
+ issue-fields:
+ - name: Priority
+ data-type: single_select
+ options:
+ - name: Critical
+ color: red
+ priority: 1
+ - name: Normal
+ color: yellow
+ priority: 2
+`;
+ const ifYaml = `- name: Priority
+ data-type: single_select
+ options:
+ - name: High
+ color: red
+ priority: 1
+ - name: Medium
+ color: yellow
+ priority: 2
+- name: Target date
+ data-type: date
+`;
+ setMockFileContent(orgsYaml, '/mock/orgs.yml');
+ setMockFileContent(ifYaml, '/mock/issue-fields.yml');
+ const result = parseOrganizations(
+ '',
+ '/mock/orgs.yml',
+ '',
+ [],
+ false,
+ '',
+ null,
+ '',
+ '',
+ null,
+ '',
+ null,
+ '',
+ '',
+ '',
+ '',
+ '/mock/issue-fields.yml'
+ );
+
+ expect(result).toHaveLength(2);
+ expect(result[0].issueFields.length).toBe(2);
+ expect(result[1].issueFields.length).toBe(2);
+ const priorityField = result[1].issueFields.find(field => field.name === 'Priority');
+ expect(priorityField.options[0].name).toBe('Critical');
+ });
});
// ─── parseOrganizationsFile ─────────────────────────────────────────────
@@ -1868,6 +2000,303 @@ orgs:
});
});
+ // ─── normalizeIssueFields ────────────────────────────────────────────────
+
+ describe('normalizeIssueFields', () => {
+ test('should normalize a single_select issue field', () => {
+ const result = normalizeIssueFields([
+ {
+ name: 'Priority',
+ 'data-type': 'single_select',
+ description: 'Priority level',
+ visibility: 'all',
+ options: [
+ { name: 'High', color: 'red', priority: 1 },
+ { name: 'Medium', color: 'yellow', priority: 2 }
+ ]
+ }
+ ]);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].name).toBe('Priority');
+ expect(result[0].data_type).toBe('single_select');
+ expect(result[0].options).toHaveLength(2);
+ expect(result[0].visibility).toBe('all');
+ });
+
+ test('should throw when data-type is missing', () => {
+ expect(() => normalizeIssueFields([{ name: 'Priority' }])).toThrow('must have a non-empty "data-type" field');
+ });
+
+ test('should throw for invalid option color', () => {
+ expect(() =>
+ normalizeIssueFields([
+ {
+ name: 'Priority',
+ 'data-type': 'single_select',
+ options: [{ name: 'High', color: 'black' }]
+ }
+ ])
+ ).toThrow('has invalid color');
+ });
+ });
+
+ // ─── compareIssueField ──────────────────────────────────────────────────
+
+ describe('compareIssueField', () => {
+ test('should detect no changes for identical issue fields', () => {
+ const existing = {
+ name: 'Priority',
+ description: 'Priority level',
+ data_type: 'single_select',
+ visibility: 'organization_members_only',
+ options: [
+ { name: 'High', description: null, color: 'red', priority: 1 },
+ { name: 'Medium', description: null, color: 'yellow', priority: 2 }
+ ]
+ };
+
+ const desired = {
+ name: 'Priority',
+ description: 'Priority level',
+ data_type: 'single_select',
+ visibility: 'organization_members_only',
+ options: [
+ { name: 'High', description: null, color: 'red', priority: 1 },
+ { name: 'Medium', description: null, color: 'yellow', priority: 2 }
+ ]
+ };
+
+ const { changed, incompatibleTypeChange } = compareIssueField(existing, desired);
+ expect(changed).toBe(false);
+ expect(incompatibleTypeChange).toBe(false);
+ });
+
+ test('should detect data_type changes as incompatible', () => {
+ const existing = { name: 'Priority', description: null, data_type: 'text' };
+ const desired = { name: 'Priority', description: null, data_type: 'single_select', options: [] };
+
+ const { changed, incompatibleTypeChange } = compareIssueField(existing, desired);
+ expect(changed).toBe(true);
+ expect(incompatibleTypeChange).toBe(true);
+ });
+
+ test('should ignore single_select option ordering when options are semantically identical', () => {
+ const existing = {
+ name: 'Priority',
+ description: 'Priority level',
+ data_type: 'single_select',
+ visibility: 'organization_members_only',
+ options: [
+ { name: 'Medium', description: null, color: 'yellow', priority: 2 },
+ { name: 'High', description: null, color: 'red', priority: 1 }
+ ]
+ };
+
+ const desired = {
+ name: 'Priority',
+ description: 'Priority level',
+ data_type: 'single_select',
+ visibility: 'organization_members_only',
+ options: [
+ { name: 'High', description: null, color: 'red', priority: 1 },
+ { name: 'Medium', description: null, color: 'yellow', priority: 2 }
+ ]
+ };
+
+ const { changed, incompatibleTypeChange } = compareIssueField(existing, desired);
+ expect(changed).toBe(false);
+ expect(incompatibleTypeChange).toBe(false);
+ });
+ });
+
+ // ─── parseIssueFieldsFile ───────────────────────────────────────────────
+
+ describe('parseIssueFieldsFile', () => {
+ test('should throw for missing file', () => {
+ expect(() => parseIssueFieldsFile('/nonexistent/file.yml')).toThrow('not found');
+ });
+
+ test('should parse the issue fields file', () => {
+ const ifYaml = `- name: Priority
+ data-type: single_select
+ options:
+ - name: High
+ color: red
+ priority: 1
+ - name: Medium
+ color: yellow
+ priority: 2
+- name: Target date
+ data-type: date
+`;
+ setMockFileContent(ifYaml, '/mock/issue-fields.yml');
+ const result = parseIssueFieldsFile('/mock/issue-fields.yml');
+
+ expect(result).toHaveLength(2);
+ expect(result[0].name).toBe('Priority');
+ expect(result[0].data_type).toBe('single_select');
+ expect(result[1].data_type).toBe('date');
+ });
+ });
+
+ // ─── mergeIssueFields ───────────────────────────────────────────────────
+
+ describe('mergeIssueFields', () => {
+ test('should merge and override by name', () => {
+ const base = [{ name: 'Priority', data_type: 'text', description: null, visibility: null, options: null }];
+ const overrides = [
+ {
+ name: 'Priority',
+ data_type: 'single_select',
+ description: null,
+ visibility: null,
+ options: [{ name: 'High', description: null, color: 'red', priority: 1 }]
+ }
+ ];
+
+ const result = mergeIssueFields(base, overrides);
+ expect(result).toHaveLength(1);
+ expect(result[0].data_type).toBe('single_select');
+ });
+ });
+
+ // ─── syncIssueFields ────────────────────────────────────────────────────
+
+ describe('syncIssueFields', () => {
+ const desiredIssueFields = [
+ {
+ name: 'Priority',
+ description: 'Priority level',
+ data_type: 'single_select',
+ visibility: 'organization_members_only',
+ options: [
+ { name: 'High', description: null, color: 'red', priority: 1 },
+ { name: 'Medium', description: null, color: 'yellow', priority: 2 }
+ ]
+ }
+ ];
+
+ test('should create a new issue field when none exist', async () => {
+ mockRequest.mockResolvedValueOnce({ data: [] });
+ mockRequest.mockResolvedValueOnce({ data: { id: 1, name: 'Priority' } });
+
+ const result = await syncIssueFields(mockOctokit, 'my-org', desiredIssueFields, false, false);
+
+ expect(result.subResults).toHaveLength(1);
+ expect(result.subResults[0].kind).toBe('issue-field-create');
+ expect(mockRequest).toHaveBeenCalledWith(
+ 'POST /orgs/{org}/issue-fields',
+ expect.objectContaining({ org: 'my-org', name: 'Priority', data_type: 'single_select' })
+ );
+ });
+
+ test('should preserve option ids when updating single_select options', async () => {
+ mockRequest.mockResolvedValueOnce({
+ data: [
+ {
+ id: 1,
+ name: 'Priority',
+ description: 'Priority level',
+ data_type: 'single_select',
+ visibility: 'organization_members_only',
+ options: [
+ { id: 11, name: 'High', description: null, color: 'red', priority: 1 },
+ { id: 12, name: 'Medium', description: null, color: 'yellow', priority: 2 }
+ ]
+ }
+ ]
+ });
+ mockRequest.mockResolvedValueOnce({ data: {} });
+
+ const desired = [
+ {
+ ...desiredIssueFields[0],
+ options: [
+ { name: 'High', description: null, color: 'orange', priority: 1 },
+ { name: 'Medium', description: null, color: 'yellow', priority: 2 }
+ ]
+ }
+ ];
+ await syncIssueFields(mockOctokit, 'my-org', desired, false, false);
+
+ expect(mockRequest).toHaveBeenCalledWith(
+ 'PATCH /orgs/{org}/issue-fields/{issue_field_id}',
+ expect.objectContaining({
+ org: 'my-org',
+ issue_field_id: 1,
+ options: [
+ expect.objectContaining({ id: 11, name: 'High', color: 'orange', priority: 1 }),
+ expect.objectContaining({ id: 12, name: 'Medium', color: 'yellow', priority: 2 })
+ ]
+ })
+ );
+ });
+
+ test('should skip issue fields with permission warning on 404 GET', async () => {
+ const error404 = new Error('Not Found');
+ error404.status = 404;
+ mockRequest.mockRejectedValueOnce(error404);
+
+ const result = await syncIssueFields(mockOctokit, 'my-org', desiredIssueFields, false, false);
+
+ expect(result.subResults).toHaveLength(1);
+ expect(result.subResults[0].kind).toBe('issue-field-fetch');
+ expect(result.subResults[0].status).toBe('warning');
+ expect(result.failed).toBe(false);
+ expect(mockRequest).toHaveBeenCalledTimes(1);
+ });
+
+ test('should fail update when data_type changes', async () => {
+ mockRequest.mockResolvedValueOnce({
+ data: [{ id: 1, name: 'Priority', description: null, data_type: 'text' }]
+ });
+ const desired = [
+ { name: 'Priority', description: null, data_type: 'single_select', visibility: null, options: [] }
+ ];
+
+ const result = await syncIssueFields(mockOctokit, 'my-org', desired, false, false);
+
+ expect(result.subResults).toHaveLength(1);
+ expect(result.subResults[0].kind).toBe('issue-field-update');
+ expect(result.subResults[0].status).toBe('warning');
+ expect(result.failed).toBe(true);
+ expect(mockRequest).toHaveBeenCalledTimes(1);
+ });
+
+ test('should not send options when only non-option fields change', async () => {
+ mockRequest.mockResolvedValueOnce({
+ data: [
+ {
+ id: 1,
+ name: 'Priority',
+ description: 'Old description',
+ data_type: 'single_select',
+ visibility: 'organization_members_only',
+ options: [
+ { id: 12, name: 'Medium', description: null, color: 'yellow', priority: 2 },
+ { id: 11, name: 'High', description: null, color: 'red', priority: 1 }
+ ]
+ }
+ ]
+ });
+ mockRequest.mockResolvedValueOnce({ data: {} });
+
+ const desired = [{ ...desiredIssueFields[0], description: 'New description' }];
+ await syncIssueFields(mockOctokit, 'my-org', desired, false, false);
+
+ expect(mockRequest).toHaveBeenCalledWith(
+ 'PATCH /orgs/{org}/issue-fields/{issue_field_id}',
+ expect.objectContaining({
+ org: 'my-org',
+ issue_field_id: 1,
+ description: 'New description'
+ })
+ );
+ expect(mockRequest.mock.calls[1][1]).not.toHaveProperty('options');
+ });
+ });
+
// ─── syncCustomProperties ──────────────────────────────────────────────
describe('syncCustomProperties', () => {
@@ -2534,6 +2963,54 @@ orgs:
expect(mockCore.setOutput).toHaveBeenCalledWith('changed-organizations', '1');
});
+ test('should process organizations with issue-fields-file input', async () => {
+ const ifYaml = `- name: Priority
+ data-type: single_select
+ options:
+ - name: High
+ color: red
+ priority: 1
+ - name: Medium
+ color: yellow
+ priority: 2
+`;
+ setMockFileContent(ifYaml, '/mock/issue-fields.yml');
+
+ mockCore.getInput.mockImplementation(name => {
+ const inputs = {
+ 'github-token': 'test-token',
+ 'github-api-url': 'https://api.github.com',
+ organizations: 'my-org',
+ 'organizations-file': '',
+ 'custom-properties-file': '',
+ 'issue-fields-file': '/mock/issue-fields.yml',
+ 'issue-types-file': '',
+ 'rulesets-file': '',
+ 'delete-unmanaged-properties': 'false',
+ 'delete-unmanaged-issue-fields': 'false',
+ 'delete-unmanaged-rulesets': 'false',
+ 'dry-run': 'true'
+ };
+ return inputs[name] ?? '';
+ });
+ mockCore.getBooleanInput.mockImplementation(name => {
+ if (name === 'dry-run') return true;
+ if (name === 'delete-unmanaged-properties') return false;
+ if (name === 'delete-unmanaged-issue-fields') return false;
+ if (name === 'delete-unmanaged-rulesets') return false;
+ return false;
+ });
+
+ // Mock: no existing issue fields
+ mockRequest.mockResolvedValueOnce({ data: [] });
+
+ await run();
+
+ expect(mockCore.setFailed).not.toHaveBeenCalled();
+ expect(mockCore.setOutput).toHaveBeenCalledWith('updated-organizations', '1');
+ expect(mockCore.setOutput).toHaveBeenCalledWith('changed-organizations', '1');
+ });
+
test('should allow empty custom org roles file when delete-unmanaged-org-roles is enabled', async () => {
setMockFileContent('[]', '/mock/custom-org-roles.yml');
diff --git a/action.yml b/action.yml
index 01eca02..071e79b 100644
--- a/action.yml
+++ b/action.yml
@@ -41,6 +41,15 @@ inputs:
required: false
default: 'false'
+ # === Issue Fields ===
+ issue-fields-file:
+ description: 'Path to a YAML file defining issue field definitions to sync to target organizations'
+ required: false
+ delete-unmanaged-issue-fields:
+ description: 'Delete issue fields not defined in the configuration file'
+ required: false
+ default: 'false'
+
# === Organization Profile ===
org-name:
description: 'Organization display name'
diff --git a/badges/coverage.svg b/badges/coverage.svg
index b693877..c9dff47 100644
--- a/badges/coverage.svg
+++ b/badges/coverage.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index f9c154b..ea9cd17 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "bulk-github-org-settings-sync-action",
- "version": "1.11.0",
+ "version": "1.12.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulk-github-org-settings-sync-action",
- "version": "1.11.0",
+ "version": "1.12.0",
"license": "MIT",
"dependencies": {
"@actions/core": "^3.0.1",
diff --git a/package.json b/package.json
index b30457c..838e835 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.11.0",
+ "version": "1.12.0",
"type": "module",
"author": {
"name": "Josh Johanning",
@@ -24,6 +24,7 @@
"settings",
"custom-properties",
"issue-types",
+ "issue-fields",
"rulesets",
"javascript",
"node-action"
diff --git a/sample-configuration/issue-fields.yml b/sample-configuration/issue-fields.yml
new file mode 100644
index 0000000..890d6bf
--- /dev/null
+++ b/sample-configuration/issue-fields.yml
@@ -0,0 +1,25 @@
+- name: Priority
+ description: 'Issue priority level'
+ data-type: single_select
+ visibility: organization_members_only
+ options:
+ - name: Urgent
+ color: red
+ priority: 1
+ - name: High
+ color: orange
+ priority: 2
+ - name: Medium
+ color: yellow
+ priority: 3
+ - name: Low
+ color: green
+ priority: 4
+
+- name: Effort
+ description: 'Estimated effort'
+ data-type: number
+
+- name: Target date
+ description: 'Target completion date'
+ data-type: date
diff --git a/sample-configuration/orgs.yml b/sample-configuration/orgs.yml
index 5d7c8f1..4486dab 100644
--- a/sample-configuration/orgs.yml
+++ b/sample-configuration/orgs.yml
@@ -30,6 +30,7 @@ orgs:
# --- Per-org boolean overrides ---
# delete-unmanaged-properties: true
# delete-unmanaged-issue-types: true
+ # delete-unmanaged-issue-fields: true
# delete-unmanaged-rulesets: true
# delete-unmanaged-org-roles: true
# delete-unmanaged-repo-roles: true
@@ -57,6 +58,18 @@ orgs:
- name: Task
description: 'A unit of work'
color: 'fbca04'
+ issue-fields:
+ - name: Priority
+ data-type: single_select
+ options:
+ - name: Critical
+ color: red
+ priority: 1
+ - name: Normal
+ color: yellow
+ priority: 2
+ - name: Target date
+ data-type: date
custom-org-roles:
- name: Security Auditor
description: 'Override for this org'
@@ -109,6 +122,7 @@ orgs:
- org: my-file-based-org
custom-properties-file: './config/custom-properties/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'
custom-repo-roles-file: './config/custom-repo-roles/file-based-org.yml'
code-security-configurations-file: './config/code-security/file-based-org.yml'
diff --git a/src/index.js b/src/index.js
index 375107d..b35c82a 100644
--- a/src/index.js
+++ b/src/index.js
@@ -45,6 +45,7 @@ function getKnownOrgConfigKeys() {
// 'org' is the organization identifier in YAML config
// 'custom-properties' is inline property definitions (YAML-only, not an action input)
// 'issue-types' is inline issue type definitions (YAML-only, not an action input)
+ // 'issue-fields' is inline issue field definitions (YAML-only, not an action input)
// 'member-privileges' is inline member privilege overrides (YAML-only, not an action input)
// 'custom-org-roles' is inline org role definitions (YAML-only, not an action input)
// 'custom-repo-roles' is inline repo role definitions (YAML-only, not an action input)
@@ -55,6 +56,7 @@ function getKnownOrgConfigKeys() {
'org',
'custom-properties',
'issue-types',
+ 'issue-fields',
'member-privileges',
'custom-org-roles',
'custom-repo-roles',
@@ -128,6 +130,11 @@ const KNOWN_CUSTOM_PROPERTY_KEYS = new Set([
* Used to warn about typos or unknown keys.
*/
const KNOWN_ISSUE_TYPE_KEYS = new Set(['name', 'description', 'color', 'is-enabled']);
+const KNOWN_ISSUE_FIELD_KEYS = new Set(['name', 'description', 'data-type', 'data_type', 'visibility', 'options']);
+const KNOWN_ISSUE_FIELD_OPTION_KEYS = new Set(['name', 'description', 'color', 'priority']);
+const ISSUE_FIELD_DATA_TYPES = new Set(['text', 'date', 'single_select', 'number']);
+const ISSUE_FIELD_VISIBILITIES = new Set(['organization_members_only', 'all']);
+const ISSUE_FIELD_OPTION_COLORS = new Set(['gray', 'blue', 'green', 'yellow', 'orange', 'red', 'pink', 'purple']);
const ISSUE_TYPE_NAMED_COLORS = ['gray', 'blue', 'green', 'yellow', 'orange', 'red', 'pink', 'purple'];
const ISSUE_TYPE_NAMED_COLOR_SET = new Set(ISSUE_TYPE_NAMED_COLORS);
const ISSUE_TYPE_HEX_COLOR_REGEX = /^[0-9a-f]{6}$/;
@@ -146,6 +153,7 @@ const KNOWN_CUSTOM_REPO_ROLE_KEYS = new Set(['name', 'description', 'base-role',
// TODO(v2): remove legacy hyphenated aliases and require GitHub API-style underscore keys.
const YAML_KEY_ALIASES = Object.freeze({
+ data_type: 'data-type',
value_type: 'value-type',
default_value: 'default-value',
allowed_values: 'allowed-values',
@@ -250,6 +258,9 @@ const ORG_CONFIG_TOP_LEVEL_KEYS = new Set([
'issue-types',
'issue-types-file',
'delete-unmanaged-issue-types',
+ 'issue-fields',
+ 'issue-fields-file',
+ 'delete-unmanaged-issue-fields',
'organization-role-team-assignments',
'organization-role-team-assignments-file',
'rulesets-file',
@@ -355,6 +366,37 @@ export function validateOrgConfig(orgConfig, orgName) {
}
}
+ // Validate issue field keys if present
+ if (Array.isArray(orgConfig['issue-fields'])) {
+ for (const issueField of orgConfig['issue-fields']) {
+ if (typeof issueField !== 'object' || issueField === null) continue;
+ const fieldName = issueField.name || '(unnamed)';
+ for (const key of Object.keys(issueField)) {
+ if (!KNOWN_ISSUE_FIELD_KEYS.has(key)) {
+ core.warning(
+ `⚠️ Unknown issue field key "${key}" found for issue field "${fieldName}" in organization "${orgName}". ` +
+ `This key may not exist or may have a typo.`
+ );
+ }
+ }
+
+ if (Array.isArray(issueField.options)) {
+ for (const option of issueField.options) {
+ if (typeof option !== 'object' || option === null) continue;
+ const optionName = option.name || '(unnamed)';
+ for (const key of Object.keys(option)) {
+ if (!KNOWN_ISSUE_FIELD_OPTION_KEYS.has(key)) {
+ core.warning(
+ `⚠️ Unknown issue field option key "${key}" found for option "${optionName}" in issue field "${fieldName}" for organization "${orgName}". ` +
+ `This key may not exist or may have a typo.`
+ );
+ }
+ }
+ }
+ }
+ }
+ }
+
// Validate delete-unmanaged-properties value if present
if (Object.prototype.hasOwnProperty.call(orgConfig, 'delete-unmanaged-properties')) {
const val = orgConfig['delete-unmanaged-properties'];
@@ -388,6 +430,17 @@ export function validateOrgConfig(orgConfig, orgName) {
}
}
+ // Validate delete-unmanaged-issue-fields value if present
+ if (Object.prototype.hasOwnProperty.call(orgConfig, 'delete-unmanaged-issue-fields')) {
+ const val = orgConfig['delete-unmanaged-issue-fields'];
+ if (typeof val !== 'boolean') {
+ core.warning(
+ `⚠️ Invalid "delete-unmanaged-issue-fields" value for organization "${orgName}": ` +
+ `expected true or false, got "${val}". This setting will be ignored.`
+ );
+ }
+ }
+
// Validate delete-unmanaged-org-roles value if present
if (Object.prototype.hasOwnProperty.call(orgConfig, 'delete-unmanaged-org-roles')) {
const val = orgConfig['delete-unmanaged-org-roles'];
@@ -435,6 +488,7 @@ export function validateOrgConfig(orgConfig, orgName) {
const FILE_PATH_CONFIG_KEYS = [
'custom-properties-file',
'issue-types-file',
+ 'issue-fields-file',
'rulesets-file',
'custom-org-roles-file',
'custom-repo-roles-file',
@@ -525,6 +579,10 @@ const SYNC_KIND_LABELS = Object.freeze({
'issue-type-update': 'issue type (updated)',
'issue-type-delete': 'issue type (deleted)',
'issue-type-fetch': 'issue type (fetch failed)',
+ 'issue-field-create': 'issue field (created)',
+ 'issue-field-update': 'issue field (updated)',
+ 'issue-field-delete': 'issue field (deleted)',
+ 'issue-field-fetch': 'issue field (fetch failed)',
'member-privileges-update': 'member privileges (updated)',
'organization-role-team-add': 'organization role team assignment (added)',
'organization-role-team-remove': 'organization role team assignment (removed)',
@@ -711,7 +769,7 @@ function formatSubResultStatus(status) {
* Per-org properties override base properties with the same name; base properties
* not overridden are preserved.
*
- * Per-org custom-properties-file, issue-types-file, rulesets-file, or actions-allow-list-file in the
+ * Per-org custom-properties-file, issue-types-file, issue-fields-file, rulesets-file, or actions-allow-list-file in the
* organizations file overrides the corresponding base file from the action input for that org.
*
* Modes:
@@ -732,11 +790,11 @@ function formatSubResultStatus(status) {
* @param {string} [actionsAllowListFile] - Path to actions allow list YAML file (base for all orgs)
* @param {string} [dotGithubSourceDir] - Path to source directory for .github repo sync
* @param {string} [dotGithubPrivateSourceDir] - Path to source directory for .github-private repo sync
- * @param {string} [organizationRoleTeamAssignmentsFile] - Path to organization role team assignments YAML file (base for all orgs)
* @param {boolean} [createMissingDotGithubRepos] - Whether to create missing .github / .github-private repos before syncing
* @param {string} [dotGithubRepoVisibility] - Visibility used when creating the .github repo
* @param {string} [dotGithubPrivateRepoVisibility] - Visibility used when creating the .github-private repo
- * @returns {Array<{ org: string, customProperties?: Array, rulesetsFiles?: string[], deleteUnmanagedRulesets?: boolean, issueTypes?: 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} [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
*/
export function parseOrganizations(
organizationsInput,
@@ -757,8 +815,19 @@ export function parseOrganizations(
organizationRoleTeamAssignmentsFile,
createMissingDotGithubRepos,
dotGithubRepoVisibility,
- dotGithubPrivateRepoVisibility
+ dotGithubPrivateRepoVisibility,
+ issueFieldsFile
) {
+ if (
+ issueFieldsFile === undefined &&
+ typeof createMissingDotGithubRepos === 'string' &&
+ dotGithubRepoVisibility === undefined &&
+ dotGithubPrivateRepoVisibility === undefined
+ ) {
+ issueFieldsFile = createMissingDotGithubRepos;
+ createMissingDotGithubRepos = undefined;
+ }
+
let resolvedCodeSecurityConfigurationsFile = codeSecurityConfigurationsFile;
let resolvedActionsPolicyFromInputs = actionsPolicyFromInputs;
let resolvedActionsAllowListFile = actionsAllowListFile;
@@ -788,6 +857,12 @@ export function parseOrganizations(
baseIssueTypes = parseIssueTypesFile(issueTypesFile);
}
+ // Load base issue fields from separate file (applies to all orgs)
+ let baseIssueFields = null;
+ if (issueFieldsFile) {
+ baseIssueFields = parseIssueFieldsFile(issueFieldsFile);
+ }
+
// Load base member privileges from direct action inputs.
let baseMemberPrivileges = null;
if (memberPrivilegesFromInputs) {
@@ -881,6 +956,27 @@ export function parseOrganizations(
// Clean up the intermediate field
delete orgConfig.issueTypesFile;
+ // Per-org issue-fields-file overrides the base for this org
+ let orgIssueFieldsBase = baseIssueFields;
+ if (orgConfig.issueFieldsFile) {
+ try {
+ orgIssueFieldsBase = parseIssueFieldsFile(orgConfig.issueFieldsFile);
+ } catch (error) {
+ throw new Error(
+ `Failed to parse issue fields file "${orgConfig.issueFieldsFile}" for organization "${orgConfig.org}": ${error.message}`,
+ { cause: error }
+ );
+ }
+ }
+
+ if (orgIssueFieldsBase) {
+ // Inline issue-fields layer on top of the base (per-org file or global file)
+ orgConfig.issueFields = mergeIssueFields(orgIssueFieldsBase, orgConfig.issueFields || []);
+ }
+
+ // Clean up the intermediate field
+ delete orgConfig.issueFieldsFile;
+
// Per-org rulesets-file overrides the base for this org
if (!orgConfig.rulesetsFiles && rulesetsFiles && rulesetsFiles.length > 0) {
orgConfig.rulesetsFiles = rulesetsFiles;
@@ -1060,6 +1156,7 @@ export function parseOrganizations(
org,
...(baseCustomProperties ? { customProperties: baseCustomProperties } : {}),
...(baseIssueTypes ? { issueTypes: baseIssueTypes } : {}),
+ ...(baseIssueFields ? { issueFields: baseIssueFields } : {}),
...(rulesetsFiles && rulesetsFiles.length > 0 ? { rulesetsFiles } : {}),
...(deleteUnmanagedRulesets !== undefined ? { deleteUnmanagedRulesets } : {}),
...(baseMemberPrivileges ? { memberPrivileges: baseMemberPrivileges } : {}),
@@ -1123,6 +1220,24 @@ export function mergeIssueTypes(baseIssueTypes, orgIssueTypes) {
return Array.from(merged.values());
}
+/**
+ * Merge base issue fields with per-org overrides.
+ * Per-org issue fields override base issue fields with the same name.
+ * Base issue fields not overridden are preserved.
+ * @param {Array