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/memory_board.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""Memory board storage layer.
|
|
2
|
+
|
|
3
|
+
Persistent project-scoped memory for Hoja agents.
|
|
4
|
+
File location: .hoja/memory.yaml (with one-release legacy fallback).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import contextlib
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import asdict, dataclass, field
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from hoja.config import _find_git_root
|
|
17
|
+
from hoja.errors import UsageError
|
|
18
|
+
|
|
19
|
+
# Prefer PyYAML if available, otherwise fall back to tomli-w + our own YAML-ish serializer
|
|
20
|
+
try:
|
|
21
|
+
import yaml as _yaml # type: ignore[import-untyped]
|
|
22
|
+
except ImportError: # pragma: no cover
|
|
23
|
+
_yaml = None # type: ignore[assignment]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class MemoryEntry:
|
|
28
|
+
"""A single memory entry on the board."""
|
|
29
|
+
|
|
30
|
+
id: str
|
|
31
|
+
priority: int = 5 # 1 = highest (forced), 10 = lowest (fleeting)
|
|
32
|
+
title: str = ""
|
|
33
|
+
body: str = ""
|
|
34
|
+
created_by: str = "agent"
|
|
35
|
+
created_at: str = field(default_factory=lambda: _now())
|
|
36
|
+
last_updated: str = field(default_factory=lambda: _now())
|
|
37
|
+
auto_heal: bool = True
|
|
38
|
+
heal_after_days: int = 30
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class MemoryBoard:
|
|
43
|
+
"""Top-level memory board container."""
|
|
44
|
+
|
|
45
|
+
forced: list[MemoryEntry] = field(default_factory=list)
|
|
46
|
+
community: list[MemoryEntry] = field(default_factory=list)
|
|
47
|
+
version: str = "1"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _now() -> str:
|
|
51
|
+
"""ISO-8601 UTC timestamp."""
|
|
52
|
+
return datetime.now(timezone.utc).isoformat()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def memory_path(start: Path | None = None) -> Path:
|
|
56
|
+
"""Resolve the canonical project memory board path."""
|
|
57
|
+
root = _find_git_root(start) or Path.cwd()
|
|
58
|
+
return root / ".hoja" / "memory.yaml"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _default_forced_entries() -> list[MemoryEntry]:
|
|
62
|
+
"""Seed forced rules that every agent must see."""
|
|
63
|
+
return [
|
|
64
|
+
MemoryEntry(
|
|
65
|
+
id="verify-auth-first",
|
|
66
|
+
priority=1,
|
|
67
|
+
title="Verify auth before any mutation",
|
|
68
|
+
body="Always run `hoja me` first. If it fails, stop and surface the auth error to the user before any mutation.",
|
|
69
|
+
created_by="system",
|
|
70
|
+
auto_heal=False,
|
|
71
|
+
heal_after_days=0,
|
|
72
|
+
),
|
|
73
|
+
MemoryEntry(
|
|
74
|
+
id="search-before-create",
|
|
75
|
+
priority=2,
|
|
76
|
+
title="Search before creating issues",
|
|
77
|
+
body='Run `hoja issue search "<query>"` before `hoja issue create` to avoid duplicate issues.',
|
|
78
|
+
created_by="system",
|
|
79
|
+
auto_heal=False,
|
|
80
|
+
heal_after_days=0,
|
|
81
|
+
),
|
|
82
|
+
MemoryEntry(
|
|
83
|
+
id="use-json-for-piping",
|
|
84
|
+
priority=3,
|
|
85
|
+
title="Use --output json for programmatic reading",
|
|
86
|
+
body="`--output human` is for terminal display only. Use `-o json` when piping, filtering, or feeding into another tool.",
|
|
87
|
+
created_by="system",
|
|
88
|
+
auto_heal=False,
|
|
89
|
+
heal_after_days=0,
|
|
90
|
+
),
|
|
91
|
+
MemoryEntry(
|
|
92
|
+
id="dry-run-on-ambiguous",
|
|
93
|
+
priority=4,
|
|
94
|
+
title="Use --dry-run on ambiguous mutations",
|
|
95
|
+
body="When the user's intent is ambiguous or destructive, use `hoja <cmd> --dry-run` first to preview before executing.",
|
|
96
|
+
created_by="system",
|
|
97
|
+
auto_heal=False,
|
|
98
|
+
heal_after_days=0,
|
|
99
|
+
),
|
|
100
|
+
MemoryEntry(
|
|
101
|
+
id="memory-remind-first",
|
|
102
|
+
priority=5,
|
|
103
|
+
title="Read the memory board before starting work",
|
|
104
|
+
body="Run `hoja memory remind` first to load forced rules and recent project context. Add what you learn with `hoja memory add`.",
|
|
105
|
+
created_by="system",
|
|
106
|
+
auto_heal=False,
|
|
107
|
+
heal_after_days=0,
|
|
108
|
+
),
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load_memory(path: Path | None = None) -> MemoryBoard:
|
|
113
|
+
"""Load the memory board from disk. Returns a seeded board if file missing."""
|
|
114
|
+
p = path or memory_path()
|
|
115
|
+
if not p.exists():
|
|
116
|
+
# Seed with forced entries on first use
|
|
117
|
+
return MemoryBoard(forced=_default_forced_entries())
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
with open(p, encoding="utf-8") as f:
|
|
121
|
+
raw = _yaml.safe_load(f) if _yaml else _parse_yaml_fallback(f.read())
|
|
122
|
+
except Exception as e:
|
|
123
|
+
raise UsageError(
|
|
124
|
+
f"Cannot read memory board: {e}",
|
|
125
|
+
hint=f"Fix or delete {p}",
|
|
126
|
+
) from e
|
|
127
|
+
|
|
128
|
+
if raw is None:
|
|
129
|
+
return MemoryBoard(forced=_default_forced_entries())
|
|
130
|
+
|
|
131
|
+
return _deserialize_board(raw)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def save_memory(board: MemoryBoard, path: Path | None = None) -> None:
|
|
135
|
+
"""Persist the memory board to disk."""
|
|
136
|
+
p = path or memory_path()
|
|
137
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
|
|
139
|
+
data = _serialize_board(board)
|
|
140
|
+
if _yaml:
|
|
141
|
+
with open(p, "w", encoding="utf-8") as f:
|
|
142
|
+
_yaml.dump(
|
|
143
|
+
data,
|
|
144
|
+
f,
|
|
145
|
+
default_flow_style=False,
|
|
146
|
+
sort_keys=False,
|
|
147
|
+
allow_unicode=True,
|
|
148
|
+
)
|
|
149
|
+
else:
|
|
150
|
+
with open(p, "w", encoding="utf-8") as f:
|
|
151
|
+
f.write(_dump_yaml_fallback(data))
|
|
152
|
+
with contextlib.suppress(OSError):
|
|
153
|
+
os.chmod(p, 0o600)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def remind(board: MemoryBoard) -> str:
|
|
157
|
+
"""Generate a formatted digest of forced + recent community entries."""
|
|
158
|
+
lines: list[str] = ["═══════════════════════════════════════════════════════════════"]
|
|
159
|
+
lines.append(" HOJA MEMORY BOARD")
|
|
160
|
+
lines.append("═══════════════════════════════════════════════════════════════")
|
|
161
|
+
lines.append("")
|
|
162
|
+
|
|
163
|
+
if board.forced:
|
|
164
|
+
lines.append("▶ FORCED RULES (always follow these)")
|
|
165
|
+
for entry in sorted(board.forced, key=lambda e: e.priority):
|
|
166
|
+
lines.append(f" [{entry.priority}] {entry.title}")
|
|
167
|
+
for para in entry.body.split("\n"):
|
|
168
|
+
lines.append(f" {para}")
|
|
169
|
+
lines.append("")
|
|
170
|
+
|
|
171
|
+
active_community = [e for e in board.community if not _is_healed(e)]
|
|
172
|
+
if active_community:
|
|
173
|
+
lines.append("▶ COMMUNITY CONTEXT (recent learnings)")
|
|
174
|
+
for entry in sorted(active_community, key=lambda e: e.priority):
|
|
175
|
+
lines.append(f" [{entry.priority}] {entry.title}")
|
|
176
|
+
for para in entry.body.split("\n"):
|
|
177
|
+
lines.append(f" {para}")
|
|
178
|
+
lines.append(f" (added {entry.created_at[:10]} by {entry.created_by})")
|
|
179
|
+
lines.append("")
|
|
180
|
+
else:
|
|
181
|
+
lines.append("▶ No community entries yet.")
|
|
182
|
+
lines.append(" Add what you learn: hoja memory add --title \"...\" --body \"...\"")
|
|
183
|
+
lines.append("")
|
|
184
|
+
|
|
185
|
+
lines.append("═══════════════════════════════════════════════════════════════")
|
|
186
|
+
return "\n".join(lines)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def heal(board: MemoryBoard) -> list[str]:
|
|
190
|
+
"""Remove stale community entries. Returns list of removed IDs."""
|
|
191
|
+
removed: list[str] = []
|
|
192
|
+
kept: list[MemoryEntry] = []
|
|
193
|
+
for entry in board.community:
|
|
194
|
+
if _is_healed(entry):
|
|
195
|
+
removed.append(entry.id)
|
|
196
|
+
else:
|
|
197
|
+
kept.append(entry)
|
|
198
|
+
board.community = kept
|
|
199
|
+
return removed
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _is_healed(entry: MemoryEntry) -> bool:
|
|
203
|
+
"""Determine if a community entry is stale and should be removed."""
|
|
204
|
+
if not entry.auto_heal:
|
|
205
|
+
return False
|
|
206
|
+
if entry.heal_after_days <= 0:
|
|
207
|
+
return False
|
|
208
|
+
try:
|
|
209
|
+
created = datetime.fromisoformat(entry.created_at)
|
|
210
|
+
except (ValueError, TypeError):
|
|
211
|
+
return False
|
|
212
|
+
age = datetime.now(timezone.utc) - created
|
|
213
|
+
return age.days > entry.heal_after_days
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ---------------------------------------------------------------------------
|
|
217
|
+
# Serialization helpers
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
def _deserialize_board(raw: dict[str, Any]) -> MemoryBoard:
|
|
221
|
+
"""Convert raw dict to MemoryBoard."""
|
|
222
|
+
forced = [_deserialize_entry(e) for e in raw.get("forced", [])]
|
|
223
|
+
community = [_deserialize_entry(e) for e in raw.get("community", [])]
|
|
224
|
+
return MemoryBoard(forced=forced, community=community, version=raw.get("version", "1"))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _deserialize_entry(raw: dict[str, Any]) -> MemoryEntry:
|
|
228
|
+
"""Convert raw dict to MemoryEntry."""
|
|
229
|
+
return MemoryEntry(
|
|
230
|
+
id=raw.get("id", ""),
|
|
231
|
+
priority=raw.get("priority", 5),
|
|
232
|
+
title=raw.get("title", ""),
|
|
233
|
+
body=raw.get("body", ""),
|
|
234
|
+
created_by=raw.get("created_by", "agent"),
|
|
235
|
+
created_at=raw.get("created_at", _now()),
|
|
236
|
+
last_updated=raw.get("last_updated", _now()),
|
|
237
|
+
auto_heal=raw.get("auto_heal", True),
|
|
238
|
+
heal_after_days=raw.get("heal_after_days", 30),
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _serialize_board(board: MemoryBoard) -> dict[str, Any]:
|
|
243
|
+
"""Convert MemoryBoard to plain dict for YAML writing."""
|
|
244
|
+
return {
|
|
245
|
+
"version": board.version,
|
|
246
|
+
"forced": [_serialize_entry(e) for e in board.forced],
|
|
247
|
+
"community": [_serialize_entry(e) for e in board.community],
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _serialize_entry(entry: MemoryEntry) -> dict[str, Any]:
|
|
252
|
+
"""Convert MemoryEntry to plain dict."""
|
|
253
|
+
d = asdict(entry)
|
|
254
|
+
# Drop default fields to keep YAML concise
|
|
255
|
+
if entry.auto_heal is False:
|
|
256
|
+
d["auto_heal"] = False
|
|
257
|
+
else:
|
|
258
|
+
d.pop("auto_heal", None)
|
|
259
|
+
if entry.heal_after_days == 0:
|
|
260
|
+
d["heal_after_days"] = 0
|
|
261
|
+
else:
|
|
262
|
+
d.pop("heal_after_days", None)
|
|
263
|
+
return d
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# ---------------------------------------------------------------------------
|
|
267
|
+
# Fallback YAML-ish parser when PyYAML is not installed
|
|
268
|
+
# ---------------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
def _parse_yaml_fallback(text: str) -> dict[str, Any]:
|
|
271
|
+
"""Minimal YAML parser for our constrained schema.
|
|
272
|
+
|
|
273
|
+
Only supports the structure we produce:
|
|
274
|
+
- Top-level keys
|
|
275
|
+
- Lists of dicts under 'forced' and 'community'
|
|
276
|
+
- Scalar string/int/bool values
|
|
277
|
+
"""
|
|
278
|
+
result: dict[str, Any] = {"forced": [], "community": []}
|
|
279
|
+
current_list: list[dict[str, Any]] | None = None
|
|
280
|
+
current_item: dict[str, Any] = {}
|
|
281
|
+
|
|
282
|
+
for raw_line in text.splitlines():
|
|
283
|
+
line = raw_line.rstrip()
|
|
284
|
+
if not line or line.startswith("#"):
|
|
285
|
+
continue
|
|
286
|
+
if line.strip() == "---":
|
|
287
|
+
continue
|
|
288
|
+
|
|
289
|
+
# Top-level key (no leading spaces)
|
|
290
|
+
if not line.startswith(" ") and line.endswith(":"):
|
|
291
|
+
key = line[:-1].strip()
|
|
292
|
+
current_list = (
|
|
293
|
+
result.setdefault(key, []) # type: ignore[assignment]
|
|
294
|
+
if key in ("forced", "community")
|
|
295
|
+
else None
|
|
296
|
+
)
|
|
297
|
+
current_item = {}
|
|
298
|
+
continue
|
|
299
|
+
|
|
300
|
+
# List item start (- id: ...)
|
|
301
|
+
stripped = line.lstrip()
|
|
302
|
+
if stripped.startswith("- "):
|
|
303
|
+
if current_item and current_list is not None:
|
|
304
|
+
current_list.append(current_item)
|
|
305
|
+
current_item = {}
|
|
306
|
+
rest = stripped[2:]
|
|
307
|
+
if ":" in rest:
|
|
308
|
+
k, v = rest.split(":", 1)
|
|
309
|
+
current_item[k.strip()] = _parse_scalar(v.strip())
|
|
310
|
+
continue
|
|
311
|
+
|
|
312
|
+
# Nested key under current item
|
|
313
|
+
if current_item is not None and ":" in line:
|
|
314
|
+
indent = len(line) - len(line.lstrip())
|
|
315
|
+
if indent >= 2:
|
|
316
|
+
k, v = line.split(":", 1)
|
|
317
|
+
current_item[k.strip()] = _parse_scalar(v.strip())
|
|
318
|
+
|
|
319
|
+
if current_item and current_list is not None:
|
|
320
|
+
current_list.append(current_item)
|
|
321
|
+
|
|
322
|
+
return result
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _parse_scalar(v: str) -> str | int | bool:
|
|
326
|
+
"""Parse a simple YAML scalar."""
|
|
327
|
+
v = v.strip().strip('"').strip("'")
|
|
328
|
+
if v.lower() in ("true", "yes"):
|
|
329
|
+
return True
|
|
330
|
+
if v.lower() in ("false", "no"):
|
|
331
|
+
return False
|
|
332
|
+
if v.isdigit() or (v.startswith("-") and v[1:].isdigit()):
|
|
333
|
+
return int(v)
|
|
334
|
+
return v
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def _dump_yaml_fallback(data: dict[str, Any]) -> str:
|
|
338
|
+
"""Minimal YAML serializer for our constrained schema."""
|
|
339
|
+
lines: list[str] = ["---"]
|
|
340
|
+
|
|
341
|
+
for key, value in data.items():
|
|
342
|
+
if isinstance(value, list):
|
|
343
|
+
lines.append(f"{key}:")
|
|
344
|
+
for item in value:
|
|
345
|
+
if isinstance(item, dict):
|
|
346
|
+
first = True
|
|
347
|
+
for k, v in item.items():
|
|
348
|
+
if first:
|
|
349
|
+
lines.append(f" - {k}: {_fmt_scalar(v)}")
|
|
350
|
+
first = False
|
|
351
|
+
else:
|
|
352
|
+
lines.append(f" {k}: {_fmt_scalar(v)}")
|
|
353
|
+
else:
|
|
354
|
+
lines.append(f" - {_fmt_scalar(item)}")
|
|
355
|
+
else:
|
|
356
|
+
lines.append(f"{key}: {_fmt_scalar(value)}")
|
|
357
|
+
lines.append("")
|
|
358
|
+
return "\n".join(lines)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _fmt_scalar(v: Any) -> str:
|
|
362
|
+
if isinstance(v, bool):
|
|
363
|
+
return "true" if v else "false"
|
|
364
|
+
if isinstance(v, int):
|
|
365
|
+
return str(v)
|
|
366
|
+
if isinstance(v, str):
|
|
367
|
+
if "\n" in v or ":" in v or v.startswith(" "):
|
|
368
|
+
return repr(v)
|
|
369
|
+
return v
|
|
370
|
+
return str(v)
|
hoja/models/__init__.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Pydantic models for Linear entities."""
|
|
2
|
+
|
|
3
|
+
from hoja.models.base import (
|
|
4
|
+
Connection,
|
|
5
|
+
LinearModel,
|
|
6
|
+
PageInfo,
|
|
7
|
+
)
|
|
8
|
+
from hoja.models.cycle import Cycle
|
|
9
|
+
from hoja.models.enums import IssuePriority
|
|
10
|
+
from hoja.models.issue import (
|
|
11
|
+
Comment,
|
|
12
|
+
Issue,
|
|
13
|
+
IssueLabel,
|
|
14
|
+
)
|
|
15
|
+
from hoja.models.label import Label
|
|
16
|
+
from hoja.models.project import Project, ProjectState
|
|
17
|
+
from hoja.models.team import Team
|
|
18
|
+
from hoja.models.user import User
|
|
19
|
+
from hoja.models.workflow import WorkflowState, WorkflowStateType
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Comment",
|
|
23
|
+
"Connection",
|
|
24
|
+
"Cycle",
|
|
25
|
+
"Issue",
|
|
26
|
+
"IssueLabel",
|
|
27
|
+
"IssuePriority",
|
|
28
|
+
"Label",
|
|
29
|
+
"LinearModel",
|
|
30
|
+
"PageInfo",
|
|
31
|
+
"Project",
|
|
32
|
+
"ProjectState",
|
|
33
|
+
"Team",
|
|
34
|
+
"User",
|
|
35
|
+
"WorkflowState",
|
|
36
|
+
"WorkflowStateType",
|
|
37
|
+
]
|
hoja/models/base.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Base Pydantic models shared across Linear entities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from typing import Generic, TypeVar
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
9
|
+
|
|
10
|
+
T = TypeVar("T")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LinearModel(BaseModel):
|
|
14
|
+
"""Base class for all Linear entities.
|
|
15
|
+
|
|
16
|
+
Configuration:
|
|
17
|
+
- extra="ignore": Linear adds new fields often; don't fail on unknown.
|
|
18
|
+
- populate_by_name: Accept both alias and field name.
|
|
19
|
+
- frozen=False: Allow mutation; mutations happen via API not local edits.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
model_config = ConfigDict(
|
|
23
|
+
extra="ignore",
|
|
24
|
+
populate_by_name=True,
|
|
25
|
+
str_strip_whitespace=True,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class PageInfo(BaseModel):
|
|
30
|
+
"""GraphQL Relay-style pagination info."""
|
|
31
|
+
|
|
32
|
+
model_config = ConfigDict(extra="ignore")
|
|
33
|
+
|
|
34
|
+
has_next_page: bool = Field(alias="hasNextPage", default=False)
|
|
35
|
+
has_previous_page: bool = Field(alias="hasPreviousPage", default=False)
|
|
36
|
+
start_cursor: str | None = Field(alias="startCursor", default=None)
|
|
37
|
+
end_cursor: str | None = Field(alias="endCursor", default=None)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class Connection(BaseModel, Generic[T]):
|
|
41
|
+
"""Generic GraphQL connection wrapper.
|
|
42
|
+
|
|
43
|
+
Linear paginates all list queries using this Relay-style envelope:
|
|
44
|
+
{ nodes: [T], pageInfo: PageInfo }
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
model_config = ConfigDict(extra="ignore")
|
|
48
|
+
|
|
49
|
+
nodes: list[T] = Field(default_factory=list)
|
|
50
|
+
page_info: PageInfo = Field(alias="pageInfo", default_factory=PageInfo)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class Timestamped(LinearModel):
|
|
54
|
+
"""Mixin for entities with createdAt/updatedAt."""
|
|
55
|
+
|
|
56
|
+
created_at: datetime | None = Field(alias="createdAt", default=None)
|
|
57
|
+
updated_at: datetime | None = Field(alias="updatedAt", default=None)
|
|
58
|
+
archived_at: datetime | None = Field(alias="archivedAt", default=None)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class Identified(LinearModel):
|
|
62
|
+
"""Mixin for entities with stable id."""
|
|
63
|
+
|
|
64
|
+
id: str
|
hoja/models/cycle.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Cycle model (Linear's sprint/iteration entity)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from pydantic import Field
|
|
8
|
+
|
|
9
|
+
from hoja.models.base import Timestamped
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Cycle(Timestamped):
|
|
13
|
+
"""A Linear cycle (sprint)."""
|
|
14
|
+
|
|
15
|
+
id: str
|
|
16
|
+
name: str | None = None
|
|
17
|
+
number: int
|
|
18
|
+
description: str | None = None
|
|
19
|
+
|
|
20
|
+
# Dates
|
|
21
|
+
starts_at: datetime | None = Field(alias="startsAt", default=None)
|
|
22
|
+
ends_at: datetime | None = Field(alias="endsAt", default=None)
|
|
23
|
+
completed_at: datetime | None = Field(alias="completedAt", default=None)
|
|
24
|
+
|
|
25
|
+
# Stats
|
|
26
|
+
progress: float | None = None
|
|
27
|
+
issue_count_history: list[int] | None = Field(
|
|
28
|
+
alias="issueCountHistory", default=None
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Relations
|
|
32
|
+
team_id: str | None = Field(alias="teamId", default=None)
|
hoja/models/enums.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Linear enums.
|
|
2
|
+
|
|
3
|
+
Generated from introspection output (schema/linear-schema.json).
|
|
4
|
+
Only the ones actually used in v1 are included.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from enum import Enum, IntEnum
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class IssuePriority(IntEnum):
|
|
13
|
+
"""Linear issue priority levels."""
|
|
14
|
+
|
|
15
|
+
NO_PRIORITY = 0
|
|
16
|
+
URGENT = 1
|
|
17
|
+
HIGH = 2
|
|
18
|
+
MEDIUM = 3
|
|
19
|
+
LOW = 4
|
|
20
|
+
|
|
21
|
+
@classmethod
|
|
22
|
+
def parse(cls, value: str | int) -> "IssuePriority":
|
|
23
|
+
"""Lenient parser: accepts int (0-4) or name (case-insensitive)."""
|
|
24
|
+
if isinstance(value, int):
|
|
25
|
+
return cls(value)
|
|
26
|
+
if isinstance(value, str):
|
|
27
|
+
v = value.strip().upper().replace(" ", "_").replace("-", "_")
|
|
28
|
+
if v.isdigit():
|
|
29
|
+
return cls(int(v))
|
|
30
|
+
try:
|
|
31
|
+
return cls[v]
|
|
32
|
+
except KeyError as e:
|
|
33
|
+
raise ValueError(
|
|
34
|
+
f"Invalid priority: {value!r}. "
|
|
35
|
+
f"Expected 0-4 or one of: {[p.name for p in cls]}"
|
|
36
|
+
) from e
|
|
37
|
+
raise TypeError(f"Cannot parse priority from {type(value).__name__}")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class StateType(str, Enum):
|
|
41
|
+
"""Linear workflow state categories.
|
|
42
|
+
|
|
43
|
+
These are the broad "buckets" — actual states are user-defined per team.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
TRIAGE = "triage"
|
|
47
|
+
BACKLOG = "backlog"
|
|
48
|
+
UNSTARTED = "unstarted"
|
|
49
|
+
STARTED = "started"
|
|
50
|
+
COMPLETED = "completed"
|
|
51
|
+
CANCELED = "canceled"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class OutputFormat(str, Enum):
|
|
55
|
+
"""CLI output format selector."""
|
|
56
|
+
|
|
57
|
+
HUMAN = "human"
|
|
58
|
+
JSON = "json"
|
|
59
|
+
YAML = "yaml"
|
|
60
|
+
MARKDOWN = "md"
|
|
61
|
+
PLAIN = "plain"
|
|
62
|
+
IDS = "ids"
|
hoja/models/issue.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Issue model — the most important Linear entity.
|
|
2
|
+
|
|
3
|
+
A Linear issue is a unit of work. It belongs to a team, has a workflow state,
|
|
4
|
+
optionally an assignee, project, cycle, labels, and comments.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import Field, field_validator
|
|
13
|
+
|
|
14
|
+
from hoja.models.base import Timestamped
|
|
15
|
+
from hoja.models.enums import IssuePriority
|
|
16
|
+
from hoja.models.user import User
|
|
17
|
+
from hoja.models.workflow import WorkflowState
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class IssueLabel(Timestamped):
|
|
21
|
+
"""A label attached to an issue."""
|
|
22
|
+
|
|
23
|
+
id: str
|
|
24
|
+
name: str
|
|
25
|
+
color: str | None = None
|
|
26
|
+
description: str | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Comment(Timestamped):
|
|
30
|
+
"""A comment on an issue."""
|
|
31
|
+
|
|
32
|
+
id: str
|
|
33
|
+
body: str
|
|
34
|
+
user: User | None = None
|
|
35
|
+
url: str | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _flatten_connection(v: Any) -> Any:
|
|
39
|
+
"""Unwrap Linear's Connection {nodes: [...]} into a flat list."""
|
|
40
|
+
if isinstance(v, dict) and "nodes" in v:
|
|
41
|
+
return v["nodes"]
|
|
42
|
+
return v
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Issue(Timestamped):
|
|
46
|
+
"""A Linear issue.
|
|
47
|
+
|
|
48
|
+
GraphQL: type Issue implements Node
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
id: str
|
|
52
|
+
identifier: str # "ENG-123"
|
|
53
|
+
title: str
|
|
54
|
+
description: str | None = None
|
|
55
|
+
priority: IssuePriority = IssuePriority.NO_PRIORITY
|
|
56
|
+
priority_label: str | None = Field(alias="priorityLabel", default=None)
|
|
57
|
+
estimate: float | None = None
|
|
58
|
+
|
|
59
|
+
# Status / state
|
|
60
|
+
state: WorkflowState | None = None
|
|
61
|
+
|
|
62
|
+
# Relations
|
|
63
|
+
assignee: User | None = None
|
|
64
|
+
creator: User | None = None
|
|
65
|
+
team: dict | None = None # lightweight ref; full Team model on demand
|
|
66
|
+
project: dict | None = None
|
|
67
|
+
cycle: dict | None = None
|
|
68
|
+
parent: dict | None = None
|
|
69
|
+
|
|
70
|
+
# Connection-wrapped relations — flattened by validator
|
|
71
|
+
subscribers: list[User] | None = None
|
|
72
|
+
labels: list[IssueLabel] | None = None
|
|
73
|
+
|
|
74
|
+
@field_validator("subscribers", "labels", mode="before")
|
|
75
|
+
@classmethod
|
|
76
|
+
def _flatten(cls, v: Any) -> Any:
|
|
77
|
+
return _flatten_connection(v)
|
|
78
|
+
|
|
79
|
+
# Dates
|
|
80
|
+
due_date: datetime | None = Field(alias="dueDate", default=None)
|
|
81
|
+
completed_at: datetime | None = Field(alias="completedAt", default=None)
|
|
82
|
+
canceled_at: datetime | None = Field(alias="canceledAt", default=None)
|
|
83
|
+
started_at: datetime | None = Field(alias="startedAt", default=None)
|
|
84
|
+
snoozed_until_at: datetime | None = Field(alias="snoozedUntilAt", default=None)
|
|
85
|
+
|
|
86
|
+
# Metadata
|
|
87
|
+
branch_name: str | None = Field(alias="branchName", default=None)
|
|
88
|
+
url: str | None = None
|
|
89
|
+
number: int | None = None
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def state_name(self) -> str:
|
|
93
|
+
"""Human-readable state name, or '?' if missing."""
|
|
94
|
+
return self.state.name if self.state else "?"
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def assignee_name(self) -> str:
|
|
98
|
+
"""Display-friendly assignee name, or '—' if unassigned."""
|
|
99
|
+
return self.assignee.short if self.assignee else "—"
|
hoja/models/label.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Label model — surfaces from `IssueLabel` GraphQL type."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import Field
|
|
6
|
+
|
|
7
|
+
from hoja.models.base import Timestamped
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Label(Timestamped):
|
|
11
|
+
"""A Linear issue label."""
|
|
12
|
+
|
|
13
|
+
id: str
|
|
14
|
+
name: str
|
|
15
|
+
color: str | None = None
|
|
16
|
+
description: str | None = None
|
|
17
|
+
team_id: str | None = Field(alias="teamId", default=None)
|