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.
opcli/resolve.py ADDED
@@ -0,0 +1,183 @@
1
+ """Resolve human-friendly references to API hrefs / ids.
2
+
3
+ Agents and humans want to write ``--status "In progress"``, ``--assignee admin``
4
+ or ``--project my-project`` rather than juggle numeric ids. These helpers take a
5
+ loose reference (numeric id, identifier, login, or name) and return the concrete
6
+ resource, raising a clear :class:`NotFoundError` when nothing matches.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Any, Callable
12
+
13
+ from .client import Client
14
+ from .errors import NotFoundError, OpError
15
+ from . import hal
16
+
17
+ Json = dict[str, Any]
18
+
19
+
20
+ def _is_id(ref: str | int) -> bool:
21
+ return isinstance(ref, int) or (isinstance(ref, str) and ref.isdigit())
22
+
23
+
24
+ def _match(elements: list[Json], ref: str, fields: list[str]) -> Json | None:
25
+ ref_l = ref.strip().lower()
26
+ # exact match, honouring FIELD priority: a login match beats a name match
27
+ # even if the name-matching element appears earlier in the collection.
28
+ for f in fields:
29
+ for el in elements:
30
+ val = el.get(f)
31
+ if isinstance(val, str) and val.lower() == ref_l:
32
+ return el
33
+ # then a unique substring match across all fields
34
+ hits = [
35
+ el
36
+ for el in elements
37
+ if any(isinstance(el.get(f), str) and ref_l in el[f].lower() for f in fields)
38
+ ]
39
+ if len(hits) == 1:
40
+ return hits[0]
41
+ return None
42
+
43
+
44
+ def _resolve_collection(
45
+ client: Client,
46
+ collection: str,
47
+ ref: str | int,
48
+ fields: list[str],
49
+ *,
50
+ label: str,
51
+ params: dict | None = None,
52
+ ) -> Json:
53
+ if _is_id(ref):
54
+ return client.get(f"{collection}/{ref}")
55
+ elements = client.collect(collection, params=params, page_size=200)
56
+ el = _match(elements, str(ref), fields)
57
+ if el is None:
58
+ names = ", ".join(sorted(str(e.get(fields[0])) for e in elements)[:25])
59
+ raise NotFoundError(f"no {label} matching '{ref}'. Available: {names}")
60
+ return el
61
+
62
+
63
+ def project(client: Client, ref: str | int) -> Json:
64
+ return _resolve_collection(
65
+ client, "projects", ref, ["identifier", "name"], label="project"
66
+ )
67
+
68
+
69
+ def project_id(client: Client, ref: str | int) -> int:
70
+ return int(project(client, ref)["id"])
71
+
72
+
73
+ def status(client: Client, ref: str | int) -> str:
74
+ el = _resolve_collection(client, "statuses", ref, ["name"], label="status")
75
+ return f"/api/v3/statuses/{el['id']}"
76
+
77
+
78
+ def wp_type(client: Client, ref: str | int) -> str:
79
+ el = _resolve_collection(client, "types", ref, ["name"], label="type")
80
+ return f"/api/v3/types/{el['id']}"
81
+
82
+
83
+ def priority(client: Client, ref: str | int) -> str:
84
+ el = _resolve_collection(client, "priorities", ref, ["name"], label="priority")
85
+ return f"/api/v3/priorities/{el['id']}"
86
+
87
+
88
+ def time_activities(
89
+ client: Client, *, project_ref: str | int | None = None, wp_id: int | None = None
90
+ ) -> list[Json]:
91
+ """List time-entry activities via the time-entry form's schema.
92
+
93
+ This version of OpenProject has no ``time_entries/activities`` *collection*
94
+ endpoint; allowed activities live in the form schema (per project/WP).
95
+ """
96
+ links: dict = {}
97
+ if wp_id is not None:
98
+ links["workPackage"] = {"href": f"/api/v3/work_packages/{wp_id}"}
99
+ elif project_ref is not None:
100
+ links["project"] = {"href": f"/api/v3/projects/{project_id(client, project_ref)}"}
101
+ form = client.post("time_entries/form", json={"_links": links})
102
+ schema = (form.get("_embedded") or {}).get("schema") or {}
103
+ activity = schema.get("activity") or {}
104
+ allowed = (activity.get("_embedded") or {}).get("allowedValues") or []
105
+ out: list[Json] = []
106
+ for a in allowed:
107
+ aid = a.get("id")
108
+ href = ((a.get("_links") or {}).get("self") or {}).get("href") or f"/api/v3/time_entries/activities/{aid}"
109
+ out.append({"id": aid, "name": a.get("name"), "href": href})
110
+ return out
111
+
112
+
113
+ def time_activity(
114
+ client: Client, ref: str | int, *, project_ref: str | int | None = None, wp_id: int | None = None
115
+ ) -> str:
116
+ if _is_id(ref):
117
+ return f"/api/v3/time_entries/activities/{ref}"
118
+ acts = time_activities(client, project_ref=project_ref, wp_id=wp_id)
119
+ el = _match(acts, str(ref), ["name"])
120
+ if el is None:
121
+ names = ", ".join(str(a.get("name")) for a in acts)
122
+ raise NotFoundError(f"no time-entry activity matching '{ref}'. Available: {names}")
123
+ return el["href"]
124
+
125
+
126
+ def role(client: Client, ref: str | int) -> str:
127
+ el = _resolve_collection(client, "roles", ref, ["name"], label="role")
128
+ return f"/api/v3/roles/{el['id']}"
129
+
130
+
131
+ def version(client: Client, ref: str | int, *, project_ref: str | int | None = None) -> Json:
132
+ """Resolve a version/milestone by id or name (project-scoped when given)."""
133
+ if _is_id(ref):
134
+ return client.get(f"versions/{ref}")
135
+ coll = "versions"
136
+ if project_ref is not None:
137
+ coll = f"projects/{project_id(client, project_ref)}/versions"
138
+ elements = client.collect(coll, page_size=200)
139
+ el = _match(elements, str(ref), ["name"])
140
+ if el is None:
141
+ names = ", ".join(str(e.get("name")) for e in elements)
142
+ raise NotFoundError(f"no version matching '{ref}'. Available: {names}")
143
+ return el
144
+
145
+
146
+ def category(client: Client, ref: str | int, *, project_ref: str | int) -> Json:
147
+ if _is_id(ref):
148
+ return client.get(f"categories/{ref}")
149
+ elements = client.collect(f"projects/{project_id(client, project_ref)}/categories", page_size=200)
150
+ el = _match(elements, str(ref), ["name"])
151
+ if el is None:
152
+ raise NotFoundError(f"no category matching '{ref}'")
153
+ return el
154
+
155
+
156
+ def user(client: Client, ref: str | int, *, project_ref: str | int | None = None) -> str:
157
+ """Resolve a user/principal to its href.
158
+
159
+ ``me`` resolves to the authenticated user. When ``project_ref`` is given we
160
+ search that project's assignable principals first (so names local to the
161
+ project resolve), then fall back to the global users collection.
162
+ """
163
+ if isinstance(ref, str) and ref.strip().lower() == "me":
164
+ return client.me()["_links"]["self"]["href"]
165
+ if _is_id(ref):
166
+ return f"/api/v3/users/{ref}"
167
+
168
+ ref_s = str(ref)
169
+ candidates: list[Json] = []
170
+ if project_ref is not None:
171
+ pid = project_id(client, project_ref)
172
+ candidates = client.collect(
173
+ f"projects/{pid}/available_assignees", page_size=200
174
+ )
175
+ el = _match(candidates, ref_s, ["login", "name", "email"])
176
+ if el is not None:
177
+ return el["_links"]["self"]["href"]
178
+ # global fallback
179
+ users = client.collect("principals", page_size=200)
180
+ el = _match(users, ref_s, ["login", "name", "email"])
181
+ if el is None:
182
+ raise NotFoundError(f"no user/principal matching '{ref}'")
183
+ return el["_links"]["self"]["href"]
opcli/searchspec.py ADDED
@@ -0,0 +1,269 @@
1
+ """Search ergonomics: a curated field registry, an operator reference, human
2
+ date specs, and a ``--where`` mini-language that compiles to OpenProject filters.
3
+
4
+ The goal is to make work-package search usable *without* memorising the raw
5
+ JSON filter syntax:
6
+
7
+ * a **field registry** so ``search fields`` can tell you what you can filter on;
8
+ * an **operator reference** so ``search operators`` explains the codes;
9
+ * **date specs** like ``7d``, ``2w``, ``today``, ``+30d`` -> concrete dates;
10
+ * a **``--where "field op value"``** compiler so you can write
11
+ ``--where "status = open" --where "updated > 7d"`` instead of JSON.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import datetime as _dt
17
+ import re
18
+ from dataclasses import dataclass, field as _dc_field
19
+ from typing import Any
20
+
21
+ from . import hal, resolve
22
+ from .client import Client
23
+ from .errors import OpError
24
+
25
+
26
+ @dataclass
27
+ class Field:
28
+ key: str
29
+ label: str
30
+ kind: str # status|type|priority|user|project|version|date|datetime|int|text|bool|duration|relation
31
+ ops: list[str] = _dc_field(default_factory=list)
32
+ flag: str = ""
33
+ desc: str = ""
34
+
35
+
36
+ # The common, well-supported filters — used for discoverability and value
37
+ # resolution. The live query schema is the source of truth for *which* filters
38
+ # exist on an instance (incl. custom fields); this adds human meaning.
39
+ REGISTRY: dict[str, Field] = {
40
+ f.key: f
41
+ for f in [
42
+ Field("search", "Full text", "text", ["**"], '"<query>" (positional)', "subject + description + comments"),
43
+ Field("subject", "Subject", "text", ["~", "!~"], "--subject", "title contains text"),
44
+ Field("id", "ID", "int", ["=", "!"], "--id 1,2,3", "specific work-package ids"),
45
+ Field("status", "Status", "status", ["=", "!", "o", "c"], "--status/--open/--closed", "open, closed, or a status name"),
46
+ Field("type", "Type", "type", ["=", "!"], "--type", "Task, Bug, Feature, ..."),
47
+ Field("priority", "Priority", "priority", ["=", "!"], "--priority", "Low, Normal, High, Immediate"),
48
+ Field("assignee", "Assignee", "user", ["=", "!", "*", "!*"], "--assignee/--mine/--unassigned", "login/name/id or 'me'"),
49
+ Field("author", "Author", "user", ["=", "!"], "--author/--created-by", "who created it"),
50
+ Field("responsible", "Accountable", "user", ["=", "!"], "--responsible", "accountable person"),
51
+ Field("watcher", "Watcher", "user", ["=", "!"], "--watching", "who watches it ('me')"),
52
+ Field("project", "Project", "project", ["=", "!"], "--project", "project id/identifier"),
53
+ Field("version", "Version", "version", ["=", "!"], "--version", "target version/milestone"),
54
+ Field("parent", "Parent", "int", ["=", "!"], "--parent", "parent work-package id"),
55
+ Field("dueDate", "Due date", "date", ["<>d", "=d", "t", "w"], "--due-before/--due-after/--overdue", "deadline"),
56
+ Field("startDate", "Start date", "date", ["<>d", "=d"], "--start-after/--start-before", "start date"),
57
+ Field("createdAt", "Created", "datetime", ["<>d", "t"], "--created-since/--created-before", "creation time"),
58
+ Field("updatedAt", "Updated", "datetime", ["<>d", "t"], "--updated-since", "last change time"),
59
+ Field("percentageDone", "% done", "int", [">=", "<="], '--where "percentageDone>=50"', "progress percent"),
60
+ Field("estimatedTime", "Estimated", "duration", [">=", "<="], '--where "estimatedTime>=PT2H"', "estimated time"),
61
+ ]
62
+ }
63
+
64
+
65
+ OPERATORS: list[tuple[str, str]] = [
66
+ ("=", "equals / one of (values are OR-ed)"),
67
+ ("!", "is none of / not equal"),
68
+ ("~", "contains (text, LIKE)"),
69
+ ("!~", "does not contain"),
70
+ ("o", "open statuses (status filter, no value)"),
71
+ ("c", "closed statuses (status filter, no value)"),
72
+ ("*", "has any value / is set"),
73
+ ("!*", "has no value / is empty (e.g. unassigned)"),
74
+ (">=", "greater than or equal (numbers)"),
75
+ ("<=", "less than or equal (numbers)"),
76
+ ("=d", "on a specific date"),
77
+ ("<>d", "between two dates (values=[from,to]; '' = open bound)"),
78
+ ("t", "today (date filter, no value)"),
79
+ ("w", "this week (date filter, no value)"),
80
+ ("t-", "exactly N days ago"),
81
+ ("t+", "exactly N days ahead"),
82
+ ("<t-", "more than N days ago"),
83
+ (">t-", "within the last N days"),
84
+ ("<t+", "within the next N days"),
85
+ (">t+", "more than N days ahead"),
86
+ ]
87
+
88
+
89
+ # ------------------------------------------------------------------ dates
90
+ _REL_RE = re.compile(r"^([+-]?)(\d+)\s*([dwmy])$")
91
+
92
+
93
+ def to_date(spec: str, *, base: _dt.date | None = None) -> _dt.date:
94
+ """Human date spec -> concrete date.
95
+
96
+ ``today``/``yesterday``/``tomorrow``; ``7d``/``2w``/``1m``/``1y`` (N units
97
+ ago); ``+7d`` (N units ahead); or an ISO ``YYYY-MM-DD`` date.
98
+ """
99
+ base = base or _dt.date.today()
100
+ s = spec.strip().lower()
101
+ if s == "today":
102
+ return base
103
+ if s == "yesterday":
104
+ return base - _dt.timedelta(days=1)
105
+ if s == "tomorrow":
106
+ return base + _dt.timedelta(days=1)
107
+ m = _REL_RE.match(s)
108
+ if m:
109
+ sign = 1 if m.group(1) == "+" else -1 # default: into the past
110
+ n = int(m.group(2)) * sign
111
+ unit = m.group(3)
112
+ if unit == "d":
113
+ return base + _dt.timedelta(days=n)
114
+ if unit == "w":
115
+ return base + _dt.timedelta(weeks=n)
116
+ if unit == "m":
117
+ return _shift_months(base, n)
118
+ if unit == "y":
119
+ try:
120
+ return base.replace(year=base.year + n)
121
+ except ValueError:
122
+ return base.replace(year=base.year + n, day=28)
123
+ try:
124
+ return _dt.date.fromisoformat(spec)
125
+ except ValueError as exc:
126
+ raise OpError(
127
+ f"invalid date '{spec}' — use YYYY-MM-DD, or relative like 7d, 2w, 1m, +30d, today, yesterday"
128
+ ) from exc
129
+
130
+
131
+ def _shift_months(d: _dt.date, months: int) -> _dt.date:
132
+ m = d.month - 1 + months
133
+ year = d.year + m // 12
134
+ month = m % 12 + 1
135
+ import calendar
136
+
137
+ day = min(d.day, calendar.monthrange(year, month)[1])
138
+ return _dt.date(year, month, day)
139
+
140
+
141
+ # --------------------------------------------------------- value resolution
142
+ def resolve_value(client: Client, kind: str, raw: str, *, project_ref: Any = None) -> str:
143
+ r = raw.strip()
144
+ if kind == "user":
145
+ if r.lower() == "me":
146
+ return "me"
147
+ href = resolve.user(client, r, project_ref=project_ref)
148
+ return str(hal.id_from_href(href))
149
+ if kind == "status":
150
+ return str(resolve._resolve_collection(client, "statuses", r, ["name"], label="status")["id"])
151
+ if kind == "type":
152
+ return str(resolve._resolve_collection(client, "types", r, ["name"], label="type")["id"])
153
+ if kind == "priority":
154
+ return str(resolve._resolve_collection(client, "priorities", r, ["name"], label="priority")["id"])
155
+ if kind == "project":
156
+ return str(resolve.project_id(client, r))
157
+ if kind == "version":
158
+ return str(resolve.version(client, r, project_ref=project_ref)["id"])
159
+ if kind in ("date", "datetime"):
160
+ return to_date(r).isoformat()
161
+ return r # int/text/bool/relation/customfield -> as-is
162
+
163
+
164
+ # friendly aliases so `--where "updated > 7d"` works, not just "updatedAt"
165
+ ALIASES = {
166
+ "updated": "updatedAt",
167
+ "created": "createdAt",
168
+ "due": "dueDate",
169
+ "start": "startDate",
170
+ "done": "percentageDone",
171
+ "progress": "percentageDone",
172
+ "estimate": "estimatedTime",
173
+ "estimated": "estimatedTime",
174
+ "assigned": "assignee",
175
+ "milestone": "version",
176
+ }
177
+
178
+
179
+ def canonical_field(name: str) -> str:
180
+ return ALIASES.get(name.strip(), name.strip())
181
+
182
+
183
+ # ------------------------------------------------------------- --where lang
184
+ # order matters: match multi-char operators before single-char ones
185
+ _WHERE_OPS = ["!~", ">=", "<=", "!=", "~", "=", ">", "<"]
186
+
187
+
188
+ def parse_where(expr: str) -> tuple[str, str, list[str]]:
189
+ """``"status = open"`` / ``"updated>7d"`` / ``"assignee:none"`` ->
190
+ (field, symbol, values). ``:kw`` forms use symbols ``:open|:closed|:none|:any``."""
191
+ e = expr.strip()
192
+ # bare keyword form: field:open
193
+ m = re.match(r"^\s*([A-Za-z0-9_]+)\s*:\s*(open|closed|none|any)\s*$", e, re.I)
194
+ if m:
195
+ return m.group(1), ":" + m.group(2).lower(), []
196
+ for sym in _WHERE_OPS:
197
+ idx = e.find(sym)
198
+ if idx > 0:
199
+ field = e[:idx].strip()
200
+ value = e[idx + len(sym):].strip()
201
+ if not field:
202
+ break
203
+ values = [v.strip() for v in value.split(",") if v.strip()]
204
+ return field, sym, values
205
+ raise OpError(f"could not parse --where '{expr}' (use e.g. \"status = open\", \"updated > 7d\", \"assignee:none\")")
206
+
207
+
208
+ def compile_where(client: Client, expr: str, *, project_ref: Any = None) -> dict:
209
+ field, sym, values = parse_where(expr)
210
+ field = canonical_field(field)
211
+ fld = REGISTRY.get(field)
212
+ kind = fld.kind if fld else ("customfield" if field.startswith("customField") else "text")
213
+
214
+ # bare keyword operators
215
+ if sym == ":open":
216
+ return {field: {"operator": "o", "values": None}}
217
+ if sym == ":closed":
218
+ return {field: {"operator": "c", "values": None}}
219
+ if sym == ":none":
220
+ return {field: {"operator": "!*", "values": None}}
221
+ if sym == ":any":
222
+ return {field: {"operator": "*", "values": None}}
223
+
224
+ if not values:
225
+ raise OpError(f"--where '{expr}' needs a value")
226
+
227
+ resolved = [resolve_value(client, kind, v, project_ref=project_ref) for v in values]
228
+
229
+ # dates: >/< become open-ended ranges; = becomes on-date
230
+ if kind in ("date", "datetime"):
231
+ if sym in (">", ">="):
232
+ return {field: {"operator": "<>d", "values": [resolved[0], ""]}}
233
+ if sym in ("<", "<="):
234
+ return {field: {"operator": "<>d", "values": ["", resolved[0]]}}
235
+ if sym == "=":
236
+ return {field: {"operator": "=d", "values": [resolved[0]]}}
237
+
238
+ op_map = {"=": "=", "!=": "!", "!": "!", "~": "~", "!~": "!~", ">=": ">=", "<=": "<=", ">": ">=", "<": "<="}
239
+ op = op_map.get(sym)
240
+ if op is None:
241
+ raise OpError(f"operator '{sym}' not supported in --where")
242
+ return {field: {"operator": op, "values": resolved}}
243
+
244
+
245
+ # ---------------------------------------------------------- live field list
246
+ def live_fields(client: Client, project_ref: Any = None) -> list[dict]:
247
+ """List the filters available on this instance (from the query schema),
248
+ enriched with human descriptions from the registry."""
249
+ path = "queries/filter_instance_schemas"
250
+ if project_ref is not None:
251
+ path = f"projects/{resolve.project_id(client, project_ref)}/queries/filter_instance_schemas"
252
+ out: list[dict] = []
253
+ for el in client.collect(path, page_size=200):
254
+ href = ((el.get("_links") or {}).get("self") or {}).get("href", "")
255
+ key = href.rstrip("/").split("/")[-1]
256
+ if not key:
257
+ continue
258
+ reg = REGISTRY.get(key)
259
+ out.append(
260
+ {
261
+ "field": key,
262
+ "label": reg.label if reg else key,
263
+ "kind": reg.kind if reg else ("customField" if key.startswith("customField") else ""),
264
+ "operators": reg.ops if reg else [],
265
+ "flag": reg.flag if reg else ("--where / --filters" if not reg else ""),
266
+ "description": reg.desc if reg else "",
267
+ }
268
+ )
269
+ return out
opcli/serialize.py ADDED
@@ -0,0 +1,225 @@
1
+ """Turn verbose HAL documents into flat, agent-friendly dicts.
2
+
3
+ Every serializer returns plain JSON-able dicts with relationships resolved to
4
+ ``{id, name}`` shapes and custom fields surfaced under a ``customFields`` map.
5
+ The full HAL document is always available via ``--raw`` on the relevant
6
+ commands, so these summaries can stay focused on what's useful.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from typing import Any
13
+
14
+ from . import hal
15
+
16
+ Json = dict[str, Any]
17
+
18
+ # Only keys like customField1, customField42 are real custom fields; the bare
19
+ # "customFields" _link is the admin collection and must be ignored.
20
+ _CF_RE = re.compile(r"^customField\d+$")
21
+
22
+
23
+ def _link_ref(doc: Json, name: str) -> Json | None:
24
+ href = hal.link_href(doc, name)
25
+ if href is None:
26
+ return None
27
+ return {"id": hal.id_from_href(href), "name": hal.link_title(doc, name), "href": href}
28
+
29
+
30
+ def _formattable(value: Any) -> str | None:
31
+ if isinstance(value, dict):
32
+ return value.get("raw")
33
+ return value
34
+
35
+
36
+ def custom_fields(doc: Json) -> Json:
37
+ """Collect customFieldN values from attributes and _links into one map."""
38
+ out: Json = {}
39
+ for key, value in doc.items():
40
+ if _CF_RE.match(key):
41
+ out[key] = _formattable(value) if isinstance(value, dict) and "raw" in value else value
42
+ for key in (doc.get("_links") or {}):
43
+ if _CF_RE.match(key):
44
+ node = doc["_links"][key]
45
+ if isinstance(node, list):
46
+ out[key] = [n.get("title") for n in node]
47
+ elif isinstance(node, dict):
48
+ out[key] = node.get("title")
49
+ return out
50
+
51
+
52
+ def work_package(doc: Json, *, include_description: bool = True) -> Json:
53
+ out: Json = {
54
+ "id": doc.get("id"),
55
+ "subject": doc.get("subject"),
56
+ "type": hal.link_title(doc, "type"),
57
+ "status": hal.link_title(doc, "status"),
58
+ "priority": hal.link_title(doc, "priority"),
59
+ "project": _link_ref(doc, "project"),
60
+ "assignee": _link_ref(doc, "assignee"),
61
+ "responsible": _link_ref(doc, "responsible"),
62
+ "author": _link_ref(doc, "author"),
63
+ "parent": _link_ref(doc, "parent"),
64
+ "startDate": doc.get("startDate"),
65
+ "dueDate": doc.get("dueDate"),
66
+ "estimatedTime": doc.get("estimatedTime"),
67
+ "spentTime": doc.get("spentTime"),
68
+ "percentageDone": doc.get("percentageDone"),
69
+ "createdAt": doc.get("createdAt"),
70
+ "updatedAt": doc.get("updatedAt"),
71
+ "lockVersion": doc.get("lockVersion"),
72
+ }
73
+ if include_description:
74
+ out["description"] = _formattable(doc.get("description"))
75
+ cf = custom_fields(doc)
76
+ if cf:
77
+ out["customFields"] = cf
78
+ return out
79
+
80
+
81
+ def project(doc: Json) -> Json:
82
+ out: Json = {
83
+ "id": doc.get("id"),
84
+ "name": doc.get("name"),
85
+ "identifier": doc.get("identifier"),
86
+ "active": doc.get("active"),
87
+ "public": doc.get("public"),
88
+ "parent": _link_ref(doc, "parent"),
89
+ "status": hal.link_title(doc, "status") or hal.link_id(doc, "status"),
90
+ "description": _formattable(doc.get("description")),
91
+ "createdAt": doc.get("createdAt"),
92
+ "updatedAt": doc.get("updatedAt"),
93
+ }
94
+ cf = custom_fields(doc)
95
+ if cf:
96
+ out["customFields"] = cf
97
+ return out
98
+
99
+
100
+ def user(doc: Json) -> Json:
101
+ return {
102
+ "id": doc.get("id"),
103
+ "login": doc.get("login"),
104
+ "name": doc.get("name"),
105
+ "firstName": doc.get("firstName"),
106
+ "lastName": doc.get("lastName"),
107
+ "email": doc.get("email"),
108
+ "admin": doc.get("admin"),
109
+ "status": doc.get("status"),
110
+ "_type": doc.get("_type"),
111
+ }
112
+
113
+
114
+ def principal(doc: Json) -> Json:
115
+ """A user, group, or placeholder user (used for assignees/members)."""
116
+ return {
117
+ "id": doc.get("id"),
118
+ "name": doc.get("name"),
119
+ "login": doc.get("login"),
120
+ "email": doc.get("email"),
121
+ "type": doc.get("_type"),
122
+ }
123
+
124
+
125
+ def comment(doc: Json) -> Json:
126
+ """An activity entry; comments have a non-empty ``comment.raw``."""
127
+ return {
128
+ "id": doc.get("id"),
129
+ "comment": _formattable(doc.get("comment")),
130
+ "user": _link_ref(doc, "user"),
131
+ "workPackage": _link_ref(doc, "workPackage"),
132
+ "version": doc.get("version"),
133
+ "createdAt": doc.get("createdAt"),
134
+ "updatedAt": doc.get("updatedAt"),
135
+ "_type": doc.get("_type"),
136
+ }
137
+
138
+
139
+ def time_entry(doc: Json) -> Json:
140
+ return {
141
+ "id": doc.get("id"),
142
+ "hours": doc.get("hours"),
143
+ "spentOn": doc.get("spentOn"),
144
+ "comment": _formattable(doc.get("comment")),
145
+ "user": _link_ref(doc, "user"),
146
+ "workPackage": _link_ref(doc, "workPackage"),
147
+ "project": _link_ref(doc, "project"),
148
+ "activity": hal.link_title(doc, "activity"),
149
+ "createdAt": doc.get("createdAt"),
150
+ "updatedAt": doc.get("updatedAt"),
151
+ "customFields": custom_fields(doc) or None,
152
+ }
153
+
154
+
155
+ def membership(doc: Json) -> Json:
156
+ roles = [n.get("title") for n in hal.iter_link_list(doc, "roles")]
157
+ return {
158
+ "id": doc.get("id"),
159
+ "principal": _link_ref(doc, "principal"),
160
+ "project": _link_ref(doc, "project"),
161
+ "roles": roles,
162
+ "createdAt": doc.get("createdAt"),
163
+ }
164
+
165
+
166
+ def notification(doc: Json) -> Json:
167
+ return {
168
+ "id": doc.get("id"),
169
+ "subject": doc.get("subject"),
170
+ "reason": doc.get("reason"),
171
+ "readIAN": doc.get("readIAN"),
172
+ "resource": _link_ref(doc, "resource"),
173
+ "project": _link_ref(doc, "project"),
174
+ "actor": _link_ref(doc, "actor"),
175
+ "createdAt": doc.get("createdAt"),
176
+ "updatedAt": doc.get("updatedAt"),
177
+ }
178
+
179
+
180
+ def attachment(doc: Json) -> Json:
181
+ return {
182
+ "id": doc.get("id"),
183
+ "fileName": doc.get("fileName"),
184
+ "fileSize": doc.get("fileSize"),
185
+ "description": _formattable(doc.get("description")),
186
+ "contentType": doc.get("contentType"),
187
+ "author": _link_ref(doc, "author"),
188
+ "downloadLocation": hal.link_href(doc, "downloadLocation"),
189
+ "createdAt": doc.get("createdAt"),
190
+ }
191
+
192
+
193
+ def file_link(doc: Json) -> Json:
194
+ origin = doc.get("originData") or {}
195
+ return {
196
+ "id": doc.get("id"),
197
+ "originName": origin.get("name"),
198
+ "originId": origin.get("id"),
199
+ "mimeType": origin.get("mimeType"),
200
+ "storage": hal.link_title(doc, "storage"),
201
+ "storageUrl": hal.link_href(doc, "storage"),
202
+ "status": hal.link_title(doc, "status"),
203
+ "createdAt": doc.get("createdAt"),
204
+ }
205
+
206
+
207
+ def custom_field_schema(name: str, spec: Json) -> Json:
208
+ """Describe one field from a resource schema (used by `cf` commands)."""
209
+ out: Json = {
210
+ "key": name,
211
+ "name": spec.get("name"),
212
+ "type": spec.get("type"),
213
+ "required": spec.get("required"),
214
+ "writable": spec.get("writable"),
215
+ }
216
+ allowed = spec.get("_embedded", {}).get("allowedValues")
217
+ if isinstance(allowed, list) and allowed:
218
+ out["allowedValues"] = [
219
+ {"id": a.get("id"), "name": a.get("name") or a.get("value")} for a in allowed if isinstance(a, dict)
220
+ ]
221
+ elif "_links" in spec and isinstance(spec["_links"].get("allowedValues"), list):
222
+ out["allowedValues"] = [
223
+ {"href": a.get("href"), "name": a.get("title")} for a in spec["_links"]["allowedValues"]
224
+ ]
225
+ return out