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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 47 additions & 27 deletions .github/actions/sync-issue-fields/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Sync Issue Fields <!-- omit in toc -->

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)
Expand Down Expand Up @@ -39,33 +39,38 @@ jobs:
> Pin to a commit SHA rather than `@main` for reproducible builds, e.g.
> `uses: OvertureMaps/workflows/.github/actions/sync-issue-fields@<sha>`.

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

### Inputs

- `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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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.
84 changes: 49 additions & 35 deletions .github/actions/sync-issue-fields/action.yml
Original file line number Diff line number Diff line change
@@ -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
Comment thread
lowlydba marked this conversation as resolved.
github-token:
description: >
Token with push access to the repo, used to read and update the
Expand Down Expand Up @@ -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) };
});
Comment thread
lowlydba marked this conversation as resolved.
Comment thread
lowlydba marked this conversation as resolved.

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 }}
Expand All @@ -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}`);
}
Loading