hoja-cli 0.21.4__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.
- VERSION +1 -0
- google_workspace/__init__.py +19 -0
- google_workspace/client.py +253 -0
- google_workspace/config.py +39 -0
- google_workspace/errors.py +19 -0
- google_workspace/mcp_server.py +100 -0
- google_workspace/oauth.py +131 -0
- google_workspace/principal.py +83 -0
- google_workspace/py.typed +0 -0
- google_workspace/tools.py +64 -0
- google_workspace/writes.py +44 -0
- hoja/__init__.py +29 -0
- hoja/__main__.py +6 -0
- hoja/auth.py +42 -0
- hoja/cli.py +161 -0
- hoja/cli_state.py +55 -0
- hoja/client.py +268 -0
- hoja/commands/__init__.py +4 -0
- hoja/commands/auth_cmd.py +271 -0
- hoja/commands/comment.py +149 -0
- hoja/commands/cycle.py +75 -0
- hoja/commands/init.py +108 -0
- hoja/commands/issue.py +398 -0
- hoja/commands/label.py +163 -0
- hoja/commands/memory.py +240 -0
- hoja/commands/project.py +56 -0
- hoja/commands/raw.py +50 -0
- hoja/commands/team.py +102 -0
- hoja/commands/update.py +162 -0
- hoja/config.py +362 -0
- hoja/errors.py +107 -0
- hoja/filters.py +150 -0
- hoja/graphql/__init__.py +5 -0
- hoja/graphql/fragments.py +138 -0
- hoja/graphql/mutations.py +160 -0
- hoja/graphql/queries.py +245 -0
- hoja/mcp/__init__.py +27 -0
- hoja/mcp/content.py +151 -0
- hoja/mcp/prompts.py +298 -0
- hoja/mcp/resources.py +213 -0
- hoja/mcp/server.py +189 -0
- hoja/memory_board.py +370 -0
- hoja/models/__init__.py +37 -0
- hoja/models/base.py +64 -0
- hoja/models/cycle.py +32 -0
- hoja/models/enums.py +62 -0
- hoja/models/issue.py +99 -0
- hoja/models/label.py +17 -0
- hoja/models/project.py +60 -0
- hoja/models/team.py +31 -0
- hoja/models/user.py +32 -0
- hoja/models/workflow.py +65 -0
- hoja/output.py +410 -0
- hoja/skill_content/commands.md +222 -0
- hoja/skill_content/examples.md +193 -0
- hoja/skill_content/filters.md +101 -0
- hoja/skill_content/output-formats.md +92 -0
- hoja/skill_content/overview.md +123 -0
- hoja/skill_content/workflows.md +174 -0
- hoja_cli-0.21.4.dist-info/METADATA +279 -0
- hoja_cli-0.21.4.dist-info/RECORD +107 -0
- hoja_cli-0.21.4.dist-info/WHEEL +4 -0
- hoja_cli-0.21.4.dist-info/entry_points.txt +4 -0
- hoja_cli-0.21.4.dist-info/licenses/LICENSE +21 -0
- hoja_server/README.md +135 -0
- hoja_server/__init__.py +11 -0
- hoja_server/abac.py +116 -0
- hoja_server/agent_chat.py +280 -0
- hoja_server/agent_conversations.py +91 -0
- hoja_server/agent_durable.py +332 -0
- hoja_server/agent_prompt.py +117 -0
- hoja_server/agent_threads.py +96 -0
- hoja_server/agent_uploads.py +60 -0
- hoja_server/app.py +2189 -0
- hoja_server/cadence.py +231 -0
- hoja_server/cli.py +191 -0
- hoja_server/cloudflare_realtime.py +110 -0
- hoja_server/comment_policy.py +70 -0
- hoja_server/connector_actions.py +119 -0
- hoja_server/connectors_api.py +375 -0
- hoja_server/db.py +1816 -0
- hoja_server/denials.py +18 -0
- hoja_server/deployment.py +28 -0
- hoja_server/email_adapter.py +94 -0
- hoja_server/google_login.py +217 -0
- hoja_server/google_oidc.py +342 -0
- hoja_server/identity_transfer.py +210 -0
- hoja_server/mcp_oauth.py +623 -0
- hoja_server/mcp_tools.py +579 -0
- hoja_server/meeting_policy.py +179 -0
- hoja_server/meetings.py +1332 -0
- hoja_server/meetings_api.py +909 -0
- hoja_server/model_providers.py +205 -0
- hoja_server/realtime.py +690 -0
- hoja_server/resolvers.py +1281 -0
- hoja_server/schema.graphql +477 -0
- hoja_server/schema_sdl.py +8 -0
- hoja_server/secrets_box.py +39 -0
- hoja_server/signup_config.py +292 -0
- hoja_server/signup_gate.py +393 -0
- hoja_server/signup_policy.py +97 -0
- hoja_server/signup_store.py +577 -0
- hoja_server/store.py +2179 -0
- hoja_server/sync.py +249 -0
- hoja_server/tenant_context.py +131 -0
- hoja_server/turnstile.py +81 -0
- hoja_server/writer.py +1262 -0
hoja_server/resolvers.py
ADDED
|
@@ -0,0 +1,1281 @@
|
|
|
1
|
+
"""Ariadne resolver bindings — maps the SDL to Store/Writer.
|
|
2
|
+
|
|
3
|
+
Auth context (user_id, organization_id) is attached to `info.context` by the
|
|
4
|
+
FastAPI middleware in app.py. Every resolver reads ctx and stays org-scoped.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
|
+
import base64
|
|
10
|
+
import binascii
|
|
11
|
+
import json
|
|
12
|
+
|
|
13
|
+
from graphql import GraphQLError
|
|
14
|
+
from ariadne import (
|
|
15
|
+
MutationType,
|
|
16
|
+
ObjectType,
|
|
17
|
+
QueryType,
|
|
18
|
+
ScalarType,
|
|
19
|
+
make_executable_schema,
|
|
20
|
+
)
|
|
21
|
+
from hoja_server.deployment import single_org_mode
|
|
22
|
+
|
|
23
|
+
query = QueryType()
|
|
24
|
+
mutation = MutationType()
|
|
25
|
+
Initiative = ObjectType("Initiative")
|
|
26
|
+
|
|
27
|
+
# Object types needing custom field resolvers
|
|
28
|
+
Team = ObjectType("Team")
|
|
29
|
+
Initiative.set_field("owner", lambda obj, info: info.context["store"].get_user(info.context["org_id"], obj.get("_owner_id")) if obj.get("_owner_id") else None)
|
|
30
|
+
Initiative.set_field("creator", lambda obj, info: info.context["store"].get_user(info.context["org_id"], obj.get("_creator_id")) if obj.get("_creator_id") else None)
|
|
31
|
+
Issue = ObjectType("Issue")
|
|
32
|
+
IssueActivity = ObjectType("IssueActivity")
|
|
33
|
+
MyIssueActivity = ObjectType("MyIssueActivity")
|
|
34
|
+
Project = ObjectType("Project")
|
|
35
|
+
ProjectUpdate = ObjectType("ProjectUpdate")
|
|
36
|
+
Comment = ObjectType("Comment")
|
|
37
|
+
WorkflowState = ObjectType("WorkflowState")
|
|
38
|
+
Cycle = ObjectType("Cycle")
|
|
39
|
+
Attachment = ObjectType("Attachment")
|
|
40
|
+
|
|
41
|
+
datetime_scalar = ScalarType("DateTime")
|
|
42
|
+
timeless_scalar = ScalarType("TimelessDate")
|
|
43
|
+
json_scalar = ScalarType("JSON")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@datetime_scalar.serializer
|
|
47
|
+
def _ser_dt(value):
|
|
48
|
+
return value # already ISO strings in the DB
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@timeless_scalar.serializer
|
|
52
|
+
def _ser_td(value):
|
|
53
|
+
return value
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@json_scalar.serializer
|
|
57
|
+
def _ser_json(value):
|
|
58
|
+
return value
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _ctx(info):
|
|
62
|
+
return info.context["store"], info.context["writer"], info.context["org_id"], info.context["user_id"]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _memo(info, kind, key, fn):
|
|
66
|
+
"""Per-request memoization for reference lookups repeated across list rows.
|
|
67
|
+
Turns the issues-list N+1 (one lookup per issue) into one lookup per distinct entity."""
|
|
68
|
+
ctx = info.context
|
|
69
|
+
cache = ctx.get("cache") if isinstance(ctx, dict) else None
|
|
70
|
+
if cache is None:
|
|
71
|
+
return fn()
|
|
72
|
+
ck = (kind, key)
|
|
73
|
+
if ck not in cache:
|
|
74
|
+
cache[ck] = fn()
|
|
75
|
+
return cache[ck]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _batch_entry(info, kind):
|
|
79
|
+
ctx = info.context
|
|
80
|
+
cache = ctx.get("cache") if isinstance(ctx, dict) else None
|
|
81
|
+
if cache is None:
|
|
82
|
+
return None
|
|
83
|
+
return cache.setdefault(("batch", kind), {"pending": set(), "loaded": {}})
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _prime(info, kind, ids):
|
|
87
|
+
"""Announce the keys a list page will resolve so the first lookup loads them all."""
|
|
88
|
+
entry = _batch_entry(info, kind)
|
|
89
|
+
if entry is not None:
|
|
90
|
+
entry["pending"].update(i for i in ids if i and i not in entry["loaded"])
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _batched(info, kind, key, load_many, single, default=None):
|
|
94
|
+
"""Resolve key from a primed batch (one query for every primed key), else singly."""
|
|
95
|
+
entry = _batch_entry(info, kind)
|
|
96
|
+
if entry is None:
|
|
97
|
+
return single()
|
|
98
|
+
if key in entry["loaded"]:
|
|
99
|
+
return entry["loaded"][key]
|
|
100
|
+
if key in entry["pending"]:
|
|
101
|
+
ids = list(entry["pending"])
|
|
102
|
+
entry["pending"].clear()
|
|
103
|
+
got = load_many(ids)
|
|
104
|
+
for i in ids:
|
|
105
|
+
entry["loaded"][i] = got.get(i, default)
|
|
106
|
+
return entry["loaded"][key]
|
|
107
|
+
value = single()
|
|
108
|
+
entry["loaded"][key] = value
|
|
109
|
+
return value
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _user_row(info, user_id):
|
|
113
|
+
store, _w, org, _uid = _ctx(info)
|
|
114
|
+
return _batched(info, "user_row", user_id, lambda ids: store.users_by_ids(org, ids),
|
|
115
|
+
lambda: store.get_user(org, user_id))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _prime_issue_nodes(info, nodes) -> None:
|
|
119
|
+
"""Per-row lookups of an issue page (labels, subscribers, people) become one query each."""
|
|
120
|
+
ids = [n["id"] for n in nodes if n]
|
|
121
|
+
_prime(info, "issue_labels", ids)
|
|
122
|
+
_prime(info, "issue_subscribers", ids)
|
|
123
|
+
_prime(info, "user_row", [n.get(k) for n in nodes if n for k in ("_assignee_id", "_creator_id")])
|
|
124
|
+
_prime(info, "project", [n.get("_project_id") for n in nodes if n])
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
_MAX_PAGE = 250
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _read_only(info) -> bool:
|
|
131
|
+
return info.context.get("access") == "read"
|
|
132
|
+
|
|
133
|
+
_DEFAULT_PAGE = 50
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _page_size(value, default=_DEFAULT_PAGE) -> int:
|
|
137
|
+
"""Clamp first/last to 1..250; null means the default. Out-of-range values are rejected."""
|
|
138
|
+
if value is None:
|
|
139
|
+
return default
|
|
140
|
+
if type(value) is not int or not 1 <= value <= _MAX_PAGE:
|
|
141
|
+
raise GraphQLError(f"first must be an integer from 1 to {_MAX_PAGE}.",
|
|
142
|
+
extensions={"code": "BAD_USER_INPUT", "field": "first"})
|
|
143
|
+
return value
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _conn(nodes: list) -> dict:
|
|
147
|
+
return {
|
|
148
|
+
"nodes": nodes,
|
|
149
|
+
"pageInfo": {
|
|
150
|
+
"hasNextPage": False, "hasPreviousPage": False,
|
|
151
|
+
"startCursor": None,
|
|
152
|
+
"endCursor": (nodes[-1].get("id") if nodes else None),
|
|
153
|
+
},
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _issue_cursor(node: dict, order_by: str) -> str:
|
|
158
|
+
value = node.get("updatedAt" if order_by == "updatedAt" else "createdAt")
|
|
159
|
+
payload = json.dumps([value, node["id"]], separators=(",", ":")).encode()
|
|
160
|
+
return base64.urlsafe_b64encode(payload).decode().rstrip("=")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _decode_issue_cursor(cursor: str | None):
|
|
164
|
+
if cursor is None:
|
|
165
|
+
return None
|
|
166
|
+
try:
|
|
167
|
+
raw = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4))
|
|
168
|
+
value = json.loads(raw)
|
|
169
|
+
if not isinstance(value, list) or len(value) != 2 or not all(isinstance(part, str) and part for part in value):
|
|
170
|
+
raise ValueError
|
|
171
|
+
return value[0], value[1]
|
|
172
|
+
except (ValueError, TypeError, UnicodeDecodeError, json.JSONDecodeError, binascii.Error) as exc:
|
|
173
|
+
raise GraphQLError("Invalid issue cursor.", extensions={"code": "BAD_USER_INPUT", "field": "after"}) from exc
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _activity_cursor(row):
|
|
177
|
+
raw = json.dumps([row["created_at"], row["id"]], separators=(",", ":")).encode()
|
|
178
|
+
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _decode_activity_cursor(cursor):
|
|
182
|
+
if not cursor:
|
|
183
|
+
return None
|
|
184
|
+
try:
|
|
185
|
+
value = json.loads(base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4)))
|
|
186
|
+
if not isinstance(value, list) or len(value) != 2 or not all(isinstance(x, str) for x in value):
|
|
187
|
+
raise ValueError
|
|
188
|
+
return value
|
|
189
|
+
except (ValueError, TypeError, UnicodeDecodeError, json.JSONDecodeError, binascii.Error) as exc:
|
|
190
|
+
raise GraphQLError("Invalid activity cursor.", extensions={"code": "BAD_USER_INPUT", "field": "after"}) from exc
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _project_update_cursor(row):
|
|
194
|
+
raw = json.dumps([row["created_at"], row["id"]], separators=(",", ":")).encode()
|
|
195
|
+
return base64.urlsafe_b64encode(raw).decode().rstrip("=")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _decode_project_update_cursor(cursor, label="project update"):
|
|
199
|
+
if cursor is None:
|
|
200
|
+
return None
|
|
201
|
+
try:
|
|
202
|
+
value = json.loads(base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4)))
|
|
203
|
+
if not isinstance(value, list) or len(value) != 2 or not all(isinstance(x, str) and x for x in value):
|
|
204
|
+
raise ValueError
|
|
205
|
+
return value
|
|
206
|
+
except (ValueError, TypeError, UnicodeDecodeError, json.JSONDecodeError, binascii.Error) as exc:
|
|
207
|
+
raise GraphQLError(f"Invalid {label} cursor.", extensions={"code": "BAD_USER_INPUT", "field": "after"}) from exc
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ----------------------------------------------------------------- Query
|
|
211
|
+
@query.field("inboxNotifications")
|
|
212
|
+
def r_inbox_notifications(_, info, first=50, after=None, unreadOnly=False, archived=False):
|
|
213
|
+
store, _w, org, uid = _ctx(info)
|
|
214
|
+
first = _page_size(first)
|
|
215
|
+
cursor = _decode_activity_cursor(after)
|
|
216
|
+
rows = store.inbox_notifications(org, uid, cursor, first + 1,
|
|
217
|
+
unread_only=unreadOnly, archived=archived)
|
|
218
|
+
nodes = [dict(row, createdAt=row["created_at"], readAt=row["read_at"],
|
|
219
|
+
archivedAt=row["archived_at"], _actor_id=row["actor_id"],
|
|
220
|
+
_issue_id=row["issue_id"]) for row in rows[:first]]
|
|
221
|
+
_prime(info, "issue_obj", [n["_issue_id"] for n in nodes])
|
|
222
|
+
_prime(info, "user_row", [n["_actor_id"] for n in nodes])
|
|
223
|
+
return {"nodes": nodes, "pageInfo": {
|
|
224
|
+
"hasNextPage": len(rows) > first, "hasPreviousPage": after is not None,
|
|
225
|
+
"startCursor": _activity_cursor(nodes[0]) if nodes else None,
|
|
226
|
+
"endCursor": _activity_cursor(nodes[-1]) if nodes else None,
|
|
227
|
+
}}
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@query.field("inboxUnreadCount")
|
|
231
|
+
def r_inbox_unread_count(_, info):
|
|
232
|
+
store, _w, org, uid = _ctx(info)
|
|
233
|
+
return store.inbox_unread_count(org, uid)
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@mutation.field("notificationMarkRead")
|
|
237
|
+
def m_notification_mark_read(_, info, id):
|
|
238
|
+
store, _w, org, uid = _ctx(info)
|
|
239
|
+
return store.inbox_mark(org, uid, id, "read_at")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@mutation.field("notificationArchive")
|
|
243
|
+
def m_notification_archive(_, info, id):
|
|
244
|
+
store, _w, org, uid = _ctx(info)
|
|
245
|
+
return store.inbox_mark(org, uid, id, "archived_at")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@mutation.field("notificationMarkAllRead")
|
|
249
|
+
def m_notification_mark_all_read(_, info):
|
|
250
|
+
store, _w, org, uid = _ctx(info)
|
|
251
|
+
return store.inbox_mark_all_read(org, uid)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
InboxNotification = ObjectType("InboxNotification")
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
@InboxNotification.field("issue")
|
|
258
|
+
def inbox_issue(obj, info):
|
|
259
|
+
return _issue_obj(info, obj["_issue_id"])
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _issue_obj(info, issue_id):
|
|
263
|
+
store, _w, org, uid = _ctx(info)
|
|
264
|
+
|
|
265
|
+
def load_many(ids):
|
|
266
|
+
got = store.issues_by_ids(org, ids, viewer_id=uid)
|
|
267
|
+
_prime_issue_nodes(info, list(got.values()))
|
|
268
|
+
return got
|
|
269
|
+
|
|
270
|
+
return _batched(info, "issue_obj", issue_id, load_many,
|
|
271
|
+
lambda: store.issue_by_id_or_identifier(org, issue_id, viewer_id=uid))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@InboxNotification.field("actor")
|
|
275
|
+
def inbox_actor(obj, info):
|
|
276
|
+
store, _w, _org, uid = _ctx(info)
|
|
277
|
+
return store.ser_user(_user_row(info, obj["_actor_id"]), uid) if obj.get("_actor_id") else None
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@query.field("viewer")
|
|
281
|
+
def r_viewer(_, info):
|
|
282
|
+
store, _w, org, uid = _ctx(info)
|
|
283
|
+
return store.viewer(org, uid)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
@query.field("myApiKeys")
|
|
287
|
+
def r_my_api_keys(_, info):
|
|
288
|
+
store, _w, org, uid = _ctx(info)
|
|
289
|
+
return store.list_api_keys(org, uid)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _preference_payload(store, uid, *, success=True):
|
|
293
|
+
values = store.user_preferences(uid)
|
|
294
|
+
if values is None:
|
|
295
|
+
return {"success": False, "theme": None, "fontSize": None}
|
|
296
|
+
return {"success": success, "theme": values.get("theme", "light"),
|
|
297
|
+
"fontSize": values.get("fontSize", "100")}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@query.field("myPreferences")
|
|
301
|
+
def r_my_preferences(_, info):
|
|
302
|
+
store, _w, _org, uid = _ctx(info)
|
|
303
|
+
return _preference_payload(store, uid)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@query.field("myNotificationPreferences")
|
|
307
|
+
def r_my_notification_preferences(_, info):
|
|
308
|
+
store, _w, _org, uid = _ctx(info)
|
|
309
|
+
prefs = store.user_preferences(uid) or {}
|
|
310
|
+
return {"success": True,
|
|
311
|
+
"issueCreated": prefs.get("notification:issueCreated", "true") == "true",
|
|
312
|
+
"issueUpdated": prefs.get("notification:issueUpdated", "true") == "true"}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@query.field("issueLabelSettings")
|
|
316
|
+
def r_issue_label_settings(_, info):
|
|
317
|
+
store, _w, org, _uid = _ctx(info)
|
|
318
|
+
return store.labels(org)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@query.field("myWorkspaces")
|
|
322
|
+
def r_my_workspaces(_, info):
|
|
323
|
+
store, _w, org, uid = _ctx(info)
|
|
324
|
+
identity_id = info.context.get("identity_id")
|
|
325
|
+
if not identity_id:
|
|
326
|
+
membership = store.membership_for_profile(org, uid)
|
|
327
|
+
identity_id = membership.get("identity_id") if membership else None
|
|
328
|
+
if not identity_id:
|
|
329
|
+
return []
|
|
330
|
+
return store.identity_workspaces(identity_id)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@query.field("organizationMembers")
|
|
334
|
+
def r_organization_members(_, info, first=100):
|
|
335
|
+
store, _w, org, uid = _ctx(info)
|
|
336
|
+
first = _page_size(first, 100)
|
|
337
|
+
return _conn([store.ser_user(member, uid) for member in store.organization_members(org)[:first]])
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
@query.field("workspace")
|
|
341
|
+
def r_workspace(_, info):
|
|
342
|
+
store, _w, org, _uid = _ctx(info)
|
|
343
|
+
return store.workspace(org)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
@query.field("teams")
|
|
347
|
+
def r_teams(_, info, first=50, after=None, filter=None):
|
|
348
|
+
store, _w, org, uid = _ctx(info)
|
|
349
|
+
first = _page_size(first)
|
|
350
|
+
key = None
|
|
351
|
+
if filter and "key" in filter and "eq" in filter["key"]:
|
|
352
|
+
key = filter["key"]["eq"]
|
|
353
|
+
return _conn(store.teams(org, key=key, viewer_id=uid)[:first])
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
@query.field("team")
|
|
357
|
+
def r_team(_, info, id):
|
|
358
|
+
store, _w, org, uid = _ctx(info)
|
|
359
|
+
row = store.team_by_id_or_key(org, id, viewer_id=uid)
|
|
360
|
+
if not row:
|
|
361
|
+
return None
|
|
362
|
+
return store.ser_team(row)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
@query.field("issues")
|
|
366
|
+
def r_issues(_, info, filter=None, first=50, after=None, orderBy="updatedAt"): # noqa: N803
|
|
367
|
+
store, _w, org, uid = _ctx(info)
|
|
368
|
+
order_by = orderBy or "updatedAt"
|
|
369
|
+
if order_by not in ("updatedAt", "createdAt"):
|
|
370
|
+
raise ValueError("Unsupported issue ordering.")
|
|
371
|
+
first = _page_size(first)
|
|
372
|
+
cursor = _decode_issue_cursor(after)
|
|
373
|
+
rows = store.issues(org, uid, filter, order_by=order_by, after=cursor, limit=first + 1)
|
|
374
|
+
nodes = rows[:first]
|
|
375
|
+
_prime_issue_nodes(info, nodes)
|
|
376
|
+
result = _conn(nodes)
|
|
377
|
+
result["pageInfo"].update({
|
|
378
|
+
"hasNextPage": len(rows) > first,
|
|
379
|
+
"hasPreviousPage": after is not None,
|
|
380
|
+
"startCursor": _issue_cursor(nodes[0], order_by) if nodes else None,
|
|
381
|
+
"endCursor": _issue_cursor(nodes[-1], order_by) if nodes else None,
|
|
382
|
+
})
|
|
383
|
+
return result
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@query.field("issue")
|
|
387
|
+
def r_issue(_, info, id):
|
|
388
|
+
store, _w, org, uid = _ctx(info)
|
|
389
|
+
return store.issue_by_id_or_identifier(org, id, viewer_id=uid)
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
@query.field("projects")
|
|
393
|
+
def r_projects(_, info, filter=None, first=50, after=None):
|
|
394
|
+
store, _w, org, uid = _ctx(info)
|
|
395
|
+
first = _page_size(first)
|
|
396
|
+
cursor = _decode_project_update_cursor(after)
|
|
397
|
+
rows = store.projects(org, filter, after=cursor, limit=first + 1, viewer_id=uid)
|
|
398
|
+
nodes = rows[:first]
|
|
399
|
+
_prime(info, "project_teams", [n["id"] for n in nodes])
|
|
400
|
+
_prime(info, "project_progress", [n["id"] for n in nodes])
|
|
401
|
+
_prime(info, "user_row", [n.get(k) for n in nodes for k in ("_lead_id", "_creator_id")])
|
|
402
|
+
result = _conn(nodes)
|
|
403
|
+
result["pageInfo"].update({
|
|
404
|
+
"hasNextPage": len(rows) > first,
|
|
405
|
+
"hasPreviousPage": after is not None,
|
|
406
|
+
"startCursor": _project_update_cursor({"created_at": nodes[0]["createdAt"], "id": nodes[0]["id"]}) if nodes else None,
|
|
407
|
+
"endCursor": _project_update_cursor({"created_at": nodes[-1]["createdAt"], "id": nodes[-1]["id"]}) if nodes else None,
|
|
408
|
+
})
|
|
409
|
+
return result
|
|
410
|
+
|
|
411
|
+
@query.field("initiatives")
|
|
412
|
+
def r_initiatives(_, info, first=50, after=None, status=None):
|
|
413
|
+
store, _w, org, _uid = _ctx(info)
|
|
414
|
+
first = _page_size(first)
|
|
415
|
+
cursor = _decode_project_update_cursor(after, "initiative")
|
|
416
|
+
rows = store.initiatives(org, after=cursor, status=status, limit=first + 1)
|
|
417
|
+
nodes = rows[:first]
|
|
418
|
+
result = _conn(nodes)
|
|
419
|
+
result["pageInfo"].update({"hasNextPage": len(rows) > first, "hasPreviousPage": after is not None, "startCursor": _project_update_cursor({"created_at": nodes[0]["createdAt"], "id": nodes[0]["id"]}) if nodes else None, "endCursor": _project_update_cursor({"created_at": nodes[-1]["createdAt"], "id": nodes[-1]["id"]}) if nodes else None})
|
|
420
|
+
return result
|
|
421
|
+
|
|
422
|
+
@query.field("initiative")
|
|
423
|
+
def r_initiative(_, info, id):
|
|
424
|
+
store, _w, org, _uid = _ctx(info)
|
|
425
|
+
return store.initiative_by_id(org, id)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
@query.field("projectUpdates")
|
|
429
|
+
def r_project_updates(_, info, first=50, after=None, filter=None):
|
|
430
|
+
store, _w, org, _uid = _ctx(info)
|
|
431
|
+
first = _page_size(first)
|
|
432
|
+
project_filter = (filter or {}).get("project") or {}
|
|
433
|
+
project_id = (project_filter.get("id") or {}).get("eq")
|
|
434
|
+
cursor = _decode_project_update_cursor(after)
|
|
435
|
+
rows = store.project_updates(org, after=cursor, project_id=project_id, limit=first + 1)
|
|
436
|
+
nodes = rows[:first]
|
|
437
|
+
_prime(info, "project", [n.get("_project_id") for n in nodes])
|
|
438
|
+
_prime(info, "user_row", [n.get("_actor_id") for n in nodes])
|
|
439
|
+
result = _conn(nodes)
|
|
440
|
+
result["pageInfo"].update({
|
|
441
|
+
"hasNextPage": len(rows) > first, "hasPreviousPage": after is not None,
|
|
442
|
+
"startCursor": _project_update_cursor(nodes[0]) if nodes else None,
|
|
443
|
+
"endCursor": _project_update_cursor(nodes[-1]) if nodes else None,
|
|
444
|
+
})
|
|
445
|
+
return result
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
@query.field("project")
|
|
449
|
+
def r_project(_, info, id):
|
|
450
|
+
store, _w, org, uid = _ctx(info)
|
|
451
|
+
return store.project_by_id(org, id, viewer_id=uid)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
@query.field("workflowStates")
|
|
455
|
+
def r_workflow_states(_, info, filter=None, first=100, after=None):
|
|
456
|
+
store, _w, org, uid = _ctx(info)
|
|
457
|
+
first = _page_size(first, 100)
|
|
458
|
+
return _conn(store.workflow_states(org, filter, viewer_id=uid)[:first])
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
@query.field("cycles")
|
|
462
|
+
def r_cycles(_, info, filter=None, first=100, after=None):
|
|
463
|
+
store, writer, org, uid = _ctx(info)
|
|
464
|
+
first = _page_size(first, 100)
|
|
465
|
+
if not _read_only(info) and filter and "team" in filter and "id" in filter["team"] and "eq" in filter["team"]["id"]:
|
|
466
|
+
writer.reconcile_team_cadence(org, filter["team"]["id"]["eq"], viewer_id=uid)
|
|
467
|
+
return _conn(store.cycles(org, filter, viewer_id=uid)[:first])
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
@query.field("teamCycleCadence")
|
|
471
|
+
def r_team_cycle_cadence(_, info, teamId):
|
|
472
|
+
store, writer, org, uid = _ctx(info)
|
|
473
|
+
if _read_only(info):
|
|
474
|
+
return store.team_cadence_snapshot(org, teamId, uid)
|
|
475
|
+
return writer.reconcile_team_cadence(org, teamId, viewer_id=uid)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
@mutation.field("teamCycleCadenceEnable")
|
|
479
|
+
def m_team_cycle_cadence_enable(_, info, teamId, weekday):
|
|
480
|
+
store, writer, org, uid = _ctx(info)
|
|
481
|
+
actor = store.get_user(org, uid)
|
|
482
|
+
if not actor or not actor.get("admin"):
|
|
483
|
+
raise ValueError("Only workspace admins can enable team cycles.")
|
|
484
|
+
return writer.enable_team_cadence(org, teamId, weekday)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
@query.field("cycle")
|
|
488
|
+
def r_cycle(_, info, id):
|
|
489
|
+
store, _w, org, uid = _ctx(info)
|
|
490
|
+
return store.cycle_by_id(org, id, viewer_id=uid)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
@query.field("issueLabels")
|
|
494
|
+
def r_labels(_, info, filter=None, first=100, after=None):
|
|
495
|
+
store, _w, org, uid = _ctx(info)
|
|
496
|
+
first = _page_size(first, 100)
|
|
497
|
+
team_id = None
|
|
498
|
+
name = None
|
|
499
|
+
if filter:
|
|
500
|
+
if "team" in filter and "id" in filter["team"] and "eq" in filter["team"]["id"]:
|
|
501
|
+
team_id = filter["team"]["id"]["eq"]
|
|
502
|
+
if "name" in filter and "eq" in filter["name"]:
|
|
503
|
+
name = filter["name"]["eq"]
|
|
504
|
+
return _conn(store.labels(org, team_id=team_id, name=name, viewer_id=uid)[:first])
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
@query.field("searchIssues")
|
|
508
|
+
def r_search(_, info, term, first=20):
|
|
509
|
+
store, _w, org, uid = _ctx(info)
|
|
510
|
+
first = _page_size(first, 20)
|
|
511
|
+
nodes = store.search_issues(org, term, viewer_id=uid)[:first]
|
|
512
|
+
_prime_issue_nodes(info, nodes)
|
|
513
|
+
return _conn(nodes)
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
@query.field("rateLimitStatus")
|
|
517
|
+
def r_rate_limit(_, info):
|
|
518
|
+
resets = (datetime.now(timezone.utc) + timedelta(hours=1)).isoformat()
|
|
519
|
+
return {
|
|
520
|
+
"identifier": "local", "kind": "unlimited",
|
|
521
|
+
"limit": 1000000, "remaining": 1000000, "requestsPerHour": 1000000,
|
|
522
|
+
"resetsAt": resets,
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
# ----------------------------------------------------------------- Team fields
|
|
527
|
+
@Team.field("states")
|
|
528
|
+
def t_states(obj, info, first=100):
|
|
529
|
+
store, _w, org, uid = _ctx(info)
|
|
530
|
+
first = _page_size(first, 100)
|
|
531
|
+
return _conn(store.team_states(org, obj["id"], viewer_id=uid)[:first])
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
@Team.field("members")
|
|
535
|
+
def t_members(obj, info, first=100):
|
|
536
|
+
store, _w, org, uid = _ctx(info)
|
|
537
|
+
first = _page_size(first, 100)
|
|
538
|
+
return _conn(store.team_members(org, obj["id"], viewer_id=uid)[:first])
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
@Team.field("parent")
|
|
542
|
+
def t_parent(obj, info):
|
|
543
|
+
store, _w, org, uid = _ctx(info)
|
|
544
|
+
parent_id = obj.get("parentTeamId")
|
|
545
|
+
if not parent_id:
|
|
546
|
+
return None
|
|
547
|
+
row = store.team_by_id_or_key(org, parent_id, viewer_id=uid)
|
|
548
|
+
return store.ser_team(row) if row else None
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
@Team.field("children")
|
|
552
|
+
def t_children(obj, info, first=100):
|
|
553
|
+
store, _w, org, uid = _ctx(info)
|
|
554
|
+
first = _page_size(first, 100)
|
|
555
|
+
return _conn(store.team_children(org, obj["id"], viewer_id=uid)[:first])
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
@Team.field("cycles")
|
|
559
|
+
def t_cycles(obj, info, first=50):
|
|
560
|
+
store, writer, org, uid = _ctx(info)
|
|
561
|
+
first = _page_size(first)
|
|
562
|
+
if not _read_only(info):
|
|
563
|
+
writer.reconcile_team_cadence(org, obj["id"], viewer_id=uid)
|
|
564
|
+
return _conn(store.team_cycles(org, obj["id"], viewer_id=uid)[:first])
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
@Team.field("activeCycle")
|
|
568
|
+
def t_active_cycle(obj, info):
|
|
569
|
+
store, writer, org, uid = _ctx(info)
|
|
570
|
+
cid = obj.get("_active_cycle_id")
|
|
571
|
+
if not cid:
|
|
572
|
+
return None
|
|
573
|
+
if _read_only(info):
|
|
574
|
+
status = store.team_cadence_snapshot(org, obj["id"], uid)
|
|
575
|
+
else:
|
|
576
|
+
status = writer.reconcile_team_cadence(org, obj["id"], viewer_id=uid)
|
|
577
|
+
return status["activeCycle"] if status["enabled"] else next((c for c in store.team_cycles(org, obj["id"], viewer_id=uid) if c["id"] == cid), None)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
# ----------------------------------------------------------------- State/cycle fields
|
|
581
|
+
@WorkflowState.field("team")
|
|
582
|
+
@Cycle.field("team")
|
|
583
|
+
def reference_team(obj, info):
|
|
584
|
+
store, _w, org, uid = _ctx(info)
|
|
585
|
+
tid = obj.get("_team_id")
|
|
586
|
+
if not tid:
|
|
587
|
+
return None
|
|
588
|
+
return _memo(info, "team", (org, uid, tid),
|
|
589
|
+
lambda: store.ser_team(store.raw_team(org, tid, viewer_id=uid)))
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
# ----------------------------------------------------------------- Issue fields
|
|
593
|
+
@Issue.field("state")
|
|
594
|
+
def i_state(obj, info):
|
|
595
|
+
store, _w, org, _uid = _ctx(info)
|
|
596
|
+
return _memo(info, "state", (org, obj["_state_id"]),
|
|
597
|
+
lambda: store.state_by_id(org, obj["_state_id"])) if obj.get("_state_id") else None
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
@Issue.field("assignee")
|
|
601
|
+
def i_assignee(obj, info):
|
|
602
|
+
store, _w, org, uid = _ctx(info)
|
|
603
|
+
return _memo(info, "user", (org, obj["_assignee_id"]),
|
|
604
|
+
lambda: store.ser_user(_user_row(info, obj["_assignee_id"]), uid)) if obj.get("_assignee_id") else None
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
@Issue.field("creator")
|
|
608
|
+
def i_creator(obj, info):
|
|
609
|
+
store, _w, org, uid = _ctx(info)
|
|
610
|
+
return _memo(info, "user", (org, obj["_creator_id"]),
|
|
611
|
+
lambda: store.ser_user(_user_row(info, obj["_creator_id"]), uid)) if obj.get("_creator_id") else None
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
@Issue.field("team")
|
|
615
|
+
def i_team(obj, info):
|
|
616
|
+
store, _w, org, uid = _ctx(info)
|
|
617
|
+
return _memo(info, "team", (org, uid, obj["_team_id"]), lambda: (
|
|
618
|
+
(lambda row: store.ser_team(row) if row else None)(
|
|
619
|
+
store.raw_team(org, obj["_team_id"], viewer_id=uid))
|
|
620
|
+
)) if obj.get("_team_id") else None
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
@Issue.field("project")
|
|
624
|
+
def i_project(obj, info):
|
|
625
|
+
store, _w, org, uid = _ctx(info)
|
|
626
|
+
pid = obj.get("_project_id")
|
|
627
|
+
return _batched(info, "project", pid, lambda ids: store.projects_by_ids(org, ids, viewer_id=uid),
|
|
628
|
+
lambda: store.project_by_id(org, pid, viewer_id=uid)) if pid else None
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
@Issue.field("cycle")
|
|
632
|
+
def i_cycle(obj, info):
|
|
633
|
+
store, _w, org, uid = _ctx(info)
|
|
634
|
+
if not obj.get("_cycle_id"):
|
|
635
|
+
return None
|
|
636
|
+
def _load():
|
|
637
|
+
with store.engine.connect() as conn:
|
|
638
|
+
from sqlalchemy import and_, select
|
|
639
|
+
|
|
640
|
+
from hoja_server.db import cycle as cyc
|
|
641
|
+
r = conn.execute(select(cyc).where(and_(cyc.c.organization_id == org, cyc.c.id == obj["_cycle_id"]))).first()
|
|
642
|
+
if not r:
|
|
643
|
+
return None
|
|
644
|
+
if not store.team_visible_for(org, uid, r._mapping["team_id"]):
|
|
645
|
+
return None
|
|
646
|
+
return store.ser_cycle(dict(r._mapping))
|
|
647
|
+
return _memo(info, "cycle", (org, uid, obj["_cycle_id"]), _load)
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
@Issue.field("parent")
|
|
651
|
+
def i_parent(obj, info):
|
|
652
|
+
store, _w, org, uid = _ctx(info)
|
|
653
|
+
return _memo(info, "issue", (org, uid, obj["_parent_id"]),
|
|
654
|
+
lambda: store.issue_by_id_or_identifier(org, obj["_parent_id"], viewer_id=uid)) if obj.get("_parent_id") else None
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
@Issue.field("children")
|
|
658
|
+
def i_children(obj, info, first=50, after=None):
|
|
659
|
+
store, _w, org, uid = _ctx(info)
|
|
660
|
+
first = _page_size(first)
|
|
661
|
+
cursor = _decode_issue_cursor(after)
|
|
662
|
+
return _memo(
|
|
663
|
+
info, "issue_children", (org, uid, obj["id"], first, cursor), lambda: _children_conn(
|
|
664
|
+
store, org, uid, obj["id"], first, cursor,
|
|
665
|
+
),
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _children_conn(store, org, uid, issue_id, first, cursor):
|
|
670
|
+
rows = store.issue_children(org, issue_id, viewer_id=uid, after=cursor, limit=first + 1)
|
|
671
|
+
nodes = rows[:first]
|
|
672
|
+
return {
|
|
673
|
+
"nodes": nodes,
|
|
674
|
+
"pageInfo": {
|
|
675
|
+
"hasNextPage": len(rows) > first,
|
|
676
|
+
"hasPreviousPage": cursor is not None,
|
|
677
|
+
"startCursor": _issue_cursor(nodes[0], "createdAt") if nodes else None,
|
|
678
|
+
"endCursor": _issue_cursor(nodes[-1], "createdAt") if nodes else None,
|
|
679
|
+
},
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
@Issue.field("labels")
|
|
684
|
+
def i_labels(obj, info, first=50):
|
|
685
|
+
store, _w, org, _uid = _ctx(info)
|
|
686
|
+
first = _page_size(first)
|
|
687
|
+
labels = _batched(info, "issue_labels", obj["id"], lambda ids: store.issue_labels_for_many(org, ids),
|
|
688
|
+
lambda: store.issue_labels_for(org, obj["id"]), default=[])
|
|
689
|
+
return _conn(labels[:first])
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
@Issue.field("subscribers")
|
|
693
|
+
def i_subscribers(obj, info, first=50):
|
|
694
|
+
store, _w, org, _uid = _ctx(info)
|
|
695
|
+
first = _page_size(first)
|
|
696
|
+
subs = _batched(info, "issue_subscribers", obj["id"],
|
|
697
|
+
lambda ids: store.issue_subscribers_for_many(org, ids),
|
|
698
|
+
lambda: store.issue_subscribers_for(org, obj["id"]), default=[])
|
|
699
|
+
return _conn(subs[:first])
|
|
700
|
+
|
|
701
|
+
|
|
702
|
+
@Issue.field("comments")
|
|
703
|
+
def i_comments(obj, info, first=50):
|
|
704
|
+
store, _w, org, _uid = _ctx(info)
|
|
705
|
+
first = _page_size(first)
|
|
706
|
+
def load():
|
|
707
|
+
comments = store.comments_for(org, obj["id"], limit=first)
|
|
708
|
+
_prime(info, "user_row", [c.get("_user_id") for c in comments])
|
|
709
|
+
return _conn(comments)
|
|
710
|
+
return _memo(info, "issue_comments", (org, obj["id"], first), load)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
@Issue.field("attachments")
|
|
714
|
+
def i_attachments(obj, info, first=50):
|
|
715
|
+
store, _w, org, _uid = _ctx(info)
|
|
716
|
+
first = _page_size(first)
|
|
717
|
+
return _memo(info, "issue_attachments", (org, obj["id"], first),
|
|
718
|
+
lambda: _conn(store.issue_attachments_for(org, obj["id"], limit=first)))
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
@query.field("myIssueActivity")
|
|
722
|
+
def r_my_issue_activity(_, info, first=50, after=None):
|
|
723
|
+
store, _w, org, uid = _ctx(info)
|
|
724
|
+
first = _page_size(first)
|
|
725
|
+
cursor = _decode_activity_cursor(after)
|
|
726
|
+
rows = store.my_issue_activity(org, uid, cursor, first + 1)
|
|
727
|
+
nodes = [dict(row, eventType=row["event_type"], createdAt=row["created_at"]) for row in rows[:first]]
|
|
728
|
+
_prime(info, "issue_obj", [n["issue_id"] for n in nodes])
|
|
729
|
+
_prime(info, "user_row", [n.get("actor_id") for n in nodes])
|
|
730
|
+
return {"nodes": nodes, "pageInfo": {
|
|
731
|
+
"hasNextPage": len(rows) > first, "hasPreviousPage": after is not None,
|
|
732
|
+
"startCursor": _activity_cursor(nodes[0]) if nodes else None,
|
|
733
|
+
"endCursor": _activity_cursor(nodes[-1]) if nodes else None,
|
|
734
|
+
}}
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
@MyIssueActivity.field("issue")
|
|
738
|
+
def mia_issue(obj, info):
|
|
739
|
+
return _issue_obj(info, obj["issue_id"])
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
@MyIssueActivity.field("actor")
|
|
743
|
+
def mia_actor(obj, info):
|
|
744
|
+
store, _w, _org, uid = _ctx(info)
|
|
745
|
+
return store.ser_user(_user_row(info, obj["actor_id"]), uid) if obj.get("actor_id") else None
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
@Issue.field("activity")
|
|
749
|
+
def i_activity(obj, info, first=50, after=None):
|
|
750
|
+
store, _w, org, _uid = _ctx(info)
|
|
751
|
+
first = _page_size(first)
|
|
752
|
+
cursor = _decode_activity_cursor(after)
|
|
753
|
+
rows = store.issue_activity_for(org, obj["id"], cursor, first + 1)
|
|
754
|
+
nodes = [dict(row, eventType=row["event_type"], createdAt=row["created_at"]) for row in rows[:first]]
|
|
755
|
+
_prime(info, "user_row", [n.get("actor_id") for n in nodes])
|
|
756
|
+
return {"nodes": nodes, "pageInfo": {
|
|
757
|
+
"hasNextPage": len(rows) > first, "hasPreviousPage": after is not None,
|
|
758
|
+
"startCursor": _activity_cursor(nodes[0]) if nodes else None,
|
|
759
|
+
"endCursor": _activity_cursor(nodes[-1]) if nodes else None,
|
|
760
|
+
}}
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
@IssueActivity.field("actor")
|
|
764
|
+
def ia_actor(obj, info):
|
|
765
|
+
store, _w, _org, _uid = _ctx(info)
|
|
766
|
+
return store.ser_user(_user_row(info, obj["actor_id"]), obj["actor_id"]) if obj.get("actor_id") else None
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
# ----------------------------------------------------------------- Project fields
|
|
770
|
+
@Project.field("lead")
|
|
771
|
+
def p_lead(obj, info):
|
|
772
|
+
store, _w, _org, uid = _ctx(info)
|
|
773
|
+
return store.ser_user(_user_row(info, obj["_lead_id"]), uid) if obj.get("_lead_id") else None
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
@ProjectUpdate.field("project")
|
|
777
|
+
def pu_project(obj, info):
|
|
778
|
+
store, _w, org, _uid = _ctx(info)
|
|
779
|
+
pid = obj["_project_id"]
|
|
780
|
+
return _batched(info, "project", pid, lambda ids: store.projects_by_ids(org, ids),
|
|
781
|
+
lambda: store.project_update_project(org, pid))
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
@ProjectUpdate.field("actor")
|
|
785
|
+
def pu_actor(obj, info):
|
|
786
|
+
store, _w, _org, uid = _ctx(info)
|
|
787
|
+
return store.ser_user(_user_row(info, obj["_actor_id"]), uid) if obj.get("_actor_id") else None
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
@Project.field("creator")
|
|
791
|
+
def p_creator(obj, info):
|
|
792
|
+
store, _w, _org, uid = _ctx(info)
|
|
793
|
+
return store.ser_user(_user_row(info, obj["_creator_id"]), uid) if obj.get("_creator_id") else None
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
@Project.field("members")
|
|
797
|
+
def p_members(obj, info, first=100):
|
|
798
|
+
store, _w, org, _uid = _ctx(info)
|
|
799
|
+
first = _page_size(first, 100)
|
|
800
|
+
return _conn(store.project_members(org, obj["id"])[:first])
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
@Project.field("activity")
|
|
804
|
+
def p_activity(obj, info, first=50, after=None, eventTypes=None):
|
|
805
|
+
store, _w, org, uid = _ctx(info)
|
|
806
|
+
first = _page_size(first)
|
|
807
|
+
cursor = _decode_activity_cursor(after)
|
|
808
|
+
rows = store.project_issue_activity(org, obj["id"], uid, cursor, first + 1, eventTypes or None)
|
|
809
|
+
nodes = [dict(row, eventType=row["event_type"], createdAt=row["created_at"]) for row in rows[:first]]
|
|
810
|
+
_prime(info, "issue_obj", [n["issue_id"] for n in nodes])
|
|
811
|
+
_prime(info, "user_row", [n.get("actor_id") for n in nodes])
|
|
812
|
+
return {"nodes": nodes, "pageInfo": {
|
|
813
|
+
"hasNextPage": len(rows) > first, "hasPreviousPage": after is not None,
|
|
814
|
+
"startCursor": _activity_cursor(nodes[0]) if nodes else None,
|
|
815
|
+
"endCursor": _activity_cursor(nodes[-1]) if nodes else None,
|
|
816
|
+
}}
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
@Project.field("progress")
|
|
820
|
+
def p_progress(obj, info):
|
|
821
|
+
store, _w, org, _uid = _ctx(info)
|
|
822
|
+
pid = obj["id"]
|
|
823
|
+
return _batched(info, "project_progress", pid, lambda ids: store.project_progress_many(org, ids),
|
|
824
|
+
lambda: store.project_progress_many(org, [pid])[pid], default=0.0)
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
@Project.field("teams")
|
|
828
|
+
def p_teams(obj, info, first=100):
|
|
829
|
+
store, _w, org, uid = _ctx(info)
|
|
830
|
+
first = _page_size(first, 100)
|
|
831
|
+
teams = _batched(info, "project_teams", obj["id"],
|
|
832
|
+
lambda ids: store.project_teams_many(org, ids, viewer_id=uid),
|
|
833
|
+
lambda: store.project_teams(org, obj["id"], viewer_id=uid), default=[])
|
|
834
|
+
return _conn(teams[:first])
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
# ----------------------------------------------------------------- Comment fields
|
|
838
|
+
@Comment.field("user")
|
|
839
|
+
def c_user(obj, info):
|
|
840
|
+
store, _w, _org, uid = _ctx(info)
|
|
841
|
+
return store.ser_user(_user_row(info, obj["_user_id"]), uid) if obj.get("_user_id") else None
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def _comment_can(obj, info, action) -> bool:
|
|
845
|
+
from hoja_server import comment_policy
|
|
846
|
+
store, _w, org, uid = _ctx(info)
|
|
847
|
+
subject = info.context.get("_comment_subject")
|
|
848
|
+
if subject is None:
|
|
849
|
+
with store.engine.connect() as conn:
|
|
850
|
+
subject = info.context["_comment_subject"] = comment_policy.subject(conn, org, uid)
|
|
851
|
+
# The comment was readable, so its team is visible to this viewer.
|
|
852
|
+
resource = {"author_id": obj.get("_user_id"), "archived": obj.get("_archived"), "team_visible": True}
|
|
853
|
+
return comment_policy.decide(action, subject, resource).allowed
|
|
854
|
+
|
|
855
|
+
|
|
856
|
+
@Comment.field("canEdit")
|
|
857
|
+
def c_can_edit(obj, info):
|
|
858
|
+
from hoja_server.comment_policy import EDIT
|
|
859
|
+
return _comment_can(obj, info, EDIT)
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
@Comment.field("canDelete")
|
|
863
|
+
def c_can_delete(obj, info):
|
|
864
|
+
from hoja_server.comment_policy import DELETE
|
|
865
|
+
return _comment_can(obj, info, DELETE)
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
# ----------------------------------------------------------------- Mutations
|
|
869
|
+
@mutation.field("workspaceCreate")
|
|
870
|
+
def m_workspace_create(_, info, input):
|
|
871
|
+
if single_org_mode():
|
|
872
|
+
raise GraphQLError(
|
|
873
|
+
"Creating a workspace is unavailable: this instance runs in single-organization mode.",
|
|
874
|
+
extensions={"code": "SINGLE_ORG_MODE", "field": "input"})
|
|
875
|
+
store, writer, org, uid = _ctx(info)
|
|
876
|
+
identity_id = info.context.get("identity_id")
|
|
877
|
+
if not identity_id:
|
|
878
|
+
membership = store.membership_for_profile(org, uid)
|
|
879
|
+
identity_id = membership.get("identity_id") if membership else None
|
|
880
|
+
if not identity_id:
|
|
881
|
+
raise ValueError("Workspace identity is unavailable")
|
|
882
|
+
created = writer.workspace_create(org, uid, input)
|
|
883
|
+
created.pop("_identity_id", None)
|
|
884
|
+
info.context["switch_target"] = {
|
|
885
|
+
"user_id": created.pop("_profile_user_id"),
|
|
886
|
+
"identity_id": identity_id,
|
|
887
|
+
"organization_id": created["id"],
|
|
888
|
+
}
|
|
889
|
+
return {"success": True, "organization": created}
|
|
890
|
+
|
|
891
|
+
@mutation.field("teamCreate")
|
|
892
|
+
def m_team_create(_, info, input):
|
|
893
|
+
store, writer, org, uid = _ctx(info)
|
|
894
|
+
tid = writer.team_create(org, uid, input)
|
|
895
|
+
row = store.team_by_id_or_key(org, tid, viewer_id=uid)
|
|
896
|
+
return {"success": True, "team": store.ser_team(row)}
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
@mutation.field("teamUpdate")
|
|
900
|
+
def m_team_update(_, info, id, input):
|
|
901
|
+
store, writer, org, uid = _ctx(info)
|
|
902
|
+
tid = writer.team_update(org, uid, id, input)
|
|
903
|
+
return {"success": True, "team": store.ser_team(store.team_by_id_or_key(org, tid))}
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
@mutation.field("teamArchive")
|
|
907
|
+
def m_team_archive(_, info, id):
|
|
908
|
+
_s, writer, org, uid = _ctx(info)
|
|
909
|
+
return {"success": writer.team_archive(org, uid, id)}
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
@mutation.field("teamDelete")
|
|
913
|
+
def m_team_delete(_, info, id, deleteIssues=False):
|
|
914
|
+
_s, writer, org, uid = _ctx(info)
|
|
915
|
+
return {"success": writer.team_delete(org, uid, id, delete_issues=bool(deleteIssues))}
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
@mutation.field("workspaceUpdate")
|
|
919
|
+
def m_workspace_update(_, info, input):
|
|
920
|
+
store, _w, org, uid = _ctx(info)
|
|
921
|
+
actor = store.get_user(org, uid)
|
|
922
|
+
if not actor or not actor.get("admin"):
|
|
923
|
+
return {"success": False, "organization": None}
|
|
924
|
+
updated = store.update_workspace(org, input)
|
|
925
|
+
return {"success": bool(updated), "organization": updated}
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
@mutation.field("apiKeyCreate")
|
|
929
|
+
def m_api_key_create(_, info, label, access="read_write", expiresAt=None):
|
|
930
|
+
store, _w, org, uid = _ctx(info)
|
|
931
|
+
created = store.create_api_key(org, uid, label, access, expiresAt)
|
|
932
|
+
if not created:
|
|
933
|
+
return {"success": False, "apiKey": None, "secret": None}
|
|
934
|
+
metadata, secret = created
|
|
935
|
+
return {"success": True, "apiKey": metadata, "secret": secret}
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
@mutation.field("apiKeyRevoke")
|
|
939
|
+
def m_api_key_revoke(_, info, id):
|
|
940
|
+
store, _w, org, uid = _ctx(info)
|
|
941
|
+
return store.revoke_api_key(org, uid, id)
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
@mutation.field("userPreferenceSet")
|
|
945
|
+
def m_user_preference_set(_, info, key, value):
|
|
946
|
+
store, _w, _org, uid = _ctx(info)
|
|
947
|
+
success = store.set_user_preference(uid, key, value)
|
|
948
|
+
return _preference_payload(store, uid, success=success)
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
@mutation.field("profileNameUpdate")
|
|
952
|
+
def m_profile_name_update(_, info, name):
|
|
953
|
+
store, _w, org, uid = _ctx(info)
|
|
954
|
+
updated = store.update_profile_name(org, uid, name)
|
|
955
|
+
return {"success": bool(updated), "user": updated, "apiKey": None}
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
@mutation.field("notificationPreferenceSet")
|
|
959
|
+
def m_notification_preference_set(_, info, kind, enabled):
|
|
960
|
+
store, _w, _org, uid = _ctx(info)
|
|
961
|
+
success = store.set_notification_preference(uid, kind, enabled)
|
|
962
|
+
prefs = store.user_preferences(uid) or {}
|
|
963
|
+
return {"success": success,
|
|
964
|
+
"issueCreated": prefs.get("notification:issueCreated", "true") == "true",
|
|
965
|
+
"issueUpdated": prefs.get("notification:issueUpdated", "true") == "true"}
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
@mutation.field("workspaceSwitch")
|
|
969
|
+
def m_workspace_switch(_, info, organizationId):
|
|
970
|
+
if single_org_mode():
|
|
971
|
+
raise GraphQLError(
|
|
972
|
+
"Switching workspace is unavailable: this instance runs in single-organization mode.",
|
|
973
|
+
extensions={"code": "SINGLE_ORG_MODE", "field": "organizationId"})
|
|
974
|
+
store, _w, org, uid = _ctx(info)
|
|
975
|
+
if not info.context.get("browser_session"):
|
|
976
|
+
return {"success": False, "organization": None}
|
|
977
|
+
member = store.membership_for_profile(org, uid)
|
|
978
|
+
target = store.switch_membership(member.get("identity_id") if member else None, organizationId)
|
|
979
|
+
if not target:
|
|
980
|
+
return {"success": False, "organization": None}
|
|
981
|
+
info.context["switch_target"] = target
|
|
982
|
+
return {"success": True, "organization": target["organization"]}
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
@mutation.field("membershipInvitationCreate")
|
|
986
|
+
def m_membership_invitation_create(_, info, userId):
|
|
987
|
+
store, _w, org, uid = _ctx(info)
|
|
988
|
+
actor = store.get_user(org, uid)
|
|
989
|
+
target = store.get_user(org, userId)
|
|
990
|
+
if not actor or not actor.get("admin") or not target:
|
|
991
|
+
return {"success": False, "user": None, "inviteCode": None}
|
|
992
|
+
code = store.create_membership_invitation(org, userId, uid)
|
|
993
|
+
return {"success": bool(code), "user": store.ser_user(store.get_user(org, userId), uid),
|
|
994
|
+
"inviteCode": code}
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
@query.field("pendingMembershipInvitations")
|
|
998
|
+
def q_pending_membership_invitations(_, info):
|
|
999
|
+
store, _w, org, uid = _ctx(info)
|
|
1000
|
+
actor = store.get_user(org, uid)
|
|
1001
|
+
if not actor or not actor.get("admin"):
|
|
1002
|
+
return []
|
|
1003
|
+
return store.pending_membership_invitations(org)
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
@mutation.field("membershipInvitationRevoke")
|
|
1007
|
+
def m_membership_invitation_revoke(_, info, userId):
|
|
1008
|
+
store, _w, org, uid = _ctx(info)
|
|
1009
|
+
actor = store.get_user(org, uid)
|
|
1010
|
+
if not actor or not actor.get("admin"):
|
|
1011
|
+
return {"success": False}
|
|
1012
|
+
return {"success": store.revoke_membership_invitation(org, userId)}
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
@mutation.field("memberDeactivate")
|
|
1016
|
+
def m_member_deactivate(_, info, userId):
|
|
1017
|
+
return _set_member_active(info, userId, active=False)
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
@mutation.field("memberReactivate")
|
|
1021
|
+
def m_member_reactivate(_, info, userId):
|
|
1022
|
+
return _set_member_active(info, userId, active=True)
|
|
1023
|
+
|
|
1024
|
+
|
|
1025
|
+
def _set_member_active(info, user_id, *, active: bool):
|
|
1026
|
+
store, _w, org, uid = _ctx(info)
|
|
1027
|
+
actor = store.get_user(org, uid)
|
|
1028
|
+
if not actor or not actor.get("admin"):
|
|
1029
|
+
return {"success": False, "user": None}
|
|
1030
|
+
target = store.set_member_active(org, uid, user_id, active)
|
|
1031
|
+
return {"success": bool(target), "user": store.ser_user(target, uid)}
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
@mutation.field("membershipInvitationAccept")
|
|
1035
|
+
def m_membership_invitation_accept(_, info, code, inviteePassword):
|
|
1036
|
+
store, _w, _org, uid = _ctx(info)
|
|
1037
|
+
identity_id = info.context.get("identity_id")
|
|
1038
|
+
organization = store.accept_membership_invitation(identity_id, code, inviteePassword) if identity_id else None
|
|
1039
|
+
return {"success": bool(organization), "organization": organization}
|
|
1040
|
+
|
|
1041
|
+
|
|
1042
|
+
@mutation.field("memberRoleUpdate")
|
|
1043
|
+
def m_member_role_update(_, info, userId, admin):
|
|
1044
|
+
store, _w, org, uid = _ctx(info)
|
|
1045
|
+
actor = store.get_user(org, uid)
|
|
1046
|
+
if not actor or not actor.get("admin"):
|
|
1047
|
+
return {"success": False, "user": None}
|
|
1048
|
+
target = store.set_member_admin_role(org, userId, admin)
|
|
1049
|
+
return {"success": bool(target), "user": store.ser_user(target, uid)}
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
@mutation.field("memberInvite")
|
|
1053
|
+
def m_member_invite(_, info, input):
|
|
1054
|
+
store, _w, org, uid = _ctx(info)
|
|
1055
|
+
actor = store.get_user(org, uid)
|
|
1056
|
+
if not actor or not actor.get("admin"):
|
|
1057
|
+
return {"success": False, "user": None, "apiKey": None}
|
|
1058
|
+
team_ids = input.get("teamIds") or []
|
|
1059
|
+
for team_id in team_ids:
|
|
1060
|
+
if not store.team_by_id_or_key(org, team_id, viewer_id=uid):
|
|
1061
|
+
raise GraphQLError("Team not found", extensions={"code": "BAD_USER_INPUT", "field": "teamIds"})
|
|
1062
|
+
created = store.invite_member(org, input.get("name", ""), input.get("email", ""),
|
|
1063
|
+
bool(input.get("admin", False)), team_ids)
|
|
1064
|
+
if not created:
|
|
1065
|
+
return {"success": False, "user": None, "apiKey": None}
|
|
1066
|
+
user_row, api_key = created
|
|
1067
|
+
return {"success": True, "user": store.ser_user(user_row, uid), "apiKey": api_key}
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def _set_team_membership(info, team_id, user_id, *, add: bool, role: str = "member"):
|
|
1071
|
+
store, _w, org, uid = _ctx(info)
|
|
1072
|
+
actor = store.get_user(org, uid)
|
|
1073
|
+
if not actor or not actor.get("admin"):
|
|
1074
|
+
return {"success": False, "user": None, "apiKey": None}
|
|
1075
|
+
if not store.team_by_id_or_key(org, team_id, viewer_id=uid):
|
|
1076
|
+
raise GraphQLError("Team not found", extensions={"code": "BAD_USER_INPUT", "field": "teamId"})
|
|
1077
|
+
target = store.get_user(org, user_id)
|
|
1078
|
+
if not target:
|
|
1079
|
+
raise GraphQLError("User not found", extensions={"code": "BAD_USER_INPUT", "field": "userId"})
|
|
1080
|
+
store.set_team_membership(org, team_id, user_id, add=add, role=role)
|
|
1081
|
+
return {"success": True, "user": store.ser_user(store.get_user(org, user_id), uid), "apiKey": None}
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
@mutation.field("teamMemberAdd")
|
|
1085
|
+
def m_team_member_add(_, info, teamId, userId, role="member"):
|
|
1086
|
+
return _set_team_membership(info, teamId, userId, add=True, role=role)
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
@mutation.field("teamMemberRemove")
|
|
1090
|
+
def m_team_member_remove(_, info, teamId, userId):
|
|
1091
|
+
return _set_team_membership(info, teamId, userId, add=False)
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
@mutation.field("issueCreate")
|
|
1095
|
+
def m_issue_create(_, info, input):
|
|
1096
|
+
store, w, org, uid = _ctx(info)
|
|
1097
|
+
iid = w.issue_create(org, uid, input)
|
|
1098
|
+
if not iid:
|
|
1099
|
+
return {"success": False, "issue": None}
|
|
1100
|
+
return {"success": True, "issue": store.issue_by_id_or_identifier(org, iid, viewer_id=uid)}
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
@mutation.field("issueUpdate")
|
|
1104
|
+
def m_issue_update(_, info, id, input):
|
|
1105
|
+
store, w, org, uid = _ctx(info)
|
|
1106
|
+
iid = w.issue_update(org, id, input, actor_id=uid)
|
|
1107
|
+
if not iid:
|
|
1108
|
+
return {"success": False, "issue": None}
|
|
1109
|
+
return {"success": True, "issue": store.issue_by_id_or_identifier(org, iid, viewer_id=uid)}
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
@mutation.field("issueDelete")
|
|
1113
|
+
def m_issue_delete(_, info, id):
|
|
1114
|
+
_s, w, org, uid = _ctx(info)
|
|
1115
|
+
return {"success": w.issue_delete(org, uid, id)}
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
@mutation.field("issueArchive")
|
|
1119
|
+
def m_issue_archive(_, info, id):
|
|
1120
|
+
_s, w, org, uid = _ctx(info)
|
|
1121
|
+
return {"success": w.issue_archive(org, uid, id)}
|
|
1122
|
+
|
|
1123
|
+
|
|
1124
|
+
@mutation.field("commentCreate")
|
|
1125
|
+
def m_comment_create(_, info, input):
|
|
1126
|
+
store, w, org, uid = _ctx(info)
|
|
1127
|
+
cid = w.comment_create(org, uid, input)
|
|
1128
|
+
if not cid:
|
|
1129
|
+
return {"success": False, "comment": None}
|
|
1130
|
+
return {"success": True, "comment": _get_comment(store, org, cid)}
|
|
1131
|
+
|
|
1132
|
+
|
|
1133
|
+
@mutation.field("commentUpdate")
|
|
1134
|
+
def m_comment_update(_, info, id, input):
|
|
1135
|
+
store, w, org, uid = _ctx(info)
|
|
1136
|
+
cid = w.comment_update(org, uid, id, input)
|
|
1137
|
+
if not cid:
|
|
1138
|
+
return {"success": False, "comment": None}
|
|
1139
|
+
return {"success": True, "comment": _get_comment(store, org, cid)}
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
@mutation.field("commentDelete")
|
|
1143
|
+
def m_comment_delete(_, info, id):
|
|
1144
|
+
_s, w, org, uid = _ctx(info)
|
|
1145
|
+
return {"success": w.comment_delete(org, uid, id)}
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
@mutation.field("issueLabelCreate")
|
|
1149
|
+
def m_label_create(_, info, input):
|
|
1150
|
+
store, w, org, uid = _ctx(info)
|
|
1151
|
+
actor = store.get_user(org, uid)
|
|
1152
|
+
if not actor or not actor.get("admin"):
|
|
1153
|
+
return {"success": False, "issueLabel": None}
|
|
1154
|
+
name = str(input.get("name", "")).strip()
|
|
1155
|
+
if not name or len(name) > 64:
|
|
1156
|
+
return {"success": False, "issueLabel": None}
|
|
1157
|
+
input = {**input, "name": name}
|
|
1158
|
+
lid = w.label_create(org, input)
|
|
1159
|
+
labels = store.labels(org)
|
|
1160
|
+
node = next((label for label in labels if label["id"] == lid), None)
|
|
1161
|
+
return {"success": bool(lid), "issueLabel": node}
|
|
1162
|
+
|
|
1163
|
+
|
|
1164
|
+
@mutation.field("issueLabelUpdate")
|
|
1165
|
+
def m_label_update(_, info, id, input):
|
|
1166
|
+
store, w, org, uid = _ctx(info)
|
|
1167
|
+
if not w.label_update(org, uid, id, input):
|
|
1168
|
+
return {"success": False, "issueLabel": None}
|
|
1169
|
+
node = next((label for label in store.labels(org) if label["id"] == id), None)
|
|
1170
|
+
return {"success": True, "issueLabel": node}
|
|
1171
|
+
|
|
1172
|
+
|
|
1173
|
+
@mutation.field("issueLabelDelete")
|
|
1174
|
+
def m_label_delete(_, info, id):
|
|
1175
|
+
store, w, org, uid = _ctx(info)
|
|
1176
|
+
actor = store.get_user(org, uid)
|
|
1177
|
+
if not actor or not actor.get("admin"):
|
|
1178
|
+
return {"success": False}
|
|
1179
|
+
return {"success": w.label_delete(org, id)}
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
@mutation.field("projectCreate")
|
|
1183
|
+
def m_project_create(_, info, input):
|
|
1184
|
+
store, w, org, uid = _ctx(info)
|
|
1185
|
+
pid = w.project_create(org, uid, input)
|
|
1186
|
+
return {"success": bool(pid), "project": store.project_by_id(org, pid, viewer_id=uid) if pid else None}
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
@mutation.field("projectUpdate")
|
|
1190
|
+
def m_project_update(_, info, id, input):
|
|
1191
|
+
store, w, org, uid = _ctx(info)
|
|
1192
|
+
pid = w.project_update(org, uid, id, input)
|
|
1193
|
+
return {"success": bool(pid), "project": store.project_by_id(org, pid, viewer_id=uid) if pid else None}
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
@mutation.field("projectUpdateCreate")
|
|
1197
|
+
def m_project_update_create(_, info, input):
|
|
1198
|
+
store, w, org, uid = _ctx(info)
|
|
1199
|
+
update_id = w.project_update_create(org, input["projectId"], uid, input)
|
|
1200
|
+
rows = store.project_updates(org, project_id=input["projectId"], limit=1) if update_id else []
|
|
1201
|
+
created = next((row for row in rows if row["id"] == update_id), None)
|
|
1202
|
+
return {"success": bool(created), "projectUpdate": created}
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
@mutation.field("projectArchive")
|
|
1206
|
+
def m_project_archive(_, info, id):
|
|
1207
|
+
_s, w, org, uid = _ctx(info)
|
|
1208
|
+
return {"success": w.project_archive(org, uid, id)}
|
|
1209
|
+
|
|
1210
|
+
@mutation.field("initiativeCreate")
|
|
1211
|
+
def m_initiative_create(_, info, input):
|
|
1212
|
+
store, writer, org, uid = _ctx(info)
|
|
1213
|
+
ident = writer.initiative_create(org, uid, input)
|
|
1214
|
+
return {"success": True, "initiative": store.initiative_by_id(org, ident)}
|
|
1215
|
+
|
|
1216
|
+
@mutation.field("initiativeUpdate")
|
|
1217
|
+
def m_initiative_update(_, info, id, input):
|
|
1218
|
+
store, writer, org, uid = _ctx(info)
|
|
1219
|
+
success = writer.initiative_update(org, uid, id, input)
|
|
1220
|
+
return {"success": success, "initiative": store.initiative_by_id(org, id) if success else None}
|
|
1221
|
+
|
|
1222
|
+
@mutation.field("initiativeArchive")
|
|
1223
|
+
def m_initiative_archive(_, info, id):
|
|
1224
|
+
_store, writer, org, uid = _ctx(info)
|
|
1225
|
+
return {"success": writer.initiative_archive(org, uid, id)}
|
|
1226
|
+
|
|
1227
|
+
|
|
1228
|
+
@mutation.field("attachmentCreate")
|
|
1229
|
+
def m_attachment_create(_, info, input):
|
|
1230
|
+
_store, w, org, uid = _ctx(info)
|
|
1231
|
+
created = w.attachment_create(org, uid, input)
|
|
1232
|
+
return {"success": bool(created), "attachment": created}
|
|
1233
|
+
|
|
1234
|
+
|
|
1235
|
+
@mutation.field("issueSubscribe")
|
|
1236
|
+
def m_issue_subscribe(_, info, id):
|
|
1237
|
+
_store, w, org, uid = _ctx(info)
|
|
1238
|
+
return bool(w.issue_subscribe(org, uid, id))
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
@mutation.field("issueUnsubscribe")
|
|
1242
|
+
def m_issue_unsubscribe(_, info, id):
|
|
1243
|
+
_store, w, org, uid = _ctx(info)
|
|
1244
|
+
return bool(w.issue_unsubscribe(org, uid, id))
|
|
1245
|
+
|
|
1246
|
+
|
|
1247
|
+
@mutation.field("cycleUpdate")
|
|
1248
|
+
def m_cycle_update(_, info, id, input):
|
|
1249
|
+
_store, w, org, _uid = _ctx(info)
|
|
1250
|
+
updated = w.cycle_update(org, id, input)
|
|
1251
|
+
return {"success": bool(updated), "cycle": updated}
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
@Attachment.field("issue")
|
|
1255
|
+
def a_issue(obj, info):
|
|
1256
|
+
store, _w, org, uid = _ctx(info)
|
|
1257
|
+
return store.issue_by_id_or_identifier(org, obj["_issue_id"], viewer_id=uid)
|
|
1258
|
+
|
|
1259
|
+
|
|
1260
|
+
def _get_comment(store, org, cid):
|
|
1261
|
+
from sqlalchemy import and_, select
|
|
1262
|
+
|
|
1263
|
+
from hoja_server.db import comment as cmt
|
|
1264
|
+
with store.engine.connect() as conn:
|
|
1265
|
+
r = conn.execute(select(cmt).where(and_(cmt.c.organization_id == org, cmt.c.id == cid))).first()
|
|
1266
|
+
return store.ser_comment(dict(r._mapping)) if r else None
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def build_schema():
|
|
1270
|
+
from pathlib import Path
|
|
1271
|
+
sdl = (Path(__file__).parent / "schema.graphql").read_text()
|
|
1272
|
+
from hoja_server.model_providers import SDL as PROVIDER_SDL
|
|
1273
|
+
from hoja_server import model_providers
|
|
1274
|
+
from hoja_server.agent_conversations import SDL as CONVERSATION_SDL, query as conversation_query
|
|
1275
|
+
return make_executable_schema(
|
|
1276
|
+
[sdl, PROVIDER_SDL, CONVERSATION_SDL], query, mutation, model_providers.query, model_providers.mutation,
|
|
1277
|
+
conversation_query,
|
|
1278
|
+
model_providers.model_provider_type, Team, Initiative, Issue, IssueActivity, MyIssueActivity, Project, ProjectUpdate, Comment,
|
|
1279
|
+
WorkflowState, Cycle, Attachment,
|
|
1280
|
+
datetime_scalar, timeless_scalar, json_scalar, InboxNotification,
|
|
1281
|
+
)
|