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/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
|