Frontmatter
| title | fix(github-workflow): propagate GraphQL errors from LabelService (#10112) |
| author | tobiu |
| state | Merged |
| createdAt | Apr 20, 2026, 1:02 AM |
| updatedAt | Apr 20, 2026, 1:13 AM |
| closedAt | Apr 20, 2026, 1:13 AM |
| mergedAt | Apr 20, 2026, 1:13 AM |
| branches | dev ← fix/10112-labelservice-propagate-errors |
| url | https://github.com/neomjs/neo/pull/10114 |

Input from Claude Opus 4.7 (Claude Code):
❋ # PR Review Summary
Status: Approved — ready for human QA
Self-Review Opening: Self-review of #10112. This implementation chose to remove
LabelService's swallow-and-wrap locally rather than refactor all 11 sibling sites across 4 services. Rationale:LabelServiceis the only one exposed through a CI-visible build script, so its diagnostic loss is user-observable; the other 10 sites route through the MCP tool boundary which already converts thrown exceptions into structured error payloads, making their wraps functionally redundant but not user-visible. A cross-service cleanup can be filed later if warranted. Key trade-offs, gaps, and one process miss below.
📊 Evaluation Metrics
[ARCH_ALIGNMENT]: 90 — Neo singleton, standardstatic config, clean removal of the now-unusedloggerimport, JSDoc@summary+@throwscontract. Faithful to framework idioms.[CONTENT_COMPLETENESS]: 88 — Anchor (full@summary+@throwsonlistLabels, explicit reference toServer.mjs:150-222MCP boundary), Echo (inline comment atlabels.mjs:55-57anchoring to #10112, spec describe-block explaining the pre-#10112 anti-pattern). Docked for the initial PR body claiming "MCP tool boundary already catches" without tracing Server.mjs line numbers or verifying text-format equivalence — corrected during review via user challenge, but the evidence should have been in the PR body from the start.[EXECUTION_QUALITY]: 78 — All 3 new tests pass; existing 2IssueSyncercases from #10110 still pass (5/5 green). Tests cover happy-path pagination, Error-typed throw propagation, and non-Error throwable propagation. Docked for a test-layer gap: no integration test drives the fullcallTool('list_labels', ...)path throughServer.mjs:151-222with a mocked GraphQL failure. That would empirically lock the architectural claim ("exceptions propagate cleanly into MCP error payloads") at the execution level. Mitigated via grep-sweep confirmation that zero existing tests depend on the old shape — but a positive lock is stronger than a negative audit.[PRODUCTIVITY]: 95 — All 4 AC items met pre-merge. The "Next CI failure prints the original GraphQL error" item is only verifiable post-merge.[IMPACT]: 55 — Scope is narrow (one service, one CLI consumer, moderate diagnostic-recovery value). Not framework-critical. Preconditions for empirical confirmation of the rate-limit hypothesis that companion #10113 attempts to root-cause.[COMPLEXITY]: 28 — 3 files, +152/-33, one service change, one CLI shape-check removal, one new spec file. Changes are mechanical.[EFFORT_PROFILE]: Quick Win — High ROI (restores observability on a critical CI pipeline) / Low complexity (net code reduction on the service side, pure additions on the test side).
🕸️ Context & Graph Linking
- Target Epic / Issue ID: Resolves #10112
- Related Graph Nodes:
- Companion: #10113 (DevIndex Spider 3x→1x; hypothesized root cause of the rate-limit pressure this PR restores visibility into)
- Built on: #10110 / PR #10111 (merged earlier in this session; same-area architectural work)
- Deferred follow-ups: 10 sibling swallow-sites in
IssueService/DiscussionService/PullRequestService(not user-visible via MCP boundary; cross-service cleanup can be filed if justified post-observation)
🧠 Graph Ingestion Notes
[RETROSPECTIVE]: When asserting "existing infrastructure already handles this" in a PR body, produce a claim→evidence table before including the claim — cite file:line, trace the code path, verify the exact output shape. My initial PR body said "MCP tool boundary already catches thrown exceptions and converts to structured MCP error payloads" without citingServer.mjs:211, tracing the catch-branch response shape, or acknowledging that the error text format differs between the wrap path and the throw path. The user's follow-up question ("does it REALLY not break our MCP tool shapes?") caught this rigor gap; the claim was ultimately correct, but arrived at through their challenge rather than my own pre-publication tracing. Bake this into future PR body drafting: never assert an architectural shortcut without the trace at hand.
[TOOLING_GAP]: The pr-review skill's Self-Review Detection step (per §2 ofpr-review-guide.md) relies onquery_raw_memoriesmatching the ticket number within the current session. The Memory Core Consolidate-Then-Save protocol persists memory at end-of-turn — so a PR authored, opened, and reviewed within a single turn has no persisted#Nmatch yet, and the detection query returns empty. This incorrectly triggers peer-review-mode for genuine self-review cases. For this PR I manually overrode to self-review mode based on factual authorship knowledge. A more robust signal would check (a)git log --authoron the PR's commits against the authenticated user, or (b) the active agent's in-flight work track, not just persisted memory. Captured here for REM-cycle ingestion.
[KB_GAP]: None significant. The architectural primitives (Server.mjsMCP boundary,GraphqlService.querythrowing contract) are well-structured and the pattern being removed is documented as legacy in adjacent tickets (#10110, #10092). No framework-concept gap led to the original anti-pattern — it was clearly a bespoke-per-service idiom that accumulated.
📋 Required Actions
None blocking. Optional items for consideration:
- Consider (not blocking): Add an integration test that drives
callTool('list_labels', ...)throughServer.mjs:151-222with a mocked GraphQL failure and asserts the returned MCP payload hasisError: true+ a text block containing the underlying error message. Would empirically lock the architectural claim at the execution layer, complementing the grep-sweep's negative audit. Skippable if the grep-sweep is judged sufficient evidence.- Post-merge validation (unavoidable): Observe the next
data-sync-pipelineCI failure — the log should now show the real GraphQL error (HTTP status + body) rather than the pre-#10112"Invalid response from GH_LabelService"wrapper. Confirms AC item that can't be validated pre-merge.- Post-merge graph hygiene (deferred, not blocking this PR): If companion #10113 lands and rate-limit hypothesis is confirmed post-observation, optionally file a cross-service error-propagation cleanup ticket covering the 10 remaining swallow-sites. No commitment yet.
Handing off to human QA per §5 of
pull-request-workflow.md. DO NOT auto-merge — human approval for squash merge required.
Summary
Removes the swallow-and-wrap pattern from
LabelService.listLabels()so GraphQL exceptions propagate with their original status + message instead of being reduced to a generic{error, message, code}object that hides diagnostics. Restores observability on thedata-sync-pipelineCI workflow — the next intermittent failure will print the actual underlying error (rate-limit 429, 5xx, network) in the CI log instead of the useless"Invalid response from GH_LabelService"wrapper.Architectural Impact
Before
async listLabels() { try { // ... paginated query ... return { count, labels }; } catch (error) { logger.error('Error fetching labels via GraphQL:', error); return { error: 'GraphQL API request failed', message: error.message, code: 'GRAPHQL_API_ERROR' }; } }{count, labels}OR{error, message, code}labels.mjs:58did!response || !response.labels→ threw its own'Invalid response from GH_LabelService', burying the real error twicelogger.errorwrites to stderr but the original Error object's status code / stack is already swallowed by string interpolation inerror.messageAfter
async listLabels() { // ... paginated query ... return { count, labels }; // No catch — GraphqlService exceptions propagate unmodified }labels.mjsno longer shape-checks; its existing outertry/catchlogs the real errorServer.mjs:150-222already catches thrown exceptions and converts to structured MCP error payloads — the in-service wrap was redundant with protocol-level handlingWhy Not a Broader Refactor?
Same swallow-and-wrap exists at 11 call sites across 4 services (
IssueService× 6,DiscussionService× 2,PullRequestService× 2,LabelService× 1). Deliberately scoped narrow toLabelServicebecause:labels.mjs) — its failures are user-visibleLeaving breadcrumbs for a future cleanup if pressure arises, but no commitment.
Diagnostic Payoff
The prevailing hypothesis for why the pipeline fails intermittently: the
DevIndex Spider3x-loop consumes GraphQL rate-limit budget → subsequent GraphQL calls in the same workflow (labels, release, tickets index generation) hit 429. This PR cannot prove that by itself — but once it lands, the next CI failure will print the actual HTTP status + GraphQL error body, and the hypothesis can be empirically confirmed or refuted. Companion ticket #10113 addresses the suspected root cause (spider loop reduction).Test Coverage
New file:
test/playwright/unit/ai/mcp/server/github-workflow/LabelService.spec.mjswith 3 cases:{count, labels}with correct orderingGraphqlService.querythrows, the exact thrown value propagates unmodified — no catch, no wrap, no synthetic replacementAll 3 pass; the existing 2
IssueSyncercases from #10110 still pass — full gh-workflow spec suite 5/5 green (570ms).Test Plan
npx playwright test -c test/playwright/playwright.config.unit.mjs test/playwright/unit/ai/mcp/server/github-workflow/→ 5/5 passingdata-sync-pipelineCI run exposes the actual underlying error when/if it failsRelated
IssueService,DiscussionService,PullRequestService) — not scoped here; harmless via MCP tool boundary which already catchesAvoided Traps
LabelService— wrong layer; retry belongs inGraphqlServiceas a cross-cutting primitiveResolves #10112
🤖 Generated with Claude Code