-
Notifications
You must be signed in to change notification settings - Fork 185
feat(ci): PGS project board sync workflow + bootstrap script #919
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6cf502f
2b90df2
16f64d0
1e1702a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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' || | ||
| 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
|
||
| - 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 }}" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
|
||
| - 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). | ||
|
|
||
| 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 |
| 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
|
||
|
|
||
| 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." | ||
| 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
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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
AI
Apr 24, 2026
There was a problem hiding this comment.
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
AI
Apr 24, 2026
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The job-level
ifonly runs when the current label set containstheme/. Forunlabeledevents that remove the lasttheme/*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'slabel.namefor labeled/unlabeled actions (e.g., run whengithub.event.label.namestarts withtheme/).