diff --git a/.github/workflows/project-sync.yml b/.github/workflows/project-sync.yml new file mode 100644 index 000000000..9194d7efc --- /dev/null +++ b/.github/workflows/project-sync.yml @@ -0,0 +1,47 @@ +name: PGS project sync + +on: + issues: + types: [opened, labeled, unlabeled, milestoned, demilestoned, closed, reopened] + pull_request_target: + types: [opened, labeled, unlabeled, closed, reopened] + workflow_dispatch: + inputs: + content_id: + description: "Issue or PR GraphQL node ID (e.g. I_kwDO... / PR_kwDO...). Obtain via: gh api graphql -f query='query{repository(owner:\"microsoft\",name:\"apm\"){issue(number:N){id}}}'" + required: true + +permissions: + contents: read + +jobs: + sync: + if: | + github.event_name == 'workflow_dispatch' || + contains(toJson(github.event.issue.labels.*.name), 'theme/') || + contains(toJson(github.event.pull_request.labels.*.name), 'theme/') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: scripts/project + - name: Resolve content node ID + id: cid + env: + # Pass workflow_dispatch input through env to avoid shell-level + # template interpolation (script-injection hardening, GHSA-pattern). + # github.event.* node IDs are GitHub-generated opaque IDs, safe to + # interpolate directly. + 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 + - name: Sync to project + env: + GITHUB_TOKEN: ${{ secrets.PROJECT_SYNC_PAT }} + run: python3 scripts/project/sync_item.py --content-id "${{ steps.cid.outputs.id }}" diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e17f629a..19d483554 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### 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) - New `pr-description-skill` skill bundle: enforces a 10-section PR body shape (TL;DR / Problem / Approach / Implementation / Diagrams / Trade-offs / Benefits / Validation / How to test, plus the `Co-authored-by` trailer) with a cite-or-omit rule for every WHY-claim, GFM-rendered output, ASCII-only template source, and validated mermaid diagrams. Captures the meta-pattern from PR #882 as a reusable scaffold so future PR bodies meet the same bar without per-PR specialist subagent intervention. (#884) - `includes:` manifest field (auto | list) for explicit governance of local `.apm/` content. Closes audit-blindness gap (#887). diff --git a/README.md b/README.md index 2dce6e952..e0fd8a2b7 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Think `package.json`, `requirements.txt`, or `Cargo.toml` — but for AI agent c GitHub Copilot · Claude Code · Cursor · OpenCode · Codex -**[Documentation](https://microsoft.github.io/apm/)** · **[Quick Start](https://microsoft.github.io/apm/getting-started/quick-start/)** · **[CLI Reference](https://microsoft.github.io/apm/reference/cli-commands/)** +**[Documentation](https://microsoft.github.io/apm/)** · **[Quick Start](https://microsoft.github.io/apm/getting-started/quick-start/)** · **[CLI Reference](https://microsoft.github.io/apm/reference/cli-commands/)** · **[Roadmap](https://github.com/orgs/microsoft/projects/2304)** --- diff --git a/scripts/project/README.md b/scripts/project/README.md new file mode 100644 index 000000000..7fcdbef02 --- /dev/null +++ b/scripts/project/README.md @@ -0,0 +1,54 @@ +# PGS project board sync + +Glue between the `microsoft/apm` repo and the [APM Roadmap project board](https://github.com/orgs/microsoft/projects/2304). + +## What this does + +`sync_item.py` reads an issue or PR's labels + milestone and projects them onto five board fields: + +| Field | Source labels / signal | Possible values | +|---|---|---| +| Theme | `theme/portability` \| `theme/security` \| `theme/governance` | Portability / Security / Governance | +| Area | `area/*` | one of 14 product areas | +| Kind | `type/*` | Bug / Feature / Docs / Refactor / Architecture / Automation / Release / Performance | +| Priority | `priority/high` \| `priority/low` \| (none) | High / Low / Normal | +| Tier | issue state + milestone title | Now / Next / Later / Shipped | + +Tier rules (see `derive_tier()`): +- Closed -> `Shipped` +- Open + open milestone titled `0.9.x`, `0.10.x`, or `0.11.x` -> `Now` (keep `NOW_MILESTONE_PREFIXES` aligned with the active release lines) +- Open + any other open milestone -> `Next` +- Open + no milestone -> `Later` + +`backfill.sh` is the one-shot helper for re-baselining the board after a label-taxonomy change. + +## How it runs + +`.github/workflows/project-sync.yml` triggers on issue + PR `opened|labeled|unlabeled|milestoned|demilestoned|closed|reopened` and on `workflow_dispatch`. The job is gated on the presence of a `theme/*` label so unrelated activity is a no-op. + +## One-time setup (org admin) + +The workflow authenticates to the org-level project via repo secret `PROJECT_SYNC_PAT`: + +1. Generate a fine-grained PAT +2. Scopes: `Projects: Read & Write` (org `microsoft`), `Issues: Read` + `Pull requests: Read` (`microsoft/apm`) +3. Save in repo settings as `PROJECT_SYNC_PAT` + +Until the secret exists the workflow runs but the sync step fails (no other side effects). + +## Manual ad-hoc sync + +```sh +GITHUB_TOKEN=$(gh auth token) python3 scripts/project/sync_item.py --content-id +``` + +To obtain a node ID: + +```sh +gh api graphql -f query='query{repository(owner:"microsoft",name:"apm"){issue(number:916){id}}}' --jq '.data.repository.issue.id' +``` + +## Followups (manual, GraphQL has no view-creation mutation) + +- 9-step UI conversion of the default view to a Now/Next/Later board sliced by Theme: see #920 +- Secondary views (Triage queue / Good first issues / Shipped log): see #920 diff --git a/scripts/project/backfill.sh b/scripts/project/backfill.sh new file mode 100755 index 000000000..4ee6eb88b --- /dev/null +++ b/scripts/project/backfill.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# Backfill all open issues+PRs in microsoft/apm that carry any theme/* label +# into the PGS project board, then sync their fields. +# +# Requires: GITHUB_TOKEN env var with project + repo scopes. +# Usage: scripts/project/backfill.sh [--limit N] +set -euo pipefail + +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="" +# NOTE: keep this theme list in sync with THEME_MAP in sync_item.py. +# Search query OR semantics require one round-trip per theme; results are +# unioned via `sort -u` below. +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 } } + } + }' --jq '.data.search.nodes[] | "\(.id)\t#\(.number) \(.title)"') + ISSUES=$(printf "%s\n%s" "$ISSUES" "$CHUNK") +done +ISSUES=$(echo "$ISSUES" | grep -v '^$' | sort -u) + +echo "$ISSUES" | while IFS=$'\t' read -r ID REF; do + if [ -z "$ID" ]; then continue; fi + echo "==> $REF" + python3 "$HERE/sync_item.py" --content-id "$ID" --project-id "$PROJECT_ID" || echo " FAIL on $REF" +done + +echo "Done." diff --git a/scripts/project/sync_item.py b/scripts/project/sync_item.py new file mode 100755 index 000000000..9a7c66db0 --- /dev/null +++ b/scripts/project/sync_item.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +"""Sync one issue or PR to the APM PGS project board. + +Reads labels and milestone from a GitHub issue/PR, then sets the +project's Theme/Area/Kind/Priority/Tier fields accordingly. + +Idempotent: safe to call on every label change. + +Usage: + python sync_item.py --content-id [--project-id ] + +Requires GITHUB_TOKEN env var with project + repo scopes. +""" +import argparse +import json +import os +import sys +import urllib.request +import urllib.error + +PROJECT_ID = "PVT_kwDOAF3p4s4BVoGw" +GQL_URL = "https://api.github.com/graphql" + +# Milestone-title prefixes that map to Tier=Now. Keep aligned with the +# active release lines (current development line + still-open patch lines). +# See pyproject.toml `version` and the open milestones in microsoft/apm. +# Anything outside this list with an open milestone falls into Tier=Next. +NOW_MILESTONE_PREFIXES = ("0.9.", "0.10.", "0.11.") + +THEME_MAP = { + "theme/portability": "Portability", + "theme/security": "Security", + "theme/governance": "Governance", +} +KIND_MAP = { + "type/bug": "Bug", + "type/feature": "Feature", + "type/docs": "Docs", + "type/refactor": "Refactor", + "type/architecture": "Architecture", + "type/automation": "Automation", + "type/release": "Release", + "type/performance": "Performance", +} +PRIORITY_MAP = { + "priority/high": "High", + "priority/low": "Low", +} +AREA_NAMES = { + "area/multi-target", "area/marketplace", "area/package-authoring", + "area/distribution", "area/mcp-config", "area/content-security", + "area/lockfile", "area/mcp-trust", "area/audit-policy", + "area/enterprise", "area/cli", "area/ci-cd", "area/testing", + "area/docs-site", +} + + +def gql(query, variables=None): + token = os.environ["GITHUB_TOKEN"] + body = json.dumps({"query": query, "variables": variables or {}}).encode() + req = urllib.request.Request( + GQL_URL, + data=body, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + "Accept": "application/json", + }, + ) + try: + with urllib.request.urlopen(req) as resp: + data = json.loads(resp.read()) + except urllib.error.HTTPError as e: + sys.stderr.write(f"HTTP {e.code}: {e.read().decode()}\n") + raise + if "errors" in data: + sys.stderr.write(json.dumps(data["errors"], indent=2) + "\n") + raise SystemExit(2) + return data["data"] + + +def fetch_project_meta(project_id): + q = """ + query($id: ID!) { + node(id: $id) { + ... on ProjectV2 { + fields(first: 50) { + nodes { + ... on ProjectV2SingleSelectField { + id name options { id name } + } + } + } + } + } + }""" + data = gql(q, {"id": project_id}) + fields = {} + for node in data["node"]["fields"]["nodes"]: + if not node: + continue + if "options" in node: + fields[node["name"]] = { + "id": node["id"], + "options": {opt["name"]: opt["id"] for opt in node["options"]}, + } + return fields + + +def fetch_content(content_id): + q = """ + query($id: ID!) { + node(id: $id) { + __typename + ... on Issue { + number title state url repository { nameWithOwner } + labels(first: 50) { nodes { name } } + milestone { title state } + } + ... on PullRequest { + number title state url repository { nameWithOwner } + labels(first: 50) { nodes { name } } + milestone { title state } + } + } + }""" + return gql(q, {"id": content_id})["node"] + + +def add_to_project(project_id, content_id): + q = """ + mutation($pid: ID!, $cid: ID!) { + addProjectV2ItemById(input: {projectId: $pid, contentId: $cid}) { + item { id } + } + }""" + return gql(q, {"pid": project_id, "cid": content_id})["addProjectV2ItemById"]["item"]["id"] + + +def update_single_select(project_id, item_id, field_id, option_id): + q = """ + mutation($pid: ID!, $iid: ID!, $fid: ID!, $oid: String!) { + updateProjectV2ItemFieldValue(input: { + projectId: $pid, itemId: $iid, fieldId: $fid, + value: { singleSelectOptionId: $oid } + }) { projectV2Item { id } } + }""" + gql(q, {"pid": project_id, "iid": item_id, "fid": field_id, "oid": option_id}) + + +def clear_field(project_id, item_id, field_id): + q = """ + mutation($pid: ID!, $iid: ID!, $fid: ID!) { + clearProjectV2ItemFieldValue(input: {projectId: $pid, itemId: $iid, fieldId: $fid}) { + projectV2Item { id } + } + }""" + gql(q, {"pid": project_id, "iid": item_id, "fid": field_id}) + + +def derive_tier(content): + state = content.get("state", "OPEN") + ms = content.get("milestone") + if state == "CLOSED": + return "Shipped" + if ms and ms.get("state") == "OPEN": + title = ms["title"] + if title.startswith(NOW_MILESTONE_PREFIXES): + return "Now" + return "Next" + return "Later" + + +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 main(): + ap = argparse.ArgumentParser() + ap.add_argument("--content-id", required=True) + ap.add_argument("--project-id", default=PROJECT_ID) + args = ap.parse_args() + + fields = fetch_project_meta(args.project_id) + content = fetch_content(args.content_id) + if not content: + sys.stderr.write(f"content not found: {args.content_id}\n") + return 1 + + labels = [n["name"] for n in content["labels"]["nodes"]] + print(f"Syncing {content.get('repository', {}).get('nameWithOwner')}#{content['number']}: {content['title']}") + print(f" labels: {labels}") + + item_id = add_to_project(args.project_id, args.content_id) + print(f" item: {item_id}") + + theme = derive_field_value(labels, THEME_MAP) + kind = derive_field_value(labels, KIND_MAP) + priority = derive_field_value(labels, PRIORITY_MAP) or "Normal" + area = derive_area(labels) + tier = derive_tier(content) + + plan = {"Theme": theme, "Area": area, "Kind": kind, "Priority": priority, "Tier": tier} + for field_name, value in plan.items(): + field = fields.get(field_name) + if not field: + print(f" ! field {field_name} missing from project") + continue + if value is None: + clear_field(args.project_id, item_id, field["id"]) + print(f" - {field_name}: cleared") + continue + opt_id = field["options"].get(value) + if not opt_id: + print(f" ! {field_name}: option '{value}' not found") + continue + update_single_select(args.project_id, item_id, field["id"], opt_id) + print(f" + {field_name}: {value}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main())