Repository navigation
ci: rebuild task board state machine with composite actions + bidirectional sync #75
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 7703a1f
fix(ci): orchestrator re-eval triggers + COMMENTED reviews
EricAndrechek 6b4c401
fix(ci): address Copilot + Gemini review feedback on Phase 1
EricAndrechek f9f0557
fix(ci): Copilot review round 2 + admin-approval stale check
EricAndrechek 173fb44
fix(ci): Copilot review round 3
EricAndrechek 77e3261
fix(ci): Claude + Copilot review round 4
EricAndrechek dea6440
fix(ci): Copilot review round 5 — error distinguishing + admin-specif…
EricAndrechek 6388b0c
fix(ci): Copilot review round 6 — error-handling uniformity
EricAndrechek d94164c
fix(ci): reeval path loses reviewer-assign when card pre-set to non-B…
EricAndrechek 15116e4
fix(ci): split per-side reconciliation + defensive sort_by before gro…
EricAndrechek 047f885
fix(ci): Copilot R8 — checkout-ref security + strict-mode guard
EricAndrechek af43df0
fix(ci): Copilot R9 — request-review gate + GraphQL error checks ever…
EricAndrechek 76d0243
chore(ci): empty commit to force fresh CI run
EricAndrechek 00752f9
ci: pin board-state-sync.yml to ARC runner label
EricAndrechek 46cef85
fix(ci): Claude post-rebase review — SKIP_BOARD review-request + PR-s…
EricAndrechek ddabe3a
fix(make): security + size-tree(-map) targets after tool version bumps
EricAndrechek File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") | ||
|
|
||
| # 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" | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.