feat: add createdByDuo dimension/filter and acceptanceRate metric for MR analytics
What does this MR do and why?
Adds createdByDuo and acceptanceRate to the MergeRequests analytics source, so the
DAP Impact v1 dashboard's Work tab can render its "MR cycle time: median and p75, Duo vs
non-Duo" panel.
The GitLab MergeRequests aggregation engine (gitlab-org/gitlab#608191, shipping in 19.5) exposes three things GLQL did not yet know about:
| Engine addition | GraphQL name | Exposed in GLQL as |
|---|---|---|
| Dimension | createdByDuo: Boolean on MergeRequestsAggregationResponseDimensions |
dimension + sort field |
| Filter argument | createdByDuo: [Boolean!] on analytics.mergeRequests |
filter field (= only) |
| Metric | acceptanceRate: Float on MergeRequestsAggregationResponse |
metric + sort field |
Two details worth a reviewer's attention:
createdByDuoaccepts=and!=. The engine argument is a[Boolean!]allow-list with no negation, but no operator restriction is needed:transform_expressionrewrites(_, NotEqual, Bool(b))to(Equal, !b)before codegen, socreatedByDuo != truecompiles tocreatedByDuo: [false].inis still rejected, sinceBooleanLikedoes not admit it.- A new
bool_list_filter_valuehelper wraps the scalar into the one-element list the engine wants (createdByDuo: [true]), mirroring the existingstring_list_filter_valueandlowercase_string_list_filter_valuehelpers next to it.
AcceptanceRate already existed as a field (CodeSuggestions uses it), so only CreatedByDuo
is a new Field variant.
The other two commits are supporting work:
test(merge_requests): the new dimension tests asserteddimensions {and the field name as two independent substrings of the compiled query, which held whether or not the field landed inside the block — a name appearing only as a filter argument satisfied both. A newdimensions_blocktest helper slices the body of thedimensions { … }selection so the assertions pin placement, and the metric test now also pins the negative (acceptanceRatemust sit outside the block).refactor(analytics): review feedback — drops the operator restriction described above, and documentsbool_list_filter_value's unreachableListarm as defensive, matching howstring_list_filter_valuedocuments itsTokenarm.
Example Usage
Build prerequisite, named once:
cd glql_rb
bundle install
bundle exec rake compileDuo vs non-Duo cycle time (the dashboard panel)
mode: analytics
query: type = MergeRequest and group = "gitlab-org" and merged > -30d
dimensions: createdByDuo
metrics: timeToMergeQuantile(0.5) as "median", timeToMergeQuantile(0.75) as "p75", acceptanceRateTest 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 = MergeRequest and group = \"gitlab-org\" and merged > -30d"
context = {
mode: "analytics",
dimensions: "createdByDuo",
metrics: "timeToMergeQuantile(0.5) as \"median\", timeToMergeQuantile(0.75) as \"p75\", acceptanceRate"
}
compiled = Glql.compile(query, context)
puts "== Generated GraphQL =="
puts compiled["output"]
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")
# Analytics needs read_cycle_analytics, so a token is required here:
req["PRIVATE-TOKEN"] = ENV.fetch("GITLAB_TOKEN")
req.body = { query: compiled["output"], variables: { limit: 3 } }.to_json
response = JSON.parse(http.request(req).body)
transformed = Glql.transform(response["data"], { mode: "analytics", fields: compiled["fields"] })
puts "\n== Transformed rows =="
puts JSON.pretty_generate(transformed["data"])
RUBYGenerated GraphQL:
query GLQL($before: String, $after: String, $limit: Int) {
group(fullPath: "gitlab-org") {
analytics {
mergeRequests(metricMergedAtFrom: "2026-08-18 23:59") {
aggregated(before: $before, after: $after, first: $limit) {
count
nodes {
dimensions {
createdByDuo
}
timeToMerge {
timeToMergeQuantile_quantile_0_d5: quantile(quantile: 0.5)
}
timeToMerge {
timeToMergeQuantile_quantile_0_d75: quantile(quantile: 0.75)
}
acceptanceRate
}
}
}
}
}
}Transformed rows — one row per createdByDuo value, with the median / p75 aliases applied
by the transformer (they are not in the wire query):
{
"nodes": [
{
"createdByDuo": true,
"median": 143520000,
"p75": 402180000,
"acceptanceRate": 0.9124,
"timeToMergeQuantile_quantile_0_d5": 143520000,
"timeToMergeQuantile_quantile_0_d75": 402180000
},
{
"createdByDuo": false,
"median": 268140000,
"p75": 720300000,
"acceptanceRate": 0.8341,
"timeToMergeQuantile_quantile_0_d5": 268140000,
"timeToMergeQuantile_quantile_0_d75": 720300000
}
]
}The GraphQL above is the compiler's real output. The rows are the real transform output over a representative engine response rather than live data:
analytics.mergeRequestsrequiresread_cycle_analytics, so an unauthenticated call returnsThe following sources are not accessible: gitlab-org. That response does confirm the API accepts the query against its schema — the failure is authorization, not an unknown field or argument. Re-run the snippet above with aGITLAB_TOKENfor live numbers.
Filtering to Duo-created merge requests
mode: analytics
query: type = MergeRequest and group = "gitlab-org" and createdByDuo = true
dimensions: createdByDuo
metrics: acceptanceRateTest via ruby extension
Same snippet as above with:
query = "type = MergeRequest and group = \"gitlab-org\" and createdByDuo = true"
context = { mode: "analytics", dimensions: "createdByDuo", metrics: "acceptanceRate" }Generated GraphQL — note the scalar true compiled to the [Boolean!] list the engine expects:
query GLQL($before: String, $after: String, $limit: Int) {
group(fullPath: "gitlab-org") {
analytics {
mergeRequests(createdByDuo: [true]) {
aggregated(before: $before, after: $after, first: $limit) {
count
nodes {
dimensions {
createdByDuo
}
acceptanceRate
}
}
}
}
}
}How to set up and validate locally
-
Check out the branch and build:
git checkout feat/created-by-duo-mr-analytics cargo build -
Run the suite. The MergeRequests analytics tests carry 11 new cases covering the dimension, the
= true/= falsefilters, the metric, both sort identifiers, the!=rejection,acceptanceRatebeing rejected as a dimension, the end-to-end dashboard query, and the boolean dimension surviving the transform:cargo test --test merge_request_analytics_tests -
Confirm
schema.jsonis exactly what the generator produces (CI'sschemajob checks this):cargo run --bin generate-schema && npm run lint:prettier:fix git diff --stat src/schema/schema.json # expected: no output -
Validate every compiled query in the suite against GitLab's real schema:
DUMP_GRAPHQL=1 cargo test && npm run test:graphqlThis may report two invalid documents — duplicate
$epicId1/$parentId1variable definitions. They are pre-existing and unrelated to this MR, and they are not about any particular query:unique_idcounters are process-global andcompileresets them, so concurrent compiles interfere.cargo testruns tests as threads in one process and can trigger it; re-running with-- --test-threads=1yields a clean corpus and exit 0, which isolates concurrency as the cause. CI is unaffected becausecargo nextest runis process-per-test. Reported in #217 rather than fixed here. -
Reproduce the examples above with the Ruby extension:
cd glql_rb && bundle install && bundle exec rake compile
Related
- Closes #211 (closed)
- Engine change: https://gitlab.com/gitlab-org/gitlab/-/work_items/608191
- Engine MR (
createdByDuo+acceptanceRate): gitlab!254727 (merged) - Tracking issue: https://gitlab.com/gitlab-org/glql/-/issues/188
- Parent epic: gitlab-org#21207
The user-facing docs table (doc/user/glql/data_sources/merge_request_analytics.md) follows in
gitlab-org/gitlab after the release and version bump; series labels for true / false are a
frontend concern.