Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
305 changes: 305 additions & 0 deletions .github/scripts/add-to-project/classify-initiative.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,305 @@
#!/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; do
case "${name}" in ''|'#'*) continue ;; esac
[ -n "${rx}" ] || continue
if printf '%s' "${sig}" | grep -Eiq -- "${rx}"; then
printf '%s' "${name}"
return 0
fi
done < "${rules}"
Comment thread
don-petry marked this conversation as resolved.
Outdated
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; do
case "${init}" in ''|'#'*) continue ;; esac
if [ "${init}" = "${want}" ]; then
printf '%s' "${theme}"
return 0
fi
done < "${tax}"
Comment thread
don-petry marked this conversation as resolved.
Outdated
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
Comment thread
don-petry marked this conversation as resolved.
Outdated
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 // ""')
Comment thread
don-petry marked this conversation as resolved.
Outdated
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)"')
Comment thread
don-petry marked this conversation as resolved.
Outdated
}

# _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[]?]')
Comment thread
don-petry marked this conversation as resolved.
Outdated

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 // ""')
Comment thread
don-petry marked this conversation as resolved.
Outdated

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
25 changes: 25 additions & 0 deletions .github/scripts/add-to-project/initiative-rules.tsv
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# 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 matching row wins, so put specific initiatives
# above the generic bucket they'd otherwise be swallowed by (e.g. Compliance
# Blitz and Org Standards before Compliance program). An item that matches no
# rule is left BLANK and reported for triage — the classifier never guesses
# "Ad hoc". Tune rows here; coverage is measured on every dry-run.
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)|advisory bot|coderabbit|(gemini|copilot|sonarcloud|codex) review|fix.?request|extract_verdict|mention.?(ack|trigger)|changes_requested|@petry-review|@donpetry-bot|review-one-pr
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)|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
Self-healing self.?heal|auto.?remediat|auto.?fix|self.?repair|stall detect|pipeline stall|recovery escalation
dev-lead agent dev.?lead|deep.?impl|bmad|engine.?(timeout|error)|story churn|phantom.?spec|thin.?caller|inlined (claude|workflow)|claude\.yml|claude-fix|prompting framework
GH-AW gh-?aw|agentic.?workflow|\baw\b|ci.?failure|failure.?analyst|ci-?analyst|failure-to-agent|lock\.?file|lock\.yml
Comment thread
don-petry marked this conversation as resolved.
Outdated
Copilot Instructions copilot.*instruction|copilot coding agent|copilot governance|copilot custom|\.github/copilot|copilot-instructions
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|token.?(cost|report|metric|observ)|weekly .*report
Compliance Blitz compliance.?blitz|compliance.?day|compliance backlog
Org Standards sonarcloud|sonar[ ._-]|s7[0-9]{3}|s86[0-9]{2}|s59[0-9]{2}|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 detection|integrity verification|execution protection|security linting|pull request limits|pr.?limits|admission gate
Compliance program compliance|audit|remediat
Release Strategy release.?(strategy|train|notes|channel|cadence|automation)|channel.?(promote|cut|soak|pin)|ring.?(rollout|promote|enrol)|canary|version(ing)? standard|semantic versioning|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|repo template
19 changes: 19 additions & 0 deletions .github/scripts/add-to-project/initiative-taxonomy.tsv
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# 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
42 changes: 42 additions & 0 deletions .github/scripts/add-to-project/lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading
Loading