Generated banner for Gitlab
Generated logo for Gitlab

Gitlab

MicrosoftOfficialPublished by Microsoft

  • Skill
  • Tool integration
  • Software development
  • MIT
  • Low risk
  • Has scripts

From the author

Manage GitLab merge requests and pipelines with a Python CLI
— the description by Microsoft

AutoGineer summary

Written by AutoGineer from the skill’s own files, not by the author

The GitLab skill provides a repository-local Python interface for interacting with GitLab.com or self-managed GitLab instances. It enables users to perform common development tasks such as listing and updating merge requests, triggering pipelines, and inspecting job logs directly from the command line. The tool supports both OAuth and legacy personal access token authentication modes and includes optional audit logging for tracking API operations.

What it does

  • Authenticates via OAuth or legacy personal access tokens
  • Lists, creates, and updates merge requests
  • Retrieves merge request notes and comments
  • Triggers pipelines for specific branches or tags
  • Lists pipeline jobs and retrieves raw job logs
  • Provides structured audit logging for API requests

When to use it

  • When you need to automate GitLab interactions within a local development workflow
  • When you prefer a command-line interface for managing merge requests and pipelines
  • When you need to extract specific data fields from GitLab resources for reporting
  • When you require an auditable trail of API operations for internal tracking

Advantages

  • Supports concise output filtering using the --fields parameter
  • Integrates with existing git repositories via automatic project detection
  • Offers secure OAuth authentication with PKCE support
  • Provides a structured JSON Lines audit trail for operations

Usage examples

“List all open merge requests for the current project.”
The CLI outputs a list of open merge requests, displaying the IID, title, and author name for each.
“Get the job logs for pipeline 12345.”
The CLI retrieves and prints the raw log output for the specified pipeline job.
“Create a new merge request from a JSON payload.”
The CLI submits the provided JSON data to create a new merge request on the configured GitLab project.
“Add a comment to merge request 42.”
The CLI posts the provided text as a comment on the specified merge request.

Install

Documentation by Microsoft

GitLab Skill

Overview

Use this skill to inspect and update GitLab merge requests, notes, pipelines, and job logs against GitLab.com or self-managed GitLab instances.

This skill is the repository-local Python workflow for GitLab tasks. It is not the official GitLab MCP server integration surface.

This first hve-core implementation is Python-only. Run the CLI through python scripts/gitlab.py and prefer --fields for read operations to keep output concise.

Prerequisites

The skill requires Python 3.11 or later.

Set these environment variables before running any command:

VariableRequiredExamplePurpose
GITLAB_URLYeshttps://gitlab.comGitLab instance URL
GITLAB_AUTH_MODEOptionaloauthoauth or explicit legacy-token compatibility mode
GITLAB_OAUTH_CLIENT_IDOAuthApplication IDNon-confidential OAuth application ID; no client secret
GITLAB_TOKENLegacy modeglpat-...PAT accepted only with GITLAB_AUTH_MODE=legacy-token
GITLAB_PROFILENodefaultNamed OAuth profile
GITLAB_TOKEN_STORENopathOAuth store override; parent must be owner-only on POSIX
GITLAB_ALLOW_INSECURENo1Permit explicit loopback HTTP development endpoints only
XDG_DATA_HOMENopathPOSIX data root used for the default OAuth store
LOCALAPPDATANopathReserved Windows data root; OAuth persistence currently fails closed on Windows
GITLAB_PROJECTNogroup/projectProject path or numeric project ID

If GITLAB_PROJECT is not set, the script attempts to detect the project from git remote get-url origin. Set the variable explicitly when you are not in a git repository or when you want to target a different project.

Operational Variables
VariableRequiredPurpose
GITLAB_AUDIT_LOGNoPath to a JSON Lines audit log. When set, every request is audited (see Audit Logging).
GITLAB_AUDIT_ACTORNoOverrides the recorded actor identity (for example, a CI service principal).
GITLAB_DEBUGNoSet to 1 to print a redacted traceback on failure. Never disables redaction.
Audit Logging

When GITLAB_AUDIT_LOG is set, the script writes a structured JSON Lines audit trail for every REST API and OAuth form request. Auditing is fail-closed and write-ahead:

  • An attempt record is written before the request is sent. If the audit log cannot be written, the operation is aborted and nothing is sent to GitLab.

  • An outcome record (success or error, with HTTP status on failure) is written after the request completes.

REST records include a UTC timestamp, actor, auth mode, normalized origin, operation, HTTP method, and event. OAuth records use bounded operation and failure kinds without actor, origin, client, profile, code, body, or free-form error fields. Resource paths, queries, profile names, and profile contents are excluded. The optional audit sink is operationally sensitive because origins can identify tenants or internal hosts. Place it at an access-controlled path and retain it only as long as operations require. Tokens and authorization headers are never written. Audit failures after the request emit a warning without altering the result.

Credential Rotation

OAuth is the default. Register a non-confidential user-, group-, or self-managed instance-owned application with redirect URI http://127.0.0.1:8766/callback and the api scope. Set only GITLAB_URL and GITLAB_OAUTH_CLIENT_ID, then run auth login or the human-assisted auth device-login. Browser login requires a rotating refresh token; access tokens refresh before expiry and replacements are stored atomically. Device login can return an access-token-only profile and then requires another device login after expiry. A provider/local crash window can still require re-login.

The default store is $XDG_DATA_HOME/hve-core/gitlab/gitlab-token.json on POSIX, falling back to ~/.local/share/hve-core/gitlab/gitlab-token.json. POSIX store and lock files use mode 0600 inside a dedicated owner-only directory. OAuth profile persistence fails closed on Windows because Python 3.11 mode bits cannot enforce an owner-only DACL. Use explicit legacy-token mode on Windows until a protected OAuth backend is available.

PAT authentication remains available through GITLAB_AUTH_MODE=legacy-token. Device authorization requires user approval on a browser-capable device and is not unattended workload identity.

Quick Start

Export your environment variables, then run a read command with --fields.

export GITLAB_URL="https://gitlab.com"
export GITLAB_OAUTH_CLIENT_ID="your-application-id"
export GITLAB_PROJECT="group/project"

python scripts/gitlab.py auth login
python scripts/gitlab.py mr-list opened --fields iid,title,author.name

Read pipeline jobs for a known pipeline:

python scripts/gitlab.py pipeline-jobs 12345 --fields id,name,status,stage

Parameters Reference

Common Option
ParameterApplies ToExampleDescription
--fieldsmr-list, mr-get, mr-notes, pipeline-get, pipeline-jobs--fields iid,title,stateExtract specific fields with dot notation and print concise tabular or key-value output
Commands
CommandArgumentsDescription
auth loginNoneAuthenticate with Authorization Code and PKCE
auth device-loginNoneAuthenticate through human-assisted device authorization
auth statusNonePrint secret-free local profile status
auth logoutNoneDelete one local profile without revoking server authorization
mr-list[state] [max]List merge requests, defaulting to all states and 20 results
mr-get<mr-iid>Get one merge request by project-scoped IID
mr-create<json> or stdinCreate a merge request from a JSON payload
mr-update<mr-iid> <json> or stdinUpdate merge request fields from a JSON payload
mr-comment<mr-iid> <body> or stdinAdd a comment to a merge request
mr-notes<mr-iid> [max]List merge request notes, excluding system notes when using --fields
pipeline-get<pipeline-id>Get one pipeline by numeric ID
pipeline-run<branch-or-tag>Trigger a pipeline for a branch or tag
pipeline-jobs<pipeline-id>List jobs for a pipeline
job-log<job-id>Print raw log output for a job

Script Reference

List recent open merge requests:

python scripts/gitlab.py mr-list opened --fields iid,title,author.name,user_notes_count

Get one merge request:

python scripts/gitlab.py mr-get 42 --fields iid,title,state,source_branch,target_branch

Create a merge request from inline JSON:

python scripts/gitlab.py mr-create '{
  "source_branch": "feature/add-auth",
  "target_branch": "main",
  "title": "feat(auth): add OAuth login"
}'

Add a merge request comment from standard input:

echo "CI passed. Ready for review." | python scripts/gitlab.py mr-comment 42

Inspect a failed pipeline:

python scripts/gitlab.py pipeline-get 12345 --fields id,status,web_url
python scripts/gitlab.py pipeline-jobs 12345 --fields id,name,status,stage
python scripts/gitlab.py job-log 67890

Troubleshooting

SymptomCauseResolution
GITLAB_URL is not setRequired environment variable missingExport GITLAB_URL before running the script
GITLAB_TOKEN is not set for legacy-token modeExplicit legacy mode has no PATExport a PAT only with GITLAB_AUTH_MODE=legacy-token
GITLAB_TOKEN must not be set in oauth modeLegacy PAT leaked into the OAuth configurationRemove GITLAB_TOKEN or explicitly select legacy-token mode
OAuth persistence is unavailable on WindowsNo protected Windows profile-store backendUse explicit legacy-token mode or run OAuth from POSIX
GITLAB_OAUTH_CLIENT_ID is not setOAuth application is not configuredSet the public client ID, then run auth login
Callback listener cannot bindPort 8766 is unavailable or not forwardedFree the port or use auth device-login
timed out waiting for the GitLab OAuth token store lockAnother process holds the profile-store lockWait for the other command to finish, then retry
GitLab device response has invalid verification_uriThe verification URI is unsafe to display or does not match GITLAB_URLConfirm GITLAB_URL matches the instance that serves device verification
cannot parse git remote URLProject autodetection failedSet GITLAB_PROJECT explicitly
HTTP 401 or HTTP 403Credential is invalid or lacks accessRe-login for OAuth; rotate the PAT in explicit legacy mode
HTTP 404Wrong project, MR IID, pipeline ID, or job IDVerify GITLAB_PROJECT and confirm the numeric identifiers
expected numeric IDNon-numeric value passed to an ID argumentUse project MR IID values and numeric pipeline or job IDs
python3 is required or syntax errors on launchUnsupported interpreterRun the script with Python 3.11 or later

GitLab uses MR IIDs such as !42 inside a project. This skill expects the numeric IID, not the global merge request ID.

Files

tests/test_gitlab_oauth.py

# Copyright (c) 2026 Microsoft Corporation. All rights reserved.
# SPDX-License-Identifier: MIT
"""Tests for GitLab public-client OAuth helpers."""

from __future__ import annotations

import http.client
import json
import socket
import threading
import urllib.error

import _gitlab_oauth as oauth
import pytest


class _Response:
    def __init__(self, payload: dict[str, object]) -> None:
        self.headers = {"Content-Type": "application/json"}
        self._body = json.dumps(payload).encode()

    def __enter__(self) -> "_Response":
        return self

    def __exit__(self, *_args: object) -> bool:
        return False

    def read(self, _amount: int = -1) -> bytes:
        return self._body


def test_pkce_pair_is_s256_and_has_no_secret() -> None:
    verifier, challenge = oauth.generate_pkce_pair()
    url = oauth.build_authorize_url(
        "https://gitlab.example.com", "client", "state", challenge
    )

    assert 43 <= len(verifier) <= 128
    assert "code_challenge_method=S256" in url
    assert "client_secret" not in url


def test_token_profile_requires_rotating_refresh_token() -> None:
    with pytest.raises(oauth.OAuthError, match="refresh_token"):
        oauth.token_profile(
            {"access_token": "access", "expires_in": 7200},
            issuer="https://gitlab.example.com",
            client_id="client",
            now=100.0,
        )


def test_token_profile_uses_provider_expiry() -> None:
    profile = oauth.token_profile(
        {
            "access_token": "access",
            "refresh_token": "refresh",
            "expires_in": 7200,
            "scope": "api",
        },
        issuer="https://gitlab.example.com",
        client_id="client",
        now=100.0,
    )

    assert profile["expires_at"] == 7300
    assert profile["scopes"] == ["api"]


@pytest.mark.parametrize(
    "payload",
    [
        {
            "access_token": "access",
            "refresh_token": "refresh",
            "expires_in": oauth.MAX_EXPIRES_IN_SECONDS + 1,
        },
        {
            "access_token": "access",
            "refresh_token": "refresh",
            "expires_in": 7200,
            "token_type": "MAC",
        },
    ],
)
def test_token_profile_rejects_unbounded_or_unsupported_tokens(
    payload: dict[str, object],
) -> None:
    with pytest.raises(oauth.OAuthError):
        oauth.token_profile(
            payload,
            issuer="https://gitlab.example.com",
            client_id="client",
            now=100.0,
        )


def test_device_login_honors_pending_and_slow_down() -> None:
    responses: list[object] = [
        _Response(
            {
                "device_code": "device-secret",
                "user_code": "ABCD-EFGH",
                "verification_uri": "https://gitlab.example.com/oauth/device",
                "expires_in": 300,
                "interval": 5,
            }
        ),
        oauth.OAuthError(
            "GitLab OAuth request failed: authorization_pending",
            code="authorization_pending",
        ),
        oauth.OAuthError(
            "GitLab OAuth request failed: slow_down",
            code="slow_down",
        ),
        _Response(
            {
                "access_token": "access",
                "expires_in": 7200,
                "scope": "api",
            }
        ),
    ]
    times = [0.0]
    sleeps: list[float] = []

    def opener(*_args: object, **_kwargs: object) -> object:
        response = responses.pop(0)
        if isinstance(response, Exception):
            raise response
        return response

    def sleep(seconds: float) -> None:
        sleeps.append(seconds)
        times[0] += seconds

    profile = oauth.device_login(
        "https://gitlab.example.com",
        "client",
        opener=opener,
        timeout=300,
        emit_instructions=lambda _uri, _code: None,
        now=lambda: times[0],
        monotonic=lambda: times[0],
        sleep=sleep,
    )

    assert profile["access_token"] == "access"
    assert profile["refresh_token"] == ""
    assert sleeps == [5, 5, 10]


def test_device_login_does_not_poll_past_deadline() -> None:
    responses: list[object] = [
        _Response(
            {
                "device_code": "device-secret",
                "user_code": "ABCD-EFGH",
                "verification_uri": "https://gitlab.example.com/oauth/device",
                "expires_in": 4,
                "interval": 10,
            }
        ),
        oauth.OAuthError("pending", code="authorization_pending"),
    ]
    times = [0.0]
    sleeps: list[float] = []

    def opener(*_args: object, **_kwargs: object) -> object:
        response = responses.pop(0)
        if isinstance(response, Exception):
            raise response
        return response

    def sleep(seconds: float) -> None:
        sleeps.append(seconds)
        times[0] += seconds

    with pytest.raises(oauth.OAuthError, match="timed out"):
        oauth.device_login(
            "https://gitlab.example.com",
            "client",
            opener=opener,
            timeout=300,
            emit_instructions=lambda _uri, _code: None,
            now=lambda: times[0],
            monotonic=lambda: times[0],
            sleep=sleep,
        )

    assert sleeps == [4]
    assert len(responses) == 1


def test_post_form_preserves_provider_error_code_and_retryability() -> None:
    error = urllib.error.HTTPError(
        url="https://gitlab.example.com/oauth/token",
        code=503,
        msg="unavailable",
        hdrs={"Content-Type": "application/json"},
        fp=None,
    )
    error.read = lambda _amount=-1: b'{"error":"temporarily_unavailable"}'  # type: ignore[method-assign]

    with pytest.raises(oauth.OAuthError) as exc_info:
        oauth.post_form(
            "https://gitlab.example.com",
            "/oauth/token",
            {"grant_type": "refresh_token"},
            opener=lambda *_args, **_kwargs: (_ for _ in ()).throw(error),
            timeout=30,
            operation="oauth.refresh",
        )

    assert exc_info.value.code == "temporarily_unavailable"
    assert exc_info.value.retryable is True
    assert exc_info.value.completion_uncertain is True


@pytest.mark.parametrize(
    ("error", "completion_uncertain"),
    [
        (TimeoutError("timed out"), True),
        (ConnectionResetError("reset"), True),
        (http.client.IncompleteRead(b"partial"), True),
        (http.client.BadStatusLine("bad"), True),
        (urllib.error.URLError(socket.gaierror("dns")), False),
        (urllib.error.URLError(ConnectionRefusedError("refused")), False),
    ],
)
def test_post_form_classifies_transport_failures(
    error: Exception,
    completion_uncertain: bool,
) -> None:
    with pytest.raises(oauth.OAuthError) as exc_info:
        oauth.post_form(
            "https://gitlab.example.com",
            "/oauth/token",
            {"grant_type": "refresh_token"},
            opener=lambda *_args, **_kwargs: (_ for _ in ()).throw(error),
            timeout=30,
            operation="oauth.refresh",
        )

    assert exc_info.value.retryable is True
    assert exc_info.value.completion_uncertain is completion_uncertain
    assert exc_info.value.__cause__ is error


def test_refresh_profile_rejects_binding_mismatch_before_network() -> None:
    profile = oauth.token_profile(
        {
            "access_token": "access",
            "refresh_token": "refresh",
            "expires_in": 7200,
        },
        issuer="https://gitlab.example.com",
        client_id="client",
        now=100.0,
    )

    with pytest.raises(oauth.OAuthError, match="not bound"):
        oauth.refresh_profile(
            profile,
            expected_issuer="https://other.example.com",
            expected_client_id="client",
            opener=lambda *_args, **_kwargs: (_ for _ in ()).throw(
                AssertionError("network must not run")
            ),
            timeout=30,
        )


def test_refresh_profile_marks_malformed_success_as_uncertain() -> None:
    profile = oauth.token_profile(
        {
            "access_token": "access",
            "refresh_token": "refresh",
            "expires_in": 7200,
        },
        issuer="https://gitlab.example.com",
        client_id="client",
        now=100.0,
    )

    with pytest.raises(oauth.OAuthError) as exc_info:
        oauth.refresh_profile(
            profile,
            expected_issuer="https://gitlab.example.com",
            expected_client_id="client",
            opener=lambda *_args, **_kwargs: _Response(
                {"access_token": "replacement", "expires_in": 7200}
            ),
            timeout=30,
        )

    assert exc_info.value.completion_uncertain is True


def test_authorization_login_converts_callback_bind_failure(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setattr(
        oauth,
        "_CallbackServer",
        lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("in use")),
    )

    with pytest.raises(oauth.OAuthError, match="device-login"):
        oauth.authorization_code_login(
            "https://gitlab.example.com",
            "client",
            opener=lambda *_args, **_kwargs: None,
            timeout=1,
        )


def test_callback_ignores_wrong_path_then_accepts_valid_state(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    server = oauth._CallbackServer(
        (oauth.CALLBACK_HOST, 0),
        oauth._CallbackHandler,
    )
    server.callback_result = oauth._CallbackResult()
    server.callback_received = threading.Event()
    server.stop_requested = threading.Event()
    server.expected_state = "expected-state"
    port = server.server_address[1]
    monkeypatch.setattr(oauth, "CALLBACK_PORT", port)

    def serve_two_requests() -> None:
        for _ in range(2):
            server.handle_request()

    thread = threading.Thread(target=serve_two_requests)
    thread.start()
    try:
        wrong = http.client.HTTPConnection(oauth.CALLBACK_HOST, port, timeout=1)
        wrong.request("GET", "/wrong", headers={"Host": f"127.0.0.1:{port}"})
        assert wrong.getresponse().status == 404
        wrong.close()
        assert server.callback_received.is_set() is False

        valid = http.client.HTTPConnection(oauth.CALLBACK_HOST, port, timeout=1)
        valid.request(
            "GET",
            "/callback?code=authorization-code&state=expected-state",
            headers={"Host": f"127.0.0.1:{port}"},
        )
        assert valid.getresponse().status == 200
        valid.close()
        thread.join(timeout=1)
    finally:
        server.server_close()

    assert server.callback_received.is_set() is True
    assert server.callback_result.code == "authorization-code"


def test_callback_socket_has_bounded_read_timeout(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monkeypatch.setattr(oauth, "CALLBACK_REQUEST_TIMEOUT_SECONDS", 0.05)
    server = oauth._CallbackServer((oauth.CALLBACK_HOST, 0), oauth._CallbackHandler)
    port = server.server_address[1]
    peer = socket.create_connection((oauth.CALLBACK_HOST, port), timeout=1)

    thread = threading.Thread(target=server.handle_request)
    thread.start()
    thread.join(timeout=0.5)
    peer.close()
    server.server_close()

    assert thread.is_alive() is False


def test_device_login_uses_injected_monotonic_clock() -> None:
    responses: list[object] = [
        _Response(
            {
                "device_code": "device-secret",
                "user_code": "ABCD-EFGH",
                "verification_uri": "https://gitlab.example.com/oauth/device",
                "expires_in": 4,
                "interval": 10,
            }
        )
    ]
    elapsed = [0.0]

    def opener(*_args: object, **_kwargs: object) -> object:
        return responses.pop(0)

    def sleep(seconds: float) -> None:
        elapsed[0] += seconds

    with pytest.raises(oauth.OAuthError, match="timed out"):
        oauth.device_login(
            "https://gitlab.example.com",
            "client",
            opener=opener,
            timeout=300,
            emit_instructions=lambda _uri, _code: None,
            monotonic=lambda: elapsed[0],
            sleep=sleep,
        )

    assert elapsed == [4.0]


class _ImmediateCallbackServer:
    """Stand in for the loopback listener with an already-received callback."""

    def __init__(self, *_args: object, **_kwargs: object) -> None:
        self.callback_result = oauth._CallbackResult()
        self.callback_received = threading.Event()
        self.stop_requested = threading.Event()
        self.expected_state = ""
        self.timeout = 0.0

    def handle_request(self) -> None:  # pragma: no cover - never serves
        return

    def server_close(self) -> None:
        return


@pytest.fixture()
def immediate_callback(monkeypatch: pytest.MonkeyPatch) -> list[dict[str, object]]:
    """Replace the listener so authorization-code tests stay deterministic."""
    servers: list[_ImmediateCallbackServer] = []

    def build_server(*args: object, **kwargs: object) -> _ImmediateCallbackServer:
        server = _ImmediateCallbackServer(*args, **kwargs)
        servers.append(server)
        return server

    def start_thread(self: threading.Thread) -> None:
        for server in servers:
            server.callback_result = oauth._CallbackResult(
                code="authorization-code",
                state=server.expected_state,
            )
            server.callback_received.set()

    monkeypatch.setattr(oauth, "_CallbackServer", build_server)
    monkeypatch.setattr(threading.Thread, "start", start_thread)
    monkeypatch.setattr(threading.Thread, "join", lambda self, timeout=None: None)
    return servers


def test_authorization_login_passes_remaining_budget_to_exchange(
    immediate_callback: list[object],
) -> None:
    calls: list[float] = []
    clock = [0.0]

    def opener(*_args: object, **kwargs: object) -> object:
        calls.append(float(kwargs["timeout"]))
        clock[0] += 0.0
        return _Response(
            {
                "access_token": "access",
                "refresh_token": "refresh",
                "expires_in": 7200,
            }
        )

    def monotonic() -> float:
        value = clock[0]
        clock[0] += 2.0
        return value

    profile = oauth.authorization_code_login(
        "https://gitlab.example.com",
        "client",
        opener=opener,
        timeout=10,
        open_browser=lambda _url: True,
        now=lambda: 100.0,
        monotonic=monotonic,
    )

    assert profile["access_token"] == "access"
    assert calls == [pytest.approx(6.0)]


def test_authorization_login_refuses_exchange_without_remaining_budget(
    immediate_callback: list[object],
) -> None:
    clock = [0.0]

    def monotonic() -> float:
        value = clock[0]
        clock[0] += 10.0
        return value

    with pytest.raises(oauth.OAuthError, match="deadline expired"):
        oauth.authorization_code_login(
            "https://gitlab.example.com",
            "client",
            opener=lambda *_args, **_kwargs: (_ for _ in ()).throw(
                AssertionError("exchange must not run")
            ),
            timeout=10,
            open_browser=lambda _url: True,
            now=lambda: 100.0,
            monotonic=monotonic,
        )


def _device_response(verification_uri: str, user_code: str) -> "_Response":
    return _Response(
        {
            "device_code": "device-secret",
            "user_code": user_code,
            "verification_uri": verification_uri,
            "expires_in": 300,
            "interval": 5,
        }
    )


def _run_device_login(
    verification_uri: str,
    user_code: str,
    issuer: str = "https://gitlab.example.com",
) -> list[tuple[str, str]]:
    emitted: list[tuple[str, str]] = []
    responses: list[object] = [_device_response(verification_uri, user_code)]
    times = [0.0]

    def opener(*_args: object, **_kwargs: object) -> object:
        if responses:
            return responses.pop(0)
        raise oauth.OAuthError("pending", code="authorization_pending")

    def sleep(seconds: float) -> None:
        times[0] += seconds

    with pytest.raises(oauth.OAuthError):
        oauth.device_login(
            issuer,
            "client",
            opener=opener,
            timeout=6,
            emit_instructions=lambda uri, code: emitted.append((uri, code)),
            now=lambda: times[0],
            monotonic=lambda: times[0],
            sleep=sleep,
        )
    return emitted


@pytest.mark.parametrize(
    "user_code",
    ["ABCD\nEFGH", "ABCD\x1b[2JEFGH", "ABCD\x7fEFGH"],
)
def test_device_login_rejects_control_characters_in_user_code(user_code: str) -> None:
    emitted = _run_device_login(
        "https://gitlab.example.com/oauth/device",
        user_code,
    )

    assert emitted == []


@pytest.mark.parametrize(
    "verification_uri",
    [
        "https://gitlab.example.com/oauth/de vice",
        "https://gitlab.example.com\\oauth",
        "https://user:[email protected]/oauth/device",
        "http://gitlab.example.com/oauth/device",
        "https://attacker.example.com/oauth/device",
        "https://gitlab.example.com:0/oauth/device",
        "https://gitlab.example.com:99999/oauth/device",
    ],
)
def test_device_login_rejects_unsafe_verification_uri(verification_uri: str) -> None:
    emitted = _run_device_login(verification_uri, "ABCD-EFGH")

    assert emitted == []


@pytest.mark.parametrize(
    ("verification_uri", "user_code"),
    [
        ("https://gitlab.example.com/oauth/device", "ABCD-EFGH"),
        ("https://gitlab.example.com/oauth/device?user_code=ABCD", "ABCD-EFGH"),
        ("https://gitlab.example.com/oauth/device#frag", "ABCD EFGH"),
        ("https://GitLab.Example.com/oauth/device", "ABCD.EFGH!"),
        ("https://gitlab.example.com:443/oauth/device", "\u00c5\u00c4\u00d6-1234"),
    ],
)
def test_device_login_preserves_compatible_instructions(
    verification_uri: str,
    user_code: str,
) -> None:
    emitted = _run_device_login(verification_uri, user_code)

    assert emitted == [(verification_uri, user_code)]


def test_device_login_accepts_loopback_development_issuer() -> None:
    emitted = _run_device_login(
        "http://127.0.0.1:8080/oauth/device",
        "ABCD-EFGH",
        issuer="http://127.0.0.1:8080",
    )

    assert emitted == [("http://127.0.0.1:8080/oauth/device", "ABCD-EFGH")]

Requirements and compatibility

Written for: GitHub Copilot

Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta

“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.

Needs: Needs an MCP server, Needs a command-line tool, Needs Python

The author says: Requires Python 3.11+. GitLab OAuth public-client credentials or explicit legacy-token mode.

Security

What our scanner noticed· Low risk

  • Scripts that your agent may run

This skill includes scripts that your agent may run. Read them before you use it.

A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.

Attribution and licence

Published by
Microsoft
Publisher
Microsoft (Official)
Source
microsoft/hve-core · .github/skills/project-planning/gitlab · commit f425e94
Licence
MIT License (redistribution allowed)
  • LICENSE (added by AutoGineer from LICENSE)
  • ATTRIBUTION.md (written by AutoGineer)
Synced

AutoGineer is not affiliated with or endorsed by Microsoft.

Get it from the source

8 of this skill’s files are not mirrored here, so there is no ZIP. Get the complete skill from its source: microsoft/hve-core