Skip to content

ci(requirements-sync): fetch board deterministically with a projects-scoped token - #899

Closed
SarahLittlejohn wants to merge 1 commit into
masterfrom
ci/requirements-sync-board-access
Closed

ci(requirements-sync): fetch board deterministically with a projects-scoped token#899
SarahLittlejohn wants to merge 1 commit into
masterfrom
ci/requirements-sync-board-access

Conversation

@SarahLittlejohn

@SarahLittlejohn SarahLittlejohn commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Why

The nightly sync has been reporting "no drift" while the requirements DB fell 39 gated issues behind, plus #438 stale at approved vs implemented on the board. Last night's run succeeded at 45 turns and $3.33 having seen nothing.

Root cause: the job authenticates as the public Claude App (claude[bot], bot_id 41898282). Its declared permissions are actions, checks, contents, discussions, issues, members, metadata, pull_requests, repository_hooks, statuses, workflows — no organization_projects or repository_projects. Project #43 is org-owned by hmcts, so the board read in prompt step 2 could never succeed.

This is not an unchecked toggle on the installation. The App doesn't declare the permission, so there's nothing to grant and no workflow-only fix.

When that query failed mid-prompt, Claude improvised a fallback nobody specified: closed issue + merged closing PR as a proxy for Done=verified, recorded only in SQL comments in migrations 008, 009 and 010. That proxy structurally cannot see the 20 Refined Tickets items — they have no PR by definition — so it found nothing. The integrity gate passed throughout, because a DB missing 39 rows is still internally consistent.

What changed

  • requirements/scripts/fetch_board.sh — pages Project VIBE-159 Add confirm upload details page #43, maps board columns to requirement statuses, filters to gated issues, emits JSON Lines. Exits non-zero if the GraphQL call errors, if the project node comes back null (how a token without projects access actually presents), or if zero items are returned. An unreadable board now stops the run instead of silently degrading it.
  • New "Fetch board state" step runs it before Claude using PROJECTS_READ_TOKEN, failing with an actionable message if the secret is absent. Claude reads the resulting file instead of querying GitHub, so it can't substitute a proxy for data it no longer fetches.
  • Prompt step 2 rewritten to read BOARD_STATE_FILE and forbid inferring status from issue state or merged PRs. Step 3's now-redundant column mapping removed so the two can't drift apart.
  • Bash(gh:*) narrowed to Bash(gh pr:*) so the board isn't reachable even if the prompt is misread.
  • STATUS_FIELD_ID dropped — the script queries the Status field by name.

⚠️ Action required before merge

Create secret PROJECTS_READ_TOKEN: a fine-grained PAT with organization Projects: Read on hmcts. Without it the job fails fast with a clear error rather than silently going blind.

Being user-owned, this PAT runs as that person's identity, expires within a year, and dies on offboarding. An org-owned App declaring organization_projects: read is the durable replacement and needs no change here beyond the secret reference.

Verification

Against the live board: 281 items read, 191 gated, no duplicate issue numbers, and reproduces the known drift exactly (39 new, #438 mismatch). Both failure paths exercised — invalid project id and invalid token each exit 1 with no partial output.

Out of scope

Does not backfill the drift. Migrations 008–010 were all written under the proxy, so their contents are suspect too — that needs a reviewed migration of its own.

Supersedes the direct-push approach in #884 (closed unmerged); this fixes detection, which that PR assumed was working.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Improvements

    • Requirements synchronisation now uses the latest project board data, providing more accurate requirement statuses.
    • Board data is validated before processing, with clearer failures when access or configuration is unavailable.
    • Merged pull request details and affected files are captured more consistently for implemented requirements.
  • Reliability

    • Board data retrieval now handles larger project boards through pagination and reports empty or unreadable boards as errors.

…scoped token

The nightly sync has been reporting "no drift" while the requirements DB fell 39
gated issues behind, plus #438 stale at approved vs implemented on the board.

Root cause: the job authenticates as the public Claude App (claude[bot], bot_id
41898282), whose declared permissions are actions, checks, contents, discussions,
issues, members, metadata, pull_requests, repository_hooks, statuses, workflows.
There is no organization_projects or repository_projects among them, and Project #43
is org-owned by hmcts, so the board read in prompt step 2 could never succeed. This
is not an unchecked toggle on the installation — the App does not declare the
permission, so there is nothing to grant and no workflow-only fix.

When that query failed mid-prompt, Claude improvised a fallback nobody specified:
closed issue + merged closing PR as a proxy for Done=verified, recorded only in SQL
comments in migrations 008, 009 and 010. That proxy structurally cannot see the 20
"Refined Tickets" items, which have no PR by definition, so it found nothing and the
job faithfully reported no drift. The integrity gate passed throughout, because a DB
missing 39 rows is still internally consistent.

Fixed by moving the board read out of the prompt:

- New requirements/scripts/fetch_board.sh pages Project #43, maps board columns to
  requirement statuses, filters to gated issues and emits JSON Lines. It exits
  non-zero if the GraphQL call errors, if the project node comes back null (how a
  token without projects access actually presents), or if zero items are returned —
  an unreadable board now stops the run instead of silently degrading it.
- A "Fetch board state" step runs it BEFORE Claude, using PROJECTS_READ_TOKEN, and
  fails with an actionable message if that secret is absent. Claude reads the
  resulting file instead of querying GitHub, so it cannot substitute a proxy for
  data it no longer fetches.
- Prompt step 2 rewritten to read BOARD_STATE_FILE and forbid inferring status from
  issue state or merged PRs; step 3's now-redundant column mapping removed so the
  two cannot drift apart. Bash(gh:*) narrowed to Bash(gh pr:*) so the board is not
  reachable even if the prompt is misread. STATUS_FIELD_ID dropped — the script
  queries the Status field by name.

PROJECTS_READ_TOKEN is a fine-grained PAT with organization Projects: Read. Being
user-owned, it runs as that person's identity, expires within a year, and dies on
offboarding — an org-owned App declaring organization_projects: read is the durable
replacement and needs no change here beyond the secret reference.

Verified against the live board: the script reads 281 items, 191 gated, no duplicate
issue numbers, and reproduces the known drift exactly (39 new, #438 mismatch). Both
failure paths were exercised — invalid project id and invalid token each exit 1 with
no partial output.

Does not backfill the drift. Migrations 008-010 were all written under the proxy, so
their contents are suspect too; that needs a reviewed migration of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The workflow now fetches Project #43 into a validated JSONL file before Claude runs. Claude consumes that file for requirement statuses and merged PR metadata, while direct board queries and status inference are prohibited.

Changes

Requirements synchronisation

Layer / File(s) Summary
Board fetch and JSONL generation
requirements/scripts/fetch_board.sh
Queries and paginates Project #43, maps gated statuses, emits issue and merged PR metadata as JSON Lines, and fails on unreadable or empty board state.
Workflow and Claude integration
.github/workflows/requirements-sync.yml
Adds the board-fetch step and token validation, passes BOARD_STATE_FILE to Claude, removes STATUS_FIELD_ID, and narrows Claude’s board-state and GitHub tool instructions.

Sequence Diagram(s)

sequenceDiagram
  participant GitHub Actions
  participant fetch_board.sh
  participant GitHub GraphQL API
  participant Claude
  GitHub Actions->>fetch_board.sh: Provide PROJECTS_READ_TOKEN and PROJECT_ID
  fetch_board.sh->>GitHub GraphQL API: Fetch paginated Project `#43` data
  GitHub GraphQL API-->>fetch_board.sh: Return board items and merged PR metadata
  fetch_board.sh-->>GitHub Actions: Write BOARD_STATE_FILE
  GitHub Actions->>Claude: Provide BOARD_STATE_FILE
  Claude-->>GitHub Actions: Compute requirement delta from JSONL
Loading

Possibly related PRs

  • hmcts/cath-service#695: Earlier Project #43 reading and requirement-delta logic in the same requirements-sync workflow.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly reflects the main change: requirements sync now fetches the project board deterministically using a projects-scoped token.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ci/requirements-sync-board-access

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

🎭 Playwright E2E Test Results

82 tests   52 ✅  3m 59s ⏱️
31 suites  30 💤
 1 files     0 ❌

Results for commit 2929c53.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 182144af-a8cf-4913-adca-eb6840d26866

📥 Commits

Reviewing files that changed from the base of the PR and between d9320ea and 2929c53.

📒 Files selected for processing (2)
  • .github/workflows/requirements-sync.yml
  • requirements/scripts/fetch_board.sh

Comment on lines +59 to +66
closedByPullRequestsReferences(first: 20, includeClosedPrs: true) {
nodes {
number
state
mergeCommit { oid }
files(first: 100) { nodes { path } }
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Emitted JSON leaks the raw boardStatus, and mergedPrs has no timestamp to determine "latest".

Two related contract gaps for the downstream consumer (the Claude prompt in requirements-sync.yml):

  1. Line 101 merges status onto the object built at lines 105‑118, but doesn't drop the original boardStatus key first. The final JSONL line therefore contains both the raw column name and the mapped status. The prompt explicitly forbids inferring status from anything but status — leaving the raw value in the file undermines the very guarantee this PR exists to provide.
  2. The GraphQL query (lines 59‑66) fetches number, state, mergeCommit { oid } and files for each merged PR but no merge timestamp (e.g. mergedAt). The prompt instructs Claude to pick "the latest merged closing PR's mergeCommitOid from mergedPrs" (requirements-sync.yml line 159), but without a timestamp there's no reliable signal for "latest" — Claude is left guessing from array order or PR number.
♻️ Proposed fix
                   closedByPullRequestsReferences(first: 20, includeClosedPrs: true) {
                     nodes {
                       number
                       state
+                      mergedAt
                       mergeCommit { oid }
                       files(first: 100) { nodes { path } }
                     }
                   }
         mergedPrs: [
           .content.closedByPullRequestsReferences.nodes[]
           | select(.state == "MERGED")
-          | {number, mergeCommitOid: .mergeCommit.oid, paths: [.files.nodes[].path]}
+          | {number, mergedAt, mergeCommitOid: .mergeCommit.oid, paths: [.files.nodes[].path]}
         ]
       }' <<<"$resp")
   done < <(...)
     jq -c --arg status "$mapped" '. + {status: $status}' <<<"$item"

And drop boardStatus from the final emitted object (e.g. del(.boardStatus) after the merge, or omit it from the object construction and pass board_status to map_status via --argjson/an intermediate variable instead).

Also applies to: 102-118

Comment on lines +81 to +90
if ! resp=$(read_page "$cursor" 2>&1); then
echo "::error::Cannot read Project board — GraphQL query failed. The token needs organization Projects: Read on hmcts. Response: ${resp}" >&2
exit 1
fi

# A token without projects access gets a null node rather than an HTTP error.
if [ "$(jq -r '.data.node // "null"' <<<"$resp")" = "null" ]; then
echo "::error::Project board returned no node for PROJECT_ID=${PROJECT_ID}. The token almost certainly lacks organization Projects: Read on hmcts." >&2
exit 1
fi

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Stray stderr merges into $resp even on success, corrupting downstream jq parsing.

resp=$(read_page "$cursor" 2>&1) redirects stderr into the captured value unconditionally, not just on failure. If gh ever writes anything to stderr on a successful call (rate-limit notices, deprecation warnings, etc.), $resp stops being valid JSON and every subsequent jq call (lines 87, 92, 102, 118, 120‑121) silently fails inside a command substitution — which set -e does not catch — rather than triggering the loud, explicit failure this script is designed to guarantee.

🛡️ Proposed fix: capture stderr separately, only surface it on failure
-  if ! resp=$(read_page "$cursor" 2>&1); then
-    echo "::error::Cannot read Project board — GraphQL query failed. The token needs organization Projects: Read on hmcts. Response: ${resp}" >&2
+  err_file=$(mktemp)
+  if ! resp=$(read_page "$cursor" 2>"$err_file"); then
+    echo "::error::Cannot read Project board — GraphQL query failed. The token needs organization Projects: Read on hmcts. Response: $(cat "$err_file")" >&2
+    rm -f "$err_file"
     exit 1
   fi
+  rm -f "$err_file"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if ! resp=$(read_page "$cursor" 2>&1); then
echo "::error::Cannot read Project board — GraphQL query failed. The token needs organization Projects: Read on hmcts. Response: ${resp}" >&2
exit 1
fi
# A token without projects access gets a null node rather than an HTTP error.
if [ "$(jq -r '.data.node // "null"' <<<"$resp")" = "null" ]; then
echo "::error::Project board returned no node for PROJECT_ID=${PROJECT_ID}. The token almost certainly lacks organization Projects: Read on hmcts." >&2
exit 1
fi
err_file=$(mktemp)
if ! resp=$(read_page "$cursor" 2>"$err_file"); then
echo "::error::Cannot read Project board — GraphQL query failed. The token needs organization Projects: Read on hmcts. Response: $(cat "$err_file")" >&2
rm -f "$err_file"
exit 1
fi
rm -f "$err_file"
# A token without projects access gets a null node rather than an HTTP error.
if [ "$(jq -r '.data.node // "null"' <<<"$resp")" = "null" ]; then
echo "::error::Project board returned no node for PROJECT_ID=${PROJECT_ID}. The token almost certainly lacks organization Projects: Read on hmcts." >&2
exit 1
fi

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant