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.
Files changed (107) hide show
  1. VERSION +1 -0
  2. google_workspace/__init__.py +19 -0
  3. google_workspace/client.py +253 -0
  4. google_workspace/config.py +39 -0
  5. google_workspace/errors.py +19 -0
  6. google_workspace/mcp_server.py +100 -0
  7. google_workspace/oauth.py +131 -0
  8. google_workspace/principal.py +83 -0
  9. google_workspace/py.typed +0 -0
  10. google_workspace/tools.py +64 -0
  11. google_workspace/writes.py +44 -0
  12. hoja/__init__.py +29 -0
  13. hoja/__main__.py +6 -0
  14. hoja/auth.py +42 -0
  15. hoja/cli.py +161 -0
  16. hoja/cli_state.py +55 -0
  17. hoja/client.py +268 -0
  18. hoja/commands/__init__.py +4 -0
  19. hoja/commands/auth_cmd.py +271 -0
  20. hoja/commands/comment.py +149 -0
  21. hoja/commands/cycle.py +75 -0
  22. hoja/commands/init.py +108 -0
  23. hoja/commands/issue.py +398 -0
  24. hoja/commands/label.py +163 -0
  25. hoja/commands/memory.py +240 -0
  26. hoja/commands/project.py +56 -0
  27. hoja/commands/raw.py +50 -0
  28. hoja/commands/team.py +102 -0
  29. hoja/commands/update.py +162 -0
  30. hoja/config.py +362 -0
  31. hoja/errors.py +107 -0
  32. hoja/filters.py +150 -0
  33. hoja/graphql/__init__.py +5 -0
  34. hoja/graphql/fragments.py +138 -0
  35. hoja/graphql/mutations.py +160 -0
  36. hoja/graphql/queries.py +245 -0
  37. hoja/mcp/__init__.py +27 -0
  38. hoja/mcp/content.py +151 -0
  39. hoja/mcp/prompts.py +298 -0
  40. hoja/mcp/resources.py +213 -0
  41. hoja/mcp/server.py +189 -0
  42. hoja/memory_board.py +370 -0
  43. hoja/models/__init__.py +37 -0
  44. hoja/models/base.py +64 -0
  45. hoja/models/cycle.py +32 -0
  46. hoja/models/enums.py +62 -0
  47. hoja/models/issue.py +99 -0
  48. hoja/models/label.py +17 -0
  49. hoja/models/project.py +60 -0
  50. hoja/models/team.py +31 -0
  51. hoja/models/user.py +32 -0
  52. hoja/models/workflow.py +65 -0
  53. hoja/output.py +410 -0
  54. hoja/skill_content/commands.md +222 -0
  55. hoja/skill_content/examples.md +193 -0
  56. hoja/skill_content/filters.md +101 -0
  57. hoja/skill_content/output-formats.md +92 -0
  58. hoja/skill_content/overview.md +123 -0
  59. hoja/skill_content/workflows.md +174 -0
  60. hoja_cli-0.21.4.dist-info/METADATA +279 -0
  61. hoja_cli-0.21.4.dist-info/RECORD +107 -0
  62. hoja_cli-0.21.4.dist-info/WHEEL +4 -0
  63. hoja_cli-0.21.4.dist-info/entry_points.txt +4 -0
  64. hoja_cli-0.21.4.dist-info/licenses/LICENSE +21 -0
  65. hoja_server/README.md +135 -0
  66. hoja_server/__init__.py +11 -0
  67. hoja_server/abac.py +116 -0
  68. hoja_server/agent_chat.py +280 -0
  69. hoja_server/agent_conversations.py +91 -0
  70. hoja_server/agent_durable.py +332 -0
  71. hoja_server/agent_prompt.py +117 -0
  72. hoja_server/agent_threads.py +96 -0
  73. hoja_server/agent_uploads.py +60 -0
  74. hoja_server/app.py +2189 -0
  75. hoja_server/cadence.py +231 -0
  76. hoja_server/cli.py +191 -0
  77. hoja_server/cloudflare_realtime.py +110 -0
  78. hoja_server/comment_policy.py +70 -0
  79. hoja_server/connector_actions.py +119 -0
  80. hoja_server/connectors_api.py +375 -0
  81. hoja_server/db.py +1816 -0
  82. hoja_server/denials.py +18 -0
  83. hoja_server/deployment.py +28 -0
  84. hoja_server/email_adapter.py +94 -0
  85. hoja_server/google_login.py +217 -0
  86. hoja_server/google_oidc.py +342 -0
  87. hoja_server/identity_transfer.py +210 -0
  88. hoja_server/mcp_oauth.py +623 -0
  89. hoja_server/mcp_tools.py +579 -0
  90. hoja_server/meeting_policy.py +179 -0
  91. hoja_server/meetings.py +1332 -0
  92. hoja_server/meetings_api.py +909 -0
  93. hoja_server/model_providers.py +205 -0
  94. hoja_server/realtime.py +690 -0
  95. hoja_server/resolvers.py +1281 -0
  96. hoja_server/schema.graphql +477 -0
  97. hoja_server/schema_sdl.py +8 -0
  98. hoja_server/secrets_box.py +39 -0
  99. hoja_server/signup_config.py +292 -0
  100. hoja_server/signup_gate.py +393 -0
  101. hoja_server/signup_policy.py +97 -0
  102. hoja_server/signup_store.py +577 -0
  103. hoja_server/store.py +2179 -0
  104. hoja_server/sync.py +249 -0
  105. hoja_server/tenant_context.py +131 -0
  106. hoja_server/turnstile.py +81 -0
  107. hoja_server/writer.py +1262 -0
hoja_server/writer.py ADDED
@@ -0,0 +1,1262 @@
1
+ """Write operations with transactional identifiers and derived fields."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from urllib.parse import urlparse
6
+
7
+ from graphql import GraphQLError
8
+ from sqlalchemy import and_, func, or_, select
9
+ from sqlalchemy.engine import Engine
10
+
11
+ from hoja_server.store import (
12
+ PUBLIC,
13
+ TEAM_ACCESS_LEVELS,
14
+ team_ancestor_ids,
15
+ team_descendant_ids,
16
+ team_visible,
17
+ )
18
+ from hoja_server import comment_policy
19
+ from hoja_server.db import (
20
+ PRIORITY_LABELS,
21
+ attachment,
22
+ comment,
23
+ cycle,
24
+ entity_url,
25
+ issue,
26
+ issue_activity,
27
+ inbox_notification,
28
+ issue_label,
29
+ issue_label_link,
30
+ issue_subscriber,
31
+ meeting,
32
+ new_id,
33
+ now_iso,
34
+ organization,
35
+ project,
36
+ project_member,
37
+ project_update,
38
+ initiative,
39
+ initiative_project,
40
+ project_team,
41
+ seed_states_for_team,
42
+ team,
43
+ team_member,
44
+ user,
45
+ workflow_state,
46
+ )
47
+
48
+
49
+ def _slug(text: str, n: int = 24) -> str:
50
+ out = "".join(c if c.isalnum() else "-" for c in text.lower()).strip("-")
51
+ while "--" in out:
52
+ out = out.replace("--", "-")
53
+ return out[:n].strip("-")
54
+
55
+
56
+ def _org_url_key(conn, org_id: str) -> str:
57
+ r = conn.execute(select(organization.c.url_key).where(organization.c.id == org_id)).first()
58
+ return (r[0] if r and r[0] else "local")
59
+
60
+
61
+ class InvalidReferenceError(GraphQLError):
62
+ def __init__(self, field: str) -> None:
63
+ super().__init__(
64
+ "Invalid reference",
65
+ extensions={"code": "BAD_USER_INPUT", "field": field},
66
+ )
67
+
68
+
69
+ def _require_org_reference(conn, table, org_id: str, value: str, field: str, *extra) -> str:
70
+ conditions = [
71
+ table.c.id == value,
72
+ table.c.organization_id == org_id,
73
+ *extra,
74
+ ]
75
+ if "archived_at" in table.c:
76
+ conditions.append(table.c.archived_at.is_(None))
77
+ if not conn.execute(select(table.c.id).where(and_(*conditions))).first():
78
+ raise InvalidReferenceError(field)
79
+ return value
80
+
81
+
82
+ def _require_team_user(conn, org_id: str, team_id: str, value: str, field: str) -> str:
83
+ row = conn.execute(
84
+ select(user.c.id)
85
+ .join(team_member, team_member.c.user_id == user.c.id)
86
+ .where(
87
+ and_(
88
+ user.c.id == value,
89
+ user.c.organization_id == org_id,
90
+ user.c.active.is_(True),
91
+ user.c.archived_at.is_(None),
92
+ team_member.c.team_id == team_id,
93
+ )
94
+ )
95
+ ).first()
96
+ if not row:
97
+ raise InvalidReferenceError(field)
98
+ return value
99
+
100
+
101
+ def _validate_issue_references(
102
+ conn,
103
+ org_id: str,
104
+ team_id: str,
105
+ inp: dict,
106
+ *,
107
+ issue_id: str | None = None,
108
+ default_state_id: str | None = None,
109
+ ) -> str | None:
110
+ """Validate issue references and return the normalized internal parent id."""
111
+ state_id = inp.get("stateId") if "stateId" in inp else default_state_id
112
+ if state_id:
113
+ _require_org_reference(
114
+ conn, workflow_state, org_id, state_id, "stateId",
115
+ workflow_state.c.team_id == team_id,
116
+ )
117
+ if assignee_id := inp.get("assigneeId"):
118
+ _require_team_user(conn, org_id, team_id, assignee_id, "assigneeId")
119
+ if project_id := inp.get("projectId"):
120
+ _require_org_reference(conn, project, org_id, project_id, "projectId")
121
+ project_teams = list(
122
+ conn.execute(
123
+ select(project_team.c.team_id).where(
124
+ project_team.c.project_id == project_id
125
+ )
126
+ ).scalars()
127
+ )
128
+ if project_teams and team_id not in project_teams:
129
+ raise InvalidReferenceError("projectId")
130
+ if cycle_id := inp.get("cycleId"):
131
+ _require_org_reference(
132
+ conn, cycle, org_id, cycle_id, "cycleId", cycle.c.team_id == team_id
133
+ )
134
+ parent_id = None
135
+ if supplied_parent_id := inp.get("parentId"):
136
+ parent = conn.execute(select(issue.c.id).where(and_(
137
+ issue.c.organization_id == org_id,
138
+ issue.c.team_id == team_id,
139
+ issue.c.archived_at.is_(None),
140
+ or_(issue.c.id == supplied_parent_id,
141
+ issue.c.identifier == str(supplied_parent_id).upper()),
142
+ ))).first()
143
+ if not parent:
144
+ raise InvalidReferenceError("parentId")
145
+ parent_id = parent[0]
146
+ if parent_id == issue_id:
147
+ raise InvalidReferenceError("parentId")
148
+ if issue_id is not None:
149
+ # Reparenting below any descendant would make parent traversal cyclic.
150
+ ancestor_id, seen = parent_id, set()
151
+ while ancestor_id:
152
+ if ancestor_id == issue_id or ancestor_id in seen:
153
+ raise InvalidReferenceError("parentId")
154
+ seen.add(ancestor_id)
155
+ ancestor_id = conn.execute(select(issue.c.parent_id).where(and_(
156
+ issue.c.organization_id == org_id, issue.c.id == ancestor_id,
157
+ ))).scalar()
158
+ label_ids = inp.get("labelIds") if "labelIds" in inp else None
159
+ if label_ids:
160
+ expected = set(label_ids)
161
+ applicable_teams = team_ancestor_ids(conn, org_id, team_id)
162
+ found = set(
163
+ conn.execute(
164
+ select(issue_label.c.id).where(
165
+ and_(
166
+ issue_label.c.id.in_(expected),
167
+ issue_label.c.organization_id == org_id,
168
+ issue_label.c.archived_at.is_(None),
169
+ or_(
170
+ issue_label.c.team_id.is_(None),
171
+ # A sub-team may use its own labels and every ancestor's.
172
+ issue_label.c.team_id.in_(applicable_teams),
173
+ ),
174
+ )
175
+ )
176
+ ).scalars()
177
+ )
178
+ if found != expected:
179
+ raise InvalidReferenceError("labelIds")
180
+ return parent_id
181
+
182
+
183
+ class Writer:
184
+ def __init__(self, engine: Engine) -> None:
185
+ self.engine = engine
186
+
187
+ def workspace_create(self, actor_org_id: str, actor_id: str, inp: dict) -> dict:
188
+ """Create a workspace and enroll its creating identity as admin."""
189
+ from hoja_server.db import global_identity, organization_membership
190
+
191
+ name = inp.get("name")
192
+ raw_key = inp.get("urlKey")
193
+ if not isinstance(name, str) or not name.strip():
194
+ raise GraphQLError("Workspace name is required", extensions={"code": "BAD_USER_INPUT", "field": "name"})
195
+ key = raw_key.strip().lower() if isinstance(raw_key, str) else ""
196
+ if not key or len(key) > 48 or not key.replace("-", "").isalnum() or key.startswith("-") or key.endswith("-") or "--" in key:
197
+ raise GraphQLError("Workspace key must contain letters, numbers, and hyphens", extensions={"code": "BAD_USER_INPUT", "field": "urlKey"})
198
+ ts, org_id, profile_id = now_iso(), new_id(), new_id()
199
+ with self.engine.begin() as conn:
200
+ actor = conn.execute(select(user).where(and_(
201
+ user.c.id == actor_id, user.c.organization_id == actor_org_id,
202
+ user.c.active.is_(True), user.c.archived_at.is_(None),
203
+ user.c.admin.is_(True),
204
+ ))).mappings().first()
205
+ if not actor:
206
+ raise GraphQLError("Admin access required", extensions={"code": "FORBIDDEN"})
207
+ if conn.execute(select(organization.c.id).where(
208
+ func.lower(organization.c.url_key) == key
209
+ )).first():
210
+ raise GraphQLError("Workspace key already exists", extensions={"code": "BAD_USER_INPUT", "field": "urlKey"})
211
+ member = conn.execute(select(organization_membership.c.identity_id).where(and_(
212
+ organization_membership.c.organization_id == actor_org_id,
213
+ organization_membership.c.profile_user_id == actor_id,
214
+ organization_membership.c.active.is_(True),
215
+ ))).first()
216
+ if not member:
217
+ raise GraphQLError("Workspace identity is unavailable", extensions={"code": "FORBIDDEN"})
218
+ conn.execute(organization.insert().values(
219
+ id=org_id, name=name.strip(), url_key=key, created_at=ts, updated_at=ts,
220
+ ))
221
+ conn.execute(user.insert().values(
222
+ id=profile_id, organization_id=org_id, name=actor.get("name"),
223
+ display_name=actor.get("display_name") or actor.get("name"),
224
+ email=actor.get("email"), active=True, admin=True,
225
+ timezone=actor.get("timezone") or "UTC", created_at=ts, updated_at=ts,
226
+ ))
227
+ conn.execute(organization_membership.insert().values(
228
+ id=new_id(), identity_id=member[0], organization_id=org_id,
229
+ profile_user_id=profile_id, active=True, created_at=ts,
230
+ ))
231
+ return {"id": org_id, "name": name.strip(), "urlKey": key,
232
+ "_profile_user_id": profile_id, "_identity_id": member[0]}
233
+
234
+ # --------------------------------------------------------------- teams
235
+ def team_create(self, org_id: str, viewer_id: str, inp: dict) -> str:
236
+ name, key = inp.get("name"), inp.get("key")
237
+ if not isinstance(name, str) or not name.strip():
238
+ raise GraphQLError("Team name is required", extensions={"code": "BAD_USER_INPUT", "field": "name"})
239
+ if not isinstance(key, str) or not key.strip() or not key.strip().isalnum():
240
+ raise GraphQLError("Team key must be alphanumeric", extensions={"code": "BAD_USER_INPUT", "field": "key"})
241
+ key = key.strip().upper()
242
+ with self.engine.begin() as conn:
243
+ admin = conn.execute(select(user.c.id).where(and_(
244
+ user.c.id == viewer_id, user.c.organization_id == org_id,
245
+ user.c.active.is_(True), user.c.archived_at.is_(None), user.c.admin.is_(True)
246
+ ))).first()
247
+ if not admin:
248
+ raise GraphQLError("Admin access required", extensions={"code": "FORBIDDEN"})
249
+ if conn.execute(select(team.c.id).where(and_(
250
+ team.c.organization_id == org_id, team.c.key == key
251
+ ))).first():
252
+ raise GraphQLError("Team key already exists", extensions={"code": "BAD_USER_INPUT", "field": "key"})
253
+ parent_team_id = inp.get("parentTeamId") or None
254
+ if parent_team_id:
255
+ parent_team_id = self._team_row(conn, org_id, parent_team_id)[0]
256
+ self._validate_parent(conn, org_id, None, parent_team_id)
257
+ visibility = self._validate_visibility(inp.get("visibility") or PUBLIC)
258
+ ts, tid = now_iso(), new_id()
259
+ conn.execute(team.insert().values(
260
+ id=tid, organization_id=org_id, name=name.strip(), key=key,
261
+ description=inp.get("description"), color=inp.get("color"), icon=inp.get("icon"),
262
+ visibility=visibility, parent_team_id=parent_team_id,
263
+ timezone=inp.get("timezone") or "UTC",
264
+ issue_counter=0, created_at=ts, updated_at=ts,
265
+ ))
266
+ default_state_id = seed_states_for_team(conn, org_id, tid)
267
+ conn.execute(team.update().where(team.c.id == tid).values(
268
+ default_state_id=default_state_id
269
+ ))
270
+ # The creator owns the team and, by the cascade rule, every ancestor.
271
+ self._seat_member(conn, org_id, tid, viewer_id, role="owner")
272
+ for member_id in dict.fromkeys(inp.get("memberIds") or []):
273
+ if member_id == viewer_id:
274
+ continue
275
+ if not conn.execute(select(user.c.id).where(and_(
276
+ user.c.id == member_id, user.c.organization_id == org_id,
277
+ user.c.active.is_(True), user.c.archived_at.is_(None)))).first():
278
+ raise InvalidReferenceError("memberIds")
279
+ self._seat_member(conn, org_id, tid, member_id)
280
+ return tid
281
+
282
+ def _seat_member(self, conn, org_id: str, team_id: str, user_id: str, *, role: str = "member") -> None:
283
+ """Seat someone on a team and, by the cascade rule, on every ancestor (idempotent)."""
284
+ for ancestor in team_ancestor_ids(conn, org_id, team_id):
285
+ exists = conn.execute(select(team_member.c.user_id).where(and_(
286
+ team_member.c.team_id == ancestor, team_member.c.user_id == user_id))).first()
287
+ if not exists:
288
+ conn.execute(team_member.insert().values(
289
+ team_id=ancestor, user_id=user_id,
290
+ role=role if ancestor == team_id else "member"))
291
+
292
+ def _require_team_admin(self, conn, org_id: str, viewer_id: str) -> None:
293
+ admin = conn.execute(select(user.c.id).where(and_(
294
+ user.c.id == viewer_id, user.c.organization_id == org_id,
295
+ user.c.active.is_(True), user.c.archived_at.is_(None), user.c.admin.is_(True)
296
+ ))).first()
297
+ if not admin:
298
+ raise GraphQLError("Admin access required", extensions={"code": "FORBIDDEN"})
299
+
300
+ def _require_creator_or_admin(self, conn, org_id: str, viewer_id: str, creator_id: str | None) -> None:
301
+ if creator_id and creator_id == viewer_id:
302
+ return
303
+ self._require_team_admin(conn, org_id, viewer_id)
304
+
305
+ def _require_team_visible(self, conn, org_id: str, viewer_id: str, team_id: str | None) -> None:
306
+ """Private teams are writable only by their members (or admins); never leak existence."""
307
+ if team_id and not team_visible(conn, org_id, viewer_id, team_id):
308
+ raise InvalidReferenceError("teamId")
309
+
310
+ def _team_row(self, conn, org_id: str, team_id: str, *, include_archived: bool = False):
311
+ clauses = [team.c.id == team_id, team.c.organization_id == org_id]
312
+ if not include_archived:
313
+ clauses.append(team.c.archived_at.is_(None))
314
+ row = conn.execute(select(team).where(and_(*clauses))).first()
315
+ if not row:
316
+ raise GraphQLError("Team not found", extensions={"code": "BAD_USER_INPUT", "field": "id"})
317
+ return row
318
+
319
+ # ----------------------------------------------------------- departments
320
+ MAX_TEAM_DEPTH = 3
321
+
322
+ @staticmethod
323
+ def _team_depth(conn, org_id: str, team_id: str | None) -> int:
324
+ """1 for a root team, 2 for its child, 3 for a grandchild."""
325
+ depth, current, seen = 0, team_id, set()
326
+ while current and current not in seen:
327
+ seen.add(current)
328
+ depth += 1
329
+ current = conn.execute(select(team.c.parent_team_id).where(and_(
330
+ team.c.id == current, team.c.organization_id == org_id))).scalar()
331
+ return depth
332
+
333
+ @classmethod
334
+ def _subtree_height(cls, conn, org_id: str, team_id: str) -> int:
335
+ """How many levels live at or below this team (1 when it has no children)."""
336
+ children = list(conn.execute(select(team.c.id).where(and_(
337
+ team.c.organization_id == org_id, team.c.parent_team_id == team_id))).scalars())
338
+ if not children:
339
+ return 1
340
+ return 1 + max(cls._subtree_height(conn, org_id, child) for child in children)
341
+
342
+ def _validate_parent(self, conn, org_id: str, team_id: str | None, parent_team_id: str | None) -> None:
343
+ """Reject self-parenting, cycles and any shape deeper than MAX_TEAM_DEPTH."""
344
+ if not parent_team_id:
345
+ return
346
+ parent = self._team_row(conn, org_id, parent_team_id)
347
+ if team_id is None:
348
+ # Creating a team under this parent: the new team is one level deeper.
349
+ new_depth = self._team_depth(conn, org_id, parent_team_id) + 1
350
+ if new_depth > self.MAX_TEAM_DEPTH:
351
+ raise GraphQLError(
352
+ f"Departments can nest at most {self.MAX_TEAM_DEPTH} levels deep (max depth {self.MAX_TEAM_DEPTH})",
353
+ extensions={"code": "BAD_USER_INPUT", "field": "parentTeamId"})
354
+ return
355
+ if parent_team_id == team_id:
356
+ raise GraphQLError("A team cannot be its own parent",
357
+ extensions={"code": "BAD_USER_INPUT", "field": "parentTeamId"})
358
+ # Walk up from the proposed parent: reaching the team means a cycle.
359
+ current, seen = parent_team_id, set()
360
+ while current and current not in seen:
361
+ if current == team_id:
362
+ raise GraphQLError(
363
+ "A team cannot be nested under itself or one of its descendants (cycle)",
364
+ extensions={"code": "BAD_USER_INPUT", "field": "parentTeamId"})
365
+ seen.add(current)
366
+ current = conn.execute(select(team.c.parent_team_id).where(and_(
367
+ team.c.id == current, team.c.organization_id == org_id))).scalar()
368
+ new_depth = (self._team_depth(conn, org_id, parent_team_id)
369
+ + self._subtree_height(conn, org_id, team_id))
370
+ if new_depth > self.MAX_TEAM_DEPTH:
371
+ raise GraphQLError(
372
+ f"Departments can nest at most {self.MAX_TEAM_DEPTH} levels deep (max depth {self.MAX_TEAM_DEPTH})",
373
+ extensions={"code": "BAD_USER_INPUT", "field": "parentTeamId"})
374
+ if not parent:
375
+ raise InvalidReferenceError("parentTeamId")
376
+
377
+ @staticmethod
378
+ def _validate_visibility(value) -> str:
379
+ if value not in TEAM_ACCESS_LEVELS:
380
+ raise GraphQLError("visibility must be one of public, restricted, private",
381
+ extensions={"code": "BAD_USER_INPUT", "field": "visibility"})
382
+ return value
383
+
384
+ def _apply_reparent(self, conn, org_id: str, team_id: str, previous_parent_id: str | None) -> None:
385
+ """Side effects of moving a team in or out of a department.
386
+
387
+ Nesting: every member of the moved subtree also joins the new ancestors, the
388
+ in-flight cycle is closed, and the not-yet-started cycles take the boundaries
389
+ of the nearest parent cycle so the sub-team reports on the department rhythm.
390
+ Un-nesting: labels the team was using through its old ancestors become its own
391
+ copies when issues still use them, and the unused ones simply stop applying.
392
+ """
393
+ if previous_parent_id:
394
+ self._release_inherited_labels(conn, org_id, team_id, previous_parent_id)
395
+ new_parent_id = conn.execute(select(team.c.parent_team_id).where(
396
+ team.c.id == team_id)).scalar()
397
+ if not new_parent_id:
398
+ return
399
+ subtree = [team_id, *team_descendant_ids(conn, org_id, team_id)[1:]]
400
+ ancestors = team_ancestor_ids(conn, org_id, new_parent_id)
401
+ for member_id in list(conn.execute(select(team_member.c.user_id).where(
402
+ team_member.c.team_id.in_(subtree))).scalars()):
403
+ for ancestor in ancestors:
404
+ if not conn.execute(select(team_member.c.user_id).where(and_(
405
+ team_member.c.team_id == ancestor,
406
+ team_member.c.user_id == member_id))).first():
407
+ conn.execute(team_member.insert().values(
408
+ team_id=ancestor, user_id=member_id, role="member"))
409
+ for tid in subtree:
410
+ self._align_cycles_with_parent(conn, org_id, tid)
411
+
412
+ def _release_inherited_labels(self, conn, org_id: str, team_id: str, old_parent_id: str) -> None:
413
+ """Turn labels used through the old ancestors into team-owned copies."""
414
+ ancestors = team_ancestor_ids(conn, org_id, old_parent_id)
415
+ inherited = conn.execute(select(
416
+ issue_label.c.id, issue_label.c.name, issue_label.c.color, issue_label.c.description,
417
+ ).where(and_(
418
+ issue_label.c.organization_id == org_id,
419
+ issue_label.c.team_id.in_(ancestors),
420
+ issue_label.c.archived_at.is_(None),
421
+ ))).all()
422
+ for label_id, name, color, description in inherited:
423
+ used = conn.execute(select(issue_label_link.c.issue_id).select_from(
424
+ issue_label_link.join(issue, issue.c.id == issue_label_link.c.issue_id)
425
+ ).where(and_(issue_label_link.c.label_id == label_id,
426
+ issue.c.team_id == team_id))).scalars().all()
427
+ if not used:
428
+ continue # never used here: dropping out of the department is enough
429
+ ts, copy_id = now_iso(), new_id()
430
+ conn.execute(issue_label.insert().values(
431
+ id=copy_id, organization_id=org_id, team_id=team_id, name=name,
432
+ color=color, description=description, created_at=ts, updated_at=ts))
433
+ conn.execute(issue_label_link.update().where(and_(
434
+ issue_label_link.c.label_id == label_id,
435
+ issue_label_link.c.issue_id.in_(used),
436
+ )).values(label_id=copy_id))
437
+
438
+ @staticmethod
439
+ def _align_cycles_with_parent(conn, org_id: str, team_id: str) -> None:
440
+ """Close the in-flight cycle and snap upcoming ones to the nearest ancestor cycle."""
441
+ ts = now_iso()
442
+ active_id = conn.execute(select(team.c.active_cycle_id).where(
443
+ team.c.id == team_id)).scalar()
444
+ if active_id:
445
+ conn.execute(cycle.update().where(cycle.c.id == active_id).values(
446
+ completed_at=ts, updated_at=ts))
447
+ conn.execute(team.update().where(team.c.id == team_id).values(
448
+ active_cycle_id=None, updated_at=ts))
449
+ parent_id = conn.execute(select(team.c.parent_team_id).where(
450
+ team.c.id == team_id)).scalar()
451
+ if not parent_id:
452
+ return
453
+ parent_cycles = {
454
+ number: (starts_at, ends_at, boundary_at)
455
+ for number, starts_at, ends_at, boundary_at in conn.execute(
456
+ select(cycle.c.number, cycle.c.starts_at, cycle.c.ends_at, cycle.c.cadence_boundary_at)
457
+ .where(and_(cycle.c.organization_id == org_id, cycle.c.team_id == parent_id,
458
+ cycle.c.completed_at.is_(None))))
459
+ }
460
+ if not parent_cycles:
461
+ return
462
+ upcoming = conn.execute(select(cycle.c.id, cycle.c.number).where(and_(
463
+ cycle.c.organization_id == org_id, cycle.c.team_id == team_id,
464
+ cycle.c.completed_at.is_(None), cycle.c.id != (active_id or "")))).all()
465
+ for cycle_id, number in upcoming:
466
+ nearest = min(parent_cycles, key=lambda n: abs((n or 0) - (number or 0)))
467
+ starts_at, ends_at, boundary_at = parent_cycles[nearest]
468
+ conn.execute(cycle.update().where(cycle.c.id == cycle_id).values(
469
+ starts_at=starts_at, ends_at=ends_at, cadence_boundary_at=boundary_at,
470
+ updated_at=ts))
471
+
472
+ def team_update(self, org_id: str, viewer_id: str, team_id: str, inp: dict) -> str:
473
+ values: dict = {}
474
+ if "name" in inp:
475
+ if not isinstance(inp["name"], str) or not inp["name"].strip():
476
+ raise GraphQLError("Team name is required", extensions={"code": "BAD_USER_INPUT", "field": "name"})
477
+ values["name"] = inp["name"].strip()
478
+ if "key" in inp:
479
+ key = inp["key"]
480
+ if not isinstance(key, str) or not key.strip() or not key.strip().isalnum():
481
+ raise GraphQLError("Team key must be alphanumeric", extensions={"code": "BAD_USER_INPUT", "field": "key"})
482
+ values["key"] = key.strip().upper()
483
+ if "description" in inp:
484
+ values["description"] = inp["description"]
485
+ for key, column in (("autoAddStarted", "auto_add_started"),
486
+ ("autoAddCompleted", "auto_add_completed"),
487
+ ("autoAddBacklog", "auto_add_backlog")):
488
+ if key in inp:
489
+ if not isinstance(inp[key], bool):
490
+ raise GraphQLError(f"{key} must be a boolean", extensions={"code": "BAD_USER_INPUT", "field": key})
491
+ values[column] = inp[key]
492
+ if "visibility" in inp:
493
+ values["visibility"] = self._validate_visibility(inp["visibility"])
494
+ with self.engine.begin() as conn:
495
+ self._require_team_admin(conn, org_id, viewer_id)
496
+ self._team_row(conn, org_id, team_id)
497
+ if "key" in values and conn.execute(select(team.c.id).where(and_(
498
+ team.c.organization_id == org_id, team.c.key == values["key"], team.c.id != team_id
499
+ ))).first():
500
+ raise GraphQLError("Team key already exists", extensions={"code": "BAD_USER_INPUT", "field": "key"})
501
+ reparented = False
502
+ if "parentTeamId" in inp:
503
+ parent_team_id = inp["parentTeamId"] or None
504
+ previous = conn.execute(select(team.c.parent_team_id).where(
505
+ team.c.id == team_id)).scalar()
506
+ self._validate_parent(conn, org_id, team_id, parent_team_id)
507
+ values["parent_team_id"] = parent_team_id
508
+ reparented = parent_team_id != previous
509
+ values["updated_at"] = now_iso()
510
+ conn.execute(team.update().where(team.c.id == team_id).values(**values))
511
+ if reparented:
512
+ self._apply_reparent(conn, org_id, team_id, previous)
513
+ return team_id
514
+
515
+ def team_archive(self, org_id: str, viewer_id: str, team_id: str) -> bool:
516
+ with self.engine.begin() as conn:
517
+ self._require_team_admin(conn, org_id, viewer_id)
518
+ self._team_row(conn, org_id, team_id)
519
+ conn.execute(team.update().where(team.c.id == team_id).values(archived_at=now_iso(), updated_at=now_iso()))
520
+ return True
521
+
522
+ def team_delete(self, org_id: str, viewer_id: str, team_id: str, *, delete_issues: bool = False) -> bool:
523
+ with self.engine.begin() as conn:
524
+ self._require_team_admin(conn, org_id, viewer_id)
525
+ self._team_row(conn, org_id, team_id, include_archived=True)
526
+ blockers = []
527
+ # A plain delete is the conservative path and refuses while the team still owns
528
+ # anything. deleteIssues=True is the explicit "delete this team and its work"
529
+ # path, so it also takes the team-owned relations instead of dead-ending on them.
530
+ if not delete_issues:
531
+ for label, table in (("projects", project_team), ("cycles", cycle)):
532
+ if conn.execute(select(table.c.team_id).where(table.c.team_id == team_id).limit(1)).first():
533
+ blockers.append(label)
534
+ if conn.execute(select(issue.c.id).where(issue.c.team_id == team_id).limit(1)).first():
535
+ blockers.append("issues")
536
+ if blockers:
537
+ raise GraphQLError(
538
+ f"Team still has {', '.join(blockers)}. Archive it instead, or move or delete them first.",
539
+ extensions={"code": "BAD_USER_INPUT", "field": "id"})
540
+ if delete_issues:
541
+ issue_ids = select(issue.c.id).where(issue.c.team_id == team_id)
542
+ for child in (attachment, comment, inbox_notification, issue_activity,
543
+ issue_label_link, issue_subscriber):
544
+ conn.execute(child.delete().where(child.c.issue_id.in_(issue_ids)))
545
+ conn.execute(issue.delete().where(issue.c.team_id == team_id))
546
+ # Cycles belong to their team, so they go with it; any left-over reference is
547
+ # cleared rather than left dangling.
548
+ team_cycle_ids = select(cycle.c.id).where(cycle.c.team_id == team_id)
549
+ conn.execute(issue.update().where(issue.c.cycle_id.in_(team_cycle_ids))
550
+ .values(cycle_id=None, updated_at=now_iso()))
551
+ conn.execute(cycle.delete().where(cycle.c.team_id == team_id))
552
+ # Projects are workspace-level: deleting a team only drops its link to them.
553
+ conn.execute(project_team.delete().where(project_team.c.team_id == team_id))
554
+ # Meetings belong to their host, so they survive with their team scope cleared.
555
+ conn.execute(meeting.update().where(meeting.c.team_id == team_id)
556
+ .values(team_id=None, updated_at=now_iso()))
557
+ conn.execute(team_member.delete().where(team_member.c.team_id == team_id))
558
+ conn.execute(issue_label.delete().where(issue_label.c.team_id == team_id))
559
+ conn.execute(workflow_state.delete().where(workflow_state.c.team_id == team_id))
560
+ conn.execute(team.delete().where(team.c.id == team_id))
561
+ return True
562
+
563
+ def enable_team_cadence(self, org_id: str, team_id: str, weekday: int, *, now=None) -> dict:
564
+ """Enable once and reconcile the fixed one-week, two-upcoming policy."""
565
+ from datetime import datetime, timezone
566
+ from hoja_server.cadence import reconcile_team_cadence, validate_timezone
567
+ if type(weekday) is not int or weekday not in range(7):
568
+ raise ValueError("Choose a weekday from Monday (0) through Sunday (6).")
569
+ instant = now or datetime.now(timezone.utc)
570
+ if instant.tzinfo is None:
571
+ raise ValueError("Cadence clock must be timezone-aware.")
572
+ with self.engine.begin() as conn:
573
+ if conn.dialect.name == "sqlite":
574
+ conn.exec_driver_sql("BEGIN IMMEDIATE")
575
+ row = conn.execute(select(team).where(and_(team.c.organization_id == org_id,
576
+ team.c.id == team_id, team.c.archived_at.is_(None))).with_for_update()).mappings().first()
577
+ if not row:
578
+ raise ValueError("Team not found in this workspace.")
579
+ if row["cadence_enabled"]:
580
+ if row["cadence_weekday"] != weekday:
581
+ raise ValueError("Changing a cycle schedule is not supported yet.")
582
+ return reconcile_team_cadence(conn, org_id, row, instant)
583
+ existing = conn.execute(select(cycle.c.id).where(and_(cycle.c.organization_id == org_id,
584
+ cycle.c.team_id == team_id, cycle.c.archived_at.is_(None)))).first()
585
+ if existing:
586
+ raise ValueError("This team already has cycles; reconcile existing cycles before enabling cadence.")
587
+ validate_timezone(row.get("timezone") or "UTC")
588
+ updated = conn.execute(team.update().where(and_(team.c.id == team_id,
589
+ team.c.organization_id == org_id, team.c.cadence_enabled.is_(False)))
590
+ .values(cadence_enabled=True, cadence_weekday=weekday,
591
+ cadence_anchor_at=instant.astimezone(timezone.utc).isoformat(),
592
+ cadence_policy_version=1, updated_at=now_iso()))
593
+ if updated.rowcount != 1:
594
+ raise ValueError("Cycle settings changed; reload before retrying.")
595
+ fresh = conn.execute(select(team).where(team.c.id == team_id)).mappings().one()
596
+ return reconcile_team_cadence(conn, org_id, fresh, instant)
597
+
598
+ def reconcile_team_cadence(self, org_id: str, team_id: str, *, now=None, viewer_id: str | None = None) -> dict:
599
+ from datetime import datetime, timezone
600
+ from hoja_server.cadence import reconcile_team_cadence, validate_timezone
601
+ instant = now or datetime.now(timezone.utc)
602
+ # Read paths (teams/activeCycle/cycles) call this on every request. Without a
603
+ # cadence there is nothing to reconcile, so answer without the write lock
604
+ # (BEGIN IMMEDIATE on SQLite serializes every page load with every write).
605
+ with self.engine.connect() as conn:
606
+ peek = conn.execute(select(team.c.cadence_enabled, team.c.timezone).where(and_(
607
+ team.c.organization_id == org_id, team.c.id == team_id,
608
+ team.c.archived_at.is_(None)))).first()
609
+ if peek is not None and not peek.cadence_enabled:
610
+ if viewer_id is not None:
611
+ self._require_team_visible(conn, org_id, viewer_id, team_id)
612
+ validate_timezone(peek.timezone or "UTC")
613
+ return {"enabled": False, "weekday": None, "timezone": peek.timezone or "UTC",
614
+ "policyVersion": 1, "activeCycle": None, "upcomingCycles": []}
615
+ with self.engine.begin() as conn:
616
+ if conn.dialect.name == "sqlite":
617
+ conn.exec_driver_sql("BEGIN IMMEDIATE")
618
+ row = conn.execute(select(team).where(and_(team.c.organization_id == org_id,
619
+ team.c.id == team_id, team.c.archived_at.is_(None))).with_for_update()).mappings().first()
620
+ if not row:
621
+ raise ValueError("Team not found in this workspace.")
622
+ if viewer_id is not None:
623
+ self._require_team_visible(conn, org_id, viewer_id, team_id)
624
+ return reconcile_team_cadence(conn, org_id, row, instant)
625
+
626
+ def initiative_create(self, org_id: str, actor_id: str, inp: dict) -> str:
627
+ name, status, priority = str(inp.get("name", "")).strip(), inp.get("status", "planned"), inp.get("priority", 0)
628
+ if not name or status not in {"active", "planned", "completed", "canceled"} or not isinstance(priority, int) or priority not in range(0, 5):
629
+ raise ValueError("Invalid initiative name, status, or priority.")
630
+ ident, ts, owner_id = new_id(), now_iso(), inp.get("ownerId")
631
+ with self.engine.begin() as conn:
632
+ if owner_id and not conn.execute(select(user.c.id).where(and_(user.c.id == owner_id, user.c.organization_id == org_id))).first():
633
+ raise ValueError("Owner must belong to the current organization.")
634
+ conn.execute(initiative.insert().values(id=ident, organization_id=org_id, name=name, summary=inp.get("summary"), status=status, priority=priority, owner_id=owner_id, creator_id=actor_id, target_date=inp.get("targetDate"), created_at=ts, updated_at=ts))
635
+ self._initiative_projects(conn, org_id, ident, inp.get("projectIds", []))
636
+ return ident
637
+
638
+ @staticmethod
639
+ def _initiative_projects(conn, org_id: str, ident: str, project_ids: list[str]) -> None:
640
+ for pid in dict.fromkeys(project_ids):
641
+ if not conn.execute(select(project.c.id).where(and_(project.c.id == pid, project.c.organization_id == org_id, project.c.archived_at.is_(None)))).first():
642
+ raise ValueError("Project must belong to the current organization.")
643
+ conn.execute(initiative_project.insert().values(organization_id=org_id, initiative_id=ident, project_id=pid))
644
+
645
+ def initiative_update(self, org_id: str, viewer_id: str, ident: str, inp: dict) -> bool:
646
+ with self.engine.begin() as conn:
647
+ row = conn.execute(select(initiative.c.creator_id).where(and_(
648
+ initiative.c.id == ident, initiative.c.organization_id == org_id,
649
+ initiative.c.archived_at.is_(None)))).first()
650
+ if row:
651
+ self._require_creator_or_admin(conn, org_id, viewer_id, row[0])
652
+ fields = {"name", "summary", "status", "priority", "ownerId", "targetDate"}
653
+ values = {k: v for k, v in inp.items() if k in fields}
654
+ if "name" in values:
655
+ values["name"] = str(values["name"]).strip()
656
+ if not values["name"]: raise ValueError("Initiative name must not be empty.")
657
+ if "status" in values and values["status"] not in {"active", "planned", "completed", "canceled"}: raise ValueError("Invalid initiative status.")
658
+ if "priority" in values and (not isinstance(values["priority"], int) or values["priority"] not in range(0, 5)): raise ValueError("Invalid initiative priority.")
659
+ if "targetDate" in values: values["target_date"] = values.pop("targetDate")
660
+ if "ownerId" in values:
661
+ owner = values.pop("ownerId")
662
+ if owner:
663
+ with self.engine.connect() as owner_conn:
664
+ if not owner_conn.execute(select(user.c.id).where(and_(user.c.id == owner, user.c.organization_id == org_id))).first():
665
+ raise ValueError("Owner must belong to the current organization.")
666
+ values["owner_id"] = owner
667
+ with self.engine.begin() as conn:
668
+ exists = conn.execute(select(initiative.c.id).where(and_(initiative.c.id == ident, initiative.c.organization_id == org_id, initiative.c.archived_at.is_(None)))).first()
669
+ if not exists: return False
670
+ if "projectIds" in inp:
671
+ conn.execute(initiative_project.delete().where(and_(initiative_project.c.organization_id == org_id, initiative_project.c.initiative_id == ident)))
672
+ self._initiative_projects(conn, org_id, ident, inp["projectIds"])
673
+ values["updated_at"] = now_iso()
674
+ conn.execute(initiative.update().where(and_(initiative.c.id == ident, initiative.c.organization_id == org_id)).values(**values))
675
+ return True
676
+
677
+ def initiative_archive(self, org_id: str, viewer_id: str, ident: str) -> bool:
678
+ with self.engine.begin() as conn:
679
+ row = conn.execute(select(initiative.c.creator_id).where(and_(
680
+ initiative.c.id == ident, initiative.c.organization_id == org_id,
681
+ initiative.c.archived_at.is_(None)))).first()
682
+ if row:
683
+ self._require_creator_or_admin(conn, org_id, viewer_id, row[0])
684
+ result = conn.execute(initiative.update().where(and_(initiative.c.id == ident, initiative.c.organization_id == org_id, initiative.c.archived_at.is_(None))).values(archived_at=now_iso(), updated_at=now_iso()))
685
+ return result.rowcount > 0
686
+
687
+ @staticmethod
688
+ def _record_activity(conn, org_id, issue_id, actor_id, event_type, changes, ts):
689
+ conn.execute(issue_activity.insert().values(
690
+ id=new_id(), organization_id=org_id, issue_id=issue_id,
691
+ actor_id=actor_id, event_type=event_type,
692
+ changes=json.dumps(changes, separators=(",", ":")) if changes else None,
693
+ created_at=ts,
694
+ ))
695
+
696
+ @staticmethod
697
+ def _notify_issue_recipients(conn, org_id, issue_id, actor_id, recipients, kind, ts):
698
+ for recipient_id in set(recipients) - {actor_id, None}:
699
+ conn.execute(inbox_notification.insert().values(
700
+ id=new_id(), organization_id=org_id, recipient_id=recipient_id,
701
+ issue_id=issue_id, actor_id=actor_id, kind=kind, created_at=ts,
702
+ ))
703
+
704
+ # -------------------------------------------------------------- issues
705
+ @staticmethod
706
+ def _automation_flag(team_row: dict, key: str) -> bool:
707
+ """Team auto-add toggles default to on (Linear's defaults); NULL reads as on."""
708
+ value = team_row.get(key, True)
709
+ return True if value is None else bool(value)
710
+
711
+ def _auto_add_cycle_id(self, conn, org_id: str, team_row: dict, state_id: str | None) -> str | None:
712
+ """Linear-style "auto-add active issues to current cycle".
713
+
714
+ Returns the team's active cycle id when an issue with no cycle
715
+ qualifies (started-type with auto_add_started, completed-type with
716
+ auto_add_completed, backlog-type with auto_add_backlog), else None.
717
+ Cooldown is always 0 in Hoja, so the previous-cycle attribution
718
+ nuance does not apply yet.
719
+ """
720
+ active_id = team_row.get("active_cycle_id")
721
+ if not active_id or not state_id:
722
+ return None
723
+ stype = conn.execute(select(workflow_state.c.type).where(and_(
724
+ workflow_state.c.organization_id == org_id,
725
+ workflow_state.c.id == state_id))).scalar()
726
+ if stype == "started" and self._automation_flag(team_row, "auto_add_started"):
727
+ return active_id
728
+ if stype == "completed" and self._automation_flag(team_row, "auto_add_completed"):
729
+ return active_id
730
+ if stype == "backlog" and self._automation_flag(team_row, "auto_add_backlog"):
731
+ return active_id
732
+ return None
733
+
734
+ def issue_create(self, org_id: str, viewer_id: str, inp: dict) -> str | None:
735
+ with self.engine.begin() as conn:
736
+ team_ref = inp["teamId"]
737
+ trow = conn.execute(select(team).where(and_(
738
+ team.c.organization_id == org_id,
739
+ (team.c.id == team_ref) | (team.c.key == str(team_ref).upper())))).first()
740
+ if not trow:
741
+ return None
742
+ trow = dict(trow._mapping)
743
+ tid = trow["id"]
744
+ self._require_team_visible(conn, org_id, viewer_id, tid)
745
+ parent_id = _validate_issue_references(
746
+ conn,
747
+ org_id,
748
+ tid,
749
+ inp,
750
+ default_state_id=trow.get("default_state_id"),
751
+ )
752
+ result = conn.execute(
753
+ team.update()
754
+ .where(and_(team.c.id == tid, team.c.organization_id == org_id))
755
+ .values(issue_counter=func.coalesce(team.c.issue_counter, 0) + 1)
756
+ )
757
+ if result.rowcount != 1:
758
+ raise InvalidReferenceError("teamId")
759
+ n = conn.execute(
760
+ select(team.c.issue_counter).where(
761
+ and_(team.c.id == tid, team.c.organization_id == org_id)
762
+ )
763
+ ).scalar_one()
764
+ ident = f"{trow['key']}-{n}"
765
+ prio = int(inp.get("priority", 0) or 0)
766
+ state_id = inp.get("stateId") or trow.get("default_state_id")
767
+ cycle_id = inp.get("cycleId") or self._auto_add_cycle_id(conn, org_id, trow, state_id)
768
+ ts = now_iso()
769
+ url_key = _org_url_key(conn, org_id)
770
+ iid = new_id()
771
+ conn.execute(issue.insert().values(
772
+ id=iid, organization_id=org_id, team_id=tid, number=n, identifier=ident,
773
+ title=inp["title"], description=inp.get("description"),
774
+ priority=prio, priority_label=PRIORITY_LABELS.get(prio, "No priority"),
775
+ estimate=inp.get("estimate"), state_id=state_id, state_changed_at=ts,
776
+ assignee_id=inp.get("assigneeId"), creator_id=viewer_id,
777
+ project_id=inp.get("projectId"), cycle_id=cycle_id,
778
+ parent_id=parent_id,
779
+ url=entity_url(url_key, "issue", ident),
780
+ branch_name=f"{viewer_id[:8]}/{ident.lower()}-{_slug(inp['title'])}",
781
+ due_date=inp.get("dueDate"), created_at=ts, updated_at=ts,
782
+ ))
783
+ self._apply_state_ts(conn, org_id, iid, state_id, ts)
784
+ for lid in (inp.get("labelIds") or []):
785
+ conn.execute(issue_label_link.insert().values(issue_id=iid, label_id=lid))
786
+ self._record_activity(conn, org_id, iid, viewer_id, "issueCreated", {"title": inp["title"]}, ts)
787
+ self._notify_issue_recipients(
788
+ conn, org_id, iid, viewer_id, [inp.get("assigneeId"), viewer_id], "issueCreated", ts
789
+ )
790
+ return iid
791
+
792
+ def issue_update(self, org_id: str, ident: str, inp: dict, actor_id: str | None = None) -> str | None:
793
+ with self.engine.begin() as conn:
794
+ row = conn.execute(select(issue).where(and_(
795
+ issue.c.organization_id == org_id,
796
+ (issue.c.id == ident) | (issue.c.identifier == ident.upper())))).first()
797
+ if not row:
798
+ return None
799
+ issue_row = dict(row._mapping)
800
+ iid = issue_row["id"]
801
+ if actor_id is not None:
802
+ self._require_team_visible(conn, org_id, actor_id, issue_row["team_id"])
803
+ if inp.get("teamId") is not None:
804
+ raise InvalidReferenceError("teamId")
805
+ if inp.get("parentId"):
806
+ # PostgreSQL: serialize reparenting per team so two transactions cannot
807
+ # independently validate opposite edges and commit a cycle. SQLite ignores
808
+ # FOR UPDATE and serializes writes at the database level instead.
809
+ conn.execute(select(team.c.id).where(and_(
810
+ team.c.organization_id == org_id, team.c.id == issue_row["team_id"],
811
+ )).with_for_update()).scalar_one()
812
+ parent_id = _validate_issue_references(
813
+ conn, org_id, issue_row["team_id"], inp, issue_id=iid
814
+ )
815
+ ts = now_iso()
816
+ values: dict = {"updated_at": ts}
817
+ mapping = {
818
+ "title": "title", "description": "description", "estimate": "estimate",
819
+ "assigneeId": "assignee_id", "projectId": "project_id",
820
+ "cycleId": "cycle_id", "parentId": "parent_id", "dueDate": "due_date",
821
+ }
822
+ for k, col in mapping.items():
823
+ if k in inp:
824
+ values[col] = inp[k]
825
+ if "parentId" in inp:
826
+ values["parent_id"] = parent_id
827
+ if "priority" in inp:
828
+ p = int(inp["priority"] or 0)
829
+ values["priority"] = p
830
+ values["priority_label"] = PRIORITY_LABELS.get(p, "No priority")
831
+ if "stateId" in inp:
832
+ values["state_id"] = inp["stateId"]
833
+ if inp["stateId"] != issue_row.get("state_id"):
834
+ values["state_changed_at"] = ts
835
+ # Linear-style auto-add: a transition into Started/Completed
836
+ # pulls a cycle-less issue into the active cycle, unless the
837
+ # caller explicitly set (or cleared) the cycle.
838
+ if "cycleId" not in inp and not issue_row.get("cycle_id"):
839
+ team_row = conn.execute(select(team).where(and_(
840
+ team.c.organization_id == org_id,
841
+ team.c.id == issue_row["team_id"]))).mappings().first()
842
+ if team_row:
843
+ auto_id = self._auto_add_cycle_id(
844
+ conn, org_id, dict(team_row), inp["stateId"])
845
+ if auto_id:
846
+ values["cycle_id"] = auto_id
847
+ changes = {}
848
+ for field, column in {"title": "title", "description": "description", "priority": "priority", "stateId": "state_id", "assigneeId": "assignee_id", "projectId": "project_id"}.items():
849
+ if field in inp and issue_row.get(column) != values.get(column):
850
+ changes[field] = {"from": issue_row.get(column), "to": values.get(column)}
851
+ conn.execute(issue.update().where(issue.c.id == iid).values(**values))
852
+ if "stateId" in inp:
853
+ self._apply_state_ts(conn, org_id, iid, inp["stateId"], ts)
854
+ if "labelIds" in inp:
855
+ conn.execute(issue_label_link.delete().where(issue_label_link.c.issue_id == iid))
856
+ for lid in (inp.get("labelIds") or []):
857
+ conn.execute(issue_label_link.insert().values(issue_id=iid, label_id=lid))
858
+ if changes:
859
+ self._record_activity(conn, org_id, iid, actor_id, "issueUpdated", changes, ts)
860
+ self._notify_issue_recipients(
861
+ conn, org_id, iid, actor_id,
862
+ [issue_row.get("assignee_id"), values.get("assignee_id", issue_row.get("assignee_id")),
863
+ issue_row.get("creator_id")], "issueUpdated", ts,
864
+ )
865
+ return iid
866
+
867
+ def _apply_state_ts(self, conn, org_id, iid, state_id, ts):
868
+ if not state_id:
869
+ return
870
+ srow = conn.execute(select(workflow_state.c.type).where(and_(
871
+ workflow_state.c.organization_id == org_id,
872
+ workflow_state.c.id == state_id))).first()
873
+ if not srow:
874
+ return
875
+ stype = srow[0]
876
+ vals: dict = {}
877
+ if stype == "started":
878
+ vals = {"started_at": ts, "completed_at": None, "canceled_at": None}
879
+ elif stype == "completed":
880
+ cur = conn.execute(select(issue.c.started_at).where(issue.c.id == iid)).first()
881
+ vals = {"completed_at": ts, "canceled_at": None}
882
+ if not (cur and cur[0]):
883
+ vals["started_at"] = ts
884
+ elif stype == "canceled":
885
+ vals = {"canceled_at": ts}
886
+ elif stype in ("backlog", "unstarted", "triage"):
887
+ vals = {"started_at": None, "completed_at": None, "canceled_at": None}
888
+ if vals:
889
+ conn.execute(issue.update().where(issue.c.id == iid).values(**vals))
890
+
891
+ def _authorize_issue_change(self, org_id: str, viewer_id: str, ident: str) -> None:
892
+ with self.engine.begin() as conn:
893
+ row = conn.execute(select(issue.c.creator_id, issue.c.team_id).where(and_(
894
+ issue.c.organization_id == org_id,
895
+ (issue.c.id == ident) | (issue.c.identifier == ident.upper()),
896
+ ))).first()
897
+ if row:
898
+ self._require_team_visible(conn, org_id, viewer_id, row[1])
899
+ self._require_creator_or_admin(conn, org_id, viewer_id, row[0])
900
+
901
+ def issue_archive(self, org_id: str, viewer_id: str, ident: str) -> bool:
902
+ self._authorize_issue_change(org_id, viewer_id, ident)
903
+ return self._soft_delete(issue, org_id, ident, by_identifier=True, event_type="issueArchived")
904
+
905
+ def issue_delete(self, org_id: str, viewer_id: str, ident: str) -> bool:
906
+ self._authorize_issue_change(org_id, viewer_id, ident)
907
+ return self._soft_delete(issue, org_id, ident, by_identifier=True)
908
+
909
+ def _soft_delete(self, table, org_id, ident, by_identifier=False, event_type=None) -> bool:
910
+ with self.engine.begin() as conn:
911
+ cond = (table.c.id == ident)
912
+ if by_identifier and "identifier" in table.c:
913
+ cond = cond | (table.c.identifier == ident.upper())
914
+ row = conn.execute(select(table.c.id).where(and_(
915
+ table.c.organization_id == org_id, cond))).first()
916
+ if not row:
917
+ return False
918
+ ts = now_iso()
919
+ conn.execute(table.update().where(table.c.id == row[0]).values(archived_at=ts))
920
+ if event_type:
921
+ self._record_activity(conn, org_id, row[0], None, event_type, None, ts)
922
+ return True
923
+
924
+ # -------------------------------------------------------------- comments
925
+ def comment_create(self, org_id: str, viewer_id: str, inp: dict) -> str | None:
926
+ with self.engine.begin() as conn:
927
+ irow = conn.execute(select(issue).where(and_(
928
+ issue.c.organization_id == org_id,
929
+ (issue.c.id == inp["issueId"]) | (issue.c.identifier == str(inp["issueId"]).upper())))).first()
930
+ if not irow:
931
+ return None
932
+ irow = dict(irow._mapping)
933
+ self._require_team_visible(conn, org_id, viewer_id, irow.get("team_id"))
934
+ ts = now_iso()
935
+ cid = new_id()
936
+ conn.execute(comment.insert().values(
937
+ id=cid, organization_id=org_id, issue_id=irow["id"], body=inp["body"],
938
+ user_id=viewer_id, url=f"{irow.get('url')}#comment-{cid[:8]}",
939
+ created_at=ts, updated_at=ts))
940
+ self._record_activity(conn, org_id, irow["id"], viewer_id, "commentCreated", {"commentId": cid}, ts)
941
+ return cid
942
+
943
+ def comment_update(self, org_id: str, viewer_id: str, cid: str, inp: dict) -> str | None:
944
+ with self.engine.begin() as conn:
945
+ row = self._authorize_comment(conn, org_id, viewer_id, cid, comment_policy.EDIT)
946
+ if not row:
947
+ return None
948
+ vals = {"updated_at": now_iso()}
949
+ if "body" in inp:
950
+ vals["body"] = inp["body"]
951
+ conn.execute(comment.update().where(comment.c.id == cid).values(**vals))
952
+ self._record_activity(conn, org_id, row.issue_id, viewer_id, "commentUpdated", {"commentId": cid}, vals["updated_at"])
953
+ return cid
954
+
955
+ def comment_delete(self, org_id: str, viewer_id: str, cid: str) -> bool:
956
+ with self.engine.begin() as conn:
957
+ if not self._authorize_comment(conn, org_id, viewer_id, cid, comment_policy.DELETE):
958
+ return False
959
+ conn.execute(comment.update().where(comment.c.id == cid).values(archived_at=now_iso()))
960
+ return True
961
+
962
+ def _authorize_comment(self, conn, org_id: str, viewer_id: str, cid: str, action: str):
963
+ """The comment row if the policy permits ``action``; None when it is absent or must look absent."""
964
+ row = conn.execute(
965
+ select(comment.c.user_id, comment.c.issue_id, comment.c.archived_at, issue.c.team_id)
966
+ .select_from(comment.join(issue, issue.c.id == comment.c.issue_id))
967
+ .where(and_(comment.c.organization_id == org_id, comment.c.id == cid))
968
+ ).first()
969
+ if not row:
970
+ return None
971
+ decision = comment_policy.decide(
972
+ action, comment_policy.subject(conn, org_id, viewer_id),
973
+ comment_policy.resource(conn, org_id, viewer_id, author_id=row.user_id,
974
+ archived=row.archived_at is not None, team_id=row.team_id))
975
+ if decision:
976
+ return row
977
+ if decision.rule in comment_policy.NOT_FOUND_RULES:
978
+ return None
979
+ raise GraphQLError(comment_policy.DENIED_MESSAGE[action],
980
+ extensions={"code": "FORBIDDEN", "rule": decision.rule or "default-deny"})
981
+
982
+ # -------------------------------------------------------------- attachments
983
+ def attachment_create(self, org_id: str, viewer_id: str, inp: dict) -> dict | None:
984
+ with self.engine.begin() as conn:
985
+ parsed_url = urlparse(inp["url"])
986
+ relative_upload = (
987
+ not parsed_url.scheme
988
+ and not parsed_url.netloc
989
+ and (parsed_url.path or "").startswith("/api/uploads/")
990
+ )
991
+ if not relative_upload and (
992
+ parsed_url.scheme not in {"http", "https"}
993
+ or not parsed_url.netloc
994
+ or parsed_url.username
995
+ or parsed_url.password
996
+ ):
997
+ raise InvalidReferenceError("url")
998
+ irow = conn.execute(select(issue.c.id).where(and_(
999
+ issue.c.organization_id == org_id,
1000
+ (issue.c.id == inp["issueId"])
1001
+ | (issue.c.identifier == str(inp["issueId"]).upper()),
1002
+ ))).first()
1003
+ if not irow:
1004
+ return None
1005
+ aid = new_id()
1006
+ created_at = now_iso()
1007
+ conn.execute(attachment.insert().values(
1008
+ id=aid,
1009
+ organization_id=org_id,
1010
+ issue_id=irow[0],
1011
+ creator_id=viewer_id,
1012
+ url=inp["url"],
1013
+ title=inp["title"],
1014
+ subtitle=inp.get("subtitle"),
1015
+ created_at=created_at,
1016
+ ))
1017
+ return {
1018
+ "id": aid,
1019
+ "url": inp["url"],
1020
+ "title": inp["title"],
1021
+ "subtitle": inp.get("subtitle"),
1022
+ "createdAt": created_at,
1023
+ "_issue_id": irow[0],
1024
+ }
1025
+
1026
+ # ---------------------------------------------------------- subscribers
1027
+ def _issue_row_for(self, conn, org_id: str, issue_id: str):
1028
+ return conn.execute(select(issue.c.id).where(and_(
1029
+ issue.c.organization_id == org_id,
1030
+ (issue.c.id == issue_id) | (issue.c.identifier == str(issue_id).upper()),
1031
+ ))).first()
1032
+
1033
+ def issue_subscribe(self, org_id: str, user_id: str, issue_id: str) -> bool:
1034
+ """Subscribe a user to an issue (idempotent)."""
1035
+ with self.engine.begin() as conn:
1036
+ irow = self._issue_row_for(conn, org_id, issue_id)
1037
+ if not irow:
1038
+ return False
1039
+ exists = conn.execute(select(issue_subscriber.c.user_id).where(and_(
1040
+ issue_subscriber.c.issue_id == irow[0],
1041
+ issue_subscriber.c.user_id == user_id))).first()
1042
+ if not exists:
1043
+ conn.execute(issue_subscriber.insert().values(
1044
+ issue_id=irow[0], user_id=user_id))
1045
+ return True
1046
+
1047
+ def issue_unsubscribe(self, org_id: str, user_id: str, issue_id: str) -> bool:
1048
+ """Remove a user's subscription to an issue (idempotent)."""
1049
+ with self.engine.begin() as conn:
1050
+ irow = self._issue_row_for(conn, org_id, issue_id)
1051
+ if not irow:
1052
+ return False
1053
+ conn.execute(issue_subscriber.delete().where(and_(
1054
+ issue_subscriber.c.issue_id == irow[0],
1055
+ issue_subscriber.c.user_id == user_id)))
1056
+ return True
1057
+
1058
+ # ---------------------------------------------------------------- cycles
1059
+ def cycle_update(self, org_id: str, cycle_id: str, inp: dict) -> dict | None:
1060
+ """Update a cycle's name, description, or dates (manual override)."""
1061
+ from hoja_server.store import Store
1062
+ with self.engine.begin() as conn:
1063
+ row = conn.execute(select(cycle).where(and_(
1064
+ cycle.c.organization_id == org_id, cycle.c.id == cycle_id))).first()
1065
+ if not row:
1066
+ return None
1067
+ values: dict = {}
1068
+ if inp.get("name") is not None:
1069
+ values["name"] = inp["name"]
1070
+ if inp.get("description") is not None:
1071
+ values["description"] = inp["description"]
1072
+ if inp.get("startsAt") is not None:
1073
+ values["starts_at"] = inp["startsAt"]
1074
+ if inp.get("endsAt") is not None:
1075
+ values["ends_at"] = inp["endsAt"]
1076
+ if values:
1077
+ from datetime import datetime, timezone
1078
+ values["updated_at"] = datetime.now(timezone.utc).isoformat()
1079
+ conn.execute(cycle.update().where(cycle.c.id == cycle_id).values(**values))
1080
+ fresh = conn.execute(select(cycle).where(cycle.c.id == cycle_id)).first()
1081
+ store = Store(self.engine)
1082
+ return store.ser_cycle(dict(fresh._mapping)) if fresh else None
1083
+
1084
+ # -------------------------------------------------------------- labels
1085
+ def label_create(self, org_id: str, inp: dict) -> str | None:
1086
+ with self.engine.begin() as conn:
1087
+ team_id = None
1088
+ if inp.get("teamId"):
1089
+ trow = conn.execute(select(team.c.id).where(and_(
1090
+ team.c.organization_id == org_id,
1091
+ (team.c.id == inp["teamId"]) | (team.c.key == str(inp["teamId"]).upper())))).first()
1092
+ if not trow:
1093
+ raise InvalidReferenceError("teamId")
1094
+ team_id = trow[0]
1095
+ ts = now_iso()
1096
+ lid = new_id()
1097
+ conn.execute(issue_label.insert().values(
1098
+ id=lid, organization_id=org_id, team_id=team_id, name=inp["name"],
1099
+ color=inp.get("color"), description=inp.get("description"),
1100
+ created_at=ts, updated_at=ts))
1101
+ return lid
1102
+
1103
+ def label_update(self, org_id: str, viewer_id: str, lid: str, inp: dict) -> bool:
1104
+ values: dict = {}
1105
+ if "name" in inp:
1106
+ name = inp["name"]
1107
+ if not isinstance(name, str) or not name.strip() or len(name.strip()) > 64:
1108
+ raise GraphQLError("Label name must be 1–64 characters", extensions={"code": "BAD_USER_INPUT", "field": "name"})
1109
+ values["name"] = name.strip()
1110
+ for field in ("color", "description"):
1111
+ if field in inp:
1112
+ values[field] = inp[field]
1113
+ with self.engine.begin() as conn:
1114
+ admin = conn.execute(select(user.c.id).where(and_(
1115
+ user.c.id == viewer_id, user.c.organization_id == org_id,
1116
+ user.c.active.is_(True), user.c.archived_at.is_(None), user.c.admin.is_(True),
1117
+ ))).first()
1118
+ if not admin:
1119
+ raise GraphQLError("Admin access required", extensions={"code": "FORBIDDEN"})
1120
+ if not values:
1121
+ return conn.execute(select(issue_label.c.id).where(and_(
1122
+ issue_label.c.id == lid, issue_label.c.organization_id == org_id,
1123
+ issue_label.c.archived_at.is_(None)))).first() is not None
1124
+ values["updated_at"] = now_iso()
1125
+ result = conn.execute(issue_label.update().where(and_(
1126
+ issue_label.c.id == lid, issue_label.c.organization_id == org_id,
1127
+ issue_label.c.archived_at.is_(None))).values(**values))
1128
+ return result.rowcount > 0
1129
+
1130
+ def label_delete(self, org_id: str, lid: str) -> bool:
1131
+ return self._soft_delete(issue_label, org_id, lid)
1132
+
1133
+ # -------------------------------------------------------------- projects
1134
+ def project_create(self, org_id: str, viewer_id: str, inp: dict) -> str | None:
1135
+ with self.engine.begin() as conn:
1136
+ if lead_id := inp.get("leadId"):
1137
+ _require_org_reference(
1138
+ conn, user, org_id, lead_id, "leadId",
1139
+ user.c.active.is_(True),
1140
+ )
1141
+ team_ids = set(inp.get("teamIds") or [])
1142
+ if team_ids:
1143
+ found = set(
1144
+ conn.execute(
1145
+ select(team.c.id).where(
1146
+ and_(
1147
+ team.c.id.in_(team_ids),
1148
+ team.c.organization_id == org_id,
1149
+ team.c.archived_at.is_(None),
1150
+ )
1151
+ )
1152
+ ).scalars()
1153
+ )
1154
+ if found != team_ids:
1155
+ raise InvalidReferenceError("teamIds")
1156
+ for tid in team_ids:
1157
+ if not team_visible(conn, org_id, viewer_id, tid):
1158
+ raise InvalidReferenceError("teamIds")
1159
+ ts = now_iso()
1160
+ pid = new_id()
1161
+ slug = _slug(inp["name"])
1162
+ url_key = _org_url_key(conn, org_id)
1163
+ conn.execute(project.insert().values(
1164
+ id=pid, organization_id=org_id, name=inp["name"],
1165
+ description=inp.get("description"), slug_id=slug,
1166
+ icon=inp.get("icon"), color=inp.get("color"),
1167
+ state=inp.get("state", "backlog"), progress=0,
1168
+ lead_id=inp.get("leadId"), creator_id=viewer_id,
1169
+ start_date=inp.get("startDate"), target_date=inp.get("targetDate"),
1170
+ url=entity_url(url_key, "project", slug),
1171
+ created_at=ts, updated_at=ts))
1172
+ conn.execute(project_member.insert().values(project_id=pid, user_id=viewer_id))
1173
+ for team_id in team_ids:
1174
+ conn.execute(project_team.insert().values(project_id=pid, team_id=team_id))
1175
+ return pid
1176
+
1177
+ def project_update_create(self, org_id: str, pid: str, actor_id: str, inp: dict) -> str | None:
1178
+ body = inp.get("body")
1179
+ if not isinstance(body, str) or not body.strip():
1180
+ raise GraphQLError("Project update body must not be empty.", extensions={"code": "BAD_USER_INPUT", "field": "body"})
1181
+ with self.engine.begin() as conn:
1182
+ row = conn.execute(select(project).where(and_(
1183
+ project.c.organization_id == org_id,
1184
+ project.c.id == pid,
1185
+ project.c.archived_at.is_(None),
1186
+ ))).mappings().first()
1187
+ if not row:
1188
+ return None
1189
+ update_id = new_id()
1190
+ ts = now_iso()
1191
+ conn.execute(project_update.insert().values(
1192
+ id=update_id, organization_id=org_id, project_id=pid,
1193
+ actor_id=actor_id, body=body, status=inp.get("status"),
1194
+ progress=self._project_progress(conn, org_id, pid), created_at=ts,
1195
+ ))
1196
+ return update_id
1197
+
1198
+ def _project_progress(self, conn, org_id: str, pid: str) -> float:
1199
+ """Snapshot of the project's completion percent for a project update (see Store.project_progress_many)."""
1200
+ rows = conn.execute(select(workflow_state.c.type, func.count()).select_from(
1201
+ issue.join(workflow_state, workflow_state.c.id == issue.c.state_id)
1202
+ ).where(and_(issue.c.organization_id == org_id, issue.c.project_id == pid,
1203
+ issue.c.archived_at.is_(None))).group_by(workflow_state.c.type)).all()
1204
+ scope = sum(n for kind, n in rows if kind != "canceled")
1205
+ done = sum(n for kind, n in rows if kind == "completed")
1206
+ return round(done * 100.0 / scope, 2) if scope else 0.0
1207
+
1208
+ def _require_project_owner(self, conn, org_id: str, viewer_id: str, pid: str) -> bool:
1209
+ """Creator, lead, or admin may edit/archive a project. Returns False when the project is absent."""
1210
+ row = conn.execute(select(project.c.creator_id, project.c.lead_id).where(and_(
1211
+ project.c.organization_id == org_id, project.c.id == pid,
1212
+ project.c.archived_at.is_(None)))).first()
1213
+ if not row:
1214
+ return False
1215
+ if viewer_id in (row[0], row[1]):
1216
+ return True
1217
+ self._require_team_admin(conn, org_id, viewer_id)
1218
+ return True
1219
+
1220
+ def project_update(self, org_id: str, viewer_id: str, pid: str, inp: dict) -> str | None:
1221
+ with self.engine.begin() as conn:
1222
+ row = conn.execute(select(project.c.id).where(and_(
1223
+ project.c.organization_id == org_id, project.c.id == pid))).first()
1224
+ if not row:
1225
+ return None
1226
+ if not self._require_project_owner(conn, org_id, viewer_id, pid):
1227
+ return None
1228
+ if lead_id := inp.get("leadId"):
1229
+ _require_org_reference(
1230
+ conn, user, org_id, lead_id, "leadId",
1231
+ user.c.active.is_(True),
1232
+ )
1233
+ if "teamIds" in inp:
1234
+ team_ids = set(inp.get("teamIds") or [])
1235
+ if team_ids:
1236
+ found = set(conn.execute(select(team.c.id).where(and_(
1237
+ team.c.id.in_(team_ids),
1238
+ team.c.organization_id == org_id,
1239
+ team.c.archived_at.is_(None)))).scalars())
1240
+ if found != team_ids:
1241
+ raise InvalidReferenceError("teamIds")
1242
+ for tid in team_ids:
1243
+ if not team_visible(conn, org_id, viewer_id, tid):
1244
+ raise InvalidReferenceError("teamIds")
1245
+ conn.execute(project_team.delete().where(project_team.c.project_id == pid))
1246
+ for tid in sorted(team_ids):
1247
+ conn.execute(project_team.insert().values(project_id=pid, team_id=tid))
1248
+ vals = {"updated_at": now_iso()}
1249
+ m = {"name": "name", "description": "description", "leadId": "lead_id",
1250
+ "state": "state", "color": "color", "icon": "icon",
1251
+ "startDate": "start_date", "targetDate": "target_date"}
1252
+ for k, col in m.items():
1253
+ if k in inp:
1254
+ vals[col] = inp[k]
1255
+ conn.execute(project.update().where(project.c.id == pid).values(**vals))
1256
+ return pid
1257
+
1258
+ def project_archive(self, org_id: str, viewer_id: str, pid: str) -> bool:
1259
+ with self.engine.begin() as conn:
1260
+ if not self._require_project_owner(conn, org_id, viewer_id, pid):
1261
+ return False
1262
+ return self._soft_delete(project, org_id, pid)