From 08c134eb94b2f15b10f799db7b8130599c1b6a3a Mon Sep 17 00:00:00 2001 From: John McCall Date: Thu, 6 Aug 2026 13:45:56 -0400 Subject: [PATCH] [FEATURE] Sync org-level Skillset field via generic org-fields map Replace the single-purpose scope-form-field/scope-org-field-id inputs with a generic org-fields input: newline-separated form-field=org-field-id pairs, matching the key=value multi-line convention other composite actions use for map-shaped string inputs (e.g. docker/build-push-action's build-args). Defaults to OvertureMaps' Scope and Skillset fields, so Skillset now syncs out of the box without any caller changes. Fixes #75 Signed-off-by: John McCall --- .github/actions/sync-issue-fields/README.md | 74 ++++++++++------- .github/actions/sync-issue-fields/action.yml | 84 ++++++++++++-------- 2 files changed, 96 insertions(+), 62 deletions(-) diff --git a/.github/actions/sync-issue-fields/README.md b/.github/actions/sync-issue-fields/README.md index 122c8a8..c8697b7 100644 --- a/.github/actions/sync-issue-fields/README.md +++ b/.github/actions/sync-issue-fields/README.md @@ -1,7 +1,7 @@ # Sync Issue Fields -A composite GitHub Action that syncs an issue form's Type/Scope-style dropdown -answers onto the repo-level issue `type` and an org-level issue field, without +A composite GitHub Action that syncs an issue form's dropdown answers onto +the repo-level issue `type` and any number of org-level issue fields, without clobbering values already set by manual triage. - [How-to guides](#how-to-guides) @@ -39,24 +39,25 @@ jobs: > Pin to a commit SHA rather than `@main` for reproducible builds, e.g. > `uses: OvertureMaps/workflows/.github/actions/sync-issue-fields@`. -This assumes an issue form with `type` and `scope` fields (matching the -default `type-form-field`/`scope-form-field` inputs). `scope-org-field-id` -defaults to the OvertureMaps org's `Scope` field, so no further configuration -is needed for repos in this org. Nothing happens if the issue wasn't created -from a template with those fields. +This assumes an issue form with `type`, `scope`, and `skillset` fields +(matching the default `type-form-field` input and the `org-fields` default +below). No further configuration is needed for repos in this org. Nothing +happens if the issue wasn't created from a template with those fields. -### Use different form field IDs, or target a different org-level field +### Use different form field IDs, or target different org-level fields ```yaml - name: Sync Type and Priority uses: OvertureMaps/workflows/.github/actions/sync-issue-fields@main with: github-token: ${{ secrets.GITHUB_TOKEN }} - scope-form-field: priority - scope-org-field-id: "12345678" # OvertureMaps org's "Priority" field + org-fields: | + priority=12345678 ``` -Find an org-level field's ID with `gh api orgs/OvertureMaps/issue-fields`. +Each line of `org-fields` is a `form-field=org-field-id` pair: the issue form +field `id` on the left, the numeric org-level field ID it's synced to on the +right. Find a field's ID with `gh api orgs/OvertureMaps/issue-fields`. ## Reference @@ -64,8 +65,12 @@ Find an org-level field's ID with `gh api orgs/OvertureMaps/issue-fields`. - `github-token` (**required**): Token with push access to the repo, used to read and patch the issue. Composite action inputs can't default to the `github.token` context, so this must be passed explicitly, e.g. `${{ secrets.GITHUB_TOKEN }}`. - `type-form-field` (optional): Issue form field `id` holding the Type dropdown answer. Default `type`. -- `scope-form-field` (optional): Issue form field `id` holding the Scope-equivalent dropdown answer. Default `scope`. -- `scope-org-field-id` (optional): Numeric ID of the org-level issue field to write the `scope-form-field` answer to. Default `44814929`, the OvertureMaps org's `Scope` field. Override only to target a different org-level field. +- `org-fields` (optional): Newline-separated `form-field=org-field-id` pairs mapping issue form field `id`s to org-level issue field IDs. Default: + ``` + scope=44814929 + skillset=45229885 + ``` + The OvertureMaps org's `Scope` and `Skillset` fields. Override to add, remove, or repoint mapped fields. - `issue-number` (optional): Issue number to operate on. Defaults to the triggering issue (`context.issue.number`). Override for testing or non-`issues` triggers. ### Outputs @@ -102,14 +107,15 @@ for high-volume repos with many incoming issues. This action does that copy automatically on `issues: opened`, so the form answer and the actual fields agree without a triager touching either. -### Why one PATCH for two fields +### Why one PATCH for every field -Both writes happen in a single `PATCH /repos/{owner}/{repo}/issues/{issue_number}` +All writes happen in a single `PATCH /repos/{owner}/{repo}/issues/{issue_number}` call, which accepts a `type` string param (the repo-level system field) and an -`issue_field_values: [{field_id, value}]` array (org-level custom fields) — -confirmed against the [GitHub REST API OpenAPI spec](https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/dereferenced/api.github.com.deref.json) +`issue_field_values: [{field_id, value}]` array (org-level custom fields, one +entry per `org-fields` mapping) — confirmed against the [GitHub REST API OpenAPI spec](https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/dereferenced/api.github.com.deref.json) (`patch /repos/{owner}/{repo}/issues/{issue_number}`, properties `type` and -`issue_field_values`). Both only require push access to the repo. +`issue_field_values`). Both only require push access to the repo, regardless +of how many fields are mapped. ### Doesn't clobber manual triage @@ -125,10 +131,10 @@ GitHub Actions has no way to trigger only for issues opened from a specific template, so this action always runs on every `issues: opened` event and has to cope with issues it wasn't meant for. Two layers handle that: -- If neither `type-form-field` nor `scope-form-field` shows up in the parsed - form answers, the action logs and returns without calling the API at all. - This is the common case for a free-form issue or one from an unrelated - template. +- If neither `type-form-field` nor any `org-fields` key shows up in the + parsed form answers, the action logs and returns without calling the API + at all. This is the common case for a free-form issue or one from an + unrelated template. - If the issue does have those keys but with a value that doesn't match any enabled repo type or field option (a coincidentally-similar template, or a typo'd manual value the API rejects with `422`), the `PATCH` call is wrapped @@ -147,8 +153,22 @@ second credential (a PAT or GitHub App) just to resolve a name this action only ever needs to resolve once. Since this action is OvertureMaps-only and org-level field IDs are stable -once created, `scope-org-field-id` defaults directly to the numeric ID of the -org's `Scope` field. That keeps the whole action running on nothing but -`github-token`. If OvertureMaps ever needs to target a different org-level -field, look its ID up once with `gh api orgs/OvertureMaps/issue-fields` and -pass it via `scope-org-field-id` — no extra token required either way. +once created, `org-fields` defaults directly to the numeric IDs of the org's +`Scope` and `Skillset` fields. That keeps the whole action running on +nothing but `github-token`. If OvertureMaps ever needs to target a different +org-level field, look its ID up once with `gh api orgs/OvertureMaps/issue-fields` +and add a `form-field=org-field-id` line to `org-fields` — no extra token +required either way. + +### Why `org-fields` is a map, not one input per field + +`scope-org-field-id` started as a single dedicated input because there was +only one org-level field to sync. Adding `skillset` the same way would mean a +new `skillset-form-field`/`skillset-org-field-id` pair for every future field, +with the action's inputs and script both growing linearly. `org-fields` takes +a newline-separated list of `form-field=org-field-id` pairs instead, so +mapping another org-level field is a config change, not a code change. +Newline-delimited `key=value` pairs (rather than a JSON or YAML blob) match +how other composite actions already take small maps as string inputs (e.g. +`docker/build-push-action`'s `build-args`), and stay easy to read and diff in +a workflow file without escaping quotes. diff --git a/.github/actions/sync-issue-fields/action.yml b/.github/actions/sync-issue-fields/action.yml index 0a5797a..b01e69b 100644 --- a/.github/actions/sync-issue-fields/action.yml +++ b/.github/actions/sync-issue-fields/action.yml @@ -1,25 +1,27 @@ --- name: Sync Issue Fields description: > - Writes an issue's Type and Scope form answers onto the repo-level issue - type and an org-level issue field, skipping any that are already set. + Writes an issue's Type form answer onto the repo-level issue type, and any + other configured form answers onto org-level issue fields, skipping any + that are already set. inputs: type-form-field: description: Issue form field `id` for the Type dropdown required: false default: "type" - scope-form-field: - description: Issue form field `id` for the Scope-equivalent dropdown - required: false - default: "scope" - scope-org-field-id: + org-fields: description: > - Numeric ID of the org-level issue field to write the scope answer to. - Defaults to OvertureMaps' own "Scope" field. Field IDs are per-org, so - override this when using the action outside OvertureMaps. + Newline-separated `form-field=org-field-id` pairs. Each issue form + field `id` on the left is synced to the org-level issue field ID on + the right, with the same skip-if-already-set semantics as `type`. + Defaults to OvertureMaps' own `Scope` and `Skillset` fields. Field IDs + are per-org, so override this when using the action outside + OvertureMaps, or to add/remove mapped fields. required: false - default: "44814929" + default: | + scope=44814929 + skillset=45229885 github-token: description: > Token with push access to the repo, used to read and update the @@ -66,44 +68,58 @@ runs: ANSWERS: ${{ steps.issue-parser.outputs.jsonString }} TYPE_NAME: ${{ steps.fetch-issue.outputs.type-name }} FIELD_VALUES: ${{ steps.fetch-issue.outputs.field-values }} - SCOPE_ORG_FIELD_ID: ${{ inputs.scope-org-field-id }} + ORG_FIELDS: ${{ inputs.org-fields }} TYPE_FORM_FIELD: ${{ inputs.type-form-field }} - SCOPE_FORM_FIELD: ${{ inputs.scope-form-field }} with: script: | const typeFormField = process.env.TYPE_FORM_FIELD; - const scopeFormField = process.env.SCOPE_FORM_FIELD; const answers = JSON.parse(process.env.ANSWERS || '{}'); - const scopeOrgFieldId = parseInt(process.env.SCOPE_ORG_FIELD_ID); - if (!answers[typeFormField] && !answers[scopeFormField]) { - core.info("Issue has no Type/Scope form answers (not created from the expected template), skipping."); + const orgFields = (process.env.ORG_FIELDS || '') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => { + const [formField, fieldId] = line.split('='); + return { formField: formField.trim(), fieldId: parseInt(fieldId, 10) }; + }); + + const typeAnswer = answers[typeFormField]; + const hasAnyAnswer = typeAnswer || orgFields.some(({ formField }) => answers[formField]); + if (!hasAnyAnswer) { + core.info("Issue has no recognized form answers (not created from the expected template), skipping."); return; } - const typeAnswer = answers[typeFormField]; if (typeAnswer && process.env.TYPE_NAME) { core.info(`Issue type is already set to "${process.env.TYPE_NAME}", not overwriting.`); } else if (typeAnswer) { core.setOutput('type', typeAnswer); } - const scopeAnswer = answers[scopeFormField]; const fieldValues = JSON.parse(process.env.FIELD_VALUES || '[]'); - const scopeAlreadySet = fieldValues.some((f) => f.issue_field_id === scopeOrgFieldId); - if (scopeAnswer && scopeAlreadySet) { - core.info(`Org-level field ${scopeOrgFieldId} is already set, not overwriting.`); - } else if (scopeAnswer) { - core.setOutput('scope', scopeAnswer); + const orgFieldUpdates = []; + for (const { formField, fieldId } of orgFields) { + const answer = answers[formField]; + if (!answer) { + continue; + } + if (fieldValues.some((f) => f.issue_field_id === fieldId)) { + core.info(`Org-level field ${fieldId} is already set, not overwriting.`); + continue; + } + orgFieldUpdates.push({ field_id: fieldId, value: answer }); + } + if (orgFieldUpdates.length > 0) { + core.setOutput('org-field-updates', JSON.stringify(orgFieldUpdates)); } - name: Apply issue field updates - if: steps.updates.outputs.type || steps.updates.outputs.scope + if: steps.updates.outputs.type || steps.updates.outputs.org-field-updates uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 env: TYPE: ${{ steps.updates.outputs.type }} - SCOPE: ${{ steps.updates.outputs.scope }} - SCOPE_ORG_FIELD_ID: ${{ inputs.scope-org-field-id }} + ORG_FIELD_UPDATES: ${{ steps.updates.outputs.org-field-updates }} ISSUE_NUMBER: ${{ steps.fetch-issue.outputs.issue-number }} with: github-token: ${{ inputs.github-token }} @@ -115,18 +131,16 @@ runs: if (process.env.TYPE) { patch.type = process.env.TYPE; } - if (process.env.SCOPE) { - patch.issue_field_values = [{ - field_id: parseInt(process.env.SCOPE_ORG_FIELD_ID), - value: process.env.SCOPE, - }]; + const orgFieldUpdates = JSON.parse(process.env.ORG_FIELD_UPDATES || '[]'); + if (orgFieldUpdates.length > 0) { + patch.issue_field_values = orgFieldUpdates; } try { await github.request('PATCH /repos/{owner}/{repo}/issues/{issue_number}', patch); core.info(`Updated issue #${issueNumber}: ${JSON.stringify(patch)}`); } catch (error) { - // A non-standard template can produce a "type"/"scope"-shaped answer whose value - // doesn't match any enabled repo type or field option (422). Warn, don't fail the job. - core.warning(`Couldn't update issue #${issueNumber}, the Type/Scope value may not be valid for this repo: ${error.message}`); + // A non-standard template can produce a form answer whose value doesn't match + // any enabled repo type or org field option (422). Warn, don't fail the job. + core.warning(`Couldn't update issue #${issueNumber}, a form answer may not be valid for this repo: ${error.message}`); }