From aa15e5dd803f8cc7400a4194e16268f5bd12b935 Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Sun, 17 May 2026 14:09:01 -0500 Subject: [PATCH 1/2] feat: add organization issue fields sync support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 116 +++++- __tests__/index.test.js | 417 +++++++++++++++++++++ action.yml | 9 + badges/coverage.svg | 2 +- package-lock.json | 4 +- package.json | 3 +- sample-configuration/issue-fields.yml | 25 ++ sample-configuration/orgs.yml | 14 + src/index.js | 521 +++++++++++++++++++++++++- 9 files changed, 1098 insertions(+), 13 deletions(-) create mode 100644 sample-configuration/issue-fields.yml diff --git a/README.md b/README.md index 89ffaf0..2d5bac3 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 @@ -662,6 +665,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] @@ -1220,6 +1328,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 | | @@ -1267,7 +1377,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. 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 4b7222a..bbd2838 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: @@ -207,6 +211,11 @@ const { compareIssueType, syncIssueTypes, mergeIssueTypes, + parseIssueFieldsFile, + normalizeIssueFields, + compareIssueField, + syncIssueFields, + mergeIssueFields, syncOrgRulesets, mergeCustomProperties, mergeMemberPrivileges, @@ -406,6 +415,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 => { @@ -1019,6 +1056,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 ───────────────────────────────────────────── @@ -1844,6 +1976,243 @@ 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); + }); + }); + + // ─── 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); + }); + }); + // ─── syncCustomProperties ────────────────────────────────────────────── describe('syncCustomProperties', () => { @@ -2510,6 +2879,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 f39f4be..7f998cf 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 c136ec9..5ad5179 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -Coverage: 86.78%Coverage86.78% \ No newline at end of file +Coverage: 85.64%Coverage85.64% \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e48db9b..7363ddd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "bulk-github-org-settings-sync-action", - "version": "1.10.0", + "version": "1.11.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "bulk-github-org-settings-sync-action", - "version": "1.10.0", + "version": "1.11.0", "license": "MIT", "dependencies": { "@actions/core": "^3.0.1", diff --git a/package.json b/package.json index f417052..1db92f2 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.10.0", + "version": "1.11.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 480d662..8fa3bf5 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' @@ -108,6 +121,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 c6f8f7a..0b7a903 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']); /** * Known keys for custom organization role definitions in the YAML file. @@ -143,6 +150,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', @@ -245,6 +253,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', @@ -346,6 +357,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']; @@ -379,6 +421,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']; @@ -426,6 +479,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', @@ -516,6 +570,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)', @@ -668,7 +726,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: @@ -690,7 +748,8 @@ function formatSubResultStatus(status) { * @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) - * @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 }>} 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 }>} Parsed org configs */ export function parseOrganizations( organizationsInput, @@ -708,7 +767,8 @@ export function parseOrganizations( actionsAllowListFile, dotGithubSourceDir, dotGithubPrivateSourceDir, - organizationRoleTeamAssignmentsFile + organizationRoleTeamAssignmentsFile, + issueFieldsFile ) { let resolvedCodeSecurityConfigurationsFile = codeSecurityConfigurationsFile; let resolvedActionsPolicyFromInputs = actionsPolicyFromInputs; @@ -739,6 +799,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) { @@ -832,6 +898,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; @@ -996,6 +1083,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 } : {}), @@ -1056,6 +1144,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} baseIssueFields - Base issue field definitions + * @param {Array} orgIssueFields - Per-org issue field overrides + * @returns {Array} Merged issue fields + */ +export function mergeIssueFields(baseIssueFields, orgIssueFields) { + const merged = new Map(baseIssueFields.map(field => [field.name, { ...field }])); + + for (const orgField of orgIssueFields) { + merged.set(orgField.name, { ...orgField }); + } + + return Array.from(merged.values()); +} + /** * Merge base custom roles with per-org overrides. * Per-org roles override base roles with the same name. @@ -1549,7 +1655,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, rulesetsFiles?: string[], deleteUnmanagedRulesets?: boolean, deleteUnmanagedProperties?: boolean, deleteUnmanagedIssueTypes?: 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, 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)) { @@ -1611,6 +1717,21 @@ export function parseOrganizationsFile(filePath) { result.issueTypes = normalizeIssueTypes(orgConfig['issue-types']); } + if (Object.prototype.hasOwnProperty.call(orgConfig, 'issue-fields-file')) { + const ifFile = orgConfig['issue-fields-file']; + if (typeof ifFile !== 'string' || ifFile.trim() === '') { + throw new Error(`Invalid "issue-fields-file" for org "${orgConfig.org}": expected a non-empty string`); + } + result.issueFieldsFile = ifFile.trim(); + } + + if (Object.prototype.hasOwnProperty.call(orgConfig, 'issue-fields')) { + if (!Array.isArray(orgConfig['issue-fields'])) { + throw new Error(`Invalid "issue-fields" for org "${orgConfig.org}": expected an array`); + } + result.issueFields = normalizeIssueFields(orgConfig['issue-fields']); + } + if (Object.prototype.hasOwnProperty.call(orgConfig, 'rulesets-file')) { const rsFile = orgConfig['rulesets-file']; result.rulesetsFiles = parseRulesetsFileValue(rsFile, orgConfig.org); @@ -1637,6 +1758,13 @@ export function parseOrganizationsFile(filePath) { } } + if (Object.prototype.hasOwnProperty.call(orgConfig, 'delete-unmanaged-issue-fields')) { + const val = orgConfig['delete-unmanaged-issue-fields']; + if (typeof val === 'boolean') { + result.deleteUnmanagedIssueFields = val; + } + } + if (Object.prototype.hasOwnProperty.call(orgConfig, 'member-privileges')) { result.memberPrivileges = parseMemberPrivileges(orgConfig['member-privileges'], orgConfig.org); } @@ -2160,6 +2288,360 @@ export async function syncIssueTypes(octokit, org, desiredIssueTypes, deleteUnma return { subResults, failed: hasFailed }; } +// ─── Issue Fields Parsing & Sync ──────────────────────────────────────────────── + +/** + * Parse a standalone issue fields YAML file. + * @param {string} filePath - Path to the YAML file + * @returns {Array} Normalized issue field definitions + */ +export function parseIssueFieldsFile(filePath) { + if (!fs.existsSync(filePath)) { + throw new Error(`Issue fields file not found: ${filePath}`); + } + + const content = fs.readFileSync(filePath, 'utf8'); + const issueFields = yaml.load(content); + + if (!Array.isArray(issueFields)) { + throw new Error(`Invalid issue fields file format: expected an array in ${filePath}`); + } + + return normalizeIssueFields(issueFields); +} + +/** + * Normalize and validate a single issue field option definition. + * @param {Object} option - Issue field option from YAML + * @param {string} fieldName - Parent issue field name + * @param {number} optionIndex - Option index in the array + * @returns {Object} Normalized option + */ +function normalizeIssueFieldOption(option, fieldName, optionIndex) { + if (typeof option !== 'object' || option === null || Array.isArray(option)) { + throw new Error(`Issue field "${fieldName}" option at index ${optionIndex} must be a key-value map`); + } + + if (typeof option.name !== 'string' || option.name.trim() === '') { + throw new Error(`Issue field "${fieldName}" option at index ${optionIndex} must have a non-empty "name"`); + } + + if (typeof option.color !== 'string' || !ISSUE_FIELD_OPTION_COLORS.has(option.color.trim())) { + throw new Error( + `Issue field "${fieldName}" option "${option.name}" has invalid color: expected one of ${Array.from( + ISSUE_FIELD_OPTION_COLORS + ).join(', ')}` + ); + } + + if (option.priority !== undefined && (!Number.isInteger(option.priority) || option.priority < 1)) { + throw new Error( + `Issue field "${fieldName}" option "${option.name}" has invalid priority: expected an integer >= 1` + ); + } + + return { + name: option.name.trim(), + description: option.description == null ? null : String(option.description), + color: option.color.trim(), + priority: option.priority ?? optionIndex + 1 + }; +} + +/** + * Normalize issue field definitions from YAML format to API format. + * @param {Array} issueFields - Issue field definitions from YAML + * @returns {Array} Normalized issue fields + */ +export function normalizeIssueFields(issueFields) { + const seenNames = new Set(); + + return issueFields.map((field, index) => { + if (typeof field !== 'object' || field === null || Array.isArray(field)) { + throw new Error(`Issue field entry at index ${index} must be a key-value map`); + } + + if (typeof field.name !== 'string' || field.name.trim() === '') { + throw new Error(`Issue field entry at index ${index} must have a non-empty "name" field`); + } + + const name = field.name.trim(); + const nameKey = name.toLowerCase(); + if (seenNames.has(nameKey)) { + throw new Error(`Duplicate issue field name "${name}"`); + } + seenNames.add(nameKey); + + const dataTypeRaw = getAliasedYamlValue(field, 'data_type', `Issue field "${name}"`); + if (typeof dataTypeRaw !== 'string' || dataTypeRaw.trim() === '') { + throw new Error(`Issue field "${name}" must have a non-empty "data-type" field`); + } + const dataType = dataTypeRaw.trim(); + if (!ISSUE_FIELD_DATA_TYPES.has(dataType)) { + throw new Error( + `Issue field "${name}" has invalid data-type "${dataType}": expected one of ${Array.from( + ISSUE_FIELD_DATA_TYPES + ).join(', ')}` + ); + } + + const visibilityRaw = field.visibility; + if (visibilityRaw != null) { + if (typeof visibilityRaw !== 'string' || !ISSUE_FIELD_VISIBILITIES.has(visibilityRaw.trim())) { + throw new Error( + `Issue field "${name}" has invalid visibility "${visibilityRaw}": expected one of ${Array.from( + ISSUE_FIELD_VISIBILITIES + ).join(', ')}` + ); + } + } + + const optionsRaw = field.options; + if (dataType === 'single_select') { + if (!Array.isArray(optionsRaw) || optionsRaw.length === 0) { + throw new Error(`Issue field "${name}" must have a non-empty "options" array for data-type "single_select"`); + } + } else if (optionsRaw != null) { + throw new Error(`Issue field "${name}" has "options" but data-type "${dataType}" does not support options`); + } + + return { + name, + description: field.description == null ? null : String(field.description), + data_type: dataType, + visibility: visibilityRaw == null ? null : visibilityRaw.trim(), + options: Array.isArray(optionsRaw) + ? optionsRaw.map((option, optionIndex) => normalizeIssueFieldOption(option, name, optionIndex)) + : null + }; + }); +} + +/** + * Compare two issue field definitions to check if they differ. + * @param {Object} existing - Current issue field from API + * @param {Object} desired - Desired issue field from config + * @returns {{ changed: boolean, changes: Array, incompatibleTypeChange: boolean }} + */ +export function compareIssueField(existing, desired) { + const changes = []; + let incompatibleTypeChange = false; + + if (existing.data_type !== desired.data_type) { + incompatibleTypeChange = true; + changes.push(`data_type: ${existing.data_type} → ${desired.data_type} (requires recreation)`); + } + + const existingDesc = existing.description || null; + const desiredDesc = desired.description || null; + if (existingDesc !== desiredDesc) { + changes.push('description updated'); + } + + if (desired.visibility != null) { + const existingVisibility = existing.visibility || 'organization_members_only'; + if (existingVisibility !== desired.visibility) { + changes.push(`visibility: ${existingVisibility} → ${desired.visibility}`); + } + } + + if (desired.data_type === 'single_select') { + const normalizeOptions = options => + (options || []) + .map((option, optionIndex) => ({ + name: option.name, + description: option.description || null, + color: option.color, + priority: option.priority ?? optionIndex + 1 + })) + .sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name)); + + const existingOptions = normalizeOptions(existing.options); + const desiredOptions = normalizeOptions(desired.options); + if (JSON.stringify(existingOptions) !== JSON.stringify(desiredOptions)) { + changes.push('options updated'); + } + } + + return { changed: changes.length > 0, changes, incompatibleTypeChange }; +} + +/** + * Sync issue field definitions for an organization. + * @param {Octokit} octokit - Octokit instance + * @param {string} org - Organization name + * @param {Array} desiredIssueFields - Desired issue field definitions + * @param {boolean} deleteUnmanaged - Whether to delete issue fields not in config + * @param {boolean} dryRun - Preview mode + * @returns {Promise} Result object with subResults + */ +export async function syncIssueFields(octokit, org, desiredIssueFields, deleteUnmanaged, dryRun) { + const subResults = []; + const wouldPrefix = dryRun ? 'Would ' : ''; + let hasFailed = false; + + // Fetch current issue fields + let existingIssueFields; + try { + const { data } = await octokit.request('GET /orgs/{org}/issue-fields', { org }); + existingIssueFields = data; + } catch (error) { + if (isPermissionLikeFetchError(error)) { + const message = formatPermissionFetchWarning('issue fields', org, error); + core.warning(` ⚠️ ${message}`); + subResults.push(createSubResult('issue-field-fetch', SubResultStatus.WARNING, message)); + return { subResults, failed: false }; + } + + throw error; + } + + const existingMap = new Map(existingIssueFields.map(field => [field.name, field])); + const desiredMap = new Map(desiredIssueFields.map(field => [field.name, field])); + + for (const desired of desiredIssueFields) { + const existing = existingMap.get(desired.name); + + if (!existing) { + core.info(` 🆕 ${wouldPrefix}Create issue field: ${desired.name}`); + subResults.push( + createSubResult('issue-field-create', SubResultStatus.CHANGED, `${wouldPrefix}create "${desired.name}"`) + ); + + if (!dryRun) { + try { + await octokit.request('POST /orgs/{org}/issue-fields', { + org, + name: desired.name, + data_type: desired.data_type, + ...(desired.description != null ? { description: desired.description } : {}), + ...(desired.visibility != null ? { visibility: desired.visibility } : {}), + ...(desired.options ? { options: desired.options } : {}) + }); + } catch (error) { + hasFailed = true; + core.warning(` ⚠️ Failed to create issue field "${desired.name}": ${error.message}`); + subResults[subResults.length - 1] = createSubResult( + 'issue-field-create', + SubResultStatus.WARNING, + `Failed to create "${desired.name}": ${error.message}` + ); + } + } + continue; + } + + const { changed, changes, incompatibleTypeChange } = compareIssueField(existing, desired); + if (!changed) { + core.info(` ✅ Issue field unchanged: ${desired.name}`); + continue; + } + + core.info(` 📝 ${wouldPrefix}Update issue field: ${desired.name} (${changes.join(', ')})`); + subResults.push( + createSubResult( + 'issue-field-update', + SubResultStatus.CHANGED, + `${wouldPrefix}update "${desired.name}" (${changes.join(', ')})` + ) + ); + + if (incompatibleTypeChange) { + hasFailed = true; + const message = `Failed to update "${desired.name}": cannot change data_type from "${existing.data_type}" to "${desired.data_type}" (delete and recreate the field)`; + core.warning(` ⚠️ ${message}`); + subResults[subResults.length - 1] = createSubResult('issue-field-update', SubResultStatus.WARNING, message); + continue; + } + + if (!dryRun) { + try { + const payload = { + org, + issue_field_id: existing.id + }; + + if ((existing.description || null) !== (desired.description || null)) { + payload.description = desired.description; + } + + if (desired.visibility != null && (existing.visibility || 'organization_members_only') !== desired.visibility) { + payload.visibility = desired.visibility; + } + + if (desired.data_type === 'single_select') { + const existingOptionsByName = new Map((existing.options || []).map(option => [option.name, option])); + const normalizedExistingOptions = (existing.options || []).map((option, optionIndex) => ({ + name: option.name, + description: option.description || null, + color: option.color, + priority: option.priority ?? optionIndex + 1 + })); + const normalizedDesiredOptions = (desired.options || []).map(option => ({ + name: option.name, + description: option.description || null, + color: option.color, + priority: option.priority + })); + + if (JSON.stringify(normalizedExistingOptions) !== JSON.stringify(normalizedDesiredOptions)) { + payload.options = (desired.options || []).map(option => { + const existingOption = existingOptionsByName.get(option.name); + return { + ...(existingOption?.id ? { id: existingOption.id } : {}), + name: option.name, + description: option.description, + color: option.color, + priority: option.priority + }; + }); + } + } + + await octokit.request('PATCH /orgs/{org}/issue-fields/{issue_field_id}', payload); + } catch (error) { + hasFailed = true; + core.warning(` ⚠️ Failed to update issue field "${desired.name}": ${error.message}`); + subResults[subResults.length - 1] = createSubResult( + 'issue-field-update', + SubResultStatus.WARNING, + `Failed to update "${desired.name}": ${error.message}` + ); + } + } + } + + if (deleteUnmanaged) { + for (const existing of existingIssueFields) { + if (!desiredMap.has(existing.name)) { + core.info(` 🗑️ ${wouldPrefix}Delete issue field: ${existing.name}`); + subResults.push( + createSubResult('issue-field-delete', SubResultStatus.CHANGED, `${wouldPrefix}delete "${existing.name}"`) + ); + + if (!dryRun) { + try { + await octokit.request('DELETE /orgs/{org}/issue-fields/{issue_field_id}', { + org, + issue_field_id: existing.id + }); + } catch (error) { + hasFailed = true; + core.warning(` ⚠️ Failed to delete issue field "${existing.name}": ${error.message}`); + subResults[subResults.length - 1] = createSubResult( + 'issue-field-delete', + SubResultStatus.WARNING, + `Failed to delete "${existing.name}": ${error.message}` + ); + } + } + } + } + } + + return { subResults, failed: hasFailed }; +} + // ─── Custom Organization Roles Parsing & Sync ─────────────────────────────────── /** @@ -5083,6 +5565,8 @@ export async function run() { const deleteUnmanagedRulesets = getBooleanInput('delete-unmanaged-rulesets') ?? false; const issueTypesFile = core.getInput('issue-types-file'); const deleteUnmanagedIssueTypes = getBooleanInput('delete-unmanaged-issue-types') ?? false; + const issueFieldsFile = core.getInput('issue-fields-file'); + const deleteUnmanagedIssueFields = getBooleanInput('delete-unmanaged-issue-fields') ?? false; const dotGithubSourceDir = core.getInput('dot-github-source-dir') || ''; const dotGithubPrivateSourceDir = core.getInput('dot-github-private-source-dir') || ''; const memberPrivilegesFromInputs = getMemberPrivilegesFromInputs(); @@ -5126,13 +5610,15 @@ export async function run() { actionsAllowListFile, dotGithubSourceDir, dotGithubPrivateSourceDir, - organizationRoleTeamAssignmentsFile + organizationRoleTeamAssignmentsFile, + issueFieldsFile ); // Check that at least one setting type is specified const hasCustomProperties = orgList.some(o => o.customProperties && o.customProperties.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); const hasMemberPrivileges = orgList.some(o => o.memberPrivileges && Object.keys(o.memberPrivileges).length > 0); const hasOrganizationRoleTeamAssignments = orgList.some( o => o.organizationRoleTeamAssignments && o.organizationRoleTeamAssignments.length > 0 @@ -5159,6 +5645,7 @@ export async function run() { !hasCustomProperties && !hasRulesets && !hasIssueTypes && + !hasIssueFields && !hasMemberPrivileges && !hasOrganizationRoleTeamAssignments && !hasDotGithub && @@ -5172,7 +5659,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, ' + - 'provide issue types via "issue-types-file", rulesets via "rulesets-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", ' + 'custom org roles via "custom-org-roles-file", custom repo roles via "custom-repo-roles-file", ' + @@ -5203,6 +5690,10 @@ export async function run() { core.info('⚠️ delete-unmanaged-issue-types is enabled: issue types not in config will be deleted'); } + if (deleteUnmanagedIssueFields) { + core.info('⚠️ delete-unmanaged-issue-fields is enabled: issue fields not in config will be deleted'); + } + if (deleteUnmanagedOrgRoles) { core.info('⚠️ delete-unmanaged-org-roles is enabled: custom org roles not in config will be deleted'); } @@ -5274,6 +5765,24 @@ export async function run() { } } + // Sync issue fields + if (orgConfig.issueFields && orgConfig.issueFields.length > 0) { + core.info(` 🏷️ Syncing issue fields (${orgConfig.issueFields.length} defined)...`); + const ifResult = await syncIssueFields( + octokit, + org, + orgConfig.issueFields, + orgConfig.deleteUnmanagedIssueFields ?? deleteUnmanagedIssueFields, + dryRun + ); + result.subResults.push(...ifResult.subResults); + + if (ifResult.failed) { + result.success = false; + result.error = result.error ? `${result.error}; Issue fields sync failed` : 'Issue fields sync failed'; + } + } + // Sync rulesets if (orgConfig.rulesetsFiles && orgConfig.rulesetsFiles.length > 0) { core.info(` 📋 Syncing rulesets from ${orgConfig.rulesetsFiles.length} file(s)...`); From 0fb41f423792c4e103e42a61d8ce35b01787c9b3 Mon Sep 17 00:00:00 2001 From: Josh Johanning Date: Wed, 20 May 2026 10:14:03 -0500 Subject: [PATCH 2/2] fix: avoid noisy issue field option patches Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- __tests__/index.test.js | 60 +++++++++++++++++++++++++++++++++++++++++ badges/coverage.svg | 2 +- src/index.js | 39 +++++++++++---------------- 3 files changed, 76 insertions(+), 25 deletions(-) diff --git a/__tests__/index.test.js b/__tests__/index.test.js index 4c92495..247d795 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -2080,6 +2080,34 @@ orgs: 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 ─────────────────────────────────────────────── @@ -2235,6 +2263,38 @@ orgs: 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 ────────────────────────────────────────────── diff --git a/badges/coverage.svg b/badges/coverage.svg index a179d36..c9dff47 100644 --- a/badges/coverage.svg +++ b/badges/coverage.svg @@ -1 +1 @@ -Coverage: 85.77%Coverage85.77% \ No newline at end of file +Coverage: 85.84%Coverage85.84% \ No newline at end of file diff --git a/src/index.js b/src/index.js index 4de22fb..b35c82a 100644 --- a/src/index.js +++ b/src/index.js @@ -2557,6 +2557,17 @@ export function normalizeIssueFields(issueFields) { }); } +function normalizeIssueFieldOptionsForComparison(options) { + return (options || []) + .map((option, optionIndex) => ({ + name: option.name, + description: option.description || null, + color: option.color, + priority: option.priority ?? optionIndex + 1 + })) + .sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name)); +} + /** * Compare two issue field definitions to check if they differ. * @param {Object} existing - Current issue field from API @@ -2586,18 +2597,8 @@ export function compareIssueField(existing, desired) { } if (desired.data_type === 'single_select') { - const normalizeOptions = options => - (options || []) - .map((option, optionIndex) => ({ - name: option.name, - description: option.description || null, - color: option.color, - priority: option.priority ?? optionIndex + 1 - })) - .sort((a, b) => a.priority - b.priority || a.name.localeCompare(b.name)); - - const existingOptions = normalizeOptions(existing.options); - const desiredOptions = normalizeOptions(desired.options); + const existingOptions = normalizeIssueFieldOptionsForComparison(existing.options); + const desiredOptions = normalizeIssueFieldOptionsForComparison(desired.options); if (JSON.stringify(existingOptions) !== JSON.stringify(desiredOptions)) { changes.push('options updated'); } @@ -2711,18 +2712,8 @@ export async function syncIssueFields(octokit, org, desiredIssueFields, deleteUn if (desired.data_type === 'single_select') { const existingOptionsByName = new Map((existing.options || []).map(option => [option.name, option])); - const normalizedExistingOptions = (existing.options || []).map((option, optionIndex) => ({ - name: option.name, - description: option.description || null, - color: option.color, - priority: option.priority ?? optionIndex + 1 - })); - const normalizedDesiredOptions = (desired.options || []).map(option => ({ - name: option.name, - description: option.description || null, - color: option.color, - priority: option.priority - })); + const normalizedExistingOptions = normalizeIssueFieldOptionsForComparison(existing.options); + const normalizedDesiredOptions = normalizeIssueFieldOptionsForComparison(desired.options); if (JSON.stringify(normalizedExistingOptions) !== JSON.stringify(normalizedDesiredOptions)) { payload.options = (desired.options || []).map(option => {