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