Skip to content

refactor(ingest): harden action routing and payload validation in Ben… #79

refactor(ingest): harden action routing and payload validation in Ben…

refactor(ingest): harden action routing and payload validation in Ben… #79

name: Board state sync

Check failure on line 1 in .github/workflows/board-state-sync.yml

View workflow run for this annotation

GitHub Actions / .github/workflows/board-state-sync.yml

Invalid workflow file

(Line: 33, Col: 3): Unexpected value 'projects_v2_item'
# When a human manually moves a card's Status on the Task Board, mirror
# the change to the linked PR/Issue so the two cards stay in the
# opposite-status pairing the state machine expects:
#
# PR Ready ↔ Issue In review
# PR In review ↔ Issue Ready
# PR / Issue In progress → other stays wherever it is (manual move
# mid-review; don't clobber)
# PR Done ↔ Issue Done
#
# Event: `projects_v2_item: edited`. Payload's `changes.field_value`
# tells us which field and what the old/new values were. We only react
# when the Status field changed; other field edits (Priority, Iteration,
# etc.) are ignored.
#
# Loop prevention: before writing the mirror, check what the target
# card's Status already is. If it already matches the expected mirror
# value, skip — otherwise the mirror would re-fire this workflow
# indefinitely.
#
# The workflow runs only when the token configured in
# PROJECT_BOARD_TOKEN has project:write + repo:read on Wave-RF's
# project #7. The repo:read scope is needed by the GraphQL queries
# below that resolve linked PRs/Issues (closingIssuesReferences /
# closedByPullRequestsReferences) — on private repos, project:write
# alone isn't enough. Fine-grained PATs need both scopes selected
# explicitly. Fork PRs, forks of this repo, and fresh installs
# silently no-op.
on:
projects_v2_item:
types: [edited]
permissions:
contents: read
env:
PROJECT_NUMBER: "7"
PROJECT_ID: PVT_kwDOCdKSOc4BUEKD
STATUS_FIELD_ID: PVTSSF_lADOCdKSOc4BUEKDzhBO0l8
STATUS_READY: "61e4505c"
STATUS_IN_PROGRESS: "47fc9ee4"
STATUS_IN_REVIEW: "df73e18b"
STATUS_DONE: "98236657"
STATUS_BACKLOG: "f75ad846"
jobs:
mirror:
# Only on our project.
if: github.event.projects_v2_item.project_node_id == 'PVT_kwDOCdKSOc4BUEKD'
runs-on: [wave-rf-runners]
steps:
- name: Guard — require PROJECT_BOARD_TOKEN
env:
PROJECT_TOKEN: ${{ secrets.PROJECT_BOARD_TOKEN }}
run: |
# `exit 0` only ends *this* step's shell — the job continues,
# so subsequent steps would run with an empty GH_TOKEN and
# error out on gh api calls. Emit a SKIP_BOARD marker to
# GITHUB_ENV instead; every later step guards on it. Matches
# the pattern already used in project-orchestrator.yml.
# (Claude review flagged this as Medium severity.)
if [[ -z "$PROJECT_TOKEN" ]]; then
echo "::warning::PROJECT_BOARD_TOKEN not configured; skipping."
echo "SKIP_BOARD=true" >> "$GITHUB_ENV"
fi
- name: Inspect event + filter to Status-field changes
id: event
if: env.SKIP_BOARD != 'true'
run: |
set -euo pipefail
# The projects_v2_item event payload's `changes.field_value`
# is populated only when a field's value changed. It has
# field_node_id, field_type ("single_select"), and from/to
# option references. Other changes (like archived_at) have a
# different shape.
field_node_id=$(jq -r '.changes.field_value.field_node_id // empty' "$GITHUB_EVENT_PATH")
if [ -z "$field_node_id" ]; then
echo "Not a field-value change — skipping."
echo "run=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "$field_node_id" != "$STATUS_FIELD_ID" ]; then
echo "Field-value change is not Status ($field_node_id) — skipping."
echo "run=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# New Status option (the option the user moved to).
new_option_id=$(jq -r '.changes.field_value.to.id // empty' "$GITHUB_EVENT_PATH")
if [ -z "$new_option_id" ]; then
echo "Could not read new Status option id — skipping."
echo "run=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# The content this project item wraps (PR / Issue / Draft).
content_node_id=$(jq -r '.projects_v2_item.content_node_id // empty' "$GITHUB_EVENT_PATH")
content_type=$(jq -r '.projects_v2_item.content_type // empty' "$GITHUB_EVENT_PATH")
if [ -z "$content_node_id" ] || [ -z "$content_type" ]; then
echo "Project item has no content (draft issue?) — skipping."
echo "run=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "run=true" >> "$GITHUB_OUTPUT"
echo "new_option_id=$new_option_id" >> "$GITHUB_OUTPUT"
echo "content_node_id=$content_node_id" >> "$GITHUB_OUTPUT"
echo "content_type=$content_type" >> "$GITHUB_OUTPUT"
- name: Resolve linked counterpart + compute mirror Status
id: mirror
if: steps.event.outputs.run == 'true' && env.SKIP_BOARD != 'true'
env:
GH_TOKEN: ${{ secrets.PROJECT_BOARD_TOKEN }}
NEW_OPTION_ID: ${{ steps.event.outputs.new_option_id }}
CONTENT_NODE_ID: ${{ steps.event.outputs.content_node_id }}
CONTENT_TYPE: ${{ steps.event.outputs.content_type }}
run: |
set -euo pipefail
# Map the moved card's new Status to the mirror Status that
# the linked counterpart should show. Done is the only "both
# same" state; everything else is opposite-pair. In progress
# on one side doesn't dictate the other (reviewer "in
# progress" while author is In review, for example) so we
# leave the counterpart alone in those cases.
case "$NEW_OPTION_ID" in
"$STATUS_READY")
mirror_option_id="$STATUS_IN_REVIEW"
;;
"$STATUS_IN_REVIEW")
mirror_option_id="$STATUS_READY"
;;
"$STATUS_DONE")
mirror_option_id="$STATUS_DONE"
;;
*)
echo "New Status ($NEW_OPTION_ID) has no mirror action — skipping."
echo "run=false" >> "$GITHUB_OUTPUT"
exit 0
;;
esac
# Resolve the linked counterpart(s). For a PR, the linked
# issues come from `closingIssuesReferences`. For an Issue,
# the "Development"-linked PRs come from
# `closedByPullRequestsReferences` (the inverse edge).
if [ "$CONTENT_TYPE" = "PullRequest" ]; then
# Capture the full response first, then explicitly check
# `.errors`. Inline `--jq` would silently turn a GraphQL
# error into empty output — taking the "no linked
# counterpart" path and producing a silent no-op. Match
# the Issue-side pattern below for symmetry. (Claude
# review flagged 2026-04-28.)
pr_resp=$(gh api graphql -f query='query($id: ID!) {
node(id: $id) {
... on PullRequest {
closingIssuesReferences(first: 50) { nodes { id } }
}
}
}' -F id="$CONTENT_NODE_ID")
graphql_errors=$(echo "$pr_resp" | jq -c '.errors // empty')
if [ -n "$graphql_errors" ]; then
echo "::error::GraphQL error querying PR counterparts for $CONTENT_NODE_ID: $graphql_errors"
exit 1
fi
counterparts=$(echo "$pr_resp" | jq -r '.data.node.closingIssuesReferences.nodes[].id')
elif [ "$CONTENT_TYPE" = "Issue" ]; then
# `closedByPullRequestsReferences` is the inverse edge of
# the PR's `closingIssuesReferences`. It returns the PRs
# that declared `Closes #this-issue` / `Fixes #this-issue`.
# Distinguish "no linked PRs" (empty result, fine) from
# "GraphQL query failed" (real error, should be loud) —
# previously 2>/dev/null || true swallowed both and
# silently no-op'd on token/API failures (Copilot flagged).
issue_resp=$(gh api graphql -f query='query($id: ID!) {
node(id: $id) {
... on Issue {
closedByPullRequestsReferences(first: 50, includeClosedPrs: false) { nodes { id } }
}
}
}' -F id="$CONTENT_NODE_ID")
# gh api exit != 0 already aborts the step via set -e.
# A successful response with .errors set indicates a
# schema-level failure (e.g., the field isn't available on
# this GraphQL version). Log + skip in that specific case;
# in all other cases (including real errors) the script
# fails loudly.
graphql_errors=$(echo "$issue_resp" | jq -c '.errors // empty')
if [ -n "$graphql_errors" ]; then
if echo "$graphql_errors" | grep -q "closedByPullRequestsReferences"; then
echo "::notice::GraphQL schema doesn't expose closedByPullRequestsReferences on this version. Skipping Issue-side mirror. Fallback if this becomes frequent: timelineItems / ConnectedEvent."
echo "run=false" >> "$GITHUB_OUTPUT"
exit 0
else
echo "::error::Unexpected GraphQL error querying Issue counterparts: $graphql_errors"
exit 1
fi
fi
counterparts=$(echo "$issue_resp" | jq -r '.data.node.closedByPullRequestsReferences.nodes[].id')
else
echo "Content type '$CONTENT_TYPE' not supported — skipping."
echo "run=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ -z "$counterparts" ]; then
echo "No linked counterpart(s) — nothing to mirror."
echo "run=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Write to output as space-separated for downstream iteration.
# `printf '%s '` preserves the list structure without the
# word-splitting/globbing concern of unquoted $counterparts.
counterparts_flat=$(printf '%s ' $counterparts)
{
echo "counterparts=${counterparts_flat% }"
echo "mirror_option_id=$mirror_option_id"
echo "run=true"
} >> "$GITHUB_OUTPUT"
- name: Mirror Status to linked counterpart(s)
if: steps.mirror.outputs.run == 'true' && env.SKIP_BOARD != 'true'
env:
GH_TOKEN: ${{ secrets.PROJECT_BOARD_TOKEN }}
COUNTERPARTS: ${{ steps.mirror.outputs.counterparts }}
MIRROR_OPTION_ID: ${{ steps.mirror.outputs.mirror_option_id }}
run: |
set -euo pipefail
for counterpart_id in $COUNTERPARTS; do
# Find the counterpart's item id in our project and its
# current Status. Read first so we can short-circuit when
# the target already matches (loop prevention — otherwise
# the mirror itself would fire this workflow in reverse).
resp=$(gh api graphql -f query='query($id: ID!) {
node(id: $id) {
... on PullRequest {
projectItems(first: 20) {
nodes {
id
project { number }
fieldValueByName(name: "Status") {
... on ProjectV2ItemFieldSingleSelectValue { optionId }
}
}
}
}
... on Issue {
projectItems(first: 20) {
nodes {
id
project { number }
fieldValueByName(name: "Status") {
... on ProjectV2ItemFieldSingleSelectValue { optionId }
}
}
}
}
}
}' -F id="$counterpart_id")
# Guard: fail if the lookup itself errored (Copilot R9)
# instead of misinterpreting an error response as "item
# not on board" and emitting a misleading skip warning.
graphql_errors=$(echo "$resp" | jq -c '.errors // empty')
if [ -n "$graphql_errors" ]; then
echo "::error::GraphQL error looking up counterpart $counterpart_id: $graphql_errors"
exit 1
fi
if echo "$resp" | jq -e '.data.node == null' > /dev/null; then
echo "::error::GraphQL returned null node for counterpart $counterpart_id — likely a permissions or invalid-ID issue."
exit 1
fi
item_id=$(echo "$resp" | jq -r --arg n "$PROJECT_NUMBER" '
.data.node.projectItems.nodes[]?
| select(.project.number == ($n | tonumber))
| .id' | head -n1)
current_option=$(echo "$resp" | jq -r --arg n "$PROJECT_NUMBER" '
.data.node.projectItems.nodes[]?
| select(.project.number == ($n | tonumber))
| .fieldValueByName.optionId // ""' | head -n1)
if [ -z "$item_id" ]; then
# Loud warning: the orchestrator only runs on PR events,
# not on `projects_v2_item: edited`. If the linked
# counterpart isn't on the board yet, the mirror is
# silently dropped — which is *maybe* what we want for
# brand-new external-contributor PRs but confusing when
# it happens on an active PR because someone removed
# the card manually. Surface it prominently; the operator
# can re-add the counterpart to the board and re-trigger
# the mirror by nudging the Status field again. (Copilot
# flagged the silent-skip as a gap.)
echo "::warning::Counterpart $counterpart_id not on project #$PROJECT_NUMBER. Mirror dropped; add it manually (or on next relevant PR event the orchestrator will pick it up) and re-trigger by bumping its Status."
continue
fi
if [ "$current_option" = "$MIRROR_OPTION_ID" ]; then
echo "Counterpart $counterpart_id Status already matches mirror ($MIRROR_OPTION_ID) — no write, loop averted."
continue
fi
echo "Mirroring: counterpart $counterpart_id Status $current_option → $MIRROR_OPTION_ID"
gh project item-edit \
--id "$item_id" \
--field-id "$STATUS_FIELD_ID" \
--single-select-option-id "$MIRROR_OPTION_ID" \
--project-id "$PROJECT_ID" > /dev/null
done