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
113 changes: 113 additions & 0 deletions .github/actions/sync-project-status/README.md
Original file line number Diff line number Diff line change
@@ -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.
87 changes: 87 additions & 0 deletions .github/actions/sync-project-status/action.yml
Original file line number Diff line number Diff line change
@@ -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/<number>.
# 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',
});
43 changes: 43 additions & 0 deletions .github/actions/sync-project-status/src/index.js
Original file line number Diff line number Diff line change
@@ -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).`
);
};
80 changes: 80 additions & 0 deletions .github/actions/sync-project-status/src/plan.js
Original file line number Diff line number Diff line change
@@ -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 };
Loading