Skip to content
10 changes: 5 additions & 5 deletions .github/scripts/add-to-project/add-issue-or-pr.sh
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ _atp_lib_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
. "${_atp_lib_dir}/lib.sh"

evaluate_noise_gate() {
if [ "$#" -ne 1 ]; then
if [[ "$#" -ne 1 ]]; then
printf '[evaluate_noise_gate] expected 1 arg (labels_json), got %d\n' "$#" >&2
return 64
fi
Expand Down Expand Up @@ -111,15 +111,15 @@ evaluate_noise_gate() {
# Thin wrapper over the shared lookup: find the project item linked to a
# given issue/PR node id (or empty string if it isn't on the board).
find_content_item_id() {
if [ "$#" -ne 1 ]; then
if [[ "$#" -ne 1 ]]; then
printf '[find_content_item_id] expected 1 arg (content_node_id), got %d\n' "$#" >&2
return 64
fi
find_project_item content-id "$1"
}

reconcile_content_with_project() {
if [ "$#" -ne 3 ]; then
if [[ "$#" -ne 3 ]]; then
printf '[reconcile_content_with_project] expected 3 args (content_node_id content_url labels_json), got %d\n' "$#" >&2
return 64
fi
Expand Down Expand Up @@ -155,7 +155,7 @@ reconcile_content_with_project() {
fi
local existing
existing=$(find_content_item_id "${content_node_id}")
if [ -n "${existing}" ]; then
if [[ -n "${existing}" ]]; then
printf 'Removing %s from %s (no longer qualifies: %s); item %s\n' \
"${content_url}" "${PROJECT_URL:-the project}" "${reason}" "${existing}"
delete_project_item "${existing}"
Expand All @@ -170,7 +170,7 @@ reconcile_content_with_project() {
esac
}

if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
if [[ "${BASH_SOURCE[0]}" = "${0}" ]]; then
reconcile_content_with_project \
"${CONTENT_NODE_ID:?CONTENT_NODE_ID is required}" \
"${CONTENT_URL:?CONTENT_URL is required}" \
Expand Down
48 changes: 24 additions & 24 deletions .github/scripts/add-to-project/classify-initiative.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ CI_THEME_FIELD_ID=""
# 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
if [[ "$#" -ne 3 ]]; then
printf '[normalize_signature] expected 3 args (title labels_json repo), got %d\n' "$#" >&2
return 64
fi
Expand Down Expand Up @@ -97,21 +97,21 @@ normalize_signature() {

# classify_by_rules <signature> → first-matching Initiative name, or "".
classify_by_rules() {
if [ "$#" -ne 1 ]; then
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
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
while IFS=$'\t' read -r name rx || [[ -n "${name}" ]]; do
name="${name%$'\r'}"
rx="${rx%$'\r'}"
case "${name}" in ''|'#'*) continue ;; esac
[ -n "${rx}" ] || continue
[[ -n "${rx}" ]] || continue
if printf '%s' "${sig}" | grep -Eiq -- "${rx}"; then
printf '%s' "${name}"
return 0
Expand All @@ -122,21 +122,21 @@ classify_by_rules() {

# theme_for <initiative> → the Theme it rolls up to, or "".
theme_for() {
if [ "$#" -ne 1 ]; then
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
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
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
if [[ "${init}" = "${want}" ]]; then
printf '%s' "${theme}"
return 0
fi
Expand All @@ -147,13 +147,13 @@ theme_for() {
# decide_for_signature <signature> → "<initiative>\t<theme>" (theme may be
# empty), or "" when no rule matches.
decide_for_signature() {
if [ "$#" -ne 1 ]; then
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
[[ -n "${init}" ]] || return 0
theme=$(theme_for "${init}") || return $?
printf '%s\t%s' "${init}" "${theme}"
}
Expand All @@ -179,38 +179,38 @@ resolve_fields() {
}
}')

if [ "$(printf '%s' "${json}" | jq -r '.data.node?')" = "null" ]; then
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
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}"
[[ -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}"
[[ -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> <failed>
_ci_report() {
local total="$1" already="$2" matched="$3" unmatched="$4" skipped="$5" failed="${6:-0}"
local mode="apply"; [ "${DRY_RUN:-}" = "1" ] && mode="dry-run"
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}"
printf ' transient set fails : %d (left blank; refilled next sweep)\n' "${failed}"
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
if [[ -n "${GITHUB_STEP_SUMMARY:-}" ]]; then
{
printf '### classify-initiative (%s)\n\n' "${mode}"
printf '| scanned | already | matched | unmatched | skipped | failed |\n'
Expand Down Expand Up @@ -257,7 +257,7 @@ sweep_project() {
local total=0 already=0 matched=0 unmatched=0 skipped=0 failed=0
local node
while IFS= read -r node; do
[ -n "${node}" ] || continue
[[ -n "${node}" ]] || continue
total=$((total + 1))

local item_id cur title labels repo sig decided init theme optid
Expand All @@ -267,7 +267,7 @@ sweep_project() {
labels=$(printf '%s' "${node}" | jq -c '.content.labels.nodes? // []')
repo=$(printf '%s' "${node}" | jq -r '.content.repository.nameWithOwner? // ""')

if [ -n "${cur}" ] && [ "${RECLASSIFY:-}" != "all" ]; then
if [[ -n "${cur}" ]] && [[ "${RECLASSIFY:-}" != "all" ]]; then
already=$((already + 1))
continue
fi
Expand All @@ -281,14 +281,14 @@ sweep_project() {
local log_title
log_title=$(atp_log_safe "${title}")

if [ -z "${init}" ]; then
if [[ -z "${init}" ]]; then
unmatched=$((unmatched + 1))
printf 'UNMATCHED %-20s «%s»\n' "${repo:-draft}" "${log_title}"
continue
fi

optid="${CI_INIT_OPT[${init}]:-}"
if [ -z "${optid}" ]; then
if [[ -z "${optid}" ]]; then
printf '::warning::rule matched Initiative %q which is not a live project option; skipping «%s»\n' "${init}" "${log_title}" >&2
skipped=$((skipped + 1))
continue
Expand All @@ -310,9 +310,9 @@ sweep_project() {
# Theme is best-effort: co-assign only when the field and matching option
# both exist live. A missing Theme field/option is silently tolerated, and
# a transient set failure is non-fatal (Initiative already landed).
if [ -n "${theme}" ] && [ -n "${CI_THEME_FIELD_ID}" ]; then
if [[ -n "${theme}" ]] && [[ -n "${CI_THEME_FIELD_ID}" ]]; then
local topt="${CI_THEME_OPT[${theme}]:-}"
if [ -n "${topt}" ] && ! set_item_single_select_value "${item_id}" "${CI_THEME_FIELD_ID}" "${topt}"; then
if [[ -n "${topt}" ]] && ! set_item_single_select_value "${item_id}" "${CI_THEME_FIELD_ID}" "${topt}"; then
printf '::warning::failed to set Theme %q on «%s» (transient?); retry requires RECLASSIFY=all (Initiative already set)\n' "${theme}" "${log_title}" >&2
fi
fi
Expand All @@ -321,6 +321,6 @@ sweep_project() {
_ci_report "${total}" "${already}" "${matched}" "${unmatched}" "${skipped}" "${failed}"
}

if [ "${BASH_SOURCE[0]}" = "${0}" ]; then
if [[ "${BASH_SOURCE[0]}" = "${0}" ]]; then
sweep_project
fi
36 changes: 18 additions & 18 deletions .github/scripts/add-to-project/lib.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@
set -euo pipefail

_atp_require_env() {
if [ -z "${PROJECT_ID:-}" ]; then
if [[ -z "${PROJECT_ID:-}" ]]; then
printf '[%s] PROJECT_ID env var is required\n' "$1" >&2
return 64
fi
if [ -z "${GH_TOKEN:-}" ]; then
if [[ -z "${GH_TOKEN:-}" ]]; then
printf '::error::[%s] GH_TOKEN is empty. INITIATIVES_APP_ID / INITIATIVES_APP_PRIVATE_KEY are likely unset or stale. See combsmith/.github#387.\n' "$1" >&2
return 64
fi
Expand Down Expand Up @@ -60,13 +60,13 @@ atp_log_safe() {
# "is it already on the board?" from memory and skip the API call unless a
# genuine change is needed. The event path leaves both unset, so it always
# falls through to the network and its behavior is unchanged.
_atp_membership_ready() { [ "${_ATP_MEMBERSHIP_READY:-}" = "1" ]; }
_atp_membership_ready() { [[ "${_ATP_MEMBERSHIP_READY:-}" = "1" ]]; }

# _atp_on_board <content_node_id>
# Returns 0 if the cache says the content IS on the board, 1 if the cache
# says it is NOT. MUST be gated by `_atp_membership_ready` — when no cache
# has been populated the result is meaningless (there is nothing to consult).
_atp_on_board() { [ -n "${_ATP_ON_BOARD[$1]:-}" ]; }
_atp_on_board() { [[ -n "${_ATP_ON_BOARD[$1]:-}" ]]; }

# find_project_item <kind> <value>
# kind=title-prefix → match items whose content.title startswith <value>
Expand All @@ -79,7 +79,7 @@ _atp_on_board() { [ -n "${_ATP_ON_BOARD[$1]:-}" ]; }
# caller add duplicates. Lookup is paginated so the project can grow past
# the first page without silently missing matches.
find_project_item() {
if [ "$#" -ne 2 ]; then
if [[ "$#" -ne 2 ]]; then
printf '[find_project_item] expected 2 args (kind value), got %d\n' "$#" >&2
return 64
fi
Expand Down Expand Up @@ -109,7 +109,7 @@ find_project_item() {
while true; do
local json
# shellcheck disable=SC2016 # $projectId/$pageSize/$cursor are GraphQL variables
if [ -n "${cursor}" ]; then
if [[ -n "${cursor}" ]]; then
json=$(gh api graphql \
-F projectId="${PROJECT_ID}" \
-F pageSize="${page_size}" \
Expand Down Expand Up @@ -155,7 +155,7 @@ find_project_item() {
}')
fi

if [ "$(printf '%s' "${json}" | jq -r '.data.node')" = "null" ]; then
if [[ "$(printf '%s' "${json}" | jq -r '.data.node')" = "null" ]]; then
printf '[find_project_item] GraphQL returned data.node:null. PROJECT_ID=%s — token may lack access, or the project was deleted.\n' "${PROJECT_ID}" >&2
return 75
fi
Expand Down Expand Up @@ -198,8 +198,8 @@ find_project_item() {
esac
done <<< "${parsed}"

if [ -n "${match}" ]; then
if [ "${match_count}" != "1" ]; then
if [[ -n "${match}" ]]; then
if [[ "${match_count}" != "1" ]]; then
# Multi-match on a single page indicates inconsistent state (manual
# drafts shadowing automation). Warn so an operator can clean up,
# but proceed with the first match so the state machine progresses.
Expand All @@ -210,15 +210,15 @@ find_project_item() {
return 0
fi

if [ "${has_next}" != "true" ]; then
if [[ "${has_next}" != "true" ]]; then
return 0
fi
cursor="${end_cursor}"
done
}

add_content_to_project() {
if [ "$#" -ne 1 ]; then
if [[ "$#" -ne 1 ]]; then
printf '[add_content_to_project] expected 1 arg (content_node_id), got %d\n' "$#" >&2
return 64
fi
Expand All @@ -234,7 +234,7 @@ add_content_to_project() {
return 0
fi

if [ "${DRY_RUN:-}" = "1" ]; then
if [[ "${DRY_RUN:-}" = "1" ]]; then
printf '[dry-run] would add content %s to project\n' "${content_node_id}"
return 0
fi
Expand All @@ -253,14 +253,14 @@ add_content_to_project() {
}

add_draft_item() {
if [ "$#" -ne 2 ]; then
if [[ "$#" -ne 2 ]]; then
printf '[add_draft_item] expected 2 args (title body), got %d\n' "$#" >&2
return 64
fi
local title="$1"
local body="$2"

if [ "${DRY_RUN:-}" = "1" ]; then
if [[ "${DRY_RUN:-}" = "1" ]]; then
printf '[dry-run] would add draft: %s\n' "$(atp_log_safe "${title}")"
return 0
fi
Expand All @@ -278,13 +278,13 @@ add_draft_item() {
}

delete_project_item() {
if [ "$#" -ne 1 ]; then
if [[ "$#" -ne 1 ]]; then
printf '[delete_project_item] expected 1 arg (item_id), got %d\n' "$#" >&2
return 64
fi
local item_id="$1"

if [ "${DRY_RUN:-}" = "1" ]; then
if [[ "${DRY_RUN:-}" = "1" ]]; then
printf '[dry-run] would delete item %s from project\n' "${item_id}"
return 0
fi
Expand Down Expand Up @@ -323,15 +323,15 @@ delete_project_item() {
# 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
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
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
Expand Down
Loading
Loading