hoja-cli 0.21.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. VERSION +1 -0
  2. google_workspace/__init__.py +19 -0
  3. google_workspace/client.py +253 -0
  4. google_workspace/config.py +39 -0
  5. google_workspace/errors.py +19 -0
  6. google_workspace/mcp_server.py +100 -0
  7. google_workspace/oauth.py +131 -0
  8. google_workspace/principal.py +83 -0
  9. google_workspace/py.typed +0 -0
  10. google_workspace/tools.py +64 -0
  11. google_workspace/writes.py +44 -0
  12. hoja/__init__.py +29 -0
  13. hoja/__main__.py +6 -0
  14. hoja/auth.py +42 -0
  15. hoja/cli.py +161 -0
  16. hoja/cli_state.py +55 -0
  17. hoja/client.py +268 -0
  18. hoja/commands/__init__.py +4 -0
  19. hoja/commands/auth_cmd.py +271 -0
  20. hoja/commands/comment.py +149 -0
  21. hoja/commands/cycle.py +75 -0
  22. hoja/commands/init.py +108 -0
  23. hoja/commands/issue.py +398 -0
  24. hoja/commands/label.py +163 -0
  25. hoja/commands/memory.py +240 -0
  26. hoja/commands/project.py +56 -0
  27. hoja/commands/raw.py +50 -0
  28. hoja/commands/team.py +102 -0
  29. hoja/commands/update.py +162 -0
  30. hoja/config.py +362 -0
  31. hoja/errors.py +107 -0
  32. hoja/filters.py +150 -0
  33. hoja/graphql/__init__.py +5 -0
  34. hoja/graphql/fragments.py +138 -0
  35. hoja/graphql/mutations.py +160 -0
  36. hoja/graphql/queries.py +245 -0
  37. hoja/mcp/__init__.py +27 -0
  38. hoja/mcp/content.py +151 -0
  39. hoja/mcp/prompts.py +298 -0
  40. hoja/mcp/resources.py +213 -0
  41. hoja/mcp/server.py +189 -0
  42. hoja/memory_board.py +370 -0
  43. hoja/models/__init__.py +37 -0
  44. hoja/models/base.py +64 -0
  45. hoja/models/cycle.py +32 -0
  46. hoja/models/enums.py +62 -0
  47. hoja/models/issue.py +99 -0
  48. hoja/models/label.py +17 -0
  49. hoja/models/project.py +60 -0
  50. hoja/models/team.py +31 -0
  51. hoja/models/user.py +32 -0
  52. hoja/models/workflow.py +65 -0
  53. hoja/output.py +410 -0
  54. hoja/skill_content/commands.md +222 -0
  55. hoja/skill_content/examples.md +193 -0
  56. hoja/skill_content/filters.md +101 -0
  57. hoja/skill_content/output-formats.md +92 -0
  58. hoja/skill_content/overview.md +123 -0
  59. hoja/skill_content/workflows.md +174 -0
  60. hoja_cli-0.21.4.dist-info/METADATA +279 -0
  61. hoja_cli-0.21.4.dist-info/RECORD +107 -0
  62. hoja_cli-0.21.4.dist-info/WHEEL +4 -0
  63. hoja_cli-0.21.4.dist-info/entry_points.txt +4 -0
  64. hoja_cli-0.21.4.dist-info/licenses/LICENSE +21 -0
  65. hoja_server/README.md +135 -0
  66. hoja_server/__init__.py +11 -0
  67. hoja_server/abac.py +116 -0
  68. hoja_server/agent_chat.py +280 -0
  69. hoja_server/agent_conversations.py +91 -0
  70. hoja_server/agent_durable.py +332 -0
  71. hoja_server/agent_prompt.py +117 -0
  72. hoja_server/agent_threads.py +96 -0
  73. hoja_server/agent_uploads.py +60 -0
  74. hoja_server/app.py +2189 -0
  75. hoja_server/cadence.py +231 -0
  76. hoja_server/cli.py +191 -0
  77. hoja_server/cloudflare_realtime.py +110 -0
  78. hoja_server/comment_policy.py +70 -0
  79. hoja_server/connector_actions.py +119 -0
  80. hoja_server/connectors_api.py +375 -0
  81. hoja_server/db.py +1816 -0
  82. hoja_server/denials.py +18 -0
  83. hoja_server/deployment.py +28 -0
  84. hoja_server/email_adapter.py +94 -0
  85. hoja_server/google_login.py +217 -0
  86. hoja_server/google_oidc.py +342 -0
  87. hoja_server/identity_transfer.py +210 -0
  88. hoja_server/mcp_oauth.py +623 -0
  89. hoja_server/mcp_tools.py +579 -0
  90. hoja_server/meeting_policy.py +179 -0
  91. hoja_server/meetings.py +1332 -0
  92. hoja_server/meetings_api.py +909 -0
  93. hoja_server/model_providers.py +205 -0
  94. hoja_server/realtime.py +690 -0
  95. hoja_server/resolvers.py +1281 -0
  96. hoja_server/schema.graphql +477 -0
  97. hoja_server/schema_sdl.py +8 -0
  98. hoja_server/secrets_box.py +39 -0
  99. hoja_server/signup_config.py +292 -0
  100. hoja_server/signup_gate.py +393 -0
  101. hoja_server/signup_policy.py +97 -0
  102. hoja_server/signup_store.py +577 -0
  103. hoja_server/store.py +2179 -0
  104. hoja_server/sync.py +249 -0
  105. hoja_server/tenant_context.py +131 -0
  106. hoja_server/turnstile.py +81 -0
  107. hoja_server/writer.py +1262 -0
hoja/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)
@@ -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)