Skip to content

fix(github): survive GraphQL responses that carry no envelope - #132

Open
factory-nizar wants to merge 1 commit into
devfrom
sf/can-you-monitor-linear-and-the/2a92c804-520e-4c49-9184-ce275ab392d9
Open

fix(github): survive GraphQL responses that carry no envelope#132
factory-nizar wants to merge 1 commit into
devfrom
sf/can-you-monitor-linear-and-the/2a92c804-520e-4c49-9184-ce275ab392d9

Conversation

@factory-nizar

Copy link
Copy Markdown
Contributor

What happened

Droid Auto Review run 32616161689 on factory-mono#19051 went red in the Prepare validator step:

Failed to fetch PR branch data: 42 |         repo: repository.repo,
...
47 |     if (!prResult.repository.pullRequest) {
              ^
TypeError: undefined is not an object (evaluating 'prResult.repository')
      at fetchPRBranchData (/home/runner/work/_actions/Factory-AI/droid-action/dev/src/github/data/pr-fetcher.ts:47:10)

Two details from that log correct the first reading of this failure:

  • The review was not skipped before it started. Pass 1 candidates validated: 1 comments found printed 40 ms earlier, so pass 1 ran for 13 minutes and produced a finding. What the crash killed was Run Droid Exec (validator) — the pass that posts the comments. The finding was computed and thrown away.
  • It is transient, not a broken code path. fetchPRBranchData had already succeeded in the same job at 03:46:11 with the same query and the same repository (Checking out PR #19051 branch for diff computation...). It failed at 04:00:20 on the second call. Auth was fine either way (OIDC token successfully obtained / App token successfully obtained immediately before the crash). 1 failure in 78 runs of the workflow over 08-22..08-23.

The check does not block merge, so #19051 merged 99 minutes later with the review check red and no review posted.

Root cause

@octokit/graphql returns response.data.data and throws only when the body carries an errors array:

return request(requestOptions).then((response) => {
  if (response.data.errors) { throw new GraphqlResponseError(...); }
  return response.data.data;
});

So an HTTP 200 whose body is not a GraphQL envelope — an HTML error page, or JSON with no data member — resolves undefined. prResult.repository then throws. The TypeError wording pins it precisely: a GraphQL error payload would have thrown GraphqlResponseError instead, and a nulled repository field would have read null is not an object. prResult itself was undefined, i.e. GitHub answered 200 with something that was not a GraphQL response.

The old catch also discarded the cause (Failed to fetch PR branch data for PR #19051), which is why the raw response never reached the log.

Reproduction

Driving the real @octokit/graphql with a stubbed fetch that returns 200 text/html, before the fix:

[html-200] graphql() resolved to: undefined
Failed to fetch PR branch data: 42 |         repo: repository.repo,
...
47 |     if (!prResult.repository.pullRequest) {
              ^
TypeError: undefined is not an object (evaluating 'prResult.repository')
      at fetchPRBranchData (src/github/data/pr-fetcher.ts:47:10)

Byte-for-byte the CI signature, including the interleaved source excerpt. After the fix the same input produces:

Failed to fetch PR branch data for PR #19051: PR query for Factory-AI/factory-mono#19051
returned no "repository" field (GraphQL data: undefined; HTTP 200 text/html,
body: string("<html><body>unavailable</body></html>"))

The change

Both fetchers in src/github/data/pr-fetcher.ts now go through one fetchGraphQLField helper that:

  1. Checks the response shape before reading it and fails with the payload instead of a TypeError.
  2. Retries the request via utils/retry.ts (3 attempts, 1s/2s backoff), which is what actually keeps the check green — this repo's AGENTS.md already prescribes utils/retry.ts for GitHub API operations, and the same query demonstrably succeeded minutes earlier in the same job.
  3. Captures the HTTP status, content type and body through a per-call request.hook, because @octokit/graphql hands back only the data member and otherwise the next occurrence would be just as undiagnosable as this one.

A present-but-null repository or pullRequest is a stable "not found" answer, so it is reported immediately and costs one request, not three. The wrapping errors now carry their cause.

Scope. pr-fetcher.ts was the only module dereferencing a GraphQL response without optional chaining (rg '\.repository\.' src); src/mcp/github-pr-server.ts:405 already reads threadLookupQuery?.repository?.pullRequest?.... Both functions here are fixed, which covers every entry point that reads this query: generate-review-prompt, review, review-validator, security-review, fill and security-scan.

Validation

  • bun test test/github/data/pr-fetcher.test.ts — 11 pass / 0 fail (new file).

  • bun test — 588 pass / 0 fail across 54 files.

  • bun run typecheck — clean. bun run format:check — clean.

  • Mutation-checked, per the repo's rule against tests that cannot fail. Each mutation was applied to the fix and the suite re-run:

    Mutation Result
    baseline (fixed) 11 pass / 0 fail
    restore the original unguarded pr-fetcher.ts 8 fail
    maxAttempts: 3 -> 1 (no retry) 1 fail
    drop the captured HTTP body 1 fail
    drop the captured content type 1 fail
    retry the stable not-found answer 2 fail
    wrapper drops the cause (PR fetch) 5 fail
    wrapper drops the cause (default branch) 2 fail
    stop defaulting a null PR body to "" 1 fail

    Only the two happy-path tests survive reverting the fix, which is the intended shape.


Workstream · Change

`@octokit/graphql` resolves `response.data.data` and only throws when the
body carries an `errors` array, so an HTTP 200 whose body is not a GraphQL
envelope resolves `undefined`. `fetchPRBranchData` dereferenced that value
directly, turning a transient GitHub answer into
`TypeError: undefined is not an object (evaluating 'prResult.repository')`
that killed the `Prepare validator` step of Droid Auto Review and skipped
the review pass that posts the comments.

Route both fetchers in this module through one helper that checks the
response shape before reading it, retries the request via `utils/retry.ts`
per the repo convention for GitHub API calls, and reports the HTTP status,
content type and body preview captured through a request hook, since
`@octokit/graphql` otherwise discards the body that would explain the
failure. A present-but-null `repository` stays a stable "not found" answer
and is not retried.

Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com>
@factory-droid

factory-droid Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Droid finished @factory-nizar's task —— View job


Security Review

Good robustness fix for transient non-GraphQL 200 responses, with solid coverage. Only minor concern is the new retry-focused unit tests use real backoff sleeps and retry logging, which can slow and noisify CI runs.

Comment on lines +64 to +72
let consoleErrorSpy: ReturnType<typeof spyOn>;

beforeEach(() => {
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
});

afterEach(() => {
consoleErrorSpy.mockRestore();
});

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.

[P3] Avoid slow/noisy retries in unit tests

These tests intentionally exercise retry paths, but retryWithBackoff uses real setTimeout sleeps and logs each attempt, which can make the suite noticeably slower and the output noisy. Consider stubbing console.log (and, if this grows, using fake timers or a test override for retry delays) so retry-focused tests stay fast and stable.

Suggested change
let consoleErrorSpy: ReturnType<typeof spyOn>;
beforeEach(() => {
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
});
let consoleErrorSpy: ReturnType<typeof spyOn>;
let consoleLogSpy: ReturnType<typeof spyOn>;
beforeEach(() => {
consoleErrorSpy = spyOn(console, "error").mockImplementation(() => {});
consoleLogSpy = spyOn(console, "log").mockImplementation(() => {});
});
afterEach(() => {
consoleErrorSpy.mockRestore();
consoleLogSpy.mockRestore();
});

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