diff --git a/.github/scripts/add-to-project/classify-initiative.sh b/.github/scripts/add-to-project/classify-initiative.sh new file mode 100755 index 000000000..30408e1e4 --- /dev/null +++ b/.github/scripts/add-to-project/classify-initiative.sh @@ -0,0 +1,309 @@ +#!/usr/bin/env bash +# classify-initiative.sh — assign the Initiative single-select field (and its +# rolled-up Theme) on Initiatives-project items that don't have one yet. +# +# Why this exists (petry-projects/.github#415): the add-to-project automation +# only ever places items ON the board (addProjectV2ItemById); nothing set the +# Initiative field, so nearly every item sat on the board unassociated with any +# initiative. This is the continuous, deterministic classifier that fills that +# gap — and its first run back-fills the existing blank items. +# +# Classification is RULE-DRIVEN, not AI: each item's title + labels + repo are +# flattened into a lowercase "signature" and matched against ordered regex +# rules (initiative-rules.tsv, first match wins). A matched item gets its +# Initiative set, plus the Theme that Initiative rolls up to +# (initiative-taxonomy.tsv). An item that matches NO rule is left blank and +# reported for triage — the classifier never guesses a bucket. +# +# Safety: this only ever writes per-item field VALUES +# (updateProjectV2ItemFieldValue via lib.sh's set_item_single_select_value). +# It never touches the field SCHEMA (updateProjectV2Field), so it cannot trip +# the single-select option-wipe footgun documented in +# standards/initiatives-project.md. By default it only fills items whose +# Initiative is currently empty, so a human's manual assignment is never +# overwritten; RECLASSIFY=all re-evaluates every item. +# +# Required env: +# PROJECT_ID ProjectV2 node ID of the Initiatives project +# GH_TOKEN Token with org Projects: Read+write +# +# Optional env: +# PROJECT_URL Logged in human-readable messages only +# DRY_RUN=1 Log intended writes, mutate nothing +# RECLASSIFY=all Re-evaluate items that already have an Initiative +# RULES_FILE Override path to the rules TSV +# TAXONOMY_FILE Override path to the Initiative→Theme TSV +# PAGE_SIZE Items fetched per GraphQL page (default 100) +# INITIATIVE_FIELD Initiative field name (default "Initiative") +# THEME_FIELD Theme field name (default "Theme") +# +# Functions (sourceable / unit-tested): +# normalize_signature <labels_json> <repo> +# classify_by_rules <signature> → Initiative name or "" (no match) +# theme_for <initiative> → Theme name or "" +# decide_for_signature <signature> → "<initiative>\t<theme>" or "" +# resolve_fields → populate field-id / option maps +# sweep_project → paginate + reconcile the board + +set -euo pipefail + +_ci_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lib.sh +. "${_ci_dir}/lib.sh" + +# Field ids + name→optionId maps, resolved once per run by resolve_fields. +declare -gA CI_INIT_OPT CI_THEME_OPT +CI_INIT_FIELD_ID="" +CI_THEME_FIELD_ID="" + +# normalize_signature <title> <labels_json> <repo> +# Flatten an item into one lowercase line "title | l1,l2 | owner/repo" that +# the regex rules match against. A non-array labels_json (null/object from +# odd payloads) degrades to no labels rather than aborting under set -e. +# +# Gate labels are STRIPPED from the signature (SIGNATURE_IGNORE_LABELS, +# default = the required + excluded noise-gate labels). This matters: +# every board item carries the `dev-lead` required label, so leaving it in +# would make the `dev-lead agent` rule match every single item. Only labels +# that actually signal an initiative survive into the signature. +normalize_signature() { + if [ "$#" -ne 3 ]; then + printf '[normalize_signature] expected 3 args (title labels_json repo), got %d\n' "$#" >&2 + return 64 + fi + local title="$1" labels_json="$2" repo="$3" labels="" + # Drop routing/process labels that carry no initiative signal, so they can't + # drive a classification. Two mechanisms: + # 1. an explicit ignore list (SIGNATURE_IGNORE_LABELS) — the noise-gate + # labels (dev-lead + the excluded set); + # 2. a family-prefix strip for `dev-lead*` and `initiative*`, which also + # removes their colon-variants (`dev-lead:needs-human`, + # `initiative:auto`, …). Those variants contain "dev-lead"/"initiative" + # and would otherwise make the `dev-lead agent` / `Initiatives Project` + # rule tokens match every pipeline-routed item. + local ignore="${SIGNATURE_IGNORE_LABELS-dev-lead,compliance-audit,health-check,fleet-tracker,daily-report}" + if printf '%s' "${labels_json}" | jq -e 'type == "array"' >/dev/null 2>&1; then + labels=$(printf '%s' "${labels_json}" | jq -r --arg ig "${ignore}" ' + ($ig | ascii_downcase | split(",") | map(gsub("^\\s+|\\s+$";"")) | map(select(length > 0))) as $drop + | [ .[].name + | select((ascii_downcase) as $n + | (($drop | index($n)) | not) + and (($n | test("^(dev-lead|initiative)(:|$)")) | not)) ] + | join(",")') + fi + printf '%s | %s | %s' "${title}" "${labels}" "${repo}" | tr '[:upper:]' '[:lower:]' +} + +# classify_by_rules <signature> → first-matching Initiative name, or "". +classify_by_rules() { + if [ "$#" -ne 1 ]; then + printf '[classify_by_rules] expected 1 arg (signature), got %d\n' "$#" >&2 + return 64 + fi + local sig="$1" name rx + local rules="${RULES_FILE:-${_ci_dir}/initiative-rules.tsv}" + if [ ! -f "${rules}" ]; then + printf '[classify_by_rules] rules file not found: %s\n' "${rules}" >&2 + return 65 + fi + while IFS=$'\t' read -r name rx || [ -n "${name}" ]; do + name="${name%$'\r'}" + rx="${rx%$'\r'}" + case "${name}" in ''|'#'*) continue ;; esac + [ -n "${rx}" ] || continue + if printf '%s' "${sig}" | grep -Eiq -- "${rx}"; then + printf '%s' "${name}" + return 0 + fi + done < "${rules}" + return 0 +} + +# theme_for <initiative> → the Theme it rolls up to, or "". +theme_for() { + if [ "$#" -ne 1 ]; then + printf '[theme_for] expected 1 arg (initiative), got %d\n' "$#" >&2 + return 64 + fi + local want="$1" init theme + local tax="${TAXONOMY_FILE:-${_ci_dir}/initiative-taxonomy.tsv}" + if [ ! -f "${tax}" ]; then + printf '[theme_for] taxonomy file not found: %s\n' "${tax}" >&2 + return 65 + fi + while IFS=$'\t' read -r init theme || [ -n "${init}" ]; do + init="${init%$'\r'}" + theme="${theme%$'\r'}" + case "${init}" in ''|'#'*) continue ;; esac + if [ "${init}" = "${want}" ]; then + printf '%s' "${theme}" + return 0 + fi + done < "${tax}" + return 0 +} + +# decide_for_signature <signature> → "<initiative>\t<theme>" (theme may be +# empty), or "" when no rule matches. +decide_for_signature() { + if [ "$#" -ne 1 ]; then + printf '[decide_for_signature] expected 1 arg (signature), got %d\n' "$#" >&2 + return 64 + fi + local sig="$1" init theme + init=$(classify_by_rules "${sig}") || return $? + [ -n "${init}" ] || return 0 + theme=$(theme_for "${init}") || return $? + printf '%s\t%s' "${init}" "${theme}" +} + +# resolve_fields — query the project once for the Initiative + Theme +# single-select field ids and their live option name→id maps. Fails loudly +# (75) if the project node is unreachable, (65) if the Initiative field is +# absent. Theme is best-effort: its absence is not fatal. +resolve_fields() { + _atp_require_env resolve_fields || return $? + local json + # shellcheck disable=SC2016 # $projectId/$initName/$themeName are GraphQL variables + json=$(gh api graphql \ + -F projectId="${PROJECT_ID}" \ + -F initName="${INITIATIVE_FIELD:-Initiative}" \ + -F themeName="${THEME_FIELD:-Theme}" \ + -f query='query($projectId:ID!,$initName:String!,$themeName:String!){ + node(id:$projectId){ + ... on ProjectV2 { + initiative: field(name:$initName){ ... on ProjectV2SingleSelectField { id options{ id name } } } + theme: field(name:$themeName){ ... on ProjectV2SingleSelectField { id options{ id name } } } + } + } + }') + + if [ "$(printf '%s' "${json}" | jq -r '.data.node?')" = "null" ]; then + printf '[resolve_fields] GraphQL returned data.node:null for PROJECT_ID=%s — token may lack access, or the project was deleted.\n' "${PROJECT_ID}" >&2 + return 75 + fi + CI_INIT_FIELD_ID=$(printf '%s' "${json}" | jq -r '.data.node.initiative.id? // ""') + CI_THEME_FIELD_ID=$(printf '%s' "${json}" | jq -r '.data.node.theme.id? // ""') + if [ -z "${CI_INIT_FIELD_ID}" ]; then + printf '[resolve_fields] Initiative single-select field %q not found on the project.\n' "${INITIATIVE_FIELD:-Initiative}" >&2 + return 65 + fi + + local id name + while IFS=$'\t' read -r id name; do + [ -n "${id}" ] && CI_INIT_OPT["${name}"]="${id}" + done < <(printf '%s' "${json}" | jq -r '.data.node.initiative.options?[]? | "\(.id)\t\(.name)"') + while IFS=$'\t' read -r id name; do + [ -n "${id}" ] && CI_THEME_OPT["${name}"]="${id}" + done < <(printf '%s' "${json}" | jq -r '.data.node.theme.options?[]? | "\(.id)\t\(.name)"') +} + +# _ci_report <total> <already> <matched> <unmatched> <skipped> +_ci_report() { + local total="$1" already="$2" matched="$3" unmatched="$4" skipped="$5" + local mode="apply"; [ "${DRY_RUN:-}" = "1" ] && mode="dry-run" + printf '\n=== classify-initiative summary (%s) ===\n' "${mode}" + printf ' board items scanned : %d\n' "${total}" + printf ' already associated : %d (skipped; RECLASSIFY=all to re-evaluate)\n' "${already}" + printf ' newly matched : %d\n' "${matched}" + printf ' unmatched (blank) : %d\n' "${unmatched}" + printf ' option-missing skip : %d\n' "${skipped}" + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + { + printf '### classify-initiative (%s)\n\n' "${mode}" + printf '| scanned | already | matched | unmatched | skipped |\n' + printf '|--:|--:|--:|--:|--:|\n' + printf '| %d | %d | %d | %d | %d |\n' "${total}" "${already}" "${matched}" "${unmatched}" "${skipped}" + } >> "${GITHUB_STEP_SUMMARY}" + fi +} + +# sweep_project — resolve fields, page through every board item, and set the +# Initiative (+ Theme) on each qualifying item. Idempotent; DRY_RUN-aware. +sweep_project() { + _atp_require_env sweep_project || return $? + resolve_fields || return $? + + local page_size="${PAGE_SIZE:-100}" items + # --paginate concatenates one JSON document per page; jq -s slurps them and + # flattens all item nodes into a single array. + # shellcheck disable=SC2016 # GraphQL variables, not shell + items=$(gh api graphql --paginate \ + -F projectId="${PROJECT_ID}" \ + -F pageSize="${page_size}" \ + -F initName="${INITIATIVE_FIELD:-Initiative}" \ + -f query='query($projectId:ID!,$pageSize:Int!,$endCursor:String,$initName:String!){ + node(id:$projectId){ + ... on ProjectV2 { + items(first:$pageSize, after:$endCursor){ + pageInfo{ hasNextPage endCursor } + nodes{ + id + initiative: fieldValueByName(name:$initName){ ... on ProjectV2ItemFieldSingleSelectValue { name } } + content{ + __typename + ... on Issue { title labels(first:20){ nodes{ name } } repository{ nameWithOwner } } + ... on PullRequest { title labels(first:20){ nodes{ name } } repository{ nameWithOwner } } + ... on DraftIssue { title } + } + } + } + } + } + }' | jq -s '[.[].data.node.items.nodes?[]?]') + + local total=0 already=0 matched=0 unmatched=0 skipped=0 + local node + while IFS= read -r node; do + [ -n "${node}" ] || continue + total=$((total + 1)) + + local item_id cur title labels repo sig decided init theme optid + item_id=$(printf '%s' "${node}" | jq -r '.id?') + cur=$(printf '%s' "${node}" | jq -r '.initiative.name? // ""') + title=$(printf '%s' "${node}" | jq -r '.content.title? // ""') + labels=$(printf '%s' "${node}" | jq -c '.content.labels.nodes? // []') + repo=$(printf '%s' "${node}" | jq -r '.content.repository.nameWithOwner? // ""') + + if [ -n "${cur}" ] && [ "${RECLASSIFY:-}" != "all" ]; then + already=$((already + 1)) + continue + fi + + sig=$(normalize_signature "${title}" "${labels}" "${repo}") + decided=$(decide_for_signature "${sig}") + init="${decided%%$'\t'*}" + theme="${decided#*$'\t'}" + + if [ -z "${init}" ]; then + unmatched=$((unmatched + 1)) + printf 'UNMATCHED %-20s «%s»\n' "${repo:-draft}" "${title}" + continue + fi + + optid="${CI_INIT_OPT[${init}]:-}" + if [ -z "${optid}" ]; then + printf '::warning::rule matched Initiative %q which is not a live project option; skipping «%s»\n' "${init}" "${title}" >&2 + skipped=$((skipped + 1)) + continue + fi + + printf 'MATCH %-22s <- %-20s «%s»\n' "${init}" "${repo:-draft}" "${title}" + set_item_single_select_value "${item_id}" "${CI_INIT_FIELD_ID}" "${optid}" + matched=$((matched + 1)) + + # Theme is best-effort: co-assign only when the field and matching option + # both exist live. A missing Theme field/option is silently tolerated. + if [ -n "${theme}" ] && [ -n "${CI_THEME_FIELD_ID}" ]; then + local topt="${CI_THEME_OPT[${theme}]:-}" + [ -n "${topt}" ] && set_item_single_select_value "${item_id}" "${CI_THEME_FIELD_ID}" "${topt}" + fi + done < <(printf '%s' "${items}" | jq -c '.[]') + + _ci_report "${total}" "${already}" "${matched}" "${unmatched}" "${skipped}" +} + +if [ "${BASH_SOURCE[0]}" = "${0}" ]; then + sweep_project +fi diff --git a/.github/scripts/add-to-project/initiative-rules.tsv b/.github/scripts/add-to-project/initiative-rules.tsv new file mode 100644 index 000000000..ccc3e2b7a --- /dev/null +++ b/.github/scripts/add-to-project/initiative-rules.tsv @@ -0,0 +1,38 @@ +# Initiative <TAB> ERE regex — deterministic classification rules for +# classify-initiative.sh, matched (case-insensitive) against a per-item +# signature = "title | label,label | owner/repo", all lowercased. +# +# ORDER IS PRIORITY (first match wins). Two tiers: +# 1. CROSS-CUTTING infra/agentic initiatives come FIRST — they win +# regardless of which repo the work lives in. This is deliberate: a +# SonarCloud/Scorecard sweep or an auto-rebase fix inside a product repo +# is Org Standards / Auto-rebase work, not product work (see +# standards/initiatives-project.md → Org Standards covers scorecard/ +# sonarcloud across all repos). +# 2. PRODUCT initiatives come LAST — a repo-scoped fallback. Anything in a +# product repo that no infra rule claimed, plus product-feature idea +# drafts (which have no repo), get the product bucket via repo name or +# product-feature keywords. +# Unmatched items are left BLANK and reported for triage — never guessed. +Auto-rebase auto.?rebase|update.?branch|rebase (bot|loop|collision)|conflict.?comment|dismiss.?stale|last.?push.?approval|stale.?base +pr-review agent pr.?review|(code|bot|single|advisory) review|review.?(one.?pr|decision|enumeration|loop|comments?|bot|gate|findings|annotation|enhancement|dismissal|tier|cycles?|retry)|single.?review|max_review|rubber duck|cascade|advisory.?bot|coderabbit|(gemini|copilot|sonarcloud|codex) review|fix.?request|extract_verdict|verdict|mention.?(ack|trigger)|changes_requested|@petry-review|@donpetry-bot|review-one-pr|list-prs|copilot suggest|github models|churn breaker|self.?trigger|downstream.?impact|pr_diff|mcp.*(review|tiers?|knob|config|server)|lsp (index|pilot|tool)|reviewer|awaiting review|#860 +Business Analyst feature.?ideation|idea.?enhanc|idea.?triage|idea-enhancer|business analyst|enhance .*ideas?|single-idea enhancement|idea..?initiative pipeline +Model Selection model.?(selection|fallback|tier|switch)|fallback model|opus.?fallback|retire .*(opus|gpt|claude-|model)|update to opus|opus 4|cross.?provider|provider.?switch|additional providers|providers,? .*model|engine.?(availability|model|upgrade)|multi-model chain|rate.?limit resilience +Agent Shield agent.?shield|prompt.?injection|input sanitiz|sanitiz|agent.?(security|hardening|governance)|jailbreak|secret.?redact|mcp.*(governance|allowlist|registry)|credential isolation|owasp|posture scoring|egress (firewall|policy|filter|rule)|firewall policy|trust escalation|agent autonomy +Self-healing self.?heal|auto.?remediat|auto.?fix|self.?repair|stall detect|pipeline stall|recovery escalation|kill switch|circuit breaker|runaway (protection|agent|pr) +dev-lead agent dev.?lead|deep.?impl|bmad|engine.?(timeout|error)|story churn|phantom.?spec|thin.?caller|inlined (claude|workflow)|claude\.yml|claude-fix|claude-?issue|claude code workflow|claude workflow|issue automation|prompting framework|devops agent persona|duplicate .*(pr|agent)|dedup|umbrella issue|in-progress label +GH-AW gh-?aw|agentic.?workflow|(^|[^[:alnum:]])aw([^[:alnum:]]|$)|ci.?failure|failure.?analyst|ci-?analyst|failure-to-agent|lock\.?file|lock\.yml|eval scorer|held-out|rubric|proposer|skill.?eval|skillopt|skill.?candidate|plan_json|artifact (type|contract)|self-improving|eval-case|evals/|agents\.md (spec|align) +Copilot Instructions copilot.*instruction|copilot coding agent|copilot governance|copilot custom|\.github/copilot|copilot-instructions +Cost Observability cost (attribution|observ|meter|arbitrage|report|api)|token.?(cost|savings|report|metric|observ)|budget (enforcement|circuit breaker|guardrail|tracking|aware)|credit budget|observability dashboard|opentelemetry|otel|effective token|\bet\b .*anomaly|usage & cost|billing-aware|batch api cost +Fleet Monitor fleet.?(monitor|tracker|view|health)|health.?check|hourly runs?|cron entr|scheduled .*(runs?|actions) +Daily Reports daily.?report|nightly.?report|status.?digest|org.?status +Compliance Blitz compliance.?blitz|compliance.?day|compliance backlog +Org Standards sonarcloud|sonar[ ._-]|s7[0-9]{3}|s86[0-9]{2}|s59[0-9]{2}|s3776|s5906|scorecard|ruleset|codeowners|branch.?(protection|ruleset)|push.?protection|ci.?standard|baseline|standards.?(sync|deploy|reconcile)|nosonar|secret.?scan|gitleaks|dependency.?(harden|pinning)|pin.*(sha|ref|reusable|version|action)|actionlint|yamllint|lint check|dependabot|org.?standard|org-wide .*(standard|instruction|reusable)|code.?quality|zizmor|oidc|access control|standards drift|drift.?(guard|detection)|integrity verification|execution protection|security linting|pull.?request.?limits|pr.?limits|admission gate|agents\.md|claude\.md|codeql|reusable workflow|apply-repo-settings|repo settings|profile readme|coding standard|ci pipeline|markdown lint|concurrency group|check-suite|slsa|attestation|bootstrap.?new.?repo|repo.?template|non-file policy|require .*discussions|has_discussions|structured logging|cqrs|stacked pr|worktree|convention|merge queue|noise reduction|cognitive complexity|jest assertion +Compliance program compliance|audit|remediat +Release Strategy release.?(strategy|train|notes|channel|cadence|automation|cut|soak|ring)|cut.?release|autocut|soak.?(promote|and-promote)|ring.?(rollout|promote|enrol|stag)|channel.?(promote|cut|soak|pin)|canary|version(ing)? (scheme|standard)|semantic versioning|tag protection|rollback|promote .*(channel|ring|stable) +Initiatives Project initiative|add.?to.?project|projects? v2|project board|roadmap view|initiatives project|issue.?fields|github projects +Tooling tooling|scratchpad|dev.?tool|utility script|template gallery +Broodly /broodly|broodminder|\bhive\b|honey|nectar|bloom|colony|\bmite|apiary|beekeep|hornet|treatment efficacy|nfc and qr|acoustic colony|zero-tap|off-season engagement +Markets /markets|snap/ebt|produce recognition|app-averse|failed-trip|vendor catalog|north-star|discover filter|sms check-in +Google App Scripts /google-app-scripts|apps? script|script_catalog|gmail|deployed-script +TalkTerm /talkterm|avatar|elevenlabs|lip-sync|screen companion|companion mode|3d avatar|spatial audio|byok|effort modes|voice (assistant|agent|interface|provider|transcription)|conversational (voice|oauth|permission|mcp|onboarding)|screenshot context|deliberation mode|emotion-aware|multi-language voice|infinite conversation|on-device (voice|ai)|vision-native diff --git a/.github/scripts/add-to-project/initiative-taxonomy.tsv b/.github/scripts/add-to-project/initiative-taxonomy.tsv new file mode 100644 index 000000000..9dd686865 --- /dev/null +++ b/.github/scripts/add-to-project/initiative-taxonomy.tsv @@ -0,0 +1,25 @@ +# Initiative <TAB> Theme — canonical roll-up used by classify-initiative.sh. +# Comments (#) and blank lines ignored. See standards/initiatives-project.md. +dev-lead agent Agentic Framework +pr-review agent Agentic Framework +GH-AW Agentic Framework +Copilot Instructions Agentic Framework +Agent Shield Agentic Framework +Model Selection Agentic Framework +Fleet Monitor Fleet Operations +Daily Reports Fleet Operations +Org Standards Fleet Operations +Release Strategy Fleet Operations +Compliance program Compliance +Compliance Blitz Compliance +Self-healing Compliance +Auto-rebase Compliance +Initiatives Project Tooling +Tooling Tooling +Ad hoc Ad hoc +Business Analyst Agentic Framework +Cost Observability Fleet Operations +TalkTerm Products +Broodly Products +Markets Products +Google App Scripts Products diff --git a/.github/scripts/add-to-project/lib.sh b/.github/scripts/add-to-project/lib.sh index 04d19baea..9f2bb86f4 100644 --- a/.github/scripts/add-to-project/lib.sh +++ b/.github/scripts/add-to-project/lib.sh @@ -260,3 +260,45 @@ delete_project_item() { return 1 fi } + +# set_item_single_select_value <item_id> <field_id> <option_id> +# Set a single-select field on one project item to the given option. +# +# This is updateProjectV2ItemFieldValue — a per-item VALUE write. It is NOT +# updateProjectV2Field (the schema mutation that full-replaces the option +# list and can orphan every item's value; see +# standards/initiatives-project.md → "Adding or modifying single-select +# options safely"). Writing an item value here can never wipe the schema, so +# a back-fill sweep over hundreds of items is inherently safe. +# +# Exit 0 on success; 64 on bad args. DRY_RUN=1 logs the intended write and +# mutates nothing. +set_item_single_select_value() { + if [ "$#" -ne 3 ]; then + printf '[set_item_single_select_value] expected 3 args (item_id field_id option_id), got %d\n' "$#" >&2 + return 64 + fi + local item_id="$1" + local field_id="$2" + local option_id="$3" + + if [ "${DRY_RUN:-}" = "1" ]; then + printf '[dry-run] would set item %s field %s = option %s\n' "${item_id}" "${field_id}" "${option_id}" + return 0 + fi + + # shellcheck disable=SC2016 # $projectId/$itemId/$fieldId/$optionId are GraphQL variables + gh api graphql \ + -F projectId="${PROJECT_ID}" \ + -F itemId="${item_id}" \ + -F fieldId="${field_id}" \ + -F optionId="${option_id}" \ + -f query='mutation($projectId:ID!,$itemId:ID!,$fieldId:ID!,$optionId:String!){ + updateProjectV2ItemFieldValue(input:{ + projectId:$projectId, itemId:$itemId, fieldId:$fieldId, + value:{ singleSelectOptionId:$optionId } + }){ + projectV2Item { id } + } + }' >/dev/null +} diff --git a/.github/workflows/add-to-project-reconcile.yml b/.github/workflows/add-to-project-reconcile.yml index bbd5f324e..dcb94b0ae 100644 --- a/.github/workflows/add-to-project-reconcile.yml +++ b/.github/workflows/add-to-project-reconcile.yml @@ -9,19 +9,29 @@ # # Runs centrally in petry-projects/.github (the scripts live here); mints the # petry-projects-planner App token for issues/PRs read + project write. +# +# Two phases run in order each cycle: +# 1. reconcile — ensure every qualifying issue/PR/Idea is ON the board. +# 2. classify — set the Initiative (+ Theme) field on board items that +# don't have one yet (petry-projects/.github#415). Classify runs AFTER +# reconcile so items added this cycle are categorized in the same run. name: Add-to-Project Reconcile on: workflow_dispatch: inputs: dry_run: - description: "Log intended adds/removes without mutating the board" + description: "Log intended adds/removes/classifications without mutating the board" type: boolean default: true repos: description: "Optional space-separated owner/repo list (default: all App-installed repos)" type: string default: "" + reclassify_all: + description: "Re-evaluate the Initiative on items that already have one (default: only fill blanks)" + type: boolean + default: false schedule: - cron: "0 7 * * *" # daily 07:00 UTC @@ -63,3 +73,15 @@ jobs: # Manual runs default to dry-run; scheduled runs are live. DRY_RUN: ${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run) && '1' || '0' }} run: bash .github/scripts/add-to-project/reconcile-backlog.sh + + - name: Classify Initiative on board items + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + PROJECT_ID: PVT_kwDOD2inqs4BZq3- + PROJECT_URL: https://github.com/orgs/petry-projects/projects/1 + # Same dry-run gating as reconcile: manual runs default to dry-run, + # scheduled runs are live. + DRY_RUN: ${{ (github.event_name == 'workflow_dispatch' && inputs.dry_run) && '1' || '0' }} + # Only fill blank Initiatives by default; opt in to re-evaluate all. + RECLASSIFY: ${{ (github.event_name == 'workflow_dispatch' && inputs.reclassify_all) && 'all' || '' }} + run: bash .github/scripts/add-to-project/classify-initiative.sh diff --git a/standards/initiatives-project.md b/standards/initiatives-project.md index 80b27d4ea..2e6e32d7f 100644 --- a/standards/initiatives-project.md +++ b/standards/initiatives-project.md @@ -109,8 +109,8 @@ Theme. Roadmap-view grouping uses Initiative; cross-cutting filters | Theme | Initiatives | |---|---| -| **Agentic Framework** | `dev-lead agent`, `pr-review agent`, `GH-AW`, `Copilot Instructions`, `Agent Shield`, `Model fallback` | -| **Fleet Operations** | `Fleet Monitor`, `Daily Reports`, `Org Standards` | +| **Agentic Framework** | `dev-lead agent`, `pr-review agent`, `GH-AW`, `Copilot Instructions`, `Agent Shield`, `Model Selection` | +| **Fleet Operations** | `Fleet Monitor`, `Daily Reports`, `Org Standards`, `Release Strategy` | | **Compliance** | `Compliance program`, `Compliance Blitz`, `Self-healing`, `Auto-rebase` | | **Tooling** | `Initiatives Project`, `Tooling` | | **Ad hoc** | `Ad hoc` | @@ -195,12 +195,15 @@ configured manually in the UI): ```text .github/workflows/add-to-project.yml # Workflow (events → script call) -.github/workflows/add-to-project-reconcile.yml # Scheduled/manual backlog reconcile (#518) +.github/workflows/add-to-project-reconcile.yml # Scheduled/manual backlog reconcile (#518) + classify (#415) .github/scripts/add-to-project/ - lib.sh # find/add/draft/delete helpers (DRY_RUN-aware) + lib.sh # find/add/draft/delete/set-field helpers (DRY_RUN-aware) add-issue-or-pr.sh # Noise gate + addProjectV2ItemById reconcile-discussion.sh # Paginated find + 4-state reconciler reconcile-backlog.sh # Scans open issues/PRs + Ideas, reconciles via the above + classify-initiative.sh # Rule-driven Initiative/Theme back-fill (#415) + initiative-rules.tsv # Ordered "Initiative <TAB> regex" match rules + initiative-taxonomy.tsv # "Initiative <TAB> Theme" roll-up .github/workflows/add-to-project-tests.yml # shellcheck + bats CI gate test/workflows/add-to-project/ # bats tests, gh stub, fixtures ``` @@ -219,6 +222,46 @@ discussion number) serialize via concurrency group — `created` and **Project ID:** `PVT_kwDOD2inqs4BZq3-` (hardcoded in the workflow env). Multi-Project consumers of the same scripts would parameterize this. +## How Initiative classification works + +Adding an item to the board and *categorizing* it are separate concerns. The +add path (above) only sets board membership; it leaves the **Initiative** +field blank. `classify-initiative.sh` (#415) fills it. + +- **Deterministic, not AI.** Each item is flattened into a lowercase + signature `title | labels | owner/repo` and matched against ordered regex + rules in `initiative-rules.tsv` — **first match wins**, so specific + initiatives sit above the generic bucket they'd otherwise be swallowed by + (Compliance Blitz before Compliance program; Org Standards before + Compliance program). A matched item also gets the **Theme** that Initiative + rolls up to, from `initiative-taxonomy.tsv`. +- **Gate labels are stripped from the signature.** Every board item carries + the `dev-lead` required label, so if it stayed in the signature the + `dev-lead agent` rule would match *every* item. `SIGNATURE_IGNORE_LABELS` + (default = the required + excluded noise-gate labels) drops it; only labels + that actually signal an initiative survive. This is the concrete mechanism + behind the "`dev-lead` is a work-assignment signal, not a classification + signal" note in **Fields** above. +- **No guessing.** An item that matches no rule is left blank and printed as + `UNMATCHED` for triage — the classifier never assigns "Ad hoc" as a + catch-all. Coverage is reported in the job summary on every run. Tune + coverage by adding rows to `initiative-rules.tsv`. +- **Safe by construction.** It writes only per-item field *values* + (`updateProjectV2ItemFieldValue`), never the field *schema* + (`updateProjectV2Field`), so it cannot trip the option-wipe footgun in + "Adding or modifying single-select options safely" above. It resolves live + option ids by name at runtime; a rule naming an initiative that isn't a + live option is skipped with a warning rather than failing the run. +- **Non-destructive.** By default it only fills items whose Initiative is + empty, so a human's manual assignment is never overwritten. Set + `RECLASSIFY=all` (workflow input `reclassify_all`) to re-evaluate every + item. + +It runs as the second step of `add-to-project-reconcile.yml`, right after the +backlog reconcile, so items added in a cycle are categorized in the same run. +Manual dispatch defaults to `dry_run` (preview coverage + the UNMATCHED list +before mutating). The first live run back-fills the existing blank items. + ## Deferred work (not in the pilot) Tracked in [#415](https://github.com/petry-projects/.github/issues/415). diff --git a/test/workflows/add-to-project/classify-initiative.bats b/test/workflows/add-to-project/classify-initiative.bats new file mode 100644 index 000000000..a014b5fe4 --- /dev/null +++ b/test/workflows/add-to-project/classify-initiative.bats @@ -0,0 +1,314 @@ +#!/usr/bin/env bats +# Tests for classify-initiative.sh — the deterministic Initiative/Theme +# back-fill classifier: +# - pure classification (signature build, rule match, taxonomy roll-up) +# - the gate-label strip that stops the universal `dev-lead` label from +# making every item look like the "dev-lead agent" initiative +# - the sweep: resolve fields → page items → set values (DRY_RUN + apply), +# skipping already-associated items and leaving unmatched ones blank. +# +# Classification cases run against the REAL rules/taxonomy TSVs, so they also +# guard those data files against regressions. + +bats_require_minimum_version 1.5.0 + +load 'helpers/setup' + +setup() { + tt_make_tmpdir + tt_install_gh_stub + export PROJECT_ID="PVT_test_project" + export PROJECT_URL="https://example.invalid/projects/1" + export GH_TOKEN="t_test" + export GH_STUB_LOG="${TT_TMP}/gh.log" + # shellcheck source=/dev/null + . "${TT_SCRIPTS_DIR}/classify-initiative.sh" +} + +teardown() { + tt_cleanup_tmpdir +} + +# One-page items response. Each arg is a compact JSON node object. +write_items_page() { + local out_path="$1"; shift + local nodes='[]' + local n + for n in "$@"; do + nodes=$(jq --argjson node "$n" '. + [$node]' <<<"$nodes") + done + jq --argjson nodes "$nodes" \ + '{data:{node:{items:{pageInfo:{hasNextPage:false,endCursor:""},nodes:$nodes}}}}' \ + <<<"{}" >"$out_path" +} + +# Field-schema response used by resolve_fields. +write_fields_schema() { + local out_path="$1" + cat >"$out_path" <<'JSON' +{"data":{"node":{ + "initiative":{"id":"F_INIT","options":[ + {"id":"o_orgstd","name":"Org Standards"}, + {"id":"o_auto","name":"Auto-rebase"}, + {"id":"o_devlead","name":"dev-lead agent"} + ]}, + "theme":{"id":"F_THEME","options":[ + {"id":"t_fleet","name":"Fleet Operations"}, + {"id":"t_comp","name":"Compliance"}, + {"id":"t_agentic","name":"Agentic Framework"} + ]} +}}} +JSON +} + +gh_script_line() { printf '%s\t%s\t%s\n' "$1" "$2" "$3"; } + +assert_invocation_count() { + local expected="$1" actual=0 + [ -f "${GH_STUB_LOG}" ] && actual=$(wc -l <"${GH_STUB_LOG}" | tr -d ' ') + [ "$actual" -eq "$expected" ] || { + printf 'expected %d gh invocations, got %d\n' "$expected" "$actual" >&2 + [ -f "${GH_STUB_LOG}" ] && cat "${GH_STUB_LOG}" >&2 + return 1 + } +} + +assert_log_contains() { + local blob; blob=$(sed 's/\\//g' "${GH_STUB_LOG}") + for needle in "$@"; do + [[ "$blob" == *"${needle}"* ]] || { + printf 'expected gh log to contain %q\nlog: %s\n' "$needle" "$blob" >&2 + return 1 + } + done +} + +# --------------------------------------------------------------------------- +# Arg validation +# --------------------------------------------------------------------------- + +@test "normalize_signature: rejects wrong arg count" { + run normalize_signature "t" "[]" + [ "$status" -eq 64 ] +} + +@test "classify_by_rules: rejects wrong arg count" { + run classify_by_rules + [ "$status" -eq 64 ] +} + +@test "theme_for: rejects wrong arg count" { + run theme_for a b + [ "$status" -eq 64 ] +} + +@test "decide_for_signature: rejects wrong arg count" { + run decide_for_signature + [ "$status" -eq 64 ] +} + +# --------------------------------------------------------------------------- +# normalize_signature — lowercasing + gate-label strip +# --------------------------------------------------------------------------- + +@test "normalize_signature: lowercases and joins title | labels | repo" { + run normalize_signature "Fix The Thing" '[{"name":"auto-rebase"}]' "petry-projects/.github" + [ "$status" -eq 0 ] + [[ "$output" == "fix the thing | auto-rebase | petry-projects/.github" ]] +} + +@test "normalize_signature: strips the dev-lead gate label but keeps real labels" { + run normalize_signature "some title" '[{"name":"dev-lead"},{"name":"auto-rebase"}]' "o/r" + [ "$status" -eq 0 ] + [[ "$output" != *"dev-lead"* ]] + [[ "$output" == *"auto-rebase"* ]] +} + +@test "normalize_signature: strips dev-lead:* and initiative:* routing variants" { + # These colon-variants contain dev-lead/initiative and would otherwise drive + # a spurious dev-lead-agent / Initiatives-Project match. + run normalize_signature "some title" '[{"name":"dev-lead:needs-human"},{"name":"initiative:auto"},{"name":"scorecard"}]' "o/r" + [ "$status" -eq 0 ] + [[ "$output" != *"dev-lead"* ]] + [[ "$output" != *"initiative"* ]] + [[ "$output" == *"scorecard"* ]] +} + +@test "normalize_signature: non-array labels degrade to no labels (no crash)" { + run normalize_signature "title" "null" "o/r" + [ "$status" -eq 0 ] + [[ "$output" == "title | | o/r" ]] +} + +# --------------------------------------------------------------------------- +# classify_by_rules — real-rules regression guard + ordering +# --------------------------------------------------------------------------- + +@test "classify_by_rules: the bare dev-lead label does NOT match dev-lead agent" { + # Signature as normalize_signature would produce it: gate label already gone. + run classify_by_rules "some unrelated title | | petry-projects/.github" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "classify_by_rules: sonarcloud title → Org Standards" { + run classify_by_rules "sonarcloud: resolve remaining s7635 stubs | | petry-projects/.github" + [ "$output" = "Org Standards" ] +} + +@test "classify_by_rules: auto-rebase title → Auto-rebase" { + run classify_by_rules "auto-rebase: unguarded conflict-comment aborts | | x/y" + [ "$output" = "Auto-rebase" ] +} + +@test "classify_by_rules: pr-review title → pr-review agent" { + run classify_by_rules "pr-review advisory bot review gate | | x/y" + [ "$output" = "pr-review agent" ] +} + +@test "classify_by_rules: a dev-lead-agent title still matches via the word in the title" { + run classify_by_rules "dev-lead deep-impl engine-error timeout | | x/y" + [ "$output" = "dev-lead agent" ] +} + +@test "classify_by_rules: ordering — Compliance Blitz beats generic Compliance" { + run classify_by_rules "compliance blitz 2026 backlog | | x/y" + [ "$output" = "Compliance Blitz" ] +} + +@test "classify_by_rules: cross-cutting infra WINS over the product repo (SonarCloud in a product repo → Org Standards)" { + # The key ordering guarantee: a SonarCloud sweep inside a product repo is + # Org Standards work, not product work. + run classify_by_rules "sonarcloud: shell script hygiene cleanup | | petry-projects/talkterm" + [ "$output" = "Org Standards" ] +} + +@test "classify_by_rules: a genuine product feature in the product repo → the product initiative" { + run classify_by_rules "add spatial audio to the voice provider pipeline | | petry-projects/talkterm" + [ "$output" = "TalkTerm" ] +} + +@test "classify_by_rules: avatar idea draft (no repo) → TalkTerm" { + run classify_by_rules "3d avatar evolution with lip-sync | | draft" + [ "$output" = "TalkTerm" ] +} + +@test "classify_by_rules: beekeeping idea → Broodly, not TalkTerm (hive wins)" { + run classify_by_rules "conversational voice assistant grounded in hive history | | draft" + [ "$output" = "Broodly" ] +} + +@test "classify_by_rules: feature-ideation → Business Analyst" { + run classify_by_rules "feat(feature-ideation): add curated source list for mary | | petry-projects/.github" + [ "$output" = "Business Analyst" ] +} + +@test "classify_by_rules: token cost report → Cost Observability, not Daily Reports" { + run classify_by_rules "org-wide weekly token cost observatory report | | petry-projects/.github" + [ "$output" = "Cost Observability" ] +} + +@test "classify_by_rules: no match → empty output, exit 0" { + run classify_by_rules "zzq nonsense placeholder widget | | x/y" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "classify_by_rules: missing rules file → 65" { + RULES_FILE="${TT_TMP}/nope.tsv" run classify_by_rules "anything" + [ "$status" -eq 65 ] +} + +# --------------------------------------------------------------------------- +# theme_for + decide_for_signature +# --------------------------------------------------------------------------- + +@test "theme_for: Auto-rebase rolls up to Compliance" { + run theme_for "Auto-rebase" + [ "$output" = "Compliance" ] +} + +@test "theme_for: unknown initiative → empty" { + run theme_for "Nonexistent" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +@test "decide_for_signature: matched → 'initiative<TAB>theme'" { + run decide_for_signature "sonarcloud ruleset drift | | x/y" + [ "$status" -eq 0 ] + [[ "$output" == "Org Standards"$'\t'"Fleet Operations" ]] +} + +@test "decide_for_signature: unmatched → empty" { + run decide_for_signature "zzq nonsense placeholder | | x/y" + [ "$status" -eq 0 ] + [ -z "$output" ] +} + +# --------------------------------------------------------------------------- +# sweep_project — DRY_RUN: resolve + page only, correct tallies +# --------------------------------------------------------------------------- + +setup_sweep_stub() { + local fields="${TT_TMP}/fields.json" items="${TT_TMP}/items.json" + write_fields_schema "$fields" + write_items_page "$items" \ + '{"id":"PVTI_A","initiative":null,"content":{"__typename":"Issue","title":"SonarCloud: fix S7635 stubs","labels":{"nodes":[{"name":"dev-lead"}]},"repository":{"nameWithOwner":"petry-projects/.github"}}}' \ + '{"id":"PVTI_B","initiative":{"name":"Auto-rebase"},"content":{"__typename":"PullRequest","title":"auto-rebase tweak","labels":{"nodes":[{"name":"dev-lead"}]},"repository":{"nameWithOwner":"petry-projects/.github"}}}' \ + '{"id":"PVTI_C","initiative":null,"content":{"__typename":"DraftIssue","title":"zzq nonsense placeholder"}}' + local script="${TT_TMP}/script.txt" + { + gh_script_line 0 "$fields" "-" # resolve_fields + gh_script_line 0 "$items" "-" # paginate items + gh_script_line 0 "-" "-" # (apply) set Initiative on A + gh_script_line 0 "-" "-" # (apply) set Theme on A + } >"$script" + export GH_STUB_SCRIPT="$script" +} + +@test "sweep_project DRY_RUN: tallies scanned/already/matched/unmatched, no mutations" { + setup_sweep_stub + DRY_RUN=1 run sweep_project + [ "$status" -eq 0 ] + [[ "$output" == *"board items scanned : 3"* ]] + [[ "$output" == *"already associated : 1"* ]] + [[ "$output" == *"newly matched : 1"* ]] + [[ "$output" == *"unmatched (blank) : 1"* ]] + [[ "$output" == *"MATCH Org Standards"* ]] + [[ "$output" == *"UNMATCHED"* ]] + # Only the two read calls (resolve + paginate); the mutations are dry-run. + assert_invocation_count 2 +} + +@test "sweep_project apply: sets Initiative and Theme on the matched item" { + setup_sweep_stub + run sweep_project + [ "$status" -eq 0 ] + # resolve + paginate + set-initiative + set-theme + assert_invocation_count 4 + assert_log_contains "updateProjectV2ItemFieldValue" "PVTI_A" "F_INIT" "o_orgstd" + assert_log_contains "F_THEME" "t_fleet" + # Never touches the already-associated or unmatched items. + run grep -c PVTI_B "${GH_STUB_LOG}" + [ "$output" = "0" ] + run grep -c PVTI_C "${GH_STUB_LOG}" + [ "$output" = "0" ] +} + +@test "sweep_project: fails fast (75) when the project node is null" { + local nullresp="${TT_TMP}/null.json" + printf '%s' '{"data":{"node":null}}' >"$nullresp" + local script="${TT_TMP}/script.txt" + gh_script_line 0 "$nullresp" "-" >"$script" + export GH_STUB_SCRIPT="$script" + run sweep_project + [ "$status" -eq 75 ] +} + +@test "sweep_project: fails fast without GH_TOKEN" { + unset GH_TOKEN + run --separate-stderr sweep_project + [ "$status" -eq 64 ] + [[ "$stderr" == *"GH_TOKEN is empty"* ]] +}