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
47 changes: 47 additions & 0 deletions .github/workflows/project-sync.yml
Original file line number Diff line number Diff line change
@@ -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' ||
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.
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
Comment on lines +23 to +27
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.
- 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 }}"
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Comment on lines 11 to 13
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.
- 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).
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)**

---

Expand Down
54 changes: 54 additions & 0 deletions scripts/project/README.md
Original file line number Diff line number Diff line change
@@ -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 <node_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
38 changes: 38 additions & 0 deletions scripts/project/backfill.sh
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +5 to +7
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.

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."
234 changes: 234 additions & 0 deletions scripts/project/sync_item.py
Original file line number Diff line number Diff line change
@@ -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 <node_id> [--project-id <pvt_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


Comment on lines +174 to +187
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.
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}")
Comment on lines +201 to +202
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.

item_id = add_to_project(args.project_id, args.content_id)
print(f" item: {item_id}")
Comment on lines +204 to +205
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.

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())
Loading