pjdev-gitlab 5.1.10__tar.gz → 5.1.13__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.10 → pjdev_gitlab-5.1.13}/PKG-INFO +54 -1
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/README.md +53 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/pyproject.toml +1 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_work_items/SKILL.md +34 -1
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/__about__.py +1 -1
- pjdev_gitlab-5.1.13/src/pjdev_gitlab/git_sync_service.py +286 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/models.py +34 -0
- pjdev_gitlab-5.1.13/src/pjdev_gitlab/sync_cli.py +164 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/work_items_service.py +118 -6
- pjdev_gitlab-5.1.13/test_report_pjdev-gitlab.txt +107 -0
- pjdev_gitlab-5.1.13/tests/tests_for_git_sync_service.py +322 -0
- pjdev_gitlab-5.1.13/tests/tests_for_sync_cli.py +127 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/tests/tests_for_work_items_service.py +188 -0
- pjdev_gitlab-5.1.10/test_report_pjdev-gitlab.txt +0 -74
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/.gitignore +0 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/LICENSE.txt +0 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_issues/SKILL.md +0 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_merge_requests/SKILL.md +0 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_packages/SKILL.md +0 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_repo_files/SKILL.md +0 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/__init__.py +0 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/api_utilities.py +0 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/config_service.py +0 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/issues_service.py +0 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/merge_requests_service.py +0 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/oauth_cli.py +0 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/oauth_service.py +0 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/packages_service.py +0 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/py.typed +0 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/repo_files_service.py +0 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/test.sh +0 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/tests/__init__.py +0 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/tests/conftest.py +0 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/tests/tests_for_api_utilities.py +0 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/tests/tests_for_issues_service.py +0 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/tests/tests_for_merge_requests_service.py +0 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/tests/tests_for_models.py +0 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/tests/tests_for_oauth_cli.py +0 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/tests/tests_for_oauth_service.py +0 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/tests/tests_for_packages_service.py +0 -0
- {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/tests/tests_for_repo_files_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.13
|
|
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
|
|
@@ -97,6 +97,34 @@ required and the host defaults to `https://gitlab.com` (override with
|
|
|
97
97
|
`--redirect-port`, `--scopes`, `--force` (each with a `GL_OAUTH_*` env
|
|
98
98
|
equivalent).
|
|
99
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
|
+
|
|
100
128
|
### Recommended: 1Password + `op run`
|
|
101
129
|
|
|
102
130
|
On a developer laptop, keep the token in 1Password and inject it into the host
|
|
@@ -206,6 +234,31 @@ async def main() -> None:
|
|
|
206
234
|
asyncio.run(main())
|
|
207
235
|
```
|
|
208
236
|
|
|
237
|
+
#### Custom statuses
|
|
238
|
+
|
|
239
|
+
The workflow status an instance defines for itself (`Ready`, `In progress`, ...)
|
|
240
|
+
is neither a label nor the open/closed `state`, and REST does not expose it.
|
|
241
|
+
Reading it is opt-in, because GitLab ships the status widget as an experiment
|
|
242
|
+
(**17.11+**) and selecting it against an older instance fails the whole query:
|
|
243
|
+
|
|
244
|
+
```python
|
|
245
|
+
# Just the status — one small query. Preferred on a hot path.
|
|
246
|
+
status = await work_items_service.get_work_item_status(
|
|
247
|
+
42, project_path="my-group/my-project"
|
|
248
|
+
)
|
|
249
|
+
print(status.name if status else "no status set")
|
|
250
|
+
|
|
251
|
+
# Or folded into a read you were making anyway.
|
|
252
|
+
item = await work_items_service.get_work_item(
|
|
253
|
+
42, project_path="my-group/my-project", include_status=True
|
|
254
|
+
)
|
|
255
|
+
items = await work_items_service.search_work_items(
|
|
256
|
+
project_path="my-group/my-project", include_status=True
|
|
257
|
+
)
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Status names are configured per namespace — match them case-insensitively.
|
|
261
|
+
|
|
209
262
|
## Bundled agent skills
|
|
210
263
|
|
|
211
264
|
Skill files for AI agents ship under `.agents/skills/` inside the installed package, following the [library-skills.io](https://library-skills.io/create/) convention. Topics: issues, workItems, merge requests, repository files, generic packages.
|
|
@@ -69,6 +69,34 @@ required and the host defaults to `https://gitlab.com` (override with
|
|
|
69
69
|
`--redirect-port`, `--scopes`, `--force` (each with a `GL_OAUTH_*` env
|
|
70
70
|
equivalent).
|
|
71
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
|
+
|
|
72
100
|
### Recommended: 1Password + `op run`
|
|
73
101
|
|
|
74
102
|
On a developer laptop, keep the token in 1Password and inject it into the host
|
|
@@ -178,6 +206,31 @@ async def main() -> None:
|
|
|
178
206
|
asyncio.run(main())
|
|
179
207
|
```
|
|
180
208
|
|
|
209
|
+
#### Custom statuses
|
|
210
|
+
|
|
211
|
+
The workflow status an instance defines for itself (`Ready`, `In progress`, ...)
|
|
212
|
+
is neither a label nor the open/closed `state`, and REST does not expose it.
|
|
213
|
+
Reading it is opt-in, because GitLab ships the status widget as an experiment
|
|
214
|
+
(**17.11+**) and selecting it against an older instance fails the whole query:
|
|
215
|
+
|
|
216
|
+
```python
|
|
217
|
+
# Just the status — one small query. Preferred on a hot path.
|
|
218
|
+
status = await work_items_service.get_work_item_status(
|
|
219
|
+
42, project_path="my-group/my-project"
|
|
220
|
+
)
|
|
221
|
+
print(status.name if status else "no status set")
|
|
222
|
+
|
|
223
|
+
# Or folded into a read you were making anyway.
|
|
224
|
+
item = await work_items_service.get_work_item(
|
|
225
|
+
42, project_path="my-group/my-project", include_status=True
|
|
226
|
+
)
|
|
227
|
+
items = await work_items_service.search_work_items(
|
|
228
|
+
project_path="my-group/my-project", include_status=True
|
|
229
|
+
)
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
Status names are configured per namespace — match them case-insensitively.
|
|
233
|
+
|
|
181
234
|
## Bundled agent skills
|
|
182
235
|
|
|
183
236
|
Skill files for AI agents ship under `.agents/skills/` inside the installed package, following the [library-skills.io](https://library-skills.io/create/) convention. Topics: issues, workItems, merge requests, repository files, generic packages.
|
|
@@ -87,7 +87,7 @@ across_group = await work_items_service.search_work_items(
|
|
|
87
87
|
)
|
|
88
88
|
```
|
|
89
89
|
|
|
90
|
-
##
|
|
90
|
+
## Open/closed state
|
|
91
91
|
|
|
92
92
|
```python
|
|
93
93
|
from pjdev_gitlab.models import WorkItemStateEvent
|
|
@@ -100,6 +100,39 @@ await work_items_service.set_work_item_state(
|
|
|
100
100
|
)
|
|
101
101
|
```
|
|
102
102
|
|
|
103
|
+
## Custom status
|
|
104
|
+
|
|
105
|
+
Separate from open/closed: the workflow status an instance defines for itself
|
|
106
|
+
(`Ready`, `In progress`, `Code Complete`, ...). It is **not** a label, and the
|
|
107
|
+
REST API does not expose it at all — GraphQL's status widget is the only way to
|
|
108
|
+
read it.
|
|
109
|
+
|
|
110
|
+
Reading it is opt-in, because the widget is a GitLab *experiment* introduced in
|
|
111
|
+
**17.11**: selecting it against an older instance fails the entire query, so it
|
|
112
|
+
is never requested unless asked for.
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
# Just the status — one small query, no discussions dragged along.
|
|
116
|
+
# This is the right call on a hot path (e.g. per webhook event).
|
|
117
|
+
status = await work_items_service.get_work_item_status(42, project_path="group/proj")
|
|
118
|
+
print(status.name if status else "no status set")
|
|
119
|
+
|
|
120
|
+
# Or alongside everything else, when you were fetching the item anyway.
|
|
121
|
+
item = await work_items_service.get_work_item(
|
|
122
|
+
42, project_path="group/proj", include_status=True
|
|
123
|
+
)
|
|
124
|
+
print(item.status.name if item.status else "no status set")
|
|
125
|
+
|
|
126
|
+
# Also available on searches.
|
|
127
|
+
items = await work_items_service.search_work_items(
|
|
128
|
+
project_path="group/proj", include_status=True
|
|
129
|
+
)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
`None` means the item has no status assigned. Status names are configured per
|
|
133
|
+
namespace, so **match them case-insensitively** rather than hard-coding an enum —
|
|
134
|
+
`"Code Complete"` on one instance may be `"code complete"` on another.
|
|
135
|
+
|
|
103
136
|
## Comments and threaded replies
|
|
104
137
|
|
|
105
138
|
```python
|
|
@@ -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)
|
|
@@ -265,6 +265,32 @@ class WorkItemIteration(GraphQLBase):
|
|
|
265
265
|
web_url: Optional[str] = None
|
|
266
266
|
|
|
267
267
|
|
|
268
|
+
class WorkItemStatus(GraphQLBase):
|
|
269
|
+
"""A custom work-item status (``Ready``, ``In progress``, ``Code Complete``, ...).
|
|
270
|
+
|
|
271
|
+
Distinct from :class:`WorkItemState`, which is only ``OPEN``/``CLOSED``.
|
|
272
|
+
Statuses are defined per namespace, so the names are whatever the instance's
|
|
273
|
+
administrators configured — match them case-insensitively rather than
|
|
274
|
+
hard-coding an enum.
|
|
275
|
+
|
|
276
|
+
GitLab exposes this as an **experiment** (``WorkItemWidgetStatus``,
|
|
277
|
+
introduced in 17.11), which is why the widget is opt-in throughout
|
|
278
|
+
:mod:`pjdev_gitlab.work_items_service` — see ``include_status``. ``category``
|
|
279
|
+
and ``description`` are 18.1+ and are deliberately not selected, so the
|
|
280
|
+
queries here stay valid against 17.11.
|
|
281
|
+
|
|
282
|
+
Every field is optional: an instance may return a status object before it has
|
|
283
|
+
been fully configured, and a parse failure here would take down the whole
|
|
284
|
+
work-item read.
|
|
285
|
+
"""
|
|
286
|
+
|
|
287
|
+
id: Optional[str] = None
|
|
288
|
+
name: Optional[str] = None
|
|
289
|
+
icon_name: Optional[str] = None
|
|
290
|
+
color: Optional[str] = None
|
|
291
|
+
position: Optional[int] = None
|
|
292
|
+
|
|
293
|
+
|
|
268
294
|
class WorkItemNote(GraphQLBase):
|
|
269
295
|
id: str
|
|
270
296
|
body: str
|
|
@@ -316,6 +342,9 @@ class WorkItem(GraphQLBase):
|
|
|
316
342
|
start_date: Optional[str] = None
|
|
317
343
|
due_date: Optional[str] = None
|
|
318
344
|
discussions: List[WorkItemDiscussion] = []
|
|
345
|
+
status: Optional[WorkItemStatus] = None
|
|
346
|
+
"""Only populated when the read requested ``include_status=True``; ``None``
|
|
347
|
+
otherwise, which is indistinguishable from "this item has no status set"."""
|
|
319
348
|
|
|
320
349
|
widgets: List[Dict[str, Any]] = []
|
|
321
350
|
|
|
@@ -345,6 +374,11 @@ class WorkItem(GraphQLBase):
|
|
|
345
374
|
elif widget_type == "START_AND_DUE_DATE":
|
|
346
375
|
flat["start_date"] = widget.get("startDate")
|
|
347
376
|
flat["due_date"] = widget.get("dueDate")
|
|
377
|
+
elif widget_type == "STATUS":
|
|
378
|
+
# Only present when the query opted in via include_status; a work
|
|
379
|
+
# item with no status assigned still sends the widget, with a
|
|
380
|
+
# null status.
|
|
381
|
+
flat["status"] = widget.get("status")
|
|
348
382
|
elif widget_type == "NOTES":
|
|
349
383
|
nodes = (widget.get("discussions") or {}).get("nodes") or []
|
|
350
384
|
flat["discussions"] = [
|