diff --git a/.github/actions/sync-project-status/README.md b/.github/actions/sync-project-status/README.md new file mode 100644 index 0000000..b5fcf02 --- /dev/null +++ b/.github/actions/sync-project-status/README.md @@ -0,0 +1,113 @@ +# Sync Project Status + +A composite GitHub Action that keeps the `Status` field in sync for issues +belonging to more than one org-level ProjectV2. + +## Explanation + +### What it does + +ProjectsV2 has no workflow trigger for field edits, so this runs as a +scheduled batch job. For each issue (or PR) that is an item in two or more of +the configured projects: + +- If statuses differ, the most recently updated `Status` wins (based on the + field value's `updatedAt`) and is copied to the other projects +- Status option names are matched case-insensitively, so `In Progress` and + `In progress` count as already in sync +- A status set in only one project propagates to projects where it's unset +- Archived items, items in only one project, and items with no status + anywhere are ignored +- A mismatch whose status name has no matching option in the target project + is logged as a warning and skipped + +The GraphQL/IO code lives in `src/projects.js`; the planning logic in +`src/plan.js` is pure and side-effect free. + +### Why a GitHub App + +The default `GITHUB_TOKEN` cannot read or write org-level ProjectsV2. This +action mints an installation token from a dedicated GitHub App, which needs: + +- Organization permissions: Projects read & write +- Repository permissions: Issues read, Pull requests read + +## How-to guides + +### Run on a schedule (how this repo uses it) + +See [`sync-project-status.yml`](../../workflows/sync-project-status.yml): +checkout this repo, assume a narrow OIDC role, fetch the app PEM from AWS +Secrets Manager, then reference the action locally. The role, secret, and +GitHub App are managed in `omf-github-terraform`. + +```yaml +on: + schedule: + - cron: "17 */3 * * *" + workflow_dispatch: + inputs: + dry_run: + type: boolean + default: false + +jobs: + sync: + runs-on: ubuntu-slim + permissions: + contents: read + id-token: write # for OIDC authentication with AWS + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + + - uses: aws-actions/configure-aws-credentials@v6 + with: + aws-region: us-west-2 + role-to-assume: arn:aws:iam::816069134238:role/gha-project-manager-secrets-reader + + - uses: aws-actions/aws-secretsmanager-get-secrets@v3 + with: + secret-ids: | + PROJECT_MANAGER_PEM, omf-github-terraform/project-manager/pem + + - uses: ./.github/actions/sync-project-status + with: + clientId: "Iv23limfwiJlCIqHPHrd" # overture-project-manager app, not sensitive + privateKey: ${{ env.PROJECT_MANAGER_PEM }} + dryRun: ${{ inputs.dry_run || 'false' }} +``` + +### Dry run + +Trigger the workflow manually with `dry_run` checked, or pass +`dryRun: "true"` to the action. Intended changes are logged but not applied. + +## Reference + +### Inputs + +- `clientId` (**required**): Client ID of the GitHub App used to mint the + installation token. +- `privateKey` (**required**): Private key for the app. Must come from an + already-masked source (a GitHub Actions secret, or + `aws-actions/aws-secretsmanager-get-secrets` as this repo does): the + action re-masks the value line-by-line as defense in depth, but it cannot + mask the value's handling before it arrives as an input. Include the full + PEM block with a trailing newline. +- `projectNumbers` (optional): Comma-separated org project numbers to sync. + Defaults to `84,78,54` (Overture, places-surge, CloudDevOps). +- `dryRun` (optional): `"true"` to log intended changes without applying + them. Defaults to `"false"`. + +### Outputs + +This action has no outputs. Results are logged, with a summary notice at the +end. + +### Conflict resolution + +Last write wins: whichever project's `Status` was edited most recently is the +source of truth for that item on each run. Between runs, conflicting edits in +different projects are resolved in favor of the later edit. diff --git a/.github/actions/sync-project-status/action.yml b/.github/actions/sync-project-status/action.yml new file mode 100644 index 0000000..b41c24e --- /dev/null +++ b/.github/actions/sync-project-status/action.yml @@ -0,0 +1,87 @@ +--- +name: Sync Project Status +description: > + Keeps the Status field in sync for issues that belong to more than one + org-level ProjectV2. For each issue in multiple projects with mismatched + statuses, the most recently updated Status wins and is copied to the + others. Option names are matched case-insensitively. + +inputs: + projectNumbers: + # A project's number is in its URL: github.com/orgs/OvertureMaps/projects/. + # The app must also be granted access to it in the org's app installation settings. + description: Comma-separated org project numbers to sync + required: false + default: "84,78,54" # Overture, places-surge, CloudDevOps + dryRun: + description: Log intended changes without applying them + required: false + default: "false" + clientId: + description: > + GitHub App client ID used to generate an installation token with + org-level Projects read & write access. The default GITHUB_TOKEN + cannot read or write org ProjectsV2. + required: true + privateKey: + description: > + GitHub App private key. Must come from an already-masked source: a + GitHub Actions secret or aws-actions/aws-secretsmanager-get-secrets + (both register masks at fetch time). This action re-masks the value + as defense in depth, but that cannot cover the value's handling + before it reaches this action. + required: true + +runs: + using: composite + steps: + - name: Mask private key + shell: bash + run: | + echo "::group::Masking private key" + # Defense in depth only: the PEM already crossed a step boundary as + # an input, so the caller's source must register its own masks + # (GitHub secrets and aws-secretsmanager-get-secrets both do). + # Line-by-line because a single ::add-mask:: on a multi-line value + # only registers the first line. + while IFS= read -r line; do + [ -n "$line" ] && echo "::add-mask::$line" + done <<< "$INPUTS_PRIVATEKEY" + echo "::endgroup::" + env: + INPUTS_PRIVATEKEY: ${{ inputs.privateKey }} + + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 # zizmor: ignore[github-app,ref-version-mismatch] -- dedicated app scoped to org project sync + with: + client-id: ${{ inputs.clientId }} + private-key: ${{ inputs.privateKey }} + owner: ${{ github.repository_owner }} # zizmor: ignore[github-app] -- org-level project access is the point + + - name: Sync project statuses + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + PROJECT_NUMBERS: ${{ inputs.projectNumbers }} + DRY_RUN: ${{ inputs.dryRun }} + with: + github-token: ${{ steps.app-token.outputs.token }} + script: | + const path = require('path'); + const run = require(path.join(process.env.GITHUB_ACTION_PATH, 'src', 'index.js')); + const projectNumbers = process.env.PROJECT_NUMBERS + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + .map(Number); + if (projectNumbers.length < 2 || projectNumbers.some((n) => !Number.isInteger(n) || n <= 0)) { + core.setFailed(`projectNumbers must be 2+ comma-separated positive integers, got "${process.env.PROJECT_NUMBERS}"`); + return; + } + await run({ + github, + core, + org: context.repo.owner, + projectNumbers, + dryRun: process.env.DRY_RUN === 'true', + }); diff --git a/.github/actions/sync-project-status/src/index.js b/.github/actions/sync-project-status/src/index.js new file mode 100644 index 0000000..073c021 --- /dev/null +++ b/.github/actions/sync-project-status/src/index.js @@ -0,0 +1,43 @@ +// Entry point: fetch projects, plan the sync, apply (or log, in dry-run). +'use strict'; + +const { fetchProject, applyChange } = require('./projects.js'); +const { buildPlan } = require('./plan.js'); + +module.exports = async function run({ github, core, org, projectNumbers, dryRun }) { + const projects = await Promise.all(projectNumbers.map((n) => fetchProject(github, org, n))); + + for (const p of projects) { + if (!p.statusField?.options?.length) { + core.setFailed(`Project "${p.title}" (#${p.number}) has no Status single-select field.`); + return; + } + core.info(`Project "${p.title}" (#${p.number}): ${p.items.length} items`); + } + + const { changes, skipped } = buildPlan(projects); + + for (const s of skipped) { + core.warning( + `${s.ref}: project "${s.projectTitle}" has no Status option matching "${s.sourceStatus}", skipping.` + ); + } + + for (const change of changes) { + const action = dryRun ? 'would set' : 'setting'; + core.info( + `${change.ref}: ${action} "${change.projectTitle}" status ` + + `"${change.currentStatus ?? '(unset)'}" -> "${change.newStatus}" ` + + `(source: "${change.sourceTitle}")` + ); + if (!dryRun) { + await applyChange(github, change); + } + } + + core.notice( + `Done: ${changes.length + skipped.length} mismatches found, ` + + `${changes.length} ${dryRun ? 'would be ' : ''}synced, ` + + `${skipped.length} skipped (no matching option).` + ); +}; diff --git a/.github/actions/sync-project-status/src/plan.js b/.github/actions/sync-project-status/src/plan.js new file mode 100644 index 0000000..61bf4d3 --- /dev/null +++ b/.github/actions/sync-project-status/src/plan.js @@ -0,0 +1,80 @@ +// Pure planning logic: given fetched projects, compute the status changes +// needed to bring shared items in sync. No IO here, so it's unit-testable. +'use strict'; + +// Groups project items by their issue/PR node ID. +function groupByContent(projects) { + const byContent = new Map(); + for (const project of projects) { + for (const item of project.items) { + const entry = byContent.get(item.content.id) ?? { content: item.content, items: [] }; + entry.items.push({ project, item }); + byContent.set(item.content.id, entry); + } + } + return byContent; +} + +// Picks the sync source for one shared item: the project entry whose Status +// was updated most recently. Returns null when no project has a status set. +function pickSource(entries) { + return ( + entries + .filter((e) => e.item.fieldValueByName?.updatedAt) + .sort( + (a, b) => + new Date(b.item.fieldValueByName.updatedAt) - new Date(a.item.fieldValueByName.updatedAt) + )[0] ?? null + ); +} + +// Computes the changes needed to sync statuses across projects. +// Returns { changes, skipped } where changes are ready to apply and skipped +// are mismatches with no matching Status option in the target project. +function buildPlan(projects) { + const changes = []; + const skipped = []; + + for (const { content, items } of groupByContent(projects).values()) { + if (items.length < 2) continue; + + const source = pickSource(items); + if (!source) continue; + + const sourceStatus = source.item.fieldValueByName.name; + const ref = `${content.repository.nameWithOwner}#${content.number}`; + + for (const target of items) { + if (target === source) continue; + const current = target.item.fieldValueByName?.name; + if (current?.toLowerCase() === sourceStatus.toLowerCase()) continue; + + const option = target.project.statusField.options.find( + (o) => o.name.toLowerCase() === sourceStatus.toLowerCase() + ); + const base = { + ref, + projectTitle: target.project.title, + sourceTitle: source.project.title, + currentStatus: current ?? null, + sourceStatus, + }; + if (!option) { + skipped.push(base); + continue; + } + changes.push({ + ...base, + newStatus: option.name, + projectId: target.project.id, + itemId: target.item.id, + fieldId: target.project.statusField.id, + optionId: option.id, + }); + } + } + + return { changes, skipped }; +} + +module.exports = { buildPlan, groupByContent, pickSource }; diff --git a/.github/actions/sync-project-status/src/projects.js b/.github/actions/sync-project-status/src/projects.js new file mode 100644 index 0000000..add0f53 --- /dev/null +++ b/.github/actions/sync-project-status/src/projects.js @@ -0,0 +1,76 @@ +// GraphQL access for org ProjectsV2: fetching projects/items and applying +// status changes. All IO lives here; planning logic is in plan.js. +'use strict'; + +const PROJECT_QUERY = ` + query($org: String!, $number: Int!, $cursor: String) { + organization(login: $org) { + projectV2(number: $number) { + id + title + field(name: "Status") { + ... on ProjectV2SingleSelectField { + id + options { id name } + } + } + items(first: 100, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + id + isArchived + content { + ... on Issue { id number repository { nameWithOwner } } + ... on PullRequest { id number repository { nameWithOwner } } + } + fieldValueByName(name: "Status") { + ... on ProjectV2ItemFieldSingleSelectValue { + name + optionId + updatedAt + } + } + } + } + } + } + }`; + +const UPDATE_STATUS_MUTATION = ` + mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $projectId + itemId: $itemId + fieldId: $fieldId + value: { singleSelectOptionId: $optionId } + }) { clientMutationId } + }`; + +// Fetches a project's Status field and all non-archived issue/PR items with +// their current Status value and when it was last set. +async function fetchProject(github, org, number) { + const project = { number, items: [] }; + let cursor = null; + do { + const result = await github.graphql(PROJECT_QUERY, { org, number, cursor }); + const p = result.organization.projectV2; + project.id = p.id; + project.title = p.title; + project.statusField = p.field; + project.items.push(...p.items.nodes.filter((n) => n && !n.isArchived && n.content?.id)); + cursor = p.items.pageInfo.hasNextPage ? p.items.pageInfo.endCursor : null; + } while (cursor); + return project; +} + +// Applies a single planned status change. +async function applyChange(github, change) { + await github.graphql(UPDATE_STATUS_MUTATION, { + projectId: change.projectId, + itemId: change.itemId, + fieldId: change.fieldId, + optionId: change.optionId, + }); +} + +module.exports = { fetchProject, applyChange }; diff --git a/.github/workflows/sync-project-status.yml b/.github/workflows/sync-project-status.yml new file mode 100644 index 0000000..c058883 --- /dev/null +++ b/.github/workflows/sync-project-status.yml @@ -0,0 +1,71 @@ +--- +# Keeps the Status field in sync for issues that belong to more than one +# Overture ProjectV2 (currently Overture #84, places-surge #78, and +# CloudDevOps #54). +# +# ProjectsV2 has no workflow trigger for field edits, so this runs as a +# scheduled batch job. Sync logic lives in .github/actions/sync-project-status; +# see its README for behavior and conflict resolution details. +# +# Auth: assumes a narrow OIDC role that can only read the +# overture-project-manager GH App PEM from Secrets Manager. The role, +# secret, and app are managed in omf-github-terraform. +# +name: Sync Project Status + +on: + schedule: + - cron: "17 */3 * * *" + workflow_dispatch: + inputs: + dry_run: + description: "Log intended changes without applying them" + required: false + type: boolean + default: false + +permissions: + contents: read # to check out the sync action + +concurrency: + group: sync-project-status + cancel-in-progress: false + +jobs: + sync: + name: Sync statuses + runs-on: ubuntu-slim + permissions: + contents: read + id-token: write # for OIDC authentication with AWS + env: + # Client ID of the org's overture-project-manager GitHub App (not sensitive). + PROJECT_MANAGER_APP_CLIENT_ID: "Iv23limfwiJlCIqHPHrd" + # Narrow OIDC role (omf-github-terraform oidc-aws.tf) that can only + # read the project-manager PEM secret. + PROJECT_MANAGER_OIDC_ROLE_ARN: arn:aws:iam::816069134238:role/gha-project-manager-secrets-reader + PROJECT_MANAGER_PEM_SECRET_ID: omf-github-terraform/project-manager/pem + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + sparse-checkout: .github/actions/sync-project-status + + - uses: aws-actions/configure-aws-credentials@517a711dbcd0e402f90c77e7e2f81e849156e31d # v6.2.2 + with: + aws-region: us-west-2 + role-to-assume: ${{ env.PROJECT_MANAGER_OIDC_ROLE_ARN }} + + # Exports the PEM as env.PROJECT_MANAGER_PEM, masked (incl. multi-line). + - name: Fetch project-manager PEM from Secrets Manager + uses: aws-actions/aws-secretsmanager-get-secrets@2cb1a461cbd4865ac4299648312e4704c646cd53 # v3.0.1 + with: + secret-ids: | + PROJECT_MANAGER_PEM,${{ env.PROJECT_MANAGER_PEM_SECRET_ID }} + + - name: Sync project statuses + uses: ./.github/actions/sync-project-status + with: + clientId: ${{ env.PROJECT_MANAGER_APP_CLIENT_ID }} + privateKey: ${{ env.PROJECT_MANAGER_PEM }} + dryRun: ${{ inputs.dry_run || 'false' }}