feat(transform): accept the organization response root
Transform side of #151 (closed) (MR 2/2). MR 1, !497 (merged), is merged; this MR must ship in the same gem/npm release, since on MR 1 alone an organization-rooted response fails at transform.
What does this MR do and why?
MR 1 compiles group in (...) / project in (...) under the organization GraphQL root. The transformer unwraps the response by its root key and only knew namespace, project and group, so an organization-scoped response would have been reported as an invalid namespace. This MR:
- recognises
organizationas a response root, keeping the existing precedence and the "empty root object is invalid" behaviour; - reports a null root as
Organization does not exist or you do not have access to it, same wording as the other roots (a missing, empty or non-object root is attributed to the first root key present rather than always to namespace); - replaces the nested
unwrap_orchain with a walk overScopeKind::ALL(added here, with a completeness test intests/derivable_apis_tests.rs), and dropsInvalidScopeType, which had become a variant-for-variant copy ofScopeKind:InvalidScopenow carries theScopeKindand the message is derived from it.
Refused engine fields
When the backend refuses an analytics request it explains why in the GraphQL errors array (for example The following sources are not accessible: a, b) and nulls the engine field, or its aggregated connection when plan validation fails. Both consumers stop on errors (Apollo throws, /api/v4/glql returns a 400), so such a payload rarely reaches the transformer, but a caller that passed response["data"] on anyway used to get success: true with data: null. In analytics mode the transformer now:
- reports a null engine field, or a null
aggregatedunder it, by name:`duoCodeSuggestions` returned null, see `errors`. - never answers with a sibling engine's rows: a refused field wins over a present sibling whether the source is named or inferred, and a response without the named engine field is "no analytics data found" rather than probed for another engine.
- leaves standard mode as it was: a null connection passes through, and an error there is a separate change.
Example Usage
cd glql_rb
bundle install
bundle exec rake compileSame queries as MR 1's examples, now carried through the transform. Analytics data is private, so the snippets send GITLAB_TOKEN from the environment. Rows are from gitlab.com on 2026-09-07.
Aggregate across several groups
mode: analytics
query: type = CodeSuggestion and group in ("gitlab-org", "gitlab-com") and timestamp >= -30d
dimensions: language
metrics: totalCount, acceptanceRate
sort: totalCount descTest via ruby extension
cd glql_rb # after the build prerequisite above
bundle exec ruby -Ilib - <<'RUBY'
require "gitlab_query_language"
require "json"
require "net/http"
query = 'type = CodeSuggestion and group in ("gitlab-org", "gitlab-com") and timestamp >= -30d'
context = { mode: "analytics", dimensions: "language", metrics: "totalCount, acceptanceRate", sort: "totalCount desc" }
# 1. Compile GLQL -> GraphQL (also returns the typed `fields` the transform needs).
compiled = Glql.compile(query, context)
puts "== Generated GraphQL =="
puts compiled["output"]
# 2. Execute against GitLab. Analytics data is private, so send a token.
uri = URI("https://gitlab.com/api/graphql")
http = Net::HTTP.new(uri.host, uri.port); http.use_ssl = true
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
req["PRIVATE-TOKEN"] = ENV.fetch("GITLAB_TOKEN")
req.body = { query: compiled["output"], variables: { limit: 3 } }.to_json
response = JSON.parse(http.request(req).body)
# 3. Transform the response back into flat rows. Pass response["data"] (the
# GraphQL `data` object, not the whole envelope) and reuse the compiled
# `fields`, which carry the resolved parameters/aliases.
transformed = Glql.transform(response["data"], { mode: context[:mode], fields: compiled["fields"] })
puts "\n== Transformed rows =="
puts JSON.pretty_generate(transformed["data"])
RUBYquery GLQL($before: String, $after: String, $limit: Int) {
organization {
analytics {
duoCodeSuggestions(descendantsScope: {groupFullPaths: ["gitlab-org", "gitlab-com"]}, timestampFrom: "2026-08-08 00:00") {
aggregated(before: $before, after: $after, first: $limit, orderBy: [{direction: DESC, identifier: "totalCount"}]) {
count
nodes {
dimensions {
language
}
totalCount
acceptanceRate
}
}
}
}
}
}{
"count": 13,
"nodes": [
{ "acceptanceRate": 0.04282744282744283, "totalCount": 2406, "language": "ruby" },
{ "acceptanceRate": 0.030821917808219176, "totalCount": 2339, "language": "" },
{ "acceptanceRate": 0.027901785714285716, "totalCount": 896, "language": "vue" }
]
}Several groups, one row per project
mode: analytics
query: type = AgentPlatformSession and group in ("gitlab-org", "gitlab-com") and created >= -30d
dimensions: project
metrics: totalCount, usersCount
sort: totalCount descTest via ruby extension
cd glql_rb # after the build prerequisite above
bundle exec ruby -Ilib - <<'RUBY'
require "gitlab_query_language"
require "json"
require "net/http"
query = 'type = AgentPlatformSession and group in ("gitlab-org", "gitlab-com") and created >= -30d'
context = { mode: "analytics", dimensions: "project", metrics: "totalCount, usersCount", sort: "totalCount desc" }
# 1. Compile GLQL -> GraphQL (also returns the typed `fields` the transform needs).
compiled = Glql.compile(query, context)
puts "== Generated GraphQL =="
puts compiled["output"]
# 2. Execute against GitLab. Analytics data is private, so send a token.
uri = URI("https://gitlab.com/api/graphql")
http = Net::HTTP.new(uri.host, uri.port); http.use_ssl = true
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
req["PRIVATE-TOKEN"] = ENV.fetch("GITLAB_TOKEN")
req.body = { query: compiled["output"], variables: { limit: 3 } }.to_json
response = JSON.parse(http.request(req).body)
# 3. Transform the response back into flat rows. Pass response["data"] (the
# GraphQL `data` object, not the whole envelope) and reuse the compiled
# `fields`, which carry the resolved parameters/aliases.
transformed = Glql.transform(response["data"], { mode: context[:mode], fields: compiled["fields"] })
puts "\n== Transformed rows =="
puts JSON.pretty_generate(transformed["data"])
RUBYRows (project object truncated to fullPath):
{
"count": 1199,
"nodes": [
{ "usersCount": 52, "totalCount": 52453, "project": { "fullPath": "gitlab-org/editor-extensions/gitlab-lsp" } },
{ "usersCount": 45, "totalCount": 25392, "project": { "fullPath": "gitlab-org/ops/artifact-registry" } },
{ "usersCount": 799, "totalCount": 24684, "project": { "fullPath": "gitlab-org/gitlab" } }
]
}A path the caller cannot read
mode: analytics
query: type = CodeSuggestion and group in ("gitlab-org", "no-such-group-glql-151") and timestamp >= -30d
dimensions: language
metrics: totalCountThe backend nulls the engine field and reports the reason in the GraphQL errors array. Both /api/glql and /api/v4/glql stop there; a caller that passes response["data"] to the transform anyway gets an error naming the field instead of success: true with data: null.
Test via ruby extension
cd glql_rb # after the build prerequisite above
bundle exec ruby -Ilib - <<'RUBY'
require "gitlab_query_language"
require "json"
require "net/http"
query = 'type = CodeSuggestion and group in ("gitlab-org", "no-such-group-glql-151") and timestamp >= -30d'
context = { mode: "analytics", dimensions: "language", metrics: "totalCount" }
# 1. Compile GLQL -> GraphQL.
compiled = Glql.compile(query, context)
# 2. Execute against GitLab. The unknown path makes the backend refuse the
# engine field: it explains why in `errors` and nulls the field in `data`.
uri = URI("https://gitlab.com/api/graphql")
http = Net::HTTP.new(uri.host, uri.port); http.use_ssl = true
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
req["PRIVATE-TOKEN"] = ENV.fetch("GITLAB_TOKEN")
req.body = { query: compiled["output"], variables: { limit: 3 } }.to_json
response = JSON.parse(http.request(req).body)
puts "== errors =="
puts JSON.pretty_generate(response["errors"].map { |e| e["message"] })
puts "\n== data =="
puts JSON.pretty_generate(response["data"])
# 3. A consumer should stop at `errors`. One that transforms anyway now gets an
# error naming the field instead of `success: true` with `data: null`.
transformed = Glql.transform(response["data"], { mode: context[:mode], fields: compiled["fields"] })
puts "\n== transform =="
puts JSON.pretty_generate(transformed.slice("success", "error", "data"))
RUBY== errors ==
[
"The following sources are not accessible: no-such-group-glql-151"
]
== data ==
{
"organization": {
"analytics": {
"duoCodeSuggestions": null
}
}
}
== transform ==
{
"success": false,
"error": "Error: `duoCodeSuggestions` returned null, see `errors`.",
"data": null
}Checklist
-
cargo test(33 suites),cargo clippy --all-targets -- -D warnings,cargo fmt --check - Transform tests: organization-scoped response (inferred and named source, plus a compile-to-transform round trip for
group in (...)), null organization root, nullanalyticswrapper, null engine field and nullaggregated(by name), refused field next to a present sibling (named and inferred), absent named source -
scope_kind_all_lists_every_variantguardsScopeKind::ALLagainst a future fifth root - Live run against gitlab.com through the Ruby extension (see Example Usage)