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.
Files changed (41) hide show
  1. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/PKG-INFO +54 -1
  2. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/README.md +53 -0
  3. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/pyproject.toml +1 -0
  4. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_work_items/SKILL.md +34 -1
  5. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/__about__.py +1 -1
  6. pjdev_gitlab-5.1.13/src/pjdev_gitlab/git_sync_service.py +286 -0
  7. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/models.py +34 -0
  8. pjdev_gitlab-5.1.13/src/pjdev_gitlab/sync_cli.py +164 -0
  9. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/work_items_service.py +118 -6
  10. pjdev_gitlab-5.1.13/test_report_pjdev-gitlab.txt +107 -0
  11. pjdev_gitlab-5.1.13/tests/tests_for_git_sync_service.py +322 -0
  12. pjdev_gitlab-5.1.13/tests/tests_for_sync_cli.py +127 -0
  13. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/tests/tests_for_work_items_service.py +188 -0
  14. pjdev_gitlab-5.1.10/test_report_pjdev-gitlab.txt +0 -74
  15. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/.gitignore +0 -0
  16. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/LICENSE.txt +0 -0
  17. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_issues/SKILL.md +0 -0
  18. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_merge_requests/SKILL.md +0 -0
  19. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_packages/SKILL.md +0 -0
  20. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/.agents/skills/pjdev_gitlab_repo_files/SKILL.md +0 -0
  21. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/__init__.py +0 -0
  22. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/api_utilities.py +0 -0
  23. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/config_service.py +0 -0
  24. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/issues_service.py +0 -0
  25. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/merge_requests_service.py +0 -0
  26. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/oauth_cli.py +0 -0
  27. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/oauth_service.py +0 -0
  28. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/packages_service.py +0 -0
  29. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/py.typed +0 -0
  30. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/src/pjdev_gitlab/repo_files_service.py +0 -0
  31. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/test.sh +0 -0
  32. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/tests/__init__.py +0 -0
  33. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/tests/conftest.py +0 -0
  34. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/tests/tests_for_api_utilities.py +0 -0
  35. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/tests/tests_for_issues_service.py +0 -0
  36. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/tests/tests_for_merge_requests_service.py +0 -0
  37. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/tests/tests_for_models.py +0 -0
  38. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/tests/tests_for_oauth_cli.py +0 -0
  39. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/tests/tests_for_oauth_service.py +0 -0
  40. {pjdev_gitlab-5.1.10 → pjdev_gitlab-5.1.13}/tests/tests_for_packages_service.py +0 -0
  41. {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.10
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.
@@ -29,6 +29,7 @@ dependencies = [
29
29
 
30
30
  [project.scripts]
31
31
  pjdev-gitlab-auth = "pjdev_gitlab.oauth_cli:main"
32
+ pjdev-gitlab-sync = "pjdev_gitlab.sync_cli:main"
32
33
 
33
34
  [project.optional-dependencies]
34
35
  dev = [
@@ -87,7 +87,7 @@ across_group = await work_items_service.search_work_items(
87
87
  )
88
88
  ```
89
89
 
90
- ## View / update status
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
@@ -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.10"
4
+ __version__ = "5.1.13"
@@ -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"] = [