Skip to content

GitLab support: extend the extractor beyond GitHub #54

Description

@caviri

Motivation

The Swiss research community publishes a large fraction of its code on self-hosted GitLab instances, not GitHub. Today GME only speaks GitHub, which leaves the bulk of the EPFL / ETHZ / Renku v1 corpus invisible to the open-pulse graph.

Public-only project counts (via GET /api/v4/projects?visibility=public, x-total header):

Instance URL Public Active (non-archived)
gitlab.renkulab.io (Renku v1) /explore 9,874
gitlab.ethz.ch /explore 2,908 2,791
gitlab.epfl.ch /explore/projects/active 1,206 1,177
Total ~14,000

Adding GitLab support roughly 10× the addressable corpus for free, with no architectural rewrite (GitLab's REST API is well-documented and similar in shape to GitHub's).

Scope

In scope:

  • Self-hosted GitLab CE/EE instances (gitlab.ethz.ch, gitlab.epfl.ch, gitlab.renkulab.io, gitlab.com).
  • Extraction of the same fields GME currently pulls for GitHub repos: identity, metadata, contributors, languages, topics, license, README, dependents-where-feasible.
  • Two-token auth: GitHub token stays, plus a per-instance GitLab PRIVATE-TOKEN env var.

Out of scope (separate follow-ups):

Provider abstraction

The cleanest path is a thin provider layer so existing GitHub code doesn't get touched on the hot path:

extractor/
  providers/
    __init__.py            # registry: resolve(url) → provider
    base.py                # Protocol: identity, fetch_metadata, fetch_contributors, ...
    github.py              # current code, refactored behind the Protocol
    gitlab.py              # new

Provider selection driven by URL parsing, not config:

def resolve_provider(url: str) -> Provider:
    host = urlparse(url).hostname
    if host == 'github.com':           return GitHubProvider()
    if host and 'gitlab' in host:      return GitLabProvider(host)
    raise UnknownProviderError(url)

URL shape differences

GitHub is strictly two-level (owner/repo). GitLab supports arbitrary group nesting:

Pattern Example
github.com/{owner}/{name} github.com/sdsc-ordes/open-pulse
gitlab.example.com/{namespace*}/{name} gitlab.epfl.ch/lasa/research/control-systems/foo

Practical consequences:

  • The extractor's notion of owner becomes a namespace path (slash-separated) for GitLab. Existing GitHub callers must keep seeing the single-segment owner.
  • Group-vs-user ownership is exposed via namespace.kind (group | user) in the API; map both to the same schema:author Object range, distinguished by rdf:type.
  • API resolution requires URL-encoding the full project path: GET /api/v4/projects/{URL_ENCODED_PATH}.

Field mapping

GitLab project payload → existing RDF shape (no new predicates needed for core fields):

GitLab field RDF predicate Notes
web_url @id (subject IRI) strip trailing /
name schema:name
path_with_namespace schema:identifier
description schema:description
created_at schema:dateCreated
last_activity_at schema:dateModified
default_branch pulse:defaultBranch already exists
topics[] schema:keywords
star_count pulse:stargazers
forks_count pulse:forks
visibility pulse:visibility public / internal / private
archived schema:archivedAt (when true)
license.key schema:license SPDX lookup
namespace.full_path schema:author (group/user IRI) nested group joins
forked_from_project.web_url pulse:forkOf
http_url_to_repo pulse:cloneUrl
readme_url dereference → schema:text

Endpoints used per repo:

  • GET /api/v4/projects/{enc(path)} — core metadata.
  • GET /api/v4/projects/{id}/repository/contributors — contributor list.
  • GET /api/v4/projects/{id}/languages — language breakdown.
  • GET /api/v4/projects/{id}/repository/files/README.md/raw?ref=HEAD — README content (404 fallback to README, README.rst, etc.).

Auth strategy

providers:
  github:
    token_env: GITHUB_TOKEN
  gitlab:
    instances:
      - host: gitlab.epfl.ch
        token_env: GITLAB_EPFL_TOKEN          # null → anonymous, public projects only
      - host: gitlab.ethz.ch
        token_env: GITLAB_ETHZ_TOKEN
      - host: gitlab.renkulab.io
        token_env: GITLAB_RENKU_TOKEN
      - host: gitlab.com
        token_env: GITLAB_TOKEN

Token is sent as PRIVATE-TOKEN: <token> (GitLab's native header) — distinct from GitHub's Authorization: Bearer. Anonymous works for public; private/internal need the appropriate per-host token.

Pagination + rate limiting

GitLab paginates with x-total, x-total-pages, x-page, x-per-page headers and a next Link header (same shape as GitHub). Re-use existing pagination helper, parameterised on header names.

Rate limits are per-instance and configurable by admins — observe the RateLimit-* headers (lowercase, hyphenated; not the GitHub form) and back off on 429.

URL discovery / seeding

The crawler today seeds from GitHub orgs and users. For GitLab we want analogous seeds:

seeds:
  gitlab:
    - host: gitlab.epfl.ch
      include: all-public           # iterate /api/v4/projects?visibility=public
    - host: gitlab.epfl.ch
      include: group               # iterate one group recursively
      group: lasa
    - host: gitlab.renkulab.io
      include: topic
      topic: renku                 # filter by GitLab topic

Three discovery modes: whole-instance, single-group (recursive), topic-filtered. Mirrors the GitHub org / user / search seed types.

Acceptance criteria

  • providers/ directory with base.py Protocol, github.py (existing code refactored), gitlab.py (new).
  • URL parser auto-resolves the provider; no config flag needed at call sites.
  • GitLabProvider supports gitlab.com + self-hosted (host passed at construction time).
  • All current schema:SoftwareSourceCode predicates emitted for GitLab repos with the same shape as GitHub, so downstream open-pulse SPARQL queries don't need a single change.
  • Per-host PRIVATE-TOKEN env vars supported; anonymous fallback works for public projects.
  • Pagination + 429 back-off honour GitLab's RateLimit-* headers.
  • CLI + API both accept GitLab URLs interchangeably with GitHub URLs.
  • Test corpus: at least one repo from each of gitlab.epfl.ch, gitlab.ethz.ch, gitlab.renkulab.io, gitlab.com, mocked + recorded.
  • README + config docs updated with the new providers.gitlab.instances block.
  • Backwards-compatible: existing GitHub-only invocations and configs continue to work unchanged.

Companion work

  • Open-pulse seed config will gain gitlab.* seed types in a follow-up PR there.
  • Renku 2.0 ingest (Ingest Renku 2.0 projects + data connectors into the RDF graph #53) is independent: it consumes Renku's JSON API, not GitLab's, even though Renku v1 projects live on gitlab.renkulab.io — once GitLab support lands, v1 projects come in via the GitLab path automatically.

Open questions (non-blocking)

  1. Owner abstraction: keep a single owner string field on Repo (storing the full namespace path for GitLab) vs introduce owner + namespace_path? Proposal: single field with semantic-by-provider; cleaner for downstream.
  2. Group/user RDF type: emit schema:Organization for GitLab groups regardless of internal kind, or thread kind through? Proposal: schema:Organization for group, schema:Person for user.
  3. Default branch handling: GitLab can have null default branch (empty repo). Skip extraction or emit minimal record? Proposal: emit minimal record with pulse:isEmpty true.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions