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
309 changes: 309 additions & 0 deletions .github/scripts/add-to-project/classify-initiative.sh
Original file line number Diff line number Diff line change
@@ -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 <title> <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
38 changes: 38 additions & 0 deletions .github/scripts/add-to-project/initiative-rules.tsv
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading