Gitlab
MicrosoftOfficialPublished by Microsoft
- Skill
- Tool integration
- Software development
- MIT
- Low risk
- Has scripts
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
8 of its files are not mirrored here, so install it from its source:
https://github.com/microsoft/hve-core/tree/f425e943bf228052a23e6bc4810b5faef030314f/.github/skills/project-planning/gitlab
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:
| Variable | Required | Example | Purpose |
|---|---|---|---|
GITLAB_URL | Yes | https://gitlab.com | GitLab instance URL |
GITLAB_AUTH_MODE | Optional | oauth | oauth or explicit legacy-token compatibility mode |
GITLAB_OAUTH_CLIENT_ID | OAuth | Application ID | Non-confidential OAuth application ID; no client secret |
GITLAB_TOKEN | Legacy mode | glpat-... | PAT accepted only with GITLAB_AUTH_MODE=legacy-token |
GITLAB_PROFILE | No | default | Named OAuth profile |
GITLAB_TOKEN_STORE | No | path | OAuth store override; parent must be owner-only on POSIX |
GITLAB_ALLOW_INSECURE | No | 1 | Permit explicit loopback HTTP development endpoints only |
XDG_DATA_HOME | No | path | POSIX data root used for the default OAuth store |
LOCALAPPDATA | No | path | Reserved Windows data root; OAuth persistence currently fails closed on Windows |
GITLAB_PROJECT | No | group/project | Project 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
| Variable | Required | Purpose |
|---|---|---|
GITLAB_AUDIT_LOG | No | Path to a JSON Lines audit log. When set, every request is audited (see Audit Logging). |
GITLAB_AUDIT_ACTOR | No | Overrides the recorded actor identity (for example, a CI service principal). |
GITLAB_DEBUG | No | Set 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
attemptrecord 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
outcomerecord (successorerror, 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.nameRead pipeline jobs for a known pipeline:
python scripts/gitlab.py pipeline-jobs 12345 --fields id,name,status,stageParameters Reference
Common Option
| Parameter | Applies To | Example | Description |
|---|---|---|---|
--fields | mr-list, mr-get, mr-notes, pipeline-get, pipeline-jobs | --fields iid,title,state | Extract specific fields with dot notation and print concise tabular or key-value output |
Commands
| Command | Arguments | Description |
|---|---|---|
auth login | None | Authenticate with Authorization Code and PKCE |
auth device-login | None | Authenticate through human-assisted device authorization |
auth status | None | Print secret-free local profile status |
auth logout | None | Delete 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 stdin | Create a merge request from a JSON payload |
mr-update | <mr-iid> <json> or stdin | Update merge request fields from a JSON payload |
mr-comment | <mr-iid> <body> or stdin | Add 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_countGet one merge request:
python scripts/gitlab.py mr-get 42 --fields iid,title,state,source_branch,target_branchCreate 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 42Inspect 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 67890Troubleshooting
| Symptom | Cause | Resolution |
|---|---|---|
GITLAB_URL is not set | Required environment variable missing | Export GITLAB_URL before running the script |
GITLAB_TOKEN is not set for legacy-token mode | Explicit legacy mode has no PAT | Export a PAT only with GITLAB_AUTH_MODE=legacy-token |
GITLAB_TOKEN must not be set in oauth mode | Legacy PAT leaked into the OAuth configuration | Remove GITLAB_TOKEN or explicitly select legacy-token mode |
| OAuth persistence is unavailable on Windows | No protected Windows profile-store backend | Use explicit legacy-token mode or run OAuth from POSIX |
GITLAB_OAUTH_CLIENT_ID is not set | OAuth application is not configured | Set the public client ID, then run auth login |
| Callback listener cannot bind | Port 8766 is unavailable or not forwarded | Free the port or use auth device-login |
timed out waiting for the GitLab OAuth token store lock | Another process holds the profile-store lock | Wait for the other command to finish, then retry |
GitLab device response has invalid verification_uri | The verification URI is unsafe to display or does not match GITLAB_URL | Confirm GITLAB_URL matches the instance that serves device verification |
cannot parse git remote URL | Project autodetection failed | Set GITLAB_PROJECT explicitly |
HTTP 401 or HTTP 403 | Credential is invalid or lacks access | Re-login for OAuth; rotate the PAT in explicit legacy mode |
HTTP 404 | Wrong project, MR IID, pipeline ID, or job ID | Verify GITLAB_PROJECT and confirm the numeric identifiers |
expected numeric ID | Non-numeric value passed to an ID argument | Use project MR IID values and numeric pipeline or job IDs |
python3 is required or syntax errors on launch | Unsupported interpreter | Run 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
- ATTRIBUTION.mdAdded by AutoGineer825 B
- LICENSELicence file added by AutoGineer1.1 KiB
- SECURITY.md33.4 KiB
- SKILL.md12.9 KiB
- pyproject.toml801 B
- scripts/_gitlab_credentials.pyScript15.1 KiB
- scripts/_gitlab_oauth.pyScript24.0 KiB
- scripts/gitlab.pyScript53.5 KiB
- tests/conftest.pyScript5.8 KiB
- tests/corpus/0814fd5a169c01a1448b1817d8ac617d6f277db8(opens on GitHub)Not mirrored: opens on GitHub6 B
- tests/corpus/0_empty(opens on GitHub)Not mirrored: opens on GitHub1 B
- tests/corpus/0_large(opens on GitHub)Not mirrored: opens on GitHub2.0 KiB
- tests/corpus/0_no_suffix(opens on GitHub)Not mirrored: opens on GitHub5 B
- tests/corpus/0_remote_path(opens on GitHub)Not mirrored: opens on GitHub18 B
- tests/corpus/0_unicode(opens on GitHub)Not mirrored: opens on GitHub11 B
- tests/corpus/0_with_suffix(opens on GitHub)Not mirrored: opens on GitHub9 B
- tests/corpus/1_empty1 B
- tests/corpus/1_float5 B
- tests/corpus/1_large15 B
- tests/corpus/1_negative3 B
- tests/corpus/1_numeric_id6 B
- tests/corpus/1_valid_id6 B
- tests/corpus/2_deeply_nested604 B
- tests/corpus/2_empty1 B
- tests/corpus/2_field_path12 B
- tests/corpus/2_nested_json26 B
- tests/corpus/2_unicode29 B
- tests/corpus/3_empty1 B
- tests/corpus/3_json_payload15 B
- tests/corpus/3_large2.0 KiB
- tests/corpus/3_malformed10 B
- tests/corpus/3_trailing10 B
- tests/corpus/3_valid_json35 B
- tests/corpus/4_empty1 B
- tests/corpus/4_float5 B
- tests/corpus/4_large15 B
- tests/corpus/4_negative3 B
- tests/corpus/4_positive_int3 B
- tests/corpus/4_unicode10 B
- tests/corpus/4_zero2 B
- tests/corpus/5_empty1 B
- tests/corpus/5_field_flag18 B
- tests/corpus/5_large899 B
- tests/corpus/5_mr_list8 B
- tests/corpus/5_multi_field25 B
- tests/corpus/5_single_field12 B
- tests/corpus/5_unicode25 B
- tests/corpus/README.md1.5 KiB
- tests/corpus/ee123d57ee4d24cece23066fb721013d717824f2(opens on GitHub)Not mirrored: opens on GitHub37 B
- tests/fuzz_harness.pyScript7.6 KiB
- tests/test_constants.pyScript1.2 KiB
- tests/test_gitlab_audit.pyScript7.8 KiB
- tests/test_gitlab_commands.pyScript10.0 KiB
- tests/test_gitlab_coverage.pyScript4.2 KiB
- tests/test_gitlab_credentials.pyScript13.2 KiB
- tests/test_gitlab_helpers.pyScript25.7 KiB
- tests/test_gitlab_main.pyScript13.2 KiB
- tests/test_gitlab_oauth.pyScript18.1 KiB
- tests/test_gitlab_transport.pyScript31.1 KiB
- uv.lock57.5 KiB
tests/test_gitlab_helpers.py
# Copyright (c) 2026 Microsoft Corporation. All rights reserved.
# SPDX-License-Identifier: MIT
"""Helper-oriented unit tests for gitlab.py."""
from __future__ import annotations
import ast
import dataclasses
import json
import logging
import pathlib
import gitlab
import pytest
from pytest_mock import MockerFixture
from test_constants import TEST_API_URL
SOURCE = pathlib.Path(gitlab.__file__).read_text(encoding="utf-8")
SOURCE_TREE = ast.parse(SOURCE)
SCRIPTS_ROOT = pathlib.Path(gitlab.__file__).parent
SECRET = "s3cr3t-Value_123"
EXPECTED_REDACT_KEYS = (
"access_token",
"refresh_token",
"code_verifier",
"client_secret",
"id_token",
"assertion",
"client_assertion",
"device_code",
"password",
"private_token",
"oauth_token",
"deploy_token",
"job_token",
)
class _NetworkInvocationVisitor(ast.NodeVisitor):
"""Collect credentialed network invocations with their owning function."""
def __init__(self, module: str) -> None:
self.module = module
self.function = "<module>"
self.invocations: list[tuple[str, str, str]] = []
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
previous = self.function
self.function = node.name
self.generic_visit(node)
self.function = previous
visit_AsyncFunctionDef = visit_FunctionDef
def visit_Call(self, node: ast.Call) -> None:
function = node.func
if (
isinstance(function, ast.Attribute)
and function.attr == "open"
and isinstance(function.value, ast.Name)
and function.value.id == "_OPENER"
):
self.invocations.append((self.module, self.function, "_OPENER.open"))
elif isinstance(function, ast.Name) and function.id == "opener":
self.invocations.append((self.module, self.function, "opener"))
elif (
isinstance(function, ast.Attribute)
and function.attr == "urlopen"
and isinstance(function.value, ast.Attribute)
and isinstance(function.value.value, ast.Name)
and function.value.value.id == "urllib"
and function.value.attr == "request"
):
self.invocations.append(
(self.module, self.function, "urllib.request.urlopen")
)
self.generic_visit(node)
def _network_invocations(source: str, module: str) -> list[tuple[str, str, str]]:
visitor = _NetworkInvocationVisitor(module)
visitor.visit(ast.parse(source))
return visitor.invocations
def _is_main_guard(node: ast.AST) -> bool:
"""Report whether a node is the ``if __name__ == "__main__":`` guard."""
if not isinstance(node, ast.If) or not isinstance(node.test, ast.Compare):
return False
left = node.test.left
operators = node.test.ops
comparators = node.test.comparators
return (
isinstance(left, ast.Name)
and left.id == "__name__"
and len(operators) == 1
and isinstance(operators[0], ast.Eq)
and len(comparators) == 1
and isinstance(comparators[0], ast.Constant)
and comparators[0].value == "__main__"
)
class _ExitMechanismVisitor(ast.NodeVisitor):
"""Collect process-exit violations using lexical import bindings."""
_IMPLICIT_BINDINGS = {
"SystemExit": "builtins.SystemExit",
"sys": "sys",
}
def __init__(self, guarded: set[int]) -> None:
self.guarded = guarded
self.scope_stack: list[tuple[dict[str, str], dict[str, str]]] = []
self.violations: list[str] = []
def visit(self, node: ast.AST) -> object:
if id(node) in self.guarded:
return None
return super().visit(node)
def visit_Module(self, node: ast.Module) -> None:
self._visit_scope(node.body, self._IMPLICIT_BINDINGS, is_class=False)
def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
if node.name == "die":
self.violations.append("def die")
self._visit_function(node)
def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
if node.name == "die":
self.violations.append("def die")
self._visit_function(node)
def visit_ClassDef(self, node: ast.ClassDef) -> None:
for decorator in node.decorator_list:
self.visit(decorator)
for base in node.bases:
self.visit(base)
for keyword in node.keywords:
self.visit(keyword)
self._visit_scope(node.body, self._child_bindings(), is_class=True)
def visit_Raise(self, node: ast.Raise) -> None:
raised = node.exc.func if isinstance(node.exc, ast.Call) else node.exc
if self._resolve_target(raised) == "builtins.SystemExit":
self.violations.append("raise SystemExit")
self.generic_visit(node)
def visit_Call(self, node: ast.Call) -> None:
if self._resolve_target(node.func) == "sys.exit":
self.violations.append("sys.exit")
self.generic_visit(node)
def _visit_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None:
for decorator in node.decorator_list:
self.visit(decorator)
self.visit(node.args)
if node.returns is not None:
self.visit(node.returns)
self._visit_scope(node.body, self._child_bindings(), is_class=False)
def _visit_scope(
self,
body: list[ast.stmt],
inherited: dict[str, str],
*,
is_class: bool,
) -> None:
bindings = inherited.copy()
for statement in body:
self._collect_import_bindings(statement, bindings)
child_bindings = inherited if is_class else bindings
self.scope_stack.append((bindings, child_bindings))
for statement in body:
self.visit(statement)
self.scope_stack.pop()
def _collect_import_bindings(self, node: ast.AST, bindings: dict[str, str]) -> None:
if id(node) in self.guarded or isinstance(
node,
(ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda),
):
return
if isinstance(node, ast.Import):
for alias in node.names:
bound_name = alias.asname or alias.name.partition(".")[0]
bindings[bound_name] = alias.name if alias.asname else bound_name
return
if isinstance(node, ast.ImportFrom) and node.module is not None:
for alias in node.names:
if alias.name != "*":
bindings[alias.asname or alias.name] = f"{node.module}.{alias.name}"
return
for child in ast.iter_child_nodes(node):
self._collect_import_bindings(child, bindings)
def _child_bindings(self) -> dict[str, str]:
return self.scope_stack[-1][1]
def _resolve_target(self, node: ast.AST | None) -> str | None:
if isinstance(node, ast.Name):
return self.scope_stack[-1][0].get(node.id)
if isinstance(node, ast.Attribute):
parent = self._resolve_target(node.value)
if parent is not None:
return f"{parent}.{node.attr}"
return None
def _exit_mechanism_violations(source: str) -> list[str]:
"""Return violations of the single-failure-mechanism contract.
``GitLabError`` is the module's only failure mechanism. Process exit belongs
to the ``__main__`` guard alone, so a ``raise SystemExit`` or ``sys.exit``
anywhere else, or any ``die`` definition, reintroduces the second mechanism
this contract exists to prevent.
"""
tree = ast.parse(source)
guarded: set[int] = set()
for node in ast.walk(tree):
if _is_main_guard(node):
for statement in node.body:
for child in ast.walk(statement):
guarded.add(id(child))
visitor = _ExitMechanismVisitor(guarded)
visitor.visit(tree)
return visitor.violations
class TestRedact:
"""Tests for _redact."""
def test_masks_header_and_query_string_secrets(self) -> None:
payload = (
"PRIVATE-TOKEN: secret123\n"
"X-API-Key: api-key-456\n"
"Cookie: session=xyz\n"
"https://example.com/?private_token=abc&access_token=def&token=ghi"
"&api_key=789&password=secret&secret=hidden"
)
redacted = gitlab._redact(payload)
assert "PRIVATE-TOKEN=[REDACTED]" in redacted
assert "X-API-Key=[REDACTED]" in redacted
assert "Cookie=[REDACTED]" in redacted
assert "private_token=[REDACTED]" in redacted
assert "access_token=[REDACTED]" in redacted
assert "token=[REDACTED]" in redacted
assert "api_key=[REDACTED]" in redacted
assert "password=[REDACTED]" in redacted
assert "secret=[REDACTED]" in redacted
assert "secret123" not in redacted
assert "abc" not in redacted
def test_masks_oauth_secrets_in_escaped_and_encoded_forms(self) -> None:
payload = (
'{"Access_Token":"mixed-secret"} '
r"{\"refresh_token\":\"escaped-secret\"} "
"access_token%3Dencoded-secret"
)
redacted = gitlab._redact(payload)
assert "mixed-secret" not in redacted
assert "escaped-secret" not in redacted
assert "encoded-secret" not in redacted
@pytest.mark.parametrize("key", EXPECTED_REDACT_KEYS)
def test_masks_all_pinned_json_and_form_keys(self, key: str) -> None:
payload = f'{{"{key}": "{SECRET}"}} {key}={SECRET}'
redacted = gitlab._redact(payload)
assert SECRET not in redacted
assert "[REDACTED]" in redacted
def test_masks_azure_sas_query_string(self) -> None:
"""SAS tokens ride in the query string, so drop everything after the ?."""
url = f"https://acct.blob.core.windows.net/c/b?sig={SECRET}&se=2026"
redacted = gitlab._redact(url)
assert redacted == "https://acct.blob.core.windows.net/c/b?[REDACTED]"
def test_masks_basic_authorization_header(self) -> None:
"""Basic credentials are base64(user:token) and must not survive."""
encoded = "dXNlckBleGFtcGxlLmNvbTp0b2tlbg=="
redacted = gitlab._redact(f"Authorization: Basic {encoded}")
assert encoded not in redacted
def test_masks_every_secret_in_a_multi_secret_line(self) -> None:
payload = f"a access_token={SECRET}1 b private_token={SECRET}2 c"
redacted = gitlab._redact(payload)
assert f"{SECRET}1" not in redacted
assert f"{SECRET}2" not in redacted
def test_is_idempotent(self) -> None:
"""Re-redacting already-redacted text must not corrupt the marker."""
once = gitlab._redact(f"access_token={SECRET}")
assert gitlab._redact(once) == once
@pytest.mark.parametrize("payload", ["", " "])
def test_handles_empty_payloads(self, payload: str) -> None:
assert gitlab._redact(payload).strip() == ""
def test_preserves_unicode_around_masked_secrets(self) -> None:
redacted = gitlab._redact(f"\u65e5\u672c\u8a9e access_token={SECRET} \u2713")
assert SECRET not in redacted
assert "\u65e5\u672c\u8a9e" in redacted
assert "\u2713" in redacted
def test_pinned_keyset_excludes_public_pkce_challenge(self) -> None:
assert gitlab._REDACT_KEYS == EXPECTED_REDACT_KEYS
assert "code_challenge" not in gitlab._REDACT_KEYS
class TestCredentialExposureContracts:
"""Tests for credential-safe objects, errors, and output sinks."""
def test_credential_is_not_a_module_level_global(self) -> None:
"""The PAT lives only on AuthContext, never in mutable module state."""
assert not hasattr(gitlab, "gitlab_token")
assert "gitlab_token" not in SOURCE
def test_auth_context_is_immutable_and_secret_free(self) -> None:
context = gitlab.AuthContext(
mode="legacy-token",
issuer="https://gitlab.example.com",
token=SECRET,
)
assert SECRET not in repr(context)
with pytest.raises(dataclasses.FrozenInstanceError):
context.token = "replacement" # type: ignore[misc]
def test_api_error_excludes_message_from_repr_and_redacts_str(self) -> None:
error = gitlab.GitLabAPIError(
status=403,
method="GET",
resource=f"{TEST_API_URL}/projects/1",
message=f"private_token={SECRET}",
)
assert SECRET not in str(error)
assert SECRET not in repr(error)
def test_emit_and_debug_traceback_redact(
self,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
caplog: pytest.LogCaptureFixture,
) -> None:
monkeypatch.setenv("GITLAB_DEBUG", "1")
error = gitlab.GitLabAPIError(message=f"private_token={SECRET}")
with caplog.at_level(logging.ERROR, logger="gitlab"):
gitlab._emit(f"private_token={SECRET}")
gitlab._emit_debug_traceback(error)
captured = capsys.readouterr()
assert SECRET not in captured.err
assert SECRET not in caplog.text
class TestSourceContracts:
"""Structural guards for redacted output and hardened transport ownership."""
def test_print_calls_remain_in_emit_helpers(self) -> None:
owners: list[str] = []
for node in ast.walk(SOURCE_TREE):
if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
if any(
isinstance(child, ast.Call)
and isinstance(child.func, ast.Name)
and child.func.id == "print"
for child in ast.walk(node)
):
owners.append(node.name)
assert set(owners) <= {
"_emit",
"_emit_stdout",
"_emit_structured_stdout",
"_emit_debug_traceback",
}
assert "LOGGER.exception(" not in SOURCE
def test_credentialed_egress_has_exactly_two_owners(self) -> None:
invocations: list[tuple[str, str, str]] = []
for path in SCRIPTS_ROOT.rglob("*.py"):
if "__pycache__" in path.parts:
continue
invocations.extend(
_network_invocations(path.read_text(encoding="utf-8"), path.name)
)
assert set(invocations) == {
("_gitlab_oauth.py", "post_form", "opener"),
("gitlab.py", "_request_bytes", "_OPENER.open"),
}
@pytest.mark.parametrize(
("source", "expected"),
[
(
"def bypass():\n return urllib.request.urlopen('https://x')\n",
"bypass",
),
("def other():\n return _OPENER.open('https://x')\n", "other"),
("def other(opener):\n return opener('https://x')\n", "other"),
],
)
def test_source_contract_detects_non_owner_invocations(
self, source: str, expected: str
) -> None:
invocations = _network_invocations(source, "unexpected.py")
assert invocations
assert invocations[0][1] == expected
def test_gitlab_error_is_the_only_failure_mechanism(self) -> None:
assert _exit_mechanism_violations(SOURCE) == []
assert not hasattr(gitlab, "die")
@pytest.mark.parametrize(
("source", "expected"),
[
("def bypass():\n raise SystemExit(2)\n", "raise SystemExit"),
("def bypass():\n sys.exit(2)\n", "sys.exit"),
("def die(message):\n return message\n", "def die"),
(
"import builtins\n\ndef bypass():\n raise builtins.SystemExit(2)\n",
"raise SystemExit",
),
(
"def owner():\n import builtins as bi\n"
" raise bi.SystemExit(2)\n\n"
"def sibling():\n raise bi.SystemExit(2)\n",
"raise SystemExit",
),
(
"class Owner:\n"
" from builtins import SystemExit as Stop\n"
" raise Stop(2)\n\n"
" def method(self):\n raise Stop(2)\n",
"raise SystemExit",
),
(
"def outer():\n import sys as system\n\n"
" def inner():\n system.exit(2)\n",
"sys.exit",
),
(
"from sys import exit\n\ndef bypass():\n exit(2)\n",
"sys.exit",
),
(
"def bypass():\n from sys import exit as stop\n stop(2)\n",
"sys.exit",
),
],
)
def test_source_contract_detects_second_failure_mechanism(
self, source: str, expected: str
) -> None:
assert _exit_mechanism_violations(source) == [expected]
def test_source_contract_allows_guarded_process_exit(self) -> None:
guarded = 'if __name__ == "__main__":\n sys.exit(main())\n'
assert _exit_mechanism_violations(guarded) == []
@pytest.mark.parametrize(
"source",
[
(
"class Owner:\n from sys import exit\n\n"
" def method(self):\n"
" def exit(code):\n return code\n\n"
" exit(2)\n"
),
(
"def owner():\n import sys as target\n\n"
"def sibling(target):\n target.exit(2)\n"
),
],
)
def test_source_contract_allows_unrelated_exit_forms(self, source: str) -> None:
assert _exit_mechanism_violations(source) == []
@pytest.mark.parametrize(
"source",
[
'if __name__ != "__main__":\n sys.exit(2)\n',
('if __name__ == "__main__":\n main()\nelse:\n sys.exit(2)\n'),
],
)
def test_source_contract_rejects_exit_outside_canonical_guard_body(
self, source: str
) -> None:
assert _exit_mechanism_violations(source) == ["sys.exit"]
def test_job_log_output_is_redacted(
self,
configured_gitlab: object,
capsys: pytest.CaptureFixture[str],
mocker: MockerFixture,
) -> None:
del configured_gitlab
mocker.patch("gitlab.project", return_value="group%2Fproject")
mocker.patch(
"gitlab._request_bytes",
return_value=f"access_token={SECRET}".encode(),
)
gitlab.cmd_job_log(["7"])
assert SECRET not in capsys.readouterr().out
class TestStripGitSuffix:
"""Tests for strip_git_suffix."""
@pytest.mark.parametrize(
("value", "expected"),
[
("group/project.git", "group/project"),
("group/project", "group/project"),
(".git", ""),
("project.git.git", "project.git"),
],
)
def test_strips_expected_suffix(self, value: str, expected: str) -> None:
assert gitlab.strip_git_suffix(value) == expected
class TestValidateNumericId:
"""Tests for validate_numeric_id."""
@pytest.mark.parametrize("value", ["7", "123456"])
def test_accepts_numeric_strings(self, value: str) -> None:
gitlab.validate_numeric_id(value)
@pytest.mark.parametrize("value", ["", "abc", "12a", "-1", "1.2", " 5 "])
def test_rejects_non_numeric_values(self, value: str) -> None:
with pytest.raises(gitlab.GitLabError) as exc_info:
gitlab.validate_numeric_id(value)
assert exc_info.value.exit_code == gitlab.EXIT_USAGE
assert f"expected numeric ID, got: {value}" in str(exc_info.value)
class TestValidatePositiveInt:
"""Tests for validate_positive_int."""
@pytest.mark.parametrize("value", ["1", "50", "100"])
def test_accepts_digit_strings(self, value: str) -> None:
gitlab.validate_positive_int(value, "max_results")
@pytest.mark.parametrize("value", ["", "ten", "5x", "-2", "3.14"])
def test_rejects_invalid_values(self, value: str) -> None:
with pytest.raises(gitlab.GitLabError) as exc_info:
gitlab.validate_positive_int(value, "max_results")
assert exc_info.value.exit_code == gitlab.EXIT_USAGE
assert f"max_results must be a positive integer, got: {value}" in str(
exc_info.value
)
class TestParseFields:
"""Tests for parse_fields."""
def test_returns_arguments_without_fields(self) -> None:
arguments = ["mr-list", "opened", "20"]
cleaned = gitlab.parse_fields(arguments)
assert cleaned == arguments
assert gitlab.selected_fields is None
def test_extracts_fields_and_strips_option(self) -> None:
cleaned = gitlab.parse_fields(
["mr-list", "opened", "--fields", "iid,title,author.name"]
)
assert cleaned == ["mr-list", "opened"]
assert gitlab.selected_fields == ["iid", "title", "author.name"]
def test_fields_can_appear_before_command_arguments(self) -> None:
cleaned = gitlab.parse_fields(["--fields", "iid,title", "mr-get", "7"])
assert cleaned == ["mr-get", "7"]
assert gitlab.selected_fields == ["iid", "title"]
def test_requires_value_after_fields(self) -> None:
with pytest.raises(gitlab.GitLabError) as exc_info:
gitlab.parse_fields(["mr-list", "--fields"])
assert exc_info.value.exit_code == gitlab.EXIT_USAGE
assert "usage: --fields requires a comma-separated value list" in str(
exc_info.value
)
class TestExtractField:
"""Tests for extract_field."""
@pytest.mark.parametrize(
("payload", "path", "expected"),
[
({"iid": 7}, "iid", "7"),
({"author": {"name": "Ada"}}, "author.name", "Ada"),
({"labels": ["bug", "urgent"]}, "labels", "bug, urgent"),
({"author": None}, "author.name", ""),
({"author": {"name": None}}, "author.name", ""),
({"author": {"name": "Ada"}}, "author.email", ""),
({"nested": {"deep": {"value": 9}}}, "nested.deep.value", "9"),
],
)
def test_extracts_supported_values(
self, payload: object, path: str, expected: str
) -> None:
assert gitlab.extract_field(payload, path) == expected
def test_returns_empty_for_non_mapping_intermediate_value(self) -> None:
assert gitlab.extract_field({"author": "Ada"}, "author.name") == ""
class TestPrintFields:
"""Tests for print_fields."""
def test_does_nothing_when_no_fields_selected(
self, capsys: pytest.CaptureFixture[str]
) -> None:
gitlab.print_fields({"iid": 7})
assert capsys.readouterr().out == ""
def test_prints_tabular_output_for_lists(
self, capsys: pytest.CaptureFixture[str]
) -> None:
gitlab.selected_fields = ["iid", "title"]
gitlab.print_fields(
[
{"iid": 1, "title": "First"},
{"iid": 2, "title": "Second"},
]
)
assert capsys.readouterr().out.splitlines() == [
"iid\ttitle",
"1\tFirst",
"2\tSecond",
]
def test_prints_key_value_output_for_single_object(
self, capsys: pytest.CaptureFixture[str]
) -> None:
gitlab.selected_fields = ["iid", "author.name"]
gitlab.print_fields({"iid": 9, "author": {"name": "Grace"}})
assert capsys.readouterr().out.splitlines() == [
"iid: 9",
"author.name: Grace",
]
def test_preserves_tsv_arity_and_ordinary_credential_words(
self, capsys: pytest.CaptureFixture[str]
) -> None:
gitlab.selected_fields = ["first", "title", "last"]
gitlab.print_fields(
[{"first": "", "title": "fix secret token password", "last": ""}]
)
lines = capsys.readouterr().out.splitlines()
assert lines[1].split("\t") == ["", "fix secret token password", ""]
def test_sanitize_structured_preserves_containers_and_redacts_descendants() -> None:
payload = {
"title": "fix secret",
"access_token": {"value": SECRET, "nested": [SECRET, {"x": SECRET}]},
"items": [{"password": [SECRET]}, "unchanged"],
}
sanitized = gitlab._sanitize_structured(payload)
assert sanitized == {
"title": "fix secret",
"access_token": {
"value": "[REDACTED]",
"nested": ["[REDACTED]", {"x": "[REDACTED]"}],
},
"items": [{"password": ["[REDACTED]"]}, "unchanged"],
}
assert json.loads(json.dumps(sanitized)) == sanitized
class TestLoadJsonPayload:
"""Tests for load_json_payload."""
@pytest.mark.parametrize(
("raw_payload", "expected"),
[
('{"title": "MR"}', {"title": "MR"}),
("[1, 2, 3]", [1, 2, 3]),
("true", True),
],
)
def test_parses_valid_json(self, raw_payload: str, expected: object) -> None:
assert gitlab.load_json_payload(raw_payload, "usage: gitlab") == expected
def test_raises_usage_error_for_invalid_json(self) -> None:
with pytest.raises(gitlab.GitLabError) as exc_info:
gitlab.load_json_payload("{bad json}", "usage: gitlab mr-create <json>")
assert exc_info.value.exit_code == gitlab.EXIT_USAGE
assert "invalid JSON payload" in str(exc_info.value)
def test_emit_writes_exactly_one_stderr_line() -> None:
"""_emit prints once; logging's lastResort must not echo the record.
_emit both logs at ERROR and prints to stderr. A logger with no handler
falls back to logging.lastResort, which writes WARNING-or-above records to
stderr as well, so every CLI error would appear twice.
"""
assert any(isinstance(h, logging.NullHandler) for h in gitlab.LOGGER.handlers)
def test_emit_is_not_duplicated_by_last_resort(
capsys: pytest.CaptureFixture[str],
) -> None:
gitlab._emit("single line")
lines = [line for line in capsys.readouterr().err.splitlines() if line.strip()]
assert lines == ["single line"]
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