agent-tool-openproject-cli 0.2.1__py3-none-any.whl
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.
- agent_tool_openproject_cli-0.2.1.dist-info/METADATA +457 -0
- agent_tool_openproject_cli-0.2.1.dist-info/RECORD +40 -0
- agent_tool_openproject_cli-0.2.1.dist-info/WHEEL +5 -0
- agent_tool_openproject_cli-0.2.1.dist-info/entry_points.txt +2 -0
- agent_tool_openproject_cli-0.2.1.dist-info/licenses/LICENSE +21 -0
- agent_tool_openproject_cli-0.2.1.dist-info/top_level.txt +1 -0
- opcli/__init__.py +3 -0
- opcli/__main__.py +6 -0
- opcli/cli.py +166 -0
- opcli/client.py +252 -0
- opcli/commands/__init__.py +1 -0
- opcli/commands/_shared.py +50 -0
- opcli/commands/attachments.py +104 -0
- opcli/commands/auth.py +113 -0
- opcli/commands/comments.py +90 -0
- opcli/commands/costs.py +205 -0
- opcli/commands/custom_fields.py +82 -0
- opcli/commands/filelinks.py +100 -0
- opcli/commands/guide.py +198 -0
- opcli/commands/memberships.py +110 -0
- opcli/commands/notifications.py +130 -0
- opcli/commands/projects.py +149 -0
- opcli/commands/raw.py +83 -0
- opcli/commands/search.py +256 -0
- opcli/commands/settings.py +59 -0
- opcli/commands/time_entries.py +214 -0
- opcli/commands/users.py +109 -0
- opcli/commands/wiki.py +49 -0
- opcli/commands/workpackages.py +338 -0
- opcli/config.py +134 -0
- opcli/context.py +117 -0
- opcli/credentials.py +139 -0
- opcli/duration.py +56 -0
- opcli/errors.py +81 -0
- opcli/hal.py +75 -0
- opcli/output.py +225 -0
- opcli/resolve.py +183 -0
- opcli/searchspec.py +269 -0
- opcli/serialize.py +225 -0
- opcli/wpfilters.py +159 -0
opcli/cli.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Top-level Typer application wiring together every command group."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from .context import AppContext
|
|
12
|
+
from .errors import OpError
|
|
13
|
+
from .output import OutputFormat, print_error
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _version_callback(value: bool) -> None:
|
|
17
|
+
if value:
|
|
18
|
+
typer.echo(__version__)
|
|
19
|
+
raise typer.Exit()
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(
|
|
22
|
+
name="openproject",
|
|
23
|
+
help=(
|
|
24
|
+
"Agent-friendly CLI for OpenProject (work packages, projects, time, search, invoicing).\n\n"
|
|
25
|
+
"Output is JSON on stdout by default (errors are JSON on stderr with a non-zero exit code); "
|
|
26
|
+
"add `-o table` or `-o markdown`, or trim with `--fields id,subject`. Pass names not ids "
|
|
27
|
+
"(`--assignee jane.doe`, `--status \"In progress\"`, `me`).\n\n"
|
|
28
|
+
"New here / no context? Run `openproject guide` for the full playbook, or "
|
|
29
|
+
"`openproject search fields` to discover what you can filter on."
|
|
30
|
+
),
|
|
31
|
+
epilog=(
|
|
32
|
+
"Learn more: `openproject guide` · `openproject guide <topic>` · "
|
|
33
|
+
"`openproject <group> --help` · discover filters with `openproject search fields`."
|
|
34
|
+
),
|
|
35
|
+
no_args_is_help=True,
|
|
36
|
+
add_completion=False,
|
|
37
|
+
pretty_exceptions_show_locals=False,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Remembered so the central error handler in main() can render errors in the
|
|
41
|
+
# same format the user selected, even for failures raised before a command runs.
|
|
42
|
+
_ERROR_FORMAT = OutputFormat.json
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.callback()
|
|
46
|
+
def _root(
|
|
47
|
+
ctx: typer.Context,
|
|
48
|
+
output: OutputFormat = typer.Option(
|
|
49
|
+
None, "--output", "-o",
|
|
50
|
+
help="Output format: json (default), table, or markdown. Also available as --format/-f anywhere on the line.",
|
|
51
|
+
),
|
|
52
|
+
fields: str = typer.Option(
|
|
53
|
+
None, "--fields", "--columns",
|
|
54
|
+
help="Comma-separated fields to return/show, e.g. 'id,subject,status,assignee.name'. Works anywhere on the line.",
|
|
55
|
+
),
|
|
56
|
+
profile: str = typer.Option(
|
|
57
|
+
None, "--profile", "-p", help="Configuration profile (overrides the active one)."
|
|
58
|
+
),
|
|
59
|
+
no_color: bool = typer.Option(False, "--no-color", help="Disable coloured output."),
|
|
60
|
+
version: bool = typer.Option(
|
|
61
|
+
None, "--version", "-V", callback=_version_callback, is_eager=True, help="Show version and exit."
|
|
62
|
+
),
|
|
63
|
+
) -> None:
|
|
64
|
+
if profile:
|
|
65
|
+
os.environ["OPCLI_PROFILE"] = profile
|
|
66
|
+
# Don't prompt for a default format for meta commands (settings/guide).
|
|
67
|
+
interactive = ctx.invoked_subcommand not in ("settings", "guide") and sys.stdin.isatty() and sys.stdout.isatty()
|
|
68
|
+
ctx.obj = AppContext(output=output, color=not no_color, interactive=interactive)
|
|
69
|
+
global _ERROR_FORMAT
|
|
70
|
+
_ERROR_FORMAT = ctx.obj.emitter.fmt
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
_FORMAT_FLAGS = ("--format", "-f", "--output", "-o")
|
|
74
|
+
_FIELDS_FLAGS = ("--fields", "--columns")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _pop_globals(argv: list[str]) -> tuple[str | None, str | None, list[str]]:
|
|
78
|
+
"""Extract the output-format and --fields flags (with values) from anywhere on
|
|
79
|
+
the command line, so they work *after* a subcommand too — e.g.
|
|
80
|
+
``openproject wp list -f markdown --fields id,subject``. Honours ``--``."""
|
|
81
|
+
out: list[str] = []
|
|
82
|
+
fmt: str | None = None
|
|
83
|
+
fields: str | None = None
|
|
84
|
+
i, stop = 0, False
|
|
85
|
+
|
|
86
|
+
def take_value(idx: int) -> tuple[str | None, int]:
|
|
87
|
+
return (argv[idx + 1], idx + 1) if idx + 1 < len(argv) else (None, idx)
|
|
88
|
+
|
|
89
|
+
while i < len(argv):
|
|
90
|
+
a = argv[i]
|
|
91
|
+
if not stop and a == "--":
|
|
92
|
+
stop = True
|
|
93
|
+
out.append(a)
|
|
94
|
+
elif not stop and a in _FORMAT_FLAGS:
|
|
95
|
+
fmt, i = take_value(i)
|
|
96
|
+
elif not stop and a in _FIELDS_FLAGS:
|
|
97
|
+
fields, i = take_value(i)
|
|
98
|
+
elif not stop and (a.startswith("--format=") or a.startswith("--output=") or a.startswith("-f=") or a.startswith("-o=")):
|
|
99
|
+
fmt = a.split("=", 1)[1]
|
|
100
|
+
elif not stop and (a.startswith("--fields=") or a.startswith("--columns=")):
|
|
101
|
+
fields = a.split("=", 1)[1]
|
|
102
|
+
else:
|
|
103
|
+
out.append(a)
|
|
104
|
+
i += 1
|
|
105
|
+
return fmt, fields, out
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---- command groups (registered below to avoid circular imports) ----
|
|
109
|
+
from .commands import ( # noqa: E402
|
|
110
|
+
attachments,
|
|
111
|
+
auth,
|
|
112
|
+
comments,
|
|
113
|
+
costs,
|
|
114
|
+
custom_fields,
|
|
115
|
+
filelinks,
|
|
116
|
+
guide,
|
|
117
|
+
memberships,
|
|
118
|
+
notifications,
|
|
119
|
+
projects,
|
|
120
|
+
raw,
|
|
121
|
+
search,
|
|
122
|
+
settings,
|
|
123
|
+
time_entries,
|
|
124
|
+
users,
|
|
125
|
+
wiki,
|
|
126
|
+
workpackages,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
app.command("guide", help="Built-in operating guide — how to use this CLI without external docs.")(guide.guide)
|
|
130
|
+
|
|
131
|
+
app.add_typer(auth.app, name="auth", help="Log in, log out, inspect credentials.")
|
|
132
|
+
app.add_typer(projects.app, name="project", help="Create, list, archive projects.")
|
|
133
|
+
app.add_typer(workpackages.app, name="wp", help="Work packages: CRUD, move, assign, watch.")
|
|
134
|
+
app.add_typer(search.app, name="search", help="Powerful work-package (and global) search.")
|
|
135
|
+
app.add_typer(comments.app, name="comment", help="Add, edit, list work-package comments.")
|
|
136
|
+
app.add_typer(time_entries.app, name="time", help="Log, edit, list time entries + reports.")
|
|
137
|
+
app.add_typer(users.app, name="user", help="Users, groups, memberships, assignable people.")
|
|
138
|
+
app.add_typer(memberships.app, name="member", help="Project memberships & roles.")
|
|
139
|
+
app.add_typer(custom_fields.app, name="cf", help="Inspect custom fields / resource schemas.")
|
|
140
|
+
app.add_typer(notifications.app, name="notify", help="In-app notifications.")
|
|
141
|
+
app.add_typer(wiki.app, name="wiki", help="Wiki pages (read + write where supported).")
|
|
142
|
+
app.add_typer(attachments.app, name="attach", help="Attachments / file uploads on work packages.")
|
|
143
|
+
app.add_typer(filelinks.app, name="filelink", help="Nextcloud/file-storage links on work packages.")
|
|
144
|
+
app.add_typer(costs.app, name="cost", help="Time & cost reporting per person/project (invoicing).")
|
|
145
|
+
app.add_typer(raw.app, name="raw", help="Escape hatch: call any API endpoint directly.")
|
|
146
|
+
app.add_typer(settings.app, name="settings", help="View & change CLI settings (default output format).")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def main() -> None:
|
|
150
|
+
fmt, fields, argv = _pop_globals(sys.argv[1:])
|
|
151
|
+
if fmt is not None:
|
|
152
|
+
os.environ["OPCLI_CLI_FORMAT"] = fmt
|
|
153
|
+
if fields is not None:
|
|
154
|
+
os.environ["OPCLI_CLI_FIELDS"] = fields
|
|
155
|
+
try:
|
|
156
|
+
app(args=argv)
|
|
157
|
+
except OpError as exc:
|
|
158
|
+
print_error(exc, _ERROR_FORMAT)
|
|
159
|
+
sys.exit(exc.exit_code)
|
|
160
|
+
except KeyboardInterrupt: # pragma: no cover
|
|
161
|
+
print_error(OpError("interrupted"), _ERROR_FORMAT)
|
|
162
|
+
sys.exit(130)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
if __name__ == "__main__": # pragma: no cover
|
|
166
|
+
main()
|
opcli/client.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Thin, robust HTTP client for the OpenProject API v3 (HAL+JSON).
|
|
2
|
+
|
|
3
|
+
Responsibilities:
|
|
4
|
+
|
|
5
|
+
* Basic-auth with ``apikey:<token>`` (the standard OpenProject API-key scheme).
|
|
6
|
+
* Normalising paths so callers may pass ``"work_packages"``,
|
|
7
|
+
``"/api/v3/work_packages/1"`` or a full URL interchangeably (following the
|
|
8
|
+
``href`` values the API returns "just works").
|
|
9
|
+
* Turning HAL error bodies into typed :class:`opcli.errors.OpError` subclasses.
|
|
10
|
+
* Auto-pagination of collection endpoints.
|
|
11
|
+
* Optimistic-locking updates (fetch ``lockVersion`` → PATCH → retry on 409).
|
|
12
|
+
* JSON *and* multipart requests (the latter for attachment uploads).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json as jsonlib
|
|
18
|
+
from typing import Any, Callable, Iterator
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from . import __version__, hal
|
|
23
|
+
from .errors import (
|
|
24
|
+
ApiError,
|
|
25
|
+
AuthError,
|
|
26
|
+
ConflictError,
|
|
27
|
+
NotFoundError,
|
|
28
|
+
OpError,
|
|
29
|
+
ValidationError,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
Json = dict[str, Any]
|
|
33
|
+
|
|
34
|
+
USER_AGENT = f"agent-tool-openproject-cli/{__version__}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Client:
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
base_url: str,
|
|
41
|
+
token: str,
|
|
42
|
+
*,
|
|
43
|
+
verify_ssl: bool = True,
|
|
44
|
+
timeout: float = 60.0,
|
|
45
|
+
):
|
|
46
|
+
self.base = base_url.rstrip("/")
|
|
47
|
+
self.api_root = self.base + "/api/v3"
|
|
48
|
+
self._http = httpx.Client(
|
|
49
|
+
auth=httpx.BasicAuth("apikey", token),
|
|
50
|
+
verify=verify_ssl,
|
|
51
|
+
timeout=timeout,
|
|
52
|
+
headers={"User-Agent": USER_AGENT, "Accept": "application/json"},
|
|
53
|
+
follow_redirects=True,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
# ---- lifecycle ---------------------------------------------------
|
|
57
|
+
def close(self) -> None:
|
|
58
|
+
self._http.close()
|
|
59
|
+
|
|
60
|
+
def __enter__(self) -> "Client":
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def __exit__(self, *exc: Any) -> None:
|
|
64
|
+
self.close()
|
|
65
|
+
|
|
66
|
+
# ---- url handling ------------------------------------------------
|
|
67
|
+
def url(self, path: str) -> str:
|
|
68
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
69
|
+
return path
|
|
70
|
+
if path.startswith("/"):
|
|
71
|
+
return self.base + path
|
|
72
|
+
return self.api_root + "/" + path
|
|
73
|
+
|
|
74
|
+
# ---- low-level request -------------------------------------------
|
|
75
|
+
def request(
|
|
76
|
+
self,
|
|
77
|
+
method: str,
|
|
78
|
+
path: str,
|
|
79
|
+
*,
|
|
80
|
+
params: dict[str, Any] | None = None,
|
|
81
|
+
json: Any = None,
|
|
82
|
+
data: dict[str, Any] | None = None,
|
|
83
|
+
files: Any = None,
|
|
84
|
+
headers: dict[str, str] | None = None,
|
|
85
|
+
parse: bool = True,
|
|
86
|
+
) -> Any:
|
|
87
|
+
url = self.url(path)
|
|
88
|
+
clean_params = {k: v for k, v in (params or {}).items() if v is not None}
|
|
89
|
+
# OpenProject rejects body-less writes with 406 unless a JSON content-type
|
|
90
|
+
# is present. Set it for JSON writes (but never for multipart uploads,
|
|
91
|
+
# where httpx must supply the multipart boundary itself).
|
|
92
|
+
eff_headers = dict(headers or {})
|
|
93
|
+
if files is None and method.upper() in ("POST", "PATCH", "PUT"):
|
|
94
|
+
eff_headers.setdefault("Content-Type", "application/json")
|
|
95
|
+
try:
|
|
96
|
+
resp = self._http.request(
|
|
97
|
+
method,
|
|
98
|
+
url,
|
|
99
|
+
params=clean_params or None,
|
|
100
|
+
json=json,
|
|
101
|
+
data=data,
|
|
102
|
+
files=files,
|
|
103
|
+
headers=eff_headers or None,
|
|
104
|
+
)
|
|
105
|
+
except httpx.ConnectError as exc:
|
|
106
|
+
raise ApiError(f"cannot connect to {self.base}: {exc}") from exc
|
|
107
|
+
except httpx.HTTPError as exc:
|
|
108
|
+
raise ApiError(f"request to {url} failed: {exc}") from exc
|
|
109
|
+
|
|
110
|
+
if resp.status_code >= 400:
|
|
111
|
+
self._raise_for_error(resp)
|
|
112
|
+
|
|
113
|
+
if not parse:
|
|
114
|
+
return resp
|
|
115
|
+
if resp.status_code == 204 or not resp.content:
|
|
116
|
+
return {}
|
|
117
|
+
ctype = resp.headers.get("content-type", "")
|
|
118
|
+
if "json" in ctype:
|
|
119
|
+
return resp.json()
|
|
120
|
+
return resp.content
|
|
121
|
+
|
|
122
|
+
# ---- error mapping -----------------------------------------------
|
|
123
|
+
def _raise_for_error(self, resp: httpx.Response) -> None:
|
|
124
|
+
status = resp.status_code
|
|
125
|
+
payload: Any = None
|
|
126
|
+
message = f"HTTP {status}"
|
|
127
|
+
field_errors = None
|
|
128
|
+
try:
|
|
129
|
+
payload = resp.json()
|
|
130
|
+
except Exception:
|
|
131
|
+
payload = resp.text or None
|
|
132
|
+
|
|
133
|
+
if isinstance(payload, dict) and payload.get("_type") == "Error":
|
|
134
|
+
message = payload.get("message", message)
|
|
135
|
+
embedded = (payload.get("_embedded") or {}).get("errors")
|
|
136
|
+
if isinstance(embedded, list) and embedded:
|
|
137
|
+
field_errors = [e.get("message") for e in embedded if isinstance(e, dict)]
|
|
138
|
+
message = "; ".join(m for m in field_errors if m) or message
|
|
139
|
+
|
|
140
|
+
if status in (401,):
|
|
141
|
+
raise AuthError(
|
|
142
|
+
message if message != f"HTTP {status}" else "authentication failed — check your API token",
|
|
143
|
+
detail=payload,
|
|
144
|
+
)
|
|
145
|
+
if status in (403,):
|
|
146
|
+
raise AuthError(
|
|
147
|
+
message if message != f"HTTP {status}" else "forbidden — the API user lacks permission",
|
|
148
|
+
detail=payload,
|
|
149
|
+
)
|
|
150
|
+
if status == 404:
|
|
151
|
+
raise NotFoundError(message if message != f"HTTP {status}" else "resource not found", detail=payload)
|
|
152
|
+
if status == 409:
|
|
153
|
+
raise ConflictError(message, detail=payload)
|
|
154
|
+
if status == 422:
|
|
155
|
+
raise ValidationError(message, detail=payload, field_errors=field_errors)
|
|
156
|
+
raise ApiError(message, status=status, detail=payload)
|
|
157
|
+
|
|
158
|
+
# ---- verb helpers ------------------------------------------------
|
|
159
|
+
def get(self, path: str, *, params: dict[str, Any] | None = None) -> Json:
|
|
160
|
+
return self.request("GET", path, params=params)
|
|
161
|
+
|
|
162
|
+
def post(self, path: str, *, json: Any = None, params: dict[str, Any] | None = None) -> Json:
|
|
163
|
+
return self.request("POST", path, json=json, params=params)
|
|
164
|
+
|
|
165
|
+
def patch(self, path: str, *, json: Any = None, params: dict[str, Any] | None = None) -> Json:
|
|
166
|
+
return self.request("PATCH", path, json=json, params=params)
|
|
167
|
+
|
|
168
|
+
def delete(self, path: str, *, params: dict[str, Any] | None = None) -> Json:
|
|
169
|
+
return self.request("DELETE", path, params=params)
|
|
170
|
+
|
|
171
|
+
# ---- collections / pagination ------------------------------------
|
|
172
|
+
def paginate(
|
|
173
|
+
self,
|
|
174
|
+
path: str,
|
|
175
|
+
*,
|
|
176
|
+
params: dict[str, Any] | None = None,
|
|
177
|
+
page_size: int = 100,
|
|
178
|
+
limit: int | None = None,
|
|
179
|
+
) -> Iterator[Json]:
|
|
180
|
+
"""Yield every element of a collection endpoint, following pages.
|
|
181
|
+
|
|
182
|
+
OpenProject paginates with 1-based ``offset`` (page number) and
|
|
183
|
+
``pageSize``. We stop when we've seen ``total`` items, an empty page,
|
|
184
|
+
or reached ``limit``.
|
|
185
|
+
|
|
186
|
+
Note: the server may cap ``pageSize`` below what we ask for, so we must
|
|
187
|
+
NOT stop merely because a page came back shorter than requested — we
|
|
188
|
+
rely on the authoritative ``total`` and on an empty page instead.
|
|
189
|
+
"""
|
|
190
|
+
offset = 1
|
|
191
|
+
seen = 0
|
|
192
|
+
# guard against a server that never advances (pathological) — bounded by total
|
|
193
|
+
while True:
|
|
194
|
+
page_params = dict(params or {})
|
|
195
|
+
page_params["offset"] = offset
|
|
196
|
+
page_params["pageSize"] = page_size
|
|
197
|
+
doc = self.get(path, params=page_params)
|
|
198
|
+
batch = hal.elements(doc)
|
|
199
|
+
if not batch:
|
|
200
|
+
break
|
|
201
|
+
for el in batch:
|
|
202
|
+
yield el
|
|
203
|
+
seen += 1
|
|
204
|
+
if limit is not None and seen >= limit:
|
|
205
|
+
return
|
|
206
|
+
if seen >= hal.total(doc):
|
|
207
|
+
break
|
|
208
|
+
offset += 1
|
|
209
|
+
|
|
210
|
+
def collect(
|
|
211
|
+
self,
|
|
212
|
+
path: str,
|
|
213
|
+
*,
|
|
214
|
+
params: dict[str, Any] | None = None,
|
|
215
|
+
page_size: int = 100,
|
|
216
|
+
limit: int | None = None,
|
|
217
|
+
) -> list[Json]:
|
|
218
|
+
return list(self.paginate(path, params=params, page_size=page_size, limit=limit))
|
|
219
|
+
|
|
220
|
+
# ---- optimistic-locking update -----------------------------------
|
|
221
|
+
def update_locked(
|
|
222
|
+
self,
|
|
223
|
+
path: str,
|
|
224
|
+
patch: Json | Callable[[Json], Json],
|
|
225
|
+
*,
|
|
226
|
+
params: dict[str, Any] | None = None,
|
|
227
|
+
retries: int = 2,
|
|
228
|
+
) -> Json:
|
|
229
|
+
"""PATCH a resource, supplying the current ``lockVersion`` automatically.
|
|
230
|
+
|
|
231
|
+
``patch`` may be a dict, or a callable receiving the freshly-fetched
|
|
232
|
+
resource and returning the patch body (useful when the new value
|
|
233
|
+
depends on the old one). Retries on 409 conflicts.
|
|
234
|
+
"""
|
|
235
|
+
last_exc: OpError | None = None
|
|
236
|
+
for _ in range(retries + 1):
|
|
237
|
+
current = self.get(path)
|
|
238
|
+
body = patch(current) if callable(patch) else jsonlib.loads(jsonlib.dumps(patch))
|
|
239
|
+
lv = current.get("lockVersion")
|
|
240
|
+
if lv is not None:
|
|
241
|
+
body.setdefault("lockVersion", lv)
|
|
242
|
+
try:
|
|
243
|
+
return self.patch(path, json=body, params=params)
|
|
244
|
+
except ConflictError as exc:
|
|
245
|
+
last_exc = exc
|
|
246
|
+
continue
|
|
247
|
+
assert last_exc is not None
|
|
248
|
+
raise last_exc
|
|
249
|
+
|
|
250
|
+
# ---- convenience -------------------------------------------------
|
|
251
|
+
def me(self) -> Json:
|
|
252
|
+
return self.get("users/me")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command groups for the OpenProject CLI."""
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Helpers shared across command modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from ..context import AppContext
|
|
11
|
+
from ..errors import OpError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def ctx_obj(ctx: typer.Context) -> AppContext:
|
|
15
|
+
return ctx.obj
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_json_option(value: str | None, *, what: str = "value") -> Any:
|
|
19
|
+
"""Parse a JSON string passed on the command line (``--fields '{...}'``)."""
|
|
20
|
+
if value is None:
|
|
21
|
+
return None
|
|
22
|
+
try:
|
|
23
|
+
return json.loads(value)
|
|
24
|
+
except json.JSONDecodeError as exc:
|
|
25
|
+
raise OpError(f"invalid JSON for {what}: {exc}") from exc
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def set_link(body: dict, name: str, href: str | None) -> None:
|
|
29
|
+
body.setdefault("_links", {})[name] = {"href": href}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def apply_custom_fields(body: dict, raw: str | None) -> None:
|
|
33
|
+
"""Merge a ``--custom-fields`` JSON object into a write body.
|
|
34
|
+
|
|
35
|
+
Scalars land at the top level; values shaped like ``{"href": ...}`` (or a
|
|
36
|
+
list of them) go under ``_links`` — matching OpenProject's split between
|
|
37
|
+
value and reference custom fields.
|
|
38
|
+
"""
|
|
39
|
+
if not raw:
|
|
40
|
+
return
|
|
41
|
+
data = parse_json_option(raw, what="--custom-fields")
|
|
42
|
+
if not isinstance(data, dict):
|
|
43
|
+
raise OpError("--custom-fields must be a JSON object keyed by customFieldN")
|
|
44
|
+
for key, val in data.items():
|
|
45
|
+
if isinstance(val, dict) and set(val.keys()) <= {"href"}:
|
|
46
|
+
set_link(body, key, val.get("href"))
|
|
47
|
+
elif isinstance(val, list) and all(isinstance(v, dict) and "href" in v for v in val):
|
|
48
|
+
body.setdefault("_links", {})[key] = val
|
|
49
|
+
else:
|
|
50
|
+
body[key] = val
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Attachment commands: list, upload, download, get, delete.
|
|
2
|
+
|
|
3
|
+
Upload is multipart/form-data with two parts: a JSON ``metadata`` part
|
|
4
|
+
(``fileName`` + optional ``description``) and the raw ``file`` part. Downloads
|
|
5
|
+
follow the attachment's ``downloadLocation`` link (which may redirect to remote
|
|
6
|
+
storage).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import mimetypes
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from .. import hal, serialize
|
|
18
|
+
from ..errors import OpError
|
|
19
|
+
from ._shared import ctx_obj
|
|
20
|
+
|
|
21
|
+
app = typer.Typer(no_args_is_help=True)
|
|
22
|
+
|
|
23
|
+
_COLUMNS = [
|
|
24
|
+
("ID", "id"),
|
|
25
|
+
("File", "fileName"),
|
|
26
|
+
("Size", "fileSize"),
|
|
27
|
+
("Type", "contentType"),
|
|
28
|
+
("Author", lambda r: (r.get("author") or {}).get("name")),
|
|
29
|
+
("When", "createdAt"),
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.command("list")
|
|
34
|
+
def list_attachments(
|
|
35
|
+
ctx: typer.Context,
|
|
36
|
+
wp_id: int = typer.Argument(..., help="Work package id."),
|
|
37
|
+
) -> None:
|
|
38
|
+
"""List attachments on a work package."""
|
|
39
|
+
obj = ctx_obj(ctx)
|
|
40
|
+
rows = [serialize.attachment(a) for a in obj.client().collect(f"work_packages/{wp_id}/attachments")]
|
|
41
|
+
obj.emitter.emit(rows, columns=_COLUMNS, empty="(no attachments)")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.command()
|
|
45
|
+
def upload(
|
|
46
|
+
ctx: typer.Context,
|
|
47
|
+
wp_id: int = typer.Argument(..., help="Work package id."),
|
|
48
|
+
path: Path = typer.Argument(..., help="Local file to upload."),
|
|
49
|
+
name: str = typer.Option(None, "--name", help="Override the stored file name."),
|
|
50
|
+
description: str = typer.Option(None, "--description", "-d", help="Attachment description."),
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Upload a file and attach it to a work package."""
|
|
53
|
+
obj = ctx_obj(ctx)
|
|
54
|
+
if not path.exists() or not path.is_file():
|
|
55
|
+
raise OpError(f"file not found: {path}")
|
|
56
|
+
file_name = name or path.name
|
|
57
|
+
metadata = {"fileName": file_name}
|
|
58
|
+
if description is not None:
|
|
59
|
+
metadata["description"] = description
|
|
60
|
+
content_type = mimetypes.guess_type(file_name)[0] or "application/octet-stream"
|
|
61
|
+
files = {
|
|
62
|
+
"metadata": (None, json.dumps(metadata), "application/json"),
|
|
63
|
+
"file": (file_name, path.read_bytes(), content_type),
|
|
64
|
+
}
|
|
65
|
+
doc = obj.client().request("POST", f"work_packages/{wp_id}/attachments", files=files)
|
|
66
|
+
obj.emitter.emit(serialize.attachment(doc))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command()
|
|
70
|
+
def get(ctx: typer.Context, attachment_id: int = typer.Argument(..., help="Attachment id.")) -> None:
|
|
71
|
+
"""Show attachment metadata."""
|
|
72
|
+
obj = ctx_obj(ctx)
|
|
73
|
+
obj.emitter.emit(serialize.attachment(obj.client().get(f"attachments/{attachment_id}")))
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@app.command()
|
|
77
|
+
def download(
|
|
78
|
+
ctx: typer.Context,
|
|
79
|
+
attachment_id: int = typer.Argument(..., help="Attachment id."),
|
|
80
|
+
output: Path = typer.Option(None, "--output", "-O", help="Output path (default: original file name)."),
|
|
81
|
+
) -> None:
|
|
82
|
+
"""Download an attachment's content."""
|
|
83
|
+
obj = ctx_obj(ctx)
|
|
84
|
+
client = obj.client()
|
|
85
|
+
meta = client.get(f"attachments/{attachment_id}")
|
|
86
|
+
href = hal.link_href(meta, "downloadLocation") or f"/api/v3/attachments/{attachment_id}/content"
|
|
87
|
+
resp = client.request("GET", href, parse=False)
|
|
88
|
+
dest = output or Path(meta.get("fileName") or f"attachment-{attachment_id}")
|
|
89
|
+
dest.write_bytes(resp.content)
|
|
90
|
+
obj.emitter.emit({"status": "downloaded", "attachment": attachment_id, "path": str(dest), "bytes": len(resp.content)})
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@app.command()
|
|
94
|
+
def delete(
|
|
95
|
+
ctx: typer.Context,
|
|
96
|
+
attachment_id: int = typer.Argument(..., help="Attachment id."),
|
|
97
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation."),
|
|
98
|
+
) -> None:
|
|
99
|
+
"""Delete an attachment."""
|
|
100
|
+
obj = ctx_obj(ctx)
|
|
101
|
+
if not yes:
|
|
102
|
+
typer.confirm(f"Delete attachment {attachment_id}?", abort=True)
|
|
103
|
+
obj.client().delete(f"attachments/{attachment_id}")
|
|
104
|
+
obj.emitter.emit({"status": "deleted", "attachment": attachment_id})
|
opcli/commands/auth.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Authentication commands: login, logout, status, whoami."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
|
|
9
|
+
from .. import credentials, serialize
|
|
10
|
+
from ..client import Client
|
|
11
|
+
from ..config import Profile
|
|
12
|
+
from ..errors import AuthError, OpError
|
|
13
|
+
from ._shared import ctx_obj
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(no_args_is_help=True)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.command()
|
|
19
|
+
def login(
|
|
20
|
+
ctx: typer.Context,
|
|
21
|
+
url: str = typer.Option(None, "--url", "-u", help="Base URL, e.g. https://op.example.com"),
|
|
22
|
+
token: str = typer.Option(None, "--token", "-t", help="API token (prompted if omitted)."),
|
|
23
|
+
name: str = typer.Option(None, "--name", help="Profile name to store under."),
|
|
24
|
+
username: str = typer.Option(None, "--username", help="Informational: the API user's login."),
|
|
25
|
+
no_verify_ssl: bool = typer.Option(False, "--no-verify-ssl", help="Skip TLS verification."),
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Verify and store an API token securely (OS keyring by default).
|
|
28
|
+
|
|
29
|
+
Non-interactive example:
|
|
30
|
+
openproject auth login --url http://localhost:8090 --token opapi-xxxx
|
|
31
|
+
"""
|
|
32
|
+
obj = ctx_obj(ctx)
|
|
33
|
+
profile_name = name or os.environ.get("OPCLI_PROFILE") or obj.config.current_profile or "default"
|
|
34
|
+
|
|
35
|
+
base_url = url or os.environ.get("OPCLI_BASE_URL")
|
|
36
|
+
if not base_url:
|
|
37
|
+
base_url = typer.prompt("OpenProject base URL")
|
|
38
|
+
if not token:
|
|
39
|
+
token = os.environ.get("OPCLI_TOKEN") or typer.prompt("API token", hide_input=True)
|
|
40
|
+
|
|
41
|
+
# Verify before persisting anything.
|
|
42
|
+
with Client(base_url, token, verify_ssl=not no_verify_ssl) as client:
|
|
43
|
+
try:
|
|
44
|
+
me = client.me()
|
|
45
|
+
except AuthError as exc:
|
|
46
|
+
raise AuthError(f"login failed: {exc.message}") from exc
|
|
47
|
+
|
|
48
|
+
prof = Profile(
|
|
49
|
+
name=profile_name,
|
|
50
|
+
base_url=base_url,
|
|
51
|
+
username=username or me.get("login"),
|
|
52
|
+
verify_ssl=not no_verify_ssl,
|
|
53
|
+
)
|
|
54
|
+
obj.config.upsert_profile(prof, make_current=True)
|
|
55
|
+
obj.config.save()
|
|
56
|
+
backend = credentials.store_token(profile_name, token)
|
|
57
|
+
|
|
58
|
+
obj.emitter.emit(
|
|
59
|
+
{
|
|
60
|
+
"status": "logged in",
|
|
61
|
+
"profile": profile_name,
|
|
62
|
+
"baseUrl": base_url,
|
|
63
|
+
"user": serialize.user(me),
|
|
64
|
+
"tokenStoredIn": backend,
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command()
|
|
70
|
+
def status(ctx: typer.Context) -> None:
|
|
71
|
+
"""Show the active profile, credential backend, and connectivity."""
|
|
72
|
+
obj = ctx_obj(ctx)
|
|
73
|
+
try:
|
|
74
|
+
prof = obj.profile()
|
|
75
|
+
except OpError as exc:
|
|
76
|
+
obj.emitter.emit({"configured": False, "reason": exc.message})
|
|
77
|
+
return
|
|
78
|
+
|
|
79
|
+
info = {
|
|
80
|
+
"profile": prof.name,
|
|
81
|
+
"baseUrl": prof.base_url,
|
|
82
|
+
"username": prof.username,
|
|
83
|
+
"verifySsl": prof.verify_ssl,
|
|
84
|
+
"credentialBackend": credentials.backend_name(),
|
|
85
|
+
"hasToken": bool(obj.token()),
|
|
86
|
+
}
|
|
87
|
+
if obj.token():
|
|
88
|
+
try:
|
|
89
|
+
info["reachable"] = True
|
|
90
|
+
info["me"] = serialize.user(obj.client().me())
|
|
91
|
+
except OpError as exc:
|
|
92
|
+
info["reachable"] = False
|
|
93
|
+
info["error"] = exc.message
|
|
94
|
+
obj.emitter.emit(info)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.command()
|
|
98
|
+
def whoami(ctx: typer.Context) -> None:
|
|
99
|
+
"""Print the authenticated user (GET /users/me)."""
|
|
100
|
+
obj = ctx_obj(ctx)
|
|
101
|
+
obj.emitter.emit(serialize.user(obj.client().me()))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@app.command()
|
|
105
|
+
def logout(
|
|
106
|
+
ctx: typer.Context,
|
|
107
|
+
name: str = typer.Option(None, "--name", help="Profile to log out (defaults to active)."),
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Remove the stored API token for a profile."""
|
|
110
|
+
obj = ctx_obj(ctx)
|
|
111
|
+
profile_name = name or obj.config.active_profile_name()
|
|
112
|
+
credentials.delete_token(profile_name)
|
|
113
|
+
obj.emitter.emit({"status": "logged out", "profile": profile_name})
|