Skip to main content

One post tagged with "gitlab"

View All Tags

New GitLab Provider Available

· 6 min read
Technologist and Cloud Consultant

We've released a new StackQL provider for GitLab:

  • gitlab - the GitLab GraphQL API as a read-only SQL surface: projects, groups, users, issues, merge_requests, ci, work_items, security, packages, snippets, boards, analytics, audit, metadata, admin, workspaces, ml and duo (18 services, 244 resources, every one of them SELECT)

The provider is generated from the introspection schema gitlab.com publishes, pinned by content hash and refreshed as a reviewed diff. It works against gitlab.com out of the box and routes to a self-managed instance from an environment variable. It is read-only by architecture: StackQL's GraphQL path is a query path, so there are no INSERT, UPDATE, DELETE or EXEC methods. What it is for is inventory, reporting and cross-provider joins over the GitLab control plane - the questions that otherwise need a script and three API clients.

Connect​

Authentication is a personal access token with the read_api scope, read from GITLAB_TOKEN - the same variable the Terraform GitLab provider uses:

export GITLAB_TOKEN=glpat-...
stackql shell

Public projects and groups on gitlab.com are readable without a token (--auth='{"gitlab": {"type": "null_auth"}}'). For a self-managed instance, set GITLAB_HOST=gitlab.example.com and every query routes there; an explicit WHERE host = ... still wins for addressing another instance in the same session.

Three scopes, one shape​

Resources follow the GraphQL schema. Instance-scoped resources take optional filters only (projects, users, runners); project-scoped resources are prefixed project_ and take the project path; group-scoped resources are prefixed group_ and take the group path. Columns are snake_case, and the nested identity objects GitLab attaches everywhere (author, namespace, milestone, user) are JSON columns one json_extract away.

Every project in a group tree, with activity and visibility signals:

SELECT full_path, visibility, archived,
star_count, forks_count,
open_issues_count, open_merge_requests_count,
last_activity_at
FROM gitlab.groups.group_projects
WHERE full_path = 'gitlab-org' AND include_subgroups = true
ORDER BY last_activity_at DESC;

GitLab connections are Relay-paginated with a hard page size of 100. StackQL walks the pageInfo cursor chain transparently, so the query above returns the whole tree, not the first page.

Filters are pushed down​

Every scalar or enum argument a GitLab field accepts is a WHERE parameter rendered into the GraphQL query itself, so the API does the filtering. Open merge requests with their approval state and age:

SELECT iid, title,
json_extract(author, '$.username') AS author,
draft, approved, approvals_left, detailed_merge_status,
ROUND(julianday('now') - julianday(created_at)) AS age_days
FROM gitlab.merge_requests.project_merge_requests
WHERE full_path = 'gitlab-org/gitlab-runner' AND state = 'opened'
ORDER BY age_days DESC;

Cycle time over a window (merged_after is a filter, merged_at a column):

SELECT iid, title,
ROUND((julianday(merged_at) - julianday(created_at)) * 24, 1) AS hours_to_merge
FROM gitlab.merge_requests.project_merge_requests
WHERE full_path = 'gitlab-org/gitlab-runner'
AND state = 'merged'
AND merged_after = '2026-09-01T00:00:00Z'
ORDER BY merged_at DESC;

Pipelines and runners​

Pipeline outcomes for a project, and the same across every project in a group by joining the inventory to each project's pipelines (the engine issues one pipelines request per project):

SELECT status, count(*) AS pipelines, ROUND(AVG(duration) / 60.0, 1) AS avg_minutes
FROM gitlab.ci.project_pipelines
WHERE full_path = 'gitlab-org/gitlab-runner'
GROUP BY status;

SELECT p.full_path,
count(*) AS pipelines,
SUM(c.status = 'FAILED') AS failed,
ROUND(100.0 * SUM(c.status = 'FAILED') / count(*), 1) AS failure_pct
FROM gitlab.groups.group_projects p
JOIN gitlab.ci.project_pipelines c ON c.full_path = p.full_path
WHERE p.full_path = 'my-group'
GROUP BY p.full_path
ORDER BY failure_pct DESC;

Runner fleet status for a group, with contact recency (the instance-wide runner listing is administrator-only on gitlab.com; the group and project listings are what a token can read):

SELECT id, description, runner_type, status, paused, contacted_at, upgrade_status
FROM gitlab.ci.group_runners
WHERE full_path = 'my-group'
ORDER BY contacted_at DESC;

Vulnerability reporting​

Findings across a group by severity and state, then the unresolved criticals with their owning project:

SELECT severity, state, count(*) AS findings
FROM gitlab.security.group_vulnerabilities
WHERE full_path = 'my-group'
GROUP BY severity, state;

SELECT json_extract(project, '$.full_path') AS project,
title, report_type, detected_at, web_url
FROM gitlab.security.group_vulnerabilities
WHERE full_path = 'my-group' AND severity = 'CRITICAL' AND state = 'DETECTED'
ORDER BY detected_at;

Membership, and a join across providers​

Group membership with access level is a table:

SELECT json_extract(user, '$.username') AS username,
json_extract(user, '$.name') AS name,
json_extract(access_level, '$.string_value') AS access_level,
expires_at
FROM gitlab.groups.group_group_members
WHERE full_path = 'my-group';

which makes the offboarding question a LEFT JOIN against the identity provider - GitLab members with no active Okta user, computed locally by the SQL engine after registry pull okta:

SELECT json_extract(m.user, '$.username') AS gitlab_username,
json_extract(m.user, '$.name') AS gitlab_name,
o.status AS okta_status
FROM gitlab.groups.group_group_members m
LEFT JOIN okta.user.users o
ON lower(json_extract(o.profile, '$.login')) = lower(json_extract(m.user, '$.username') || '@example.com')
AND o.subdomain = 'my-okta-org'
WHERE m.full_path = 'my-group'
AND (o.status IS NULL OR o.status != 'ACTIVE');

How it is built​

  • The selection set for every resource is generated by one policy from the schema: all scalar and enum fields of the node type plus a fixed allowlist of nested identity objects. The query text and the response schema come from the same field list, so DESCRIBE always matches what the wire returns.
  • GitLab enforces a query complexity limit (200 anonymous, 250 authenticated on gitlab.com). Every generated query is scored against the live limit as a build gate; the one node type that exceeded it (merge requests, at 245) was trimmed by measured per-field cost, protecting the approval and merge-state columns, to 190.
  • A handful of resolvers time out on large result sets or answer anonymous callers with a server error. Those fields are excluded by a recorded policy entry rather than left to fail whole pages.
  • all_services.csv in the repository records which schema field backs every resource and method, and regeneration fails when a mapping moves, so resource names stay stable between provider versions.

Premium-tier fields (issue weight, health status, epics) read back as null on the free tier, and the schema is gitlab.com's, so an older self-managed instance may reject fields it does not serve.

Get started​

Pull the provider from the public registry:

registry pull gitlab;

Provider docs are at gitlab-provider.stackql.io. Let us know what you build. Star us on GitHub.