pjdev-gitlab
Async GitLab automation SDK. Wraps both the GitLab REST API v4 and GraphQL API directly with httpx.AsyncClient and Pydantic models. Covers issues, workItems (the GraphQL replacement for issues — status, comments, labels, custom fields), merge requests, repository files, and the generic package registry.
Installation
pip install pjdev-gitlab
Configuration
pjdev-gitlab reads GL_* environment variables (or accepts the same values
via init()):
| variable | required | purpose |
|---|---|---|
GL_TOKEN |
yes | access token (api scope) — a PAT, or an OAuth token with GL_AUTH_SCHEME=bearer |
GL_GITLAB_URL |
yes | base URL, e.g. https://gitlab.com |
GL_AUTH_SCHEME |
no | private-token (default, PAT header) or bearer (OAuth access token) |
GL_DEFAULT_PROJECT_ID |
no | default project for service helpers |
GL_OUTPUT_PATH |
no | directory for downloaded files |
OAuth 2.0 (per-user attribution)
To attribute API writes to a real person instead of a shared bot token, mint a
short-lived OAuth access token with the interactive Authorization Code + PKCE
flow and initialize with auth_scheme="bearer":
from pjdev_gitlab import config_service, oauth_service
# Opens a browser for sign-in/consent on first run; caches + refreshes the token
# per host under ~/.config/pjdev-gitlab/tokens/ (0600) on subsequent runs.
token = oauth_service.get_access_token(
gitlab_url="https://gitlab.example.com",
client_id="<registered PKCE app client id>",
)
config_service.init(
token=token, gitlab_url="https://gitlab.example.com", auth_scheme="bearer"
)
The OAuth application must be registered as a non-confidential (PKCE) app with
scope api and Redirect URI exactly http://localhost:7331/callback
(override the port via get_access_token(redirect_port=...)). A bearer token
is sent as Authorization: Bearer <token>; the default private-token scheme
sends PRIVATE-TOKEN for personal/project/group access tokens.
pjdev-gitlab-auth console script
The same flow is exposed as a console script, so shell callers (e.g. skills) can mint a token without embedding any Python. It prints only the token to stdout and status to stderr:
TOKEN="$(uvx --from 'pjdev-gitlab' pjdev-gitlab-auth \
--gitlab-url https://gitlab.example.com --client-id <client-id>)"
The library is instance-agnostic: --client-id (or GL_OAUTH_CLIENT_ID) is
required and the host defaults to https://gitlab.com (override with
--gitlab-url / GL_GITLAB_URL). Other flags: --client-secret,
--redirect-port, --scopes, --admin-mode, --force (each with a
GL_OAUTH_* env equivalent).
Admin mode (instance admin API)
GitLab gates its instance admin API behind admin mode: /license, the admin
projection of /users (the one carrying email and external),
/users/:id/memberships and friends answer 403 — or, worse, answer 200 with
a narrower record — unless the credential itself was granted the admin_mode
scope. Being an administrator is not enough; the token has to carry it.
token = oauth_service.get_access_token(
gitlab_url="https://gitlab.example.com",
client_id="<client id>",
admin_mode=True, # -> requests "api admin_mode"
)
pjdev-gitlab-auth --gitlab-url https://gitlab.example.com \
--client-id <id> --admin-mode # or GL_OAUTH_ADMIN_MODE=1
Two things this handles that are easy to get wrong:
- The cache is scope-aware. Tokens are cached per host, so a previously
minted
apitoken would otherwise be handed straight back to anadmin_moderequest and then 403 on every admin call. A cached token whose recorded grant does not cover what is being asked for is rejected and the browser flow re-runs. (Refreshing cannot widen a grant either, so that path is skipped too.) - A silently narrowed grant is an error. GitLab issues whatever the
application registration allows, so an app registered for
apialone answers anapi admin_moderequest with anapitoken and no complaint.get_access_tokencompares the granted scopes against the requested ones and raisesOAuthErrornaming the missing scope, instead of letting you discover it as a 403 later.
The OAuth application must therefore be registered with both api and
admin_mode. A personal access token works too — select both scopes when
creating it, and initialize with auth_scheme="private-token".
users_service — the admin user API
from pjdev_gitlab import config_service, users_service
config_service.init(token=token, gitlab_url=..., auth_scheme="bearer")
external = await users_service.list_external_users()
for user in external:
print(user.username, user.email)
list_users, get_user and list_external_users all verify that GitLab
actually returned the admin projection and raise
users_service.AdminModeRequired when it did not. That check exists because the
public projection omits email and external rather than erroring, so
row.get("external") reads as None and a naive caller concludes "no external
users" — a confident wrong answer. list_external_users also filters on the
external field of each returned record rather than trusting the server-side
external=true parameter, so a silently ignored filter cannot shorten the list.
list_memberships wraps the admin-only /users/:id/memberships.
pjdev-gitlab-sync console script
pjdev-gitlab-sync clones a repo over HTTPS using an OAuth token, or
fast-forwards it if it is already present — no SSH key setup required. It is
deterministic and idempotent: the same command both installs a repo the first
time and pulls updates on every later run, so it is the one thing a non-technical
user has to remember.
pjdev-gitlab-sync developers/claude-skills \
--gitlab-url https://gitlab.example.com \
--client-id <client-id> \
--target ~/git/claude-skills # optional; defaults to the repo basename
It mints/reuses the token via the same PKCE flow as pjdev-gitlab-auth (opening
a browser only when there is no valid cached token), then hands it to git through
GIT_ASKPASS. The token is therefore never written to .git/config, placed
on a git command line, or cached in a system keychain — the stored origin
remote is always a plain, tokenless HTTPS URL you can safely inspect. The
resolved checkout path is printed to stdout (so it is scriptable, e.g.
cd "$(pjdev-gitlab-sync group/name)"); status goes to stderr.
Updates are merge --ff-only: if the checkout has diverging local commits, the
command fails loudly rather than discarding your work. Flags mirror
pjdev-gitlab-auth plus --target (GL_SYNC_TARGET) and --branch
(GL_SYNC_BRANCH); the repo path may also come from GL_REPO.
Recommended: 1Password + op run
On a developer laptop, keep the token in 1Password and inject it into the host
process with op run —
the secret never sits in your shell environment or on disk in plaintext.
-
Install the 1Password CLI (
brew install --cask 1password-cli) and turn on Settings → Developer → Integrate with 1Password CLI in the desktop app. -
Store the token in 1Password (e.g. an API Credential titled
GitLab — purplejaywith acredentialfield). -
Drop a committable
.env.opnext to your project — references only, no real secrets:# .env.op GL_TOKEN="op://Private/GitLab — purplejay/credential" GL_GITLAB_URL="https://gitlab.purplejay.io"
-
Launch your script — or the entire Claude Code session that will use this library — under
op run:op run --env-file=.env.op -- python my_script.py op run --env-file=.env.op -- claude
op run resolves the references, exports them to the subprocess, and tears
them down on exit. Inside Python, just call config_service.init() with no
arguments and the values flow in from the environment.
In CI, skip 1Password and set GL_TOKEN/GL_GITLAB_URL from the job's
existing variables (e.g. CI_JOB_TOKEN for project-scoped operations).
Usage
import asyncio
from pjdev_gitlab import config_service, issues_service
from pjdev_gitlab.models import StateEvent
async def main() -> None:
# Token & URL come from GL_TOKEN / GL_GITLAB_URL (e.g. via `op run`).
config_service.init(default_project_id="my-group/my-project")
issue = await issues_service.create_issue(
project_id="my-group/my-project",
title="Bug: timeout on /widgets",
description="The endpoint times out under load.\n\n/label ~bug ~priority::high",
labels=["bug"],
)
await issues_service.comment_on_issue(
project_id="my-group/my-project",
issue_iid=issue.iid,
body="Investigating now.",
)
await issues_service.set_issue_state(
project_id="my-group/my-project",
issue_iid=issue.iid,
state_event=StateEvent.close,
)
asyncio.run(main())
Run it: op run --env-file=.env.op -- python my_script.py.
Notes (comments) and replies
notes_service reads and replies to comments on both issues and merge
requests — they share the same endpoint shape, so one Noteable parameter
selects which.
GitLab only lets you reply to a discussion (a thread), never to a note id, so
reply_to_note resolves the note to its containing thread for you:
import asyncio
from pjdev_gitlab import config_service, notes_service
from pjdev_gitlab.notes_service import Noteable, QuickActionOnlyError
async def main() -> None:
config_service.init()
# Fetch. Notes come back newest-first; pass sort="asc" to read in order.
notes = await notes_service.list_notes(
"my-group/my-project", Noteable.merge_request, 166,
include_system=False, # drop "assigned to @x", "changed milestone", ...
sort="asc",
)
# Threads, when you need a discussion id to answer into.
threads = await notes_service.list_discussions(
"my-group/my-project", Noteable.merge_request, 166
)
# Reply, addressing the note you're answering.
await notes_service.reply_to_note(
"my-group/my-project", Noteable.merge_request, 166,
note_id=notes[0].id,
body="Good catch — fixed in the next push.",
)
# Or straight into a known thread.
await notes_service.reply_to_discussion(
"my-group/my-project", Noteable.issue, 730,
discussion_id=threads[0].id,
body="Verified on TEST1.",
)
asyncio.run(main())
list_merge_request_notes / list_issue_notes remain as thin aliases for
list_notes.
A body made up of only quick actions (e.g. /milestone %"v2.0.6") creates no
note — GitLab runs the command and returns the commands it executed instead. The
reply helpers raise QuickActionOnlyError for that case, carrying the outcome so
you can confirm the side effect landed:
try:
await notes_service.reply_to_discussion(..., body='/milestone %"v2.0.6"')
except QuickActionOnlyError as exc:
print(exc.summary) # ['Set milestone to %"v2.0.6".']
print(exc.commands_changes) # {'milestone': {...}}
WorkItems (GraphQL)
workItem is GitLab's unified replacement for the legacy Issue type — use
work_items_service for status/comments/labels on modern GitLab instances:
import asyncio
from pjdev_gitlab import config_service, work_items_service
from pjdev_gitlab.models import WorkItemState, WorkItemStateEvent
async def main() -> None:
config_service.init()
open_bugs = await work_items_service.search_work_items(
project_path="my-group/my-project",
state=WorkItemState.OPEN,
labels=["bug"],
search="timeout",
)
label = await work_items_service.create_label(
"needs-review", project_path="my-group/my-project", color="#FFAA00"
)
await work_items_service.set_work_item_labels(
open_bugs[0].iid,
[label.id],
mode="add",
project_path="my-group/my-project",
)
await work_items_service.comment_on_work_item(
open_bugs[0].iid,
"Triaged — assigning a reviewer.",
project_path="my-group/my-project",
)
await work_items_service.set_work_item_state(
open_bugs[0].iid, WorkItemStateEvent.CLOSE,
project_path="my-group/my-project",
)
asyncio.run(main())
Custom statuses
The workflow status an instance defines for itself (Ready, In progress, ...)
is neither a label nor the open/closed state, and REST does not expose it.
Reading it is opt-in, because GitLab ships the status widget as an experiment
(17.11+) and selecting it against an older instance fails the whole query:
# Just the status — one small query. Preferred on a hot path.
status = await work_items_service.get_work_item_status(
42, project_path="my-group/my-project"
)
print(status.name if status else "no status set")
# Or folded into a read you were making anyway.
item = await work_items_service.get_work_item(
42, project_path="my-group/my-project", include_status=True
)
items = await work_items_service.search_work_items(
project_path="my-group/my-project", include_status=True
)
Status names are configured per namespace — match them case-insensitively.
Custom fields
Custom fields are typed properties (single/multi select, number, text, date)
defined on a top-level group and inherited by every subgroup and project
under it (Premium/Ultimate, GitLab 18.0+). custom_fields_service accepts
either group_path or project_path everywhere and resolves it to that group:
from pjdev_gitlab import custom_fields_service
from pjdev_gitlab.models import CustomFieldType
# Define once — lands on "my-group", the project's top-level group.
team = await custom_fields_service.create_custom_field(
"Team",
CustomFieldType.SINGLE_SELECT,
project_path="my-group/my-project",
select_options=["Platform", "Payments"],
work_item_types=["Issue", "Task"], # names, ids or Global IDs
)
await custom_fields_service.create_custom_field(
"Story points", CustomFieldType.NUMBER,
group_path="my-group", work_item_types=["Issue"],
)
# Set values on a work item: keyed by field name, plain Python values.
await custom_fields_service.set_work_item_custom_fields(
42,
{"Team": "Platform", "Story points": 5},
project_path="my-group/my-project",
)
values = await custom_fields_service.get_work_item_custom_field_values(
42, project_path="my-group/my-project"
)
Values are validated against the field definition before anything is sent;
None clears a field. Updating many items? Fetch the definitions once with
list_custom_fields and pass them as fields= to skip the per-call lookup.
update_custom_field(select_options=...) replaces the option list (dropped
options lose their selections) — use add_custom_field_select_options to
append. There is no delete: archive_custom_field hides a field and keeps its
values for unarchive_custom_field.
Bundled agent skills
Skill files for AI agents ship under .agents/skills/ inside the installed package, following the library-skills.io convention. Topics: issues, workItems (including custom fields), merge requests, repository files, generic packages.
License
pjdev-gitlab is distributed under the terms of the MIT license.
Metadata
Release files for pjdev-gitlab 2026.1016.152408
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| pjdev_gitlab-2026.1016.152408.tar.gz | 81.4 kB | Without provenance | Details for pjdev_gitlab-2026.1016.152408.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| pjdev_gitlab-2026.1016.152408-py3-none-any.whl | Python 3 | none | any | Details for pjdev_gitlab-2026.1016.152408-py3-none-any.whl |
Total release size: 152.9 kB
Release files / pjdev_gitlab-2026.1016.152408.tar.gz
| Download URL | pjdev_gitlab-2026.1016.152408.tar.gz |
|---|---|
| Size | 81.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8ada6817be965a273e91aa42b36699c35bfac8de4d74e348c9aecf46a7794b3b
|
|
BLAKE2b-256 checksum How to use checksums |
32ec3bd75c19aaaac9dcbcc6a9da26a4e9e653b8b7ffb27bd4aae51fa2c0b62d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
Hatch/1.18.1 {"ci":true,"cpu":"aarch64","distro":{"id":"noble","libc":{"lib":"glibc","version":"2.39"},"name":"Ubuntu","version":"24.04"},"implementation":{"name":"CPython","version":"3.12.3"},"installer":{"name":"hatch","version":"1.18.1"},"openssl_version":"OpenSSL 3.0.13 30 Jan 2024","python":"3.12.3","system":{"name":"Linux","release":"7.0.12-linuxkit"}} HTTPX2/2.13.1
|
Release files / pjdev_gitlab-2026.1016.152408-py3-none-any.whl
| Download URL | pjdev_gitlab-2026.1016.152408-py3-none-any.whl |
|---|---|
| Size | 71.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
724a5cf3c052c93a6e2c3ef8cdcb3ca75380511ffc62252d01d3828185457981
|
|
BLAKE2b-256 checksum How to use checksums |
dbec34134cfdc342e1ddd18f73dd36459120356a5d061bcec3a7af7ec65e362d
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
Hatch/1.18.1 {"ci":true,"cpu":"aarch64","distro":{"id":"noble","libc":{"lib":"glibc","version":"2.39"},"name":"Ubuntu","version":"24.04"},"implementation":{"name":"CPython","version":"3.12.3"},"installer":{"name":"hatch","version":"1.18.1"},"openssl_version":"OpenSSL 3.0.13 30 Jan 2024","python":"3.12.3","system":{"name":"Linux","release":"7.0.12-linuxkit"}} HTTPX2/2.13.1
|
Security for pjdev-gitlab 2026.1016.152408
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.