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.
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/PKG-INFO +46 -1
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/README.md +45 -0
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/pyproject.toml +4 -0
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/__about__.py +1 -1
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/__init__.py +1 -0
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/api_utilities.py +1 -1
- pjdev_gitlab-5.1.12/src/pjdev_gitlab/git_sync_service.py +286 -0
- pjdev_gitlab-5.1.12/src/pjdev_gitlab/oauth_cli.py +122 -0
- pjdev_gitlab-5.1.12/src/pjdev_gitlab/sync_cli.py +164 -0
- pjdev_gitlab-5.1.12/test_report_pjdev-gitlab.txt +100 -0
- pjdev_gitlab-5.1.12/tests/tests_for_git_sync_service.py +322 -0
- pjdev_gitlab-5.1.12/tests/tests_for_oauth_cli.py +85 -0
- pjdev_gitlab-5.1.12/tests/tests_for_sync_cli.py +127 -0
- pjdev_gitlab-5.1.9/test_report_pjdev-gitlab.txt +0 -69
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/.gitignore +0 -0
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/LICENSE.txt +0 -0
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_issues/SKILL.md +0 -0
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_merge_requests/SKILL.md +0 -0
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_packages/SKILL.md +0 -0
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_repo_files/SKILL.md +0 -0
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_work_items/SKILL.md +0 -0
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/config_service.py +0 -0
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/issues_service.py +0 -0
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/merge_requests_service.py +0 -0
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/models.py +0 -0
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/oauth_service.py +0 -0
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/packages_service.py +0 -0
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/py.typed +0 -0
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/repo_files_service.py +0 -0
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/src/pjdev_gitlab/work_items_service.py +0 -0
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/test.sh +0 -0
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/tests/__init__.py +0 -0
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/tests/conftest.py +0 -0
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/tests/tests_for_api_utilities.py +0 -0
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/tests/tests_for_issues_service.py +0 -0
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/tests/tests_for_merge_requests_service.py +0 -0
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/tests/tests_for_models.py +0 -0
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/tests/tests_for_oauth_service.py +0 -0
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/tests/tests_for_packages_service.py +0 -0
- {pjdev_gitlab-5.1.9 → pjdev_gitlab-5.1.12}/tests/tests_for_repo_files_service.py +0 -0
- {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.
|
|
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
|
|
@@ -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,
|
|
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())
|