Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
4dd185b
ci: refactor orchestrator + add Dependabot board path + bidirectional…
EricAndrechek Apr 23, 2026
7703a1f
fix(ci): orchestrator re-eval triggers + COMMENTED reviews
EricAndrechek Apr 23, 2026
6b4c401
fix(ci): address Copilot + Gemini review feedback on Phase 1
EricAndrechek Apr 23, 2026
f9f0557
fix(ci): Copilot review round 2 + admin-approval stale check
EricAndrechek Apr 23, 2026
173fb44
fix(ci): Copilot review round 3
EricAndrechek Apr 23, 2026
77e3261
fix(ci): Claude + Copilot review round 4
EricAndrechek Apr 23, 2026
dea6440
fix(ci): Copilot review round 5 — error distinguishing + admin-specif…
EricAndrechek Apr 23, 2026
6388b0c
fix(ci): Copilot review round 6 — error-handling uniformity
EricAndrechek Apr 23, 2026
d94164c
fix(ci): reeval path loses reviewer-assign when card pre-set to non-B…
EricAndrechek Apr 23, 2026
15116e4
fix(ci): split per-side reconciliation + defensive sort_by before gro…
EricAndrechek Apr 23, 2026
047f885
fix(ci): Copilot R8 — checkout-ref security + strict-mode guard
EricAndrechek Apr 23, 2026
af43df0
fix(ci): Copilot R9 — request-review gate + GraphQL error checks ever…
EricAndrechek Apr 23, 2026
76d0243
chore(ci): empty commit to force fresh CI run
EricAndrechek Apr 23, 2026
00752f9
ci: pin board-state-sync.yml to ARC runner label
EricAndrechek Apr 28, 2026
46cef85
fix(ci): Claude post-rebase review — SKIP_BOARD review-request + PR-s…
EricAndrechek Apr 28, 2026
ddabe3a
fix(make): security + size-tree(-map) targets after tool version bumps
EricAndrechek Apr 28, 2026
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
135 changes: 135 additions & 0 deletions .github/actions/assign-and-request-review/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
name: Assign reviewers + request review
description: >-
Sets PR assignees and requests GitHub reviews from the same users. Idempotent:
users already assigned/requested don't get duplicate notifications. The pair
is always applied together because the Task Board queue (driven by assignees)
and GitHub's native review request (driven by `dismiss_stale_reviews_on_push`)
encode the same "who's on deck" signal — they must stay in sync.

inputs:
github-token:
description: Token with pull-requests:write scope. GITHUB_TOKEN is fine.
required: true
repo:
description: "owner/repo"
required: true
pr-number:
description: PR number.
required: true
reviewers:
description: >-
Space-separated GitHub logins. Duplicates are de-duped. Users already
assigned or requested are no-ops — GitHub silently ignores repeats.
required: true
request-review:
description: >-
Whether to POST /requested_reviewers in addition to setting assignees.
Default "true" — set "false" on reeval re-fires after a reviewer has
already submitted a review and GitHub legitimately cleared the
request. Assigning-only in that case lets the reviewer stay in the
Task Board queue without re-notifying them before the author has
explicitly re-requested. Copilot flagged that the all-idempotent
repair-the-pair logic in the composite was re-firing review requests
after CHANGES_REQUESTED reviews, defeating the explicit re_requested
signal.
required: false
default: "true"

runs:
using: composite
steps:
- name: Assign + request review
shell: bash
env:
GH_TOKEN: ${{ inputs.github-token }}
REPO: ${{ inputs.repo }}
PR_NUMBER: ${{ inputs.pr-number }}
REVIEWERS: ${{ inputs.reviewers }}
REQUEST_REVIEW: ${{ inputs.request-review }}
run: |
set -euo pipefail

# De-dupe and drop empties.
mapfile -t USERS < <(printf '%s\n' $REVIEWERS | awk 'NF' | sort -u)

if [ "${#USERS[@]}" -eq 0 ]; then
echo "No reviewers to assign — skipping."
exit 0
fi

# Reconcile each side (assignees, review-requests) separately.
# The board's queue is driven by assignees; GitHub's native
# review notification + dismiss-stale behavior is driven by
# review-requests. They encode the same "who's on deck" signal
# but can drift (e.g., dismiss_stale_reviews_on_push clears a
# review-request without touching the assignee). Fixing only
# when both sides are missing — which is what the earlier
# version did — left the out-of-sync state permanent. Copilot
# flagged this. Both gh endpoints are idempotent server-side
# so targeting only the truly-missing subset keeps network
# calls minimal without risk.
pr_state=$(gh pr view "$PR_NUMBER" -R "$REPO" \
--json assignees,reviewRequests \
--jq '{
assignees: [.assignees[].login],
requested: [.reviewRequests[].login]
}')

mapfile -t MISSING_ASSIGNEES < <(
for user in "${USERS[@]}"; do
has=$(echo "$pr_state" | jq -r --arg u "$user" '.assignees | index($u)')
[ "$has" = "null" ] && printf '%s\n' "$user"
done
)

mapfile -t MISSING_REVIEW_REQUESTS < <(
for user in "${USERS[@]}"; do
has=$(echo "$pr_state" | jq -r --arg u "$user" '.requested | index($u)')
[ "$has" = "null" ] && printf '%s\n' "$user"
done
)

if [ "${#MISSING_ASSIGNEES[@]}" -eq 0 ] && [ "${#MISSING_REVIEW_REQUESTS[@]}" -eq 0 ]; then
echo "All desired reviewers (${USERS[*]}) already have synchronised assignee + review-request state — skipping."
exit 0
fi

# Assign only the missing-assignee subset.
if [ "${#MISSING_ASSIGNEES[@]}" -gt 0 ]; then
assignee_csv=$(IFS=,; echo "${MISSING_ASSIGNEES[*]}")
echo "Assigning [$assignee_csv] to PR #$PR_NUMBER (missing from assignees)."
gh pr edit "$PR_NUMBER" -R "$REPO" --add-assignee "$assignee_csv" > /dev/null
fi

# Request review for the missing-review-request subset. 422
# (already-approved-on-head-SHA post-dismiss-stale) stays a
# notice; other failures surface with gh stderr inline so
# operators can debug.
#
# Callers set request-review=false on reeval re-fires after a
# reviewer has legitimately submitted a review (GitHub cleared
# the request by design). Re-firing it there would re-notify
# the reviewer before the author has hit "re-request review,"
# defeating the explicit re_requested signal.
if [ "$REQUEST_REVIEW" != "true" ]; then
if [ "${#MISSING_REVIEW_REQUESTS[@]}" -gt 0 ]; then
echo "request-review=false — skipping /requested_reviewers for [${MISSING_REVIEW_REQUESTS[*]}] (assignees reconciled)."
fi
elif [ "${#MISSING_REVIEW_REQUESTS[@]}" -gt 0 ]; then
reviewer_json=$(printf '%s\n' "${MISSING_REVIEW_REQUESTS[@]}" | jq -R . | jq -sc '{reviewers: .}')

set +e
api_out=$(gh api -X POST "repos/$REPO/pulls/$PR_NUMBER/requested_reviewers" \
--input - <<<"$reviewer_json" 2>&1)
api_rc=$?
set -e

if [ "$api_rc" -eq 0 ]; then
echo "Requested review from: ${MISSING_REVIEW_REQUESTS[*]}"
elif echo "$api_out" | grep -qE "(HTTP 422|422 Unprocessable)"; then
echo "::notice::Review request returned 422 — expected when the user already approved on this SHA post-dismiss-stale. Assignees are in sync."
else
echo "::warning::Request-review API failed (exit $api_rc). Assignees are still synchronised, but GitHub won't send a notification. gh output:"
printf ' %s\n' "$api_out"
fi
fi
141 changes: 141 additions & 0 deletions .github/actions/board-upsert-status/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
name: Upsert board item + set Status
description: >-
Ensures the given PR or Issue is on the Task Board (project #7) and sets its
Status field. Idempotent — adding an already-present item is a no-op, and
setting a Status that's already correct is a no-op. Used by both
project-orchestrator.yml (non-Dependabot PRs) and dependabot-automerge.yml
(major-bump hold path).

inputs:
project-token:
description: >-
PAT with project:write AND repo:read scopes. Required — the
default GITHUB_TOKEN lacks org-project scope. The resource(url)
GraphQL call used below dereferences the PR/Issue URL, which on
private repos requires repo read in addition to the project
write. Fine-grained PATs need both scopes explicitly selected.
required: true
project-owner:
description: Organization / user that owns the project.
required: true
project-number:
description: Project number (the integer in the project URL).
required: true
project-id:
description: Global node ID of the project (PVT_*). Used for item-edit calls.
required: true
status-field-id:
description: Global node ID of the Status field (PVTSSF_*).
required: true
status-option-id:
description: Global node ID of the target Status option (the specific single-select option).
required: true
url:
description: Full HTTPS URL of the PR or Issue to upsert.
required: true

outputs:
item-id:
description: Board item node ID (ProjectV2Item). Always populated on success.
value: ${{ steps.upsert.outputs.item_id }}
status-changed:
description: >-
Literal "true" if this invocation actually changed the Status, "false" if it was already set.
value: ${{ steps.upsert.outputs.status_changed }}

runs:
using: composite
steps:
- name: Upsert + set Status
id: upsert
shell: bash
env:
GH_TOKEN: ${{ inputs.project-token }}
PROJECT_OWNER: ${{ inputs.project-owner }}
PROJECT_NUMBER: ${{ inputs.project-number }}
PROJECT_ID: ${{ inputs.project-id }}
STATUS_FIELD_ID: ${{ inputs.status-field-id }}
STATUS_OPTION_ID: ${{ inputs.status-option-id }}
URL: ${{ inputs.url }}
run: |
set -euo pipefail

# Look up whether the item is already on the board. GraphQL resource(url)
# returns a typed object (PullRequest | Issue | …), so we query both
# fragments and filter to project #PROJECT_NUMBER. `gh api --jq` only
# accepts a single jq expression — we pipe to a standalone jq so we can
# inject PROJECT_NUMBER via --arg.
resp=$(gh api graphql -f query='query($url: URI!) {
resource(url: $url) {
... 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 url="$URL")

Comment thread
EricAndrechek marked this conversation as resolved.
# Copilot R9 flagged: without a .errors check, a successful
# HTTP response with a GraphQL-level error (bad scope, URL
# permissions, schema issue) would leave item_id empty and
# trip the "item not on board — adding" branch, which would
# mask the real failure as a spurious re-add. Fail loudly
# instead.
graphql_errors=$(echo "$resp" | jq -c '.errors // empty')
if [ -n "$graphql_errors" ]; then
echo "::error::GraphQL lookup failed for $URL: $graphql_errors"
exit 1
fi
if echo "$resp" | jq -e '.data.resource == null' > /dev/null; then
echo "::error::GraphQL returned null resource for $URL — check URL validity and that the token has read access."
exit 1
fi

item_id=$(echo "$resp" | jq -r --arg n "$PROJECT_NUMBER" '
.data.resource.projectItems.nodes[]?
| select(.project.number == ($n | tonumber))
| .id' | head -n1)

current_option=$(echo "$resp" | jq -r --arg n "$PROJECT_NUMBER" '
.data.resource.projectItems.nodes[]?
| select(.project.number == ($n | tonumber))
| .fieldValueByName.optionId // ""' | head -n1)

if [ -z "$item_id" ]; then
echo "Item not on board — adding."
item_id=$(gh project item-add "$PROJECT_NUMBER" \
--owner "$PROJECT_OWNER" --url "$URL" --format json --jq '.id')
current_option=""
fi

if [ "$current_option" != "$STATUS_OPTION_ID" ]; then
gh project item-edit \
--id "$item_id" \
--field-id "$STATUS_FIELD_ID" \
--single-select-option-id "$STATUS_OPTION_ID" \
--project-id "$PROJECT_ID" > /dev/null
echo "Status changed on item $item_id (was $current_option → $STATUS_OPTION_ID)."
echo "status_changed=true" >> "$GITHUB_OUTPUT"
else
echo "Status already correct on item $item_id ($STATUS_OPTION_ID) — no change."
echo "status_changed=false" >> "$GITHUB_OUTPUT"
fi

echo "item_id=$item_id" >> "$GITHUB_OUTPUT"
Loading
Loading