pjdev-gitlab 5.1.9__tar.gz → 5.1.12__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/PKG-INFO +46 -1
  2. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/README.md +45 -0
  3. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/pyproject.toml +4 -0
  4. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/__about__.py +1 -1
  5. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/__init__.py +1 -0
  6. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/api_utilities.py +1 -1
  7. pjdev_gitlab-5.1.12/src/pjdev_gitlab/git_sync_service.py +286 -0
  8. pjdev_gitlab-5.1.12/src/pjdev_gitlab/oauth_cli.py +122 -0
  9. pjdev_gitlab-5.1.12/src/pjdev_gitlab/sync_cli.py +164 -0
  10. pjdev_gitlab-5.1.12/test_report_pjdev-gitlab.txt +100 -0
  11. pjdev_gitlab-5.1.12/tests/tests_for_git_sync_service.py +322 -0
  12. pjdev_gitlab-5.1.12/tests/tests_for_oauth_cli.py +85 -0
  13. pjdev_gitlab-5.1.12/tests/tests_for_sync_cli.py +127 -0
  14. pjdev_gitlab-5.1.9/test_report_pjdev-gitlab.txt +0 -69
  15. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/.gitignore +0 -0
  16. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/LICENSE.txt +0 -0
  17. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_issues/SKILL.md +0 -0
  18. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_merge_requests/SKILL.md +0 -0
  19. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_packages/SKILL.md +0 -0
  20. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_repo_files/SKILL.md +0 -0
  21. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_work_items/SKILL.md +0 -0
  22. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/config_service.py +0 -0
  23. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/issues_service.py +0 -0
  24. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/merge_requests_service.py +0 -0
  25. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/models.py +0 -0
  26. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/oauth_service.py +0 -0
  27. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/packages_service.py +0 -0
  28. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/py.typed +0 -0
  29. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/repo_files_service.py +0 -0
  30. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/work_items_service.py +0 -0
  31. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/test.sh +0 -0
  32. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/tests/__init__.py +0 -0
  33. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/tests/conftest.py +0 -0
  34. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/tests/tests_for_api_utilities.py +0 -0
  35. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/tests/tests_for_issues_service.py +0 -0
  36. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/tests/tests_for_merge_requests_service.py +0 -0
  37. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/tests/tests_for_models.py +0 -0
  38. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/tests/tests_for_oauth_service.py +0 -0
  39. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/tests/tests_for_packages_service.py +0 -0
  40. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/tests/tests_for_repo_files_service.py +0 -0
  41. {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/tests/tests_for_work_items_service.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pjdev-gitlab
3
- Version: 5.1.9
3
+ Version: 5.1.12
4
4
  Project-URL: Documentation, https://gitlab.purplejay.io/keystone/python/-/tree/main/pjdev-gitlab/README.md
5
5
  Project-URL: Issues, https://gitlab.purplejay.io/keystone/python/-/issues
6
6
  Project-URL: Source, https://gitlab.purplejay.io/keystone/python
@@ -80,6 +80,51 @@ The OAuth application must be registered as a non-confidential (PKCE) app with
80
80
  is sent as `Authorization: Bearer <token>`; the default `private-token` scheme
81
81
  sends `PRIVATE-TOKEN` for personal/project/group access tokens.
82
82
 
83
+ #### `pjdev-gitlab-auth` console script
84
+
85
+ The same flow is exposed as a console script, so shell callers (e.g. skills) can
86
+ mint a token without embedding any Python. It prints only the token to stdout and
87
+ status to stderr:
88
+
89
+ ```bash
90
+ TOKEN="$(uvx --from 'pjdev-gitlab' pjdev-gitlab-auth \
91
+ --gitlab-url https://gitlab.example.com --client-id <client-id>)"
92
+ ```
93
+
94
+ The library is instance-agnostic: `--client-id` (or `GL_OAUTH_CLIENT_ID`) is
95
+ required and the host defaults to `https://gitlab.com` (override with
96
+ `--gitlab-url` / `GL_GITLAB_URL`). Other flags: `--client-secret`,
97
+ `--redirect-port`, `--scopes`, `--force` (each with a `GL_OAUTH_*` env
98
+ equivalent).
99
+
100
+ #### `pjdev-gitlab-sync` console script
101
+
102
+ `pjdev-gitlab-sync` clones a repo over HTTPS using an OAuth token, or
103
+ fast-forwards it if it is already present — **no SSH key setup required**. It is
104
+ deterministic and idempotent: the *same* command both installs a repo the first
105
+ time and pulls updates on every later run, so it is the one thing a non-technical
106
+ user has to remember.
107
+
108
+ ```bash
109
+ pjdev-gitlab-sync developers/claude-skills \
110
+ --gitlab-url https://gitlab.example.com \
111
+ --client-id <client-id> \
112
+ --target ~/git/claude-skills # optional; defaults to the repo basename
113
+ ```
114
+
115
+ It mints/reuses the token via the same PKCE flow as `pjdev-gitlab-auth` (opening
116
+ a browser only when there is no valid cached token), then hands it to git through
117
+ `GIT_ASKPASS`. The token is therefore **never** written to `.git/config`, placed
118
+ on a git command line, or cached in a system keychain — the stored `origin`
119
+ remote is always a plain, tokenless HTTPS URL you can safely inspect. The
120
+ resolved checkout path is printed to stdout (so it is scriptable, e.g.
121
+ `cd "$(pjdev-gitlab-sync group/name)"`); status goes to stderr.
122
+
123
+ Updates are `merge --ff-only`: if the checkout has diverging local commits, the
124
+ command fails loudly rather than discarding your work. Flags mirror
125
+ `pjdev-gitlab-auth` plus `--target` (`GL_SYNC_TARGET`) and `--branch`
126
+ (`GL_SYNC_BRANCH`); the repo path may also come from `GL_REPO`.
127
+
83
128
  ### Recommended: 1Password + `op run`
84
129
 
85
130
  On a developer laptop, keep the token in 1Password and inject it into the host
@@ -52,6 +52,51 @@ The OAuth application must be registered as a non-confidential (PKCE) app with
52
52
  is sent as `Authorization: Bearer <token>`; the default `private-token` scheme
53
53
  sends `PRIVATE-TOKEN` for personal/project/group access tokens.
54
54
 
55
+ #### `pjdev-gitlab-auth` console script
56
+
57
+ The same flow is exposed as a console script, so shell callers (e.g. skills) can
58
+ mint a token without embedding any Python. It prints only the token to stdout and
59
+ status to stderr:
60
+
61
+ ```bash
62
+ TOKEN="$(uvx --from 'pjdev-gitlab' pjdev-gitlab-auth \
63
+ --gitlab-url https://gitlab.example.com --client-id <client-id>)"
64
+ ```
65
+
66
+ The library is instance-agnostic: `--client-id` (or `GL_OAUTH_CLIENT_ID`) is
67
+ required and the host defaults to `https://gitlab.com` (override with
68
+ `--gitlab-url` / `GL_GITLAB_URL`). Other flags: `--client-secret`,
69
+ `--redirect-port`, `--scopes`, `--force` (each with a `GL_OAUTH_*` env
70
+ equivalent).
71
+
72
+ #### `pjdev-gitlab-sync` console script
73
+
74
+ `pjdev-gitlab-sync` clones a repo over HTTPS using an OAuth token, or
75
+ fast-forwards it if it is already present — **no SSH key setup required**. It is
76
+ deterministic and idempotent: the *same* command both installs a repo the first
77
+ time and pulls updates on every later run, so it is the one thing a non-technical
78
+ user has to remember.
79
+
80
+ ```bash
81
+ pjdev-gitlab-sync developers/claude-skills \
82
+ --gitlab-url https://gitlab.example.com \
83
+ --client-id <client-id> \
84
+ --target ~/git/claude-skills # optional; defaults to the repo basename
85
+ ```
86
+
87
+ It mints/reuses the token via the same PKCE flow as `pjdev-gitlab-auth` (opening
88
+ a browser only when there is no valid cached token), then hands it to git through
89
+ `GIT_ASKPASS`. The token is therefore **never** written to `.git/config`, placed
90
+ on a git command line, or cached in a system keychain — the stored `origin`
91
+ remote is always a plain, tokenless HTTPS URL you can safely inspect. The
92
+ resolved checkout path is printed to stdout (so it is scriptable, e.g.
93
+ `cd "$(pjdev-gitlab-sync group/name)"`); status goes to stderr.
94
+
95
+ Updates are `merge --ff-only`: if the checkout has diverging local commits, the
96
+ command fails loudly rather than discarding your work. Flags mirror
97
+ `pjdev-gitlab-auth` plus `--target` (`GL_SYNC_TARGET`) and `--branch`
98
+ (`GL_SYNC_BRANCH`); the repo path may also come from `GL_REPO`.
99
+
55
100
  ### Recommended: 1Password + `op run`
56
101
 
57
102
  On a developer laptop, keep the token in 1Password and inject it into the host
@@ -27,6 +27,10 @@ dependencies = [
27
27
  "pydantic>=2.12.5",
28
28
  ]
29
29
 
30
+ [project.scripts]
31
+ pjdev-gitlab-auth = "pjdev_gitlab.oauth_cli:main"
32
+ pjdev-gitlab-sync = "pjdev_gitlab.sync_cli:main"
33
+
30
34
  [project.optional-dependencies]
31
35
  dev = [
32
36
  "ruff",
@@ -1,4 +1,4 @@
1
1
  # SPDX-FileCopyrightText: 2026-present Chris O'Neill <chris@purplejay.io>
2
2
  #
3
3
  # SPDX-License-Identifier: MIT
4
- __version__ = "5.1.9"
4
+ __version__ = "5.1.12"
@@ -12,6 +12,7 @@ __all__ = [
12
12
  "issues_service",
13
13
  "merge_requests_service",
14
14
  "models",
15
+ "oauth_cli",
15
16
  "oauth_service",
16
17
  "packages_service",
17
18
  "repo_files_service",
@@ -2,7 +2,7 @@ import asyncio
2
2
  import ssl
3
3
  from contextlib import asynccontextmanager
4
4
  from functools import wraps
5
- from typing import Any, AsyncIterator, Callable, Dict, List, Optional
5
+ from typing import Any, AsyncIterator, Dict, List, Optional
6
6
  from urllib.parse import quote
7
7
 
8
8
  import httpx
@@ -0,0 +1,286 @@
1
+ """Clone or update a GitLab repo over HTTPS using a short-lived OAuth token.
2
+
3
+ Deterministic, idempotent counterpart to the interactive auth flow: given an
4
+ OAuth access token (see :func:`pjdev_gitlab.oauth_service.get_access_token`),
5
+ clone the repo when the target directory does not yet exist, or fast-forward it
6
+ when it does. Running the same command again is always safe -- it is the single
7
+ operation a non-technical user needs both to *install* and to *update* a repo.
8
+
9
+ The token is handed to git through ``GIT_ASKPASS`` (the token itself is passed to
10
+ the helper via an environment variable, never on a command line), so it never
11
+ lands in ``.git/config``, in git's argv, or in a system credential store. The
12
+ stored ``origin`` remote is therefore always a plain, tokenless HTTPS URL that
13
+ anyone can safely inspect -- and, because it is HTTPS rather than SSH, no SSH key
14
+ setup is required.
15
+
16
+ Updates use ``merge --ff-only``: if the checkout has diverging local commits or
17
+ uncommitted changes that would be clobbered, the command fails loudly rather than
18
+ discarding the user's work.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import os
24
+ import shutil
25
+ import stat
26
+ import subprocess # noqa: S404 - invoking git is the entire point of this module
27
+ import sys
28
+ import tempfile
29
+ from dataclasses import dataclass
30
+ from pathlib import Path
31
+ from typing import Callable, Optional, Sequence
32
+
33
+ StatusFn = Callable[[str], None]
34
+
35
+ # GitLab requires the literal username ``oauth2`` when using an OAuth token as
36
+ # HTTPS basic-auth; an ``Authorization: Bearer`` header does NOT work for
37
+ # git-over-HTTPS, only this basic-auth form does.
38
+ OAUTH2_USER = "oauth2"
39
+
40
+ # Name of the env var the generated askpass helper reads the token from, so the
41
+ # token is never written into the helper script or onto any command line.
42
+ _TOKEN_ENV = "PJDEV_GITLAB_SYNC_TOKEN"
43
+
44
+
45
+ class SyncError(RuntimeError):
46
+ """Raised when the clone/update cannot complete."""
47
+
48
+
49
+ def _default_status(msg: str) -> None:
50
+ print(msg, file=sys.stderr)
51
+
52
+
53
+ # A git runner: run ``git <args>`` in ``cwd`` with ``env``, returning captured
54
+ # stdout and raising SyncError on a non-zero exit. Injectable so tests need no
55
+ # real git or network.
56
+ GitRunner = Callable[..., str]
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class SyncResult:
61
+ """Outcome of a :func:`sync_repo` call."""
62
+
63
+ action: str # "cloned" or "updated"
64
+ path: Path # absolute path to the checkout
65
+ remote_url: str # tokenless HTTPS URL stored as origin
66
+
67
+
68
+ # --------------------------------------------------------------------------- #
69
+ # URL / path helpers (pure)
70
+ # --------------------------------------------------------------------------- #
71
+ def build_remote_url(gitlab_url: str, repo_path: str) -> str:
72
+ """Return the plain, tokenless HTTPS clone URL for ``repo_path``.
73
+
74
+ ``repo_path`` is a ``group/subgroup/name`` path; a trailing ``.git`` and
75
+ surrounding slashes are tolerated.
76
+ """
77
+ base = gitlab_url.strip().rstrip("/")
78
+ path = repo_path.strip().strip("/")
79
+ if path.endswith(".git"):
80
+ path = path[:-4]
81
+ if not path:
82
+ raise SyncError("repo path is empty")
83
+ return f"{base}/{path}.git"
84
+
85
+
86
+ def _authed_url(remote_url: str) -> str:
87
+ """Insert the ``oauth2`` username into ``remote_url`` (no token -- that comes
88
+ from the askpass helper)."""
89
+ scheme, _, rest = remote_url.partition("://")
90
+ if not rest:
91
+ raise SyncError(f"remote url is not absolute: {remote_url}")
92
+ return f"{scheme}://{OAUTH2_USER}@{rest}"
93
+
94
+
95
+ def default_target_dir(repo_path: str) -> Path:
96
+ """Default checkout directory: the repo's basename in the current directory."""
97
+ name = repo_path.strip().strip("/").rsplit("/", 1)[-1]
98
+ if name.endswith(".git"):
99
+ name = name[:-4]
100
+ if not name:
101
+ raise SyncError("could not derive a target directory from the repo path")
102
+ return Path(name)
103
+
104
+
105
+ # --------------------------------------------------------------------------- #
106
+ # git invocation
107
+ # --------------------------------------------------------------------------- #
108
+ def run_git(
109
+ args: Sequence[str],
110
+ *,
111
+ cwd: Optional[Path] = None,
112
+ env: Optional[dict[str, str]] = None,
113
+ ) -> str:
114
+ """Run ``git <args>`` (credential helpers disabled), returning its stdout.
115
+
116
+ ``-c credential.helper=`` blanks any configured helper so our ``GIT_ASKPASS``
117
+ is the only credential source and nothing gets cached into a system keychain.
118
+ Raises :class:`SyncError` on a non-zero exit.
119
+ """
120
+ full = ["git", "-c", "credential.helper=", *args]
121
+ proc = subprocess.run( # noqa: S603 - args are internally constructed, not user shell
122
+ full,
123
+ cwd=str(cwd) if cwd is not None else None,
124
+ env=env,
125
+ capture_output=True,
126
+ text=True,
127
+ )
128
+ if proc.returncode != 0:
129
+ detail = (proc.stderr or proc.stdout or "").strip()
130
+ raise SyncError(f"`git {' '.join(args)}` failed ({proc.returncode}): {detail}")
131
+ return proc.stdout
132
+
133
+
134
+ def _write_askpass(dir_path: Path) -> Path:
135
+ """Write a POSIX askpass helper that echoes the token from the environment.
136
+
137
+ The username is supplied in the URL, so git only ever asks for the password;
138
+ the helper unconditionally returns the token read from ``_TOKEN_ENV``.
139
+ """
140
+ script = dir_path / "askpass.sh"
141
+ script.write_text(f'#!/bin/sh\nprintf "%s" "${_TOKEN_ENV}"\n')
142
+ script.chmod(stat.S_IRWXU) # 0700
143
+ return script
144
+
145
+
146
+ def _current_branch(runner: GitRunner, target: Path) -> str:
147
+ """Return the branch currently checked out in ``target``.
148
+
149
+ Raises :class:`SyncError` on a detached HEAD, where there is no branch to
150
+ fast-forward and the caller must pass ``--branch`` explicitly.
151
+ """
152
+ try:
153
+ out = runner(["symbolic-ref", "--short", "HEAD"], cwd=target)
154
+ except SyncError as exc:
155
+ raise SyncError(
156
+ f"{target} is not on a branch (detached HEAD); "
157
+ "pass --branch to choose one to fast-forward"
158
+ ) from exc
159
+ branch = (out or "").strip()
160
+ if not branch:
161
+ raise SyncError(
162
+ f"{target} is not on a branch (detached HEAD); "
163
+ "pass --branch to choose one to fast-forward"
164
+ )
165
+ return branch
166
+
167
+
168
+ def _local_branch_exists(runner: GitRunner, target: Path, branch: str) -> bool:
169
+ """Whether ``branch`` already exists as a local branch in ``target``."""
170
+ try:
171
+ runner(["rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"], cwd=target)
172
+ return True
173
+ except SyncError:
174
+ return False
175
+
176
+
177
+ def _ensure_origin(runner: GitRunner, target: Path, remote_url: str) -> None:
178
+ """Point ``origin`` at the tokenless ``remote_url``, adding it if absent.
179
+
180
+ A fresh clone always has an ``origin``, but a pre-existing checkout adopted
181
+ by this tool might have none (or one named differently), so create the remote
182
+ rather than fail an update that has already succeeded.
183
+ """
184
+ try:
185
+ runner(["remote", "get-url", "origin"], cwd=target)
186
+ except SyncError:
187
+ runner(["remote", "add", "origin", remote_url], cwd=target)
188
+ else:
189
+ runner(["remote", "set-url", "origin", remote_url], cwd=target)
190
+
191
+
192
+ # --------------------------------------------------------------------------- #
193
+ # Public entry point
194
+ # --------------------------------------------------------------------------- #
195
+ def sync_repo(
196
+ *,
197
+ gitlab_url: str,
198
+ repo_path: str,
199
+ token: str,
200
+ target_dir: Optional[os.PathLike[str] | str] = None,
201
+ branch: Optional[str] = None,
202
+ on_status: Optional[StatusFn] = None,
203
+ runner: GitRunner = run_git,
204
+ ) -> SyncResult:
205
+ """Clone ``repo_path`` if absent, else fast-forward the existing checkout.
206
+
207
+ Returns a :class:`SyncResult`. Raises :class:`SyncError` on any failure,
208
+ including a fast-forward that would clobber local work.
209
+ """
210
+ if not token:
211
+ raise SyncError("an OAuth access token is required")
212
+ status = on_status or _default_status
213
+ remote_url = build_remote_url(gitlab_url, repo_path)
214
+ authed_url = _authed_url(remote_url)
215
+
216
+ target = Path(target_dir).expanduser() if target_dir else default_target_dir(repo_path)
217
+ target = target.absolute()
218
+
219
+ if target.exists():
220
+ if not (target / ".git").is_dir():
221
+ raise SyncError(
222
+ f"{target} already exists but is not a git repository; "
223
+ "choose a different target directory"
224
+ )
225
+ action = "updated"
226
+ else:
227
+ action = "cloned"
228
+
229
+ # Sensitive git operations (clone/fetch) run with this env; the token is
230
+ # reachable only via the askpass helper, never on a command line.
231
+ askpass_dir = Path(tempfile.mkdtemp(prefix="pjdev-gitlab-sync-"))
232
+ try:
233
+ askpass_dir.chmod(stat.S_IRWXU) # 0700
234
+ askpass = _write_askpass(askpass_dir)
235
+ env = dict(os.environ)
236
+ env["GIT_ASKPASS"] = str(askpass)
237
+ env["GIT_TERMINAL_PROMPT"] = "0" # never fall back to an interactive prompt
238
+ env[_TOKEN_ENV] = token
239
+
240
+ if action == "cloned":
241
+ status(f"Cloning {remote_url} into {target} ...")
242
+ target.parent.mkdir(parents=True, exist_ok=True)
243
+ clone_args = ["clone"]
244
+ if branch:
245
+ clone_args += ["--branch", branch]
246
+ clone_args += [authed_url, str(target)]
247
+ try:
248
+ runner(clone_args, env=env)
249
+ except SyncError:
250
+ # A failed clone can leave a partial target dir behind. Since
251
+ # this call created it, remove it so a re-run starts clean
252
+ # instead of tripping the "exists but not a git repo" guard.
253
+ shutil.rmtree(target, ignore_errors=True)
254
+ raise
255
+ else:
256
+ status(f"Updating {target} from {remote_url} ...")
257
+ # Fast-forward the branch the caller asked for, or -- when none is
258
+ # given -- the one actually checked out (NOT the remote's default,
259
+ # which is what a bare `fetch` + `FETCH_HEAD` would pick up once the
260
+ # user has switched branches). Fetch that branch by name over the raw
261
+ # URL so FETCH_HEAD is exactly it (no named remote, no tracking refs).
262
+ sync_branch = branch or _current_branch(runner, target)
263
+ runner(["fetch", authed_url, sync_branch], cwd=target, env=env)
264
+ if branch and not _local_branch_exists(runner, target, branch):
265
+ # First time this checkout sees the branch: create it at the
266
+ # fetched tip. Safe -- there is no local history to clobber.
267
+ runner(["checkout", "-b", branch, "FETCH_HEAD"], cwd=target, env=env)
268
+ else:
269
+ # Make sure the branch is checked out, then fast-forward it.
270
+ # --ff-only refuses to clobber diverging local commits.
271
+ runner(["checkout", sync_branch], cwd=target, env=env)
272
+ runner(["merge", "--ff-only", "FETCH_HEAD"], cwd=target, env=env)
273
+ finally:
274
+ # Best-effort cleanup of the transient askpass helper.
275
+ try:
276
+ for child in askpass_dir.iterdir():
277
+ child.unlink()
278
+ askpass_dir.rmdir()
279
+ except OSError:
280
+ pass
281
+
282
+ # Guarantee origin is the plain, tokenless (and username-less) HTTPS URL.
283
+ _ensure_origin(runner, target, remote_url)
284
+
285
+ status(f"{action.capitalize()} {target}")
286
+ return SyncResult(action=action, path=target, remote_url=remote_url)
@@ -0,0 +1,122 @@
1
+ """Command-line entry point: authenticate to GitLab and print an OAuth token.
2
+
3
+ Installed as the ``pjdev-gitlab-auth`` console script (see ``[project.scripts]``
4
+ in ``pyproject.toml``). Thin CLI over
5
+ :func:`pjdev_gitlab.oauth_service.get_access_token` that runs the OAuth 2.0
6
+ Authorization Code + PKCE browser flow so API writes are attributed to the human
7
+ invoking it rather than a shared bot. Prints ONLY the access token to stdout;
8
+ status goes to stderr. Tokens are cached + silently refreshed per host under
9
+ ``~/.config/pjdev-gitlab/tokens/``, so most runs need no interaction.
10
+
11
+ The library is instance-agnostic: the OAuth application's client id must be
12
+ supplied per call (there is no built-in default), and the GitLab host defaults to
13
+ ``https://gitlab.com``. Callers targeting another instance pass ``--gitlab-url``
14
+ (or ``GL_GITLAB_URL``) and ``--client-id`` (or ``GL_OAUTH_CLIENT_ID``).
15
+
16
+ Usage::
17
+
18
+ pjdev-gitlab-auth --gitlab-url https://gitlab.example.com --client-id <id>
19
+ # or, equivalently, via the environment:
20
+ GL_GITLAB_URL=https://gitlab.example.com GL_OAUTH_CLIENT_ID=<id> pjdev-gitlab-auth
21
+
22
+ The registered OAuth application must be a non-confidential (PKCE) app with the
23
+ requested scope (default ``api``) and Redirect URI exactly
24
+ ``http://localhost:<redirect-port>/callback``.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import argparse
30
+ import os
31
+ import sys
32
+
33
+ from pjdev_gitlab.oauth_service import (
34
+ DEFAULT_REDIRECT_PORT,
35
+ DEFAULT_SCOPES,
36
+ OAuthError,
37
+ get_access_token,
38
+ )
39
+
40
+ DEFAULT_GITLAB_URL = "https://gitlab.com"
41
+
42
+
43
+ def main() -> int:
44
+ parser = argparse.ArgumentParser(
45
+ prog="pjdev-gitlab-auth",
46
+ description="Authenticate to GitLab via OAuth and print a short-lived access token.",
47
+ )
48
+ parser.add_argument(
49
+ "--gitlab-url",
50
+ default=None,
51
+ help="GitLab base URL (or GL_GITLAB_URL; default https://gitlab.com)",
52
+ )
53
+ parser.add_argument(
54
+ "--client-id",
55
+ default=None,
56
+ help="registered OAuth app client id (or GL_OAUTH_CLIENT_ID) -- required",
57
+ )
58
+ parser.add_argument(
59
+ "--client-secret",
60
+ default=None,
61
+ help="only for a confidential app (or GL_OAUTH_CLIENT_SECRET)",
62
+ )
63
+ parser.add_argument(
64
+ "--redirect-port",
65
+ type=int,
66
+ default=None,
67
+ help=f"loopback redirect port (or GL_OAUTH_REDIRECT_PORT; default {DEFAULT_REDIRECT_PORT})",
68
+ )
69
+ parser.add_argument(
70
+ "--scopes",
71
+ default=None,
72
+ help=f'space-separated scopes (or GL_OAUTH_SCOPES; default "{DEFAULT_SCOPES}")',
73
+ )
74
+ parser.add_argument(
75
+ "--force",
76
+ action="store_true",
77
+ help="ignore any cached token and run the browser flow",
78
+ )
79
+ args = parser.parse_args()
80
+
81
+ gitlab_url = args.gitlab_url or os.environ.get("GL_GITLAB_URL") or DEFAULT_GITLAB_URL
82
+ client_id = args.client_id or os.environ.get("GL_OAUTH_CLIENT_ID")
83
+ if not client_id:
84
+ print(
85
+ "no OAuth client id: pass --client-id or set GL_OAUTH_CLIENT_ID to the "
86
+ "registered application's client id.",
87
+ file=sys.stderr,
88
+ )
89
+ return 2
90
+ client_secret = args.client_secret or os.environ.get("GL_OAUTH_CLIENT_SECRET") or None
91
+ scopes = args.scopes or os.environ.get("GL_OAUTH_SCOPES") or DEFAULT_SCOPES
92
+
93
+ port_raw = (
94
+ args.redirect_port
95
+ if args.redirect_port is not None
96
+ else os.environ.get("GL_OAUTH_REDIRECT_PORT", DEFAULT_REDIRECT_PORT)
97
+ )
98
+ try:
99
+ redirect_port = int(port_raw)
100
+ except (TypeError, ValueError):
101
+ print("redirect port must be an integer", file=sys.stderr)
102
+ return 2
103
+
104
+ try:
105
+ token = get_access_token(
106
+ gitlab_url=gitlab_url,
107
+ client_id=client_id,
108
+ client_secret=client_secret,
109
+ redirect_port=redirect_port,
110
+ scopes=scopes,
111
+ force=args.force,
112
+ )
113
+ except OAuthError as exc:
114
+ print(str(exc), file=sys.stderr)
115
+ return 1
116
+
117
+ print(token)
118
+ return 0
119
+
120
+
121
+ if __name__ == "__main__":
122
+ raise SystemExit(main())