td-ai-tools 1.0.2

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 (41) hide show
  1. package/README.md +49 -0
  2. package/agents/README.md +10 -0
  3. package/agents/horizon-component-library/AGENTS.md +184 -0
  4. package/agents/horizon-component-library/README.md +10 -0
  5. package/agents/horizon-component-library/scripts/td-guard.sh +49 -0
  6. package/bin/cli.js +209 -0
  7. package/package.json +23 -0
  8. package/scripts/smoke-install.sh +56 -0
  9. package/skills/README.md +15 -0
  10. package/skills/cache-reset/SKILL.md +18 -0
  11. package/skills/cache-reset/agents/openai.yaml +4 -0
  12. package/skills/car-ticket-generator/SKILL.md +130 -0
  13. package/skills/car-ticket-generator/agents/openai.yaml +4 -0
  14. package/skills/everhour-basecamp-estimates/.env.example +2 -0
  15. package/skills/everhour-basecamp-estimates/SKILL.md +73 -0
  16. package/skills/everhour-basecamp-estimates/agents/openai.yaml +4 -0
  17. package/skills/everhour-basecamp-estimates/scripts/update_estimates.py +518 -0
  18. package/skills/everhour-basecamp-estimates/tests/test_update_estimates.py +93 -0
  19. package/skills/horizon-component-migration/SKILL.md +59 -0
  20. package/skills/horizon-component-migration/agents/openai.yaml +4 -0
  21. package/skills/pr-solver/SKILL.md +50 -0
  22. package/skills/pr-solver/agents/openai.yaml +4 -0
  23. package/skills/pr-solver/references/github-pr-reviewthreads-graphql.md +63 -0
  24. package/skills/pr-solver/scripts/list_unresolved_threads.py +307 -0
  25. package/skills/pull-request/SKILL.md +216 -0
  26. package/skills/pull-request/agents/openai.yaml +4 -0
  27. package/skills/record-changes/SKILL.md +75 -0
  28. package/skills/record-changes/agents/openai.yaml +4 -0
  29. package/skills/record-changes/scripts/branch_diff_context.py +190 -0
  30. package/skills/stylesheet-migration/SKILL.md +36 -0
  31. package/skills/stylesheet-migration/agents/openai.yaml +6 -0
  32. package/skills/stylesheet-migration/scripts/__pycache__/liquid_stylesheet_migrator.cpython-312.pyc +0 -0
  33. package/skills/stylesheet-migration/scripts/__pycache__/test_liquid_stylesheet_migrator.cpython-312.pyc +0 -0
  34. package/skills/stylesheet-migration/scripts/liquid_stylesheet_migrator.py +204 -0
  35. package/skills/stylesheet-migration/scripts/migrate_stylesheet_tags.py +172 -0
  36. package/skills/stylesheet-migration/scripts/test_liquid_stylesheet_migrator.py +254 -0
  37. package/skills/td-js-vanilla-rules/SKILL.md +70 -0
  38. package/skills/td-js-vanilla-rules/agents/openai.yaml +3 -0
  39. package/skills/td-review/SKILL.md +122 -0
  40. package/skills/td-review/agents/openai.yaml +4 -0
  41. package/skills/td-review/agents/td-theme-reviewer.md +221 -0
@@ -0,0 +1,518 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import os
7
+ import re
8
+ import subprocess
9
+ import sys
10
+ from dataclasses import dataclass
11
+ from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
12
+ from pathlib import Path
13
+ from typing import Any
14
+ from urllib.error import HTTPError, URLError
15
+ from urllib.parse import quote, urlencode
16
+ from urllib.request import Request, urlopen
17
+
18
+ SKILL_ROOT = Path(__file__).resolve().parents[1]
19
+ DEFAULT_ENV_FILE = SKILL_ROOT / ".env"
20
+ EVERHOUR_BASE_URL = "https://api.everhour.com"
21
+ TITLE_KEYS = ("title", "name", "content", "text")
22
+ TRAILING_ESTIMATE_RE = re.compile(r"\s+\[(\d+(?:\.\d+)?)h\]$", re.IGNORECASE)
23
+ WHITESPACE_RE = re.compile(r"\s+")
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class TodoItem:
28
+ id: str
29
+ title: str
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class MatchPlan:
34
+ todo: TodoItem
35
+ hours: Decimal
36
+ seconds: int
37
+ everhour_task_id: str
38
+ everhour_task_name: str
39
+ new_basecamp_title: str
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class TodoSelection:
44
+ active: list[TodoItem]
45
+ skipped_completed: list[TodoItem]
46
+
47
+
48
+ class ScriptError(RuntimeError):
49
+ pass
50
+
51
+
52
+ def parse_args() -> argparse.Namespace:
53
+ parser = argparse.ArgumentParser(
54
+ description=(
55
+ "Update Everhour estimates for tasks matched from a Basecamp todo or "
56
+ "todolist URL, then append the estimate to the Basecamp todo titles."
57
+ )
58
+ )
59
+ parser.add_argument("basecamp_url", help="Basecamp todo or todolist URL")
60
+ parser.add_argument(
61
+ "hours",
62
+ help="Estimate in hours, or a JSON array of per-task estimates in hours",
63
+ )
64
+ parser.add_argument(
65
+ "--env-file",
66
+ default=str(DEFAULT_ENV_FILE),
67
+ help=f"Path to the skill-local .env file (default: {DEFAULT_ENV_FILE})",
68
+ )
69
+ parser.add_argument(
70
+ "--basecamp-bin",
71
+ default="basecamp",
72
+ help="Basecamp CLI executable name or path",
73
+ )
74
+ parser.add_argument(
75
+ "--dry-run",
76
+ action="store_true",
77
+ help="Print the planned changes without mutating Everhour or Basecamp",
78
+ )
79
+ return parser.parse_args()
80
+
81
+
82
+ def load_env_file(path: Path) -> dict[str, str]:
83
+ if not path.exists():
84
+ raise ScriptError(
85
+ f"Missing env file: {path}. Copy {SKILL_ROOT / '.env.example'} to "
86
+ f"{path} and fill in the values."
87
+ )
88
+
89
+ values: dict[str, str] = {}
90
+ for line in path.read_text(encoding="utf-8").splitlines():
91
+ stripped = line.strip()
92
+ if not stripped or stripped.startswith("#"):
93
+ continue
94
+ if "=" not in stripped:
95
+ raise ScriptError(f"Invalid line in {path}: {line}")
96
+ key, raw_value = stripped.split("=", 1)
97
+ values[key.strip()] = raw_value.strip().strip("'").strip('"')
98
+
99
+ for key, value in os.environ.items():
100
+ if key.startswith("EVERHOUR_"):
101
+ values[key] = value
102
+
103
+ missing = [key for key in ("EVERHOUR_API_KEY", "EVERHOUR_PROJECT_ID") if not values.get(key)]
104
+ if missing:
105
+ raise ScriptError(f"Missing required environment values: {', '.join(missing)}")
106
+
107
+ return values
108
+
109
+
110
+ def parse_hours_to_seconds(raw_hours: str) -> tuple[Decimal, int]:
111
+ return parse_hours_value(raw_hours)
112
+
113
+
114
+ def parse_hours_value(raw_hours: Any) -> tuple[Decimal, int]:
115
+ if isinstance(raw_hours, bool):
116
+ raise ScriptError(f"Invalid hours value: {raw_hours}")
117
+
118
+ if isinstance(raw_hours, Decimal):
119
+ hours = raw_hours
120
+ else:
121
+ try:
122
+ hours = Decimal(str(raw_hours))
123
+ except (InvalidOperation, ValueError) as exc:
124
+ raise ScriptError(f"Invalid hours value: {raw_hours}") from exc
125
+
126
+ if hours <= 0:
127
+ raise ScriptError("Hours must be greater than zero.")
128
+
129
+ seconds = int((hours * Decimal("3600")).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
130
+ return hours, seconds
131
+
132
+
133
+ def parse_hours_input(raw_hours: str, todo_count: int) -> list[tuple[Decimal, int]]:
134
+ stripped = raw_hours.strip()
135
+ if stripped.startswith("["):
136
+ try:
137
+ values = json.loads(stripped, parse_float=Decimal, parse_int=Decimal)
138
+ except json.JSONDecodeError as exc:
139
+ raise ScriptError(f"Invalid hours array: {raw_hours}") from exc
140
+
141
+ if not isinstance(values, list):
142
+ raise ScriptError("Hours array input must decode to a JSON array.")
143
+ if len(values) != todo_count:
144
+ raise ScriptError(
145
+ "Hours array length does not match active Basecamp todo count: "
146
+ f"expected {todo_count}, got {len(values)}."
147
+ )
148
+ return [parse_hours_value(value) for value in values]
149
+
150
+ hours, seconds = parse_hours_to_seconds(raw_hours)
151
+ return [(hours, seconds) for _ in range(todo_count)]
152
+
153
+
154
+ def format_hours(hours: Decimal) -> str:
155
+ rendered = format(hours.normalize(), "f")
156
+ return rendered.rstrip("0").rstrip(".") if "." in rendered else rendered
157
+
158
+
159
+ def strip_estimate_suffix(title: str) -> str:
160
+ return TRAILING_ESTIMATE_RE.sub("", title).strip()
161
+
162
+
163
+ def normalize_title(title: str) -> str:
164
+ collapsed = WHITESPACE_RE.sub(" ", strip_estimate_suffix(title)).strip()
165
+ return collapsed.casefold()
166
+
167
+
168
+ def build_estimated_title(title: str, hours: Decimal) -> str:
169
+ return f"{strip_estimate_suffix(title)} [{format_hours(hours)}h]"
170
+
171
+
172
+ def run_basecamp_json(basecamp_bin: str, *args: str) -> Any:
173
+ command = [basecamp_bin, *args, "--agent"]
174
+ completed = subprocess.run(command, capture_output=True, text=True)
175
+ if completed.returncode != 0:
176
+ stderr = completed.stderr.strip()
177
+ stdout = completed.stdout.strip()
178
+ raise ScriptError(
179
+ f"Basecamp command failed: {' '.join(command)}\n"
180
+ f"{stderr or stdout or 'No output returned.'}"
181
+ )
182
+
183
+ stdout = completed.stdout.strip()
184
+ if not stdout:
185
+ raise ScriptError(f"Basecamp command returned no JSON: {' '.join(command)}")
186
+
187
+ try:
188
+ return json.loads(stdout)
189
+ except json.JSONDecodeError as exc:
190
+ raise ScriptError(
191
+ f"Basecamp command returned invalid JSON: {' '.join(command)}\n{stdout}"
192
+ ) from exc
193
+
194
+
195
+ def extract_title(record: dict[str, Any]) -> str:
196
+ for key in TITLE_KEYS:
197
+ value = record.get(key)
198
+ if isinstance(value, str) and value.strip():
199
+ return value.strip()
200
+ raise ScriptError(f"Could not determine a title from Basecamp payload: {record}")
201
+
202
+
203
+ def extract_todo_objects(payload: Any) -> list[dict[str, Any]]:
204
+ if isinstance(payload, list):
205
+ candidates = [item for item in payload if is_todo_like(item)]
206
+ if candidates:
207
+ return candidates
208
+ return []
209
+
210
+ if not isinstance(payload, dict):
211
+ return []
212
+
213
+ for key in ("todos", "items", "entries", "records"):
214
+ value = payload.get(key)
215
+ if isinstance(value, list):
216
+ candidates = [item for item in value if is_todo_like(item)]
217
+ if candidates:
218
+ return candidates
219
+
220
+ for key, value in payload.items():
221
+ if "todo" in key.lower():
222
+ candidates = extract_todo_objects(value)
223
+ if candidates:
224
+ return candidates
225
+
226
+ for value in payload.values():
227
+ candidates = extract_todo_objects(value)
228
+ if candidates:
229
+ return candidates
230
+
231
+ return []
232
+
233
+
234
+ def is_todo_like(value: Any) -> bool:
235
+ return (
236
+ isinstance(value, dict)
237
+ and "id" in value
238
+ and any(isinstance(value.get(key), str) and value.get(key).strip() for key in TITLE_KEYS)
239
+ )
240
+
241
+
242
+ def is_todo_completed(record: dict[str, Any]) -> bool:
243
+ if record.get("completed") is True:
244
+ return True
245
+
246
+ status = record.get("status")
247
+ if isinstance(status, str) and status.strip().lower() in {"complete", "completed", "done", "closed"}:
248
+ return True
249
+
250
+ for key in ("completedAt", "completed_at", "completedOn", "completed_on"):
251
+ value = record.get(key)
252
+ if isinstance(value, str) and value.strip():
253
+ return True
254
+
255
+ return False
256
+
257
+
258
+ def load_basecamp_todos(basecamp_bin: str, basecamp_url: str) -> TodoSelection:
259
+ parsed = run_basecamp_json(basecamp_bin, "url", "parse", basecamp_url)
260
+ resource_type = str(parsed.get("type", "")).strip().lower()
261
+
262
+ if resource_type in {"todo", "todos"}:
263
+ payload = run_basecamp_json(basecamp_bin, "todos", "show", basecamp_url)
264
+ todo = TodoItem(id=str(payload["id"]), title=extract_title(payload))
265
+ if is_todo_completed(payload):
266
+ return TodoSelection(active=[], skipped_completed=[todo])
267
+ return TodoSelection(active=[todo], skipped_completed=[])
268
+
269
+ if resource_type in {"todolist", "todolists"}:
270
+ payload = run_basecamp_json(basecamp_bin, "todolists", "show", basecamp_url)
271
+ todo_objects = extract_todo_objects(payload)
272
+ if not todo_objects:
273
+ raise ScriptError(
274
+ "The Basecamp todolist payload did not include any todo items. "
275
+ "Inspect the todolist manually to confirm the CLI output shape."
276
+ )
277
+ active: list[TodoItem] = []
278
+ skipped_completed: list[TodoItem] = []
279
+ for item in todo_objects:
280
+ todo = TodoItem(id=str(item["id"]), title=extract_title(item))
281
+ if is_todo_completed(item):
282
+ skipped_completed.append(todo)
283
+ else:
284
+ active.append(todo)
285
+ return TodoSelection(active=active, skipped_completed=skipped_completed)
286
+
287
+ raise ScriptError(
288
+ f"Unsupported Basecamp URL type: {resource_type or 'unknown'}. "
289
+ "Use a todo or todolist URL."
290
+ )
291
+
292
+
293
+ class EverhourClient:
294
+ def __init__(self, api_key: str, project_id: str) -> None:
295
+ self.api_key = api_key
296
+ self.project_id = project_id
297
+ self._open_tasks_by_title: dict[str, list[dict[str, Any]]] | None = None
298
+
299
+ def _request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> Any:
300
+ request = Request(
301
+ url=f"{EVERHOUR_BASE_URL}{path}",
302
+ method=method,
303
+ headers={
304
+ "Accept": "application/json",
305
+ "Content-Type": "application/json",
306
+ "X-Api-Key": self.api_key,
307
+ },
308
+ data=None if payload is None else json.dumps(payload).encode("utf-8"),
309
+ )
310
+
311
+ try:
312
+ with urlopen(request) as response:
313
+ raw_body = response.read().decode("utf-8")
314
+ except HTTPError as exc:
315
+ body = exc.read().decode("utf-8", errors="replace")
316
+ raise ScriptError(f"Everhour API error {exc.code} for {method} {path}: {body}") from exc
317
+ except URLError as exc:
318
+ raise ScriptError(f"Everhour API request failed for {method} {path}: {exc.reason}") from exc
319
+
320
+ if not raw_body:
321
+ return None
322
+
323
+ try:
324
+ return json.loads(raw_body)
325
+ except json.JSONDecodeError as exc:
326
+ raise ScriptError(f"Everhour API returned invalid JSON for {method} {path}: {raw_body}") from exc
327
+
328
+ def _project_path(self, suffix: str) -> str:
329
+ return f"/projects/{quote(self.project_id, safe='')}{suffix}"
330
+
331
+ def _task_path(self, task_id: str, suffix: str = "") -> str:
332
+ return f"/tasks/{quote(task_id, safe='')}{suffix}"
333
+
334
+ def list_open_tasks(self) -> dict[str, list[dict[str, Any]]]:
335
+ if self._open_tasks_by_title is not None:
336
+ return self._open_tasks_by_title
337
+
338
+ tasks_by_title: dict[str, list[dict[str, Any]]] = {}
339
+ page = 1
340
+ limit = 100
341
+
342
+ while True:
343
+ query = urlencode({"limit": limit, "page": page})
344
+ tasks = self._request("GET", self._project_path(f"/tasks?{query}"))
345
+ if not isinstance(tasks, list):
346
+ raise ScriptError("Everhour project tasks response was not a list.")
347
+
348
+ for task in tasks:
349
+ if not isinstance(task, dict) or "id" not in task:
350
+ continue
351
+ tasks_by_title.setdefault(normalize_title(str(task.get("name", ""))), []).append(task)
352
+
353
+ if len(tasks) < limit:
354
+ break
355
+ page += 1
356
+
357
+ self._open_tasks_by_title = tasks_by_title
358
+ return tasks_by_title
359
+
360
+ def search_tasks(self, title: str) -> list[dict[str, Any]]:
361
+ query = urlencode(
362
+ {
363
+ "query": title,
364
+ "limit": 100,
365
+ "searchInClosed": "true",
366
+ }
367
+ )
368
+ tasks = self._request("GET", self._project_path(f"/tasks/search?{query}"))
369
+ if not isinstance(tasks, list):
370
+ raise ScriptError("Everhour task search response was not a list.")
371
+ return [task for task in tasks if isinstance(task, dict)]
372
+
373
+ def match_task(self, title: str) -> dict[str, Any]:
374
+ normalized = normalize_title(title)
375
+ open_matches = self.list_open_tasks().get(normalized, [])
376
+ if len(open_matches) == 1:
377
+ return open_matches[0]
378
+ if len(open_matches) > 1:
379
+ matches = ", ".join(str(task.get("id")) for task in open_matches)
380
+ raise ScriptError(f"Ambiguous Everhour matches for '{title}': {matches}")
381
+
382
+ search_matches = [
383
+ task
384
+ for task in self.search_tasks(strip_estimate_suffix(title))
385
+ if normalize_title(str(task.get("name", ""))) == normalized
386
+ ]
387
+ if not search_matches:
388
+ raise ScriptError(
389
+ f"No Everhour task match found for Basecamp title '{title}' in project {self.project_id}."
390
+ )
391
+ if len(search_matches) > 1:
392
+ matches = ", ".join(str(task.get("id")) for task in search_matches)
393
+ raise ScriptError(f"Ambiguous Everhour matches for '{title}': {matches}")
394
+ return search_matches[0]
395
+
396
+ def update_task_estimate(self, task_id: str, total_seconds: int) -> None:
397
+ self._request(
398
+ "PUT",
399
+ self._task_path(task_id, "/estimate"),
400
+ {"total": total_seconds, "type": "overall"},
401
+ )
402
+
403
+
404
+ def update_basecamp_title(basecamp_bin: str, todo_id: str, title: str) -> None:
405
+ run_basecamp_json(basecamp_bin, "todos", "update", todo_id, "--title", title)
406
+
407
+
408
+ def build_plan(
409
+ todos: list[TodoItem],
410
+ estimates: list[tuple[Decimal, int]],
411
+ everhour: EverhourClient,
412
+ ) -> list[MatchPlan]:
413
+ if len(todos) != len(estimates):
414
+ raise ScriptError(
415
+ "Estimate count does not match todo count: "
416
+ f"expected {len(todos)}, got {len(estimates)}."
417
+ )
418
+
419
+ plans: list[MatchPlan] = []
420
+ seen_titles: dict[str, TodoItem] = {}
421
+ seen_task_ids: dict[str, MatchPlan] = {}
422
+ for todo, (hours, seconds) in zip(todos, estimates):
423
+ normalized_title = normalize_title(todo.title)
424
+ prior_todo = seen_titles.get(normalized_title)
425
+ if prior_todo is not None:
426
+ raise ScriptError(
427
+ "Cannot map Basecamp todos one-to-one to Everhour tasks because "
428
+ f"'{todo.title}' duplicates todo {prior_todo.id} after normalization "
429
+ f"(conflicts with todo {todo.id})."
430
+ )
431
+ seen_titles[normalized_title] = todo
432
+
433
+ task = everhour.match_task(todo.title)
434
+ task_id = str(task.get("id", "")).strip()
435
+ task_name = str(task.get("name", "")).strip()
436
+ if not task_id or not task_name:
437
+ raise ScriptError(f"Everhour task payload missing id or name: {task}")
438
+ plan = MatchPlan(
439
+ todo=todo,
440
+ hours=hours,
441
+ seconds=seconds,
442
+ everhour_task_id=task_id,
443
+ everhour_task_name=task_name,
444
+ new_basecamp_title=build_estimated_title(todo.title, hours),
445
+ )
446
+ prior_plan = seen_task_ids.get(task_id)
447
+ if prior_plan is not None:
448
+ raise ScriptError(
449
+ "Cannot update the same Everhour task for multiple Basecamp todos: "
450
+ f"todo {prior_plan.todo.id} and todo {todo.id} both matched Everhour "
451
+ f"task {task_id} ({task_name})."
452
+ )
453
+ seen_task_ids[task_id] = plan
454
+ plans.append(plan)
455
+ return plans
456
+
457
+
458
+ def print_plan(
459
+ plans: list[MatchPlan],
460
+ skipped_completed: list[TodoItem],
461
+ dry_run: bool,
462
+ ) -> None:
463
+ mode = "DRY RUN" if dry_run else "PLAN"
464
+ distinct_estimates = {(plan.hours, plan.seconds) for plan in plans}
465
+ if len(distinct_estimates) == 1:
466
+ hours, seconds = next(iter(distinct_estimates))
467
+ print(f"{mode}: apply {format_hours(hours)}h ({seconds} seconds) to {len(plans)} task(s)")
468
+ else:
469
+ print(f"{mode}: apply per-task estimates to {len(plans)} task(s)")
470
+ if skipped_completed:
471
+ print(f"Skipping {len(skipped_completed)} completed Basecamp todo(s):")
472
+ for todo in skipped_completed:
473
+ print(f"- Basecamp todo {todo.id}: {todo.title}")
474
+ for plan in plans:
475
+ print(
476
+ f"- Basecamp todo {plan.todo.id}: {plan.todo.title}\n"
477
+ f" Everhour task {plan.everhour_task_id}: {plan.everhour_task_name}\n"
478
+ f" Estimate: {format_hours(plan.hours)}h ({plan.seconds} seconds)\n"
479
+ f" New Basecamp title: {plan.new_basecamp_title}"
480
+ )
481
+
482
+
483
+ def main() -> int:
484
+ args = parse_args()
485
+ env = load_env_file(Path(args.env_file))
486
+ selection = load_basecamp_todos(args.basecamp_bin, args.basecamp_url)
487
+ if not selection.active:
488
+ if selection.skipped_completed:
489
+ raise ScriptError("All matching Basecamp todos are already completed; nothing to update.")
490
+ raise ScriptError("No Basecamp todos were found for the provided URL.")
491
+ estimates = parse_hours_input(args.hours, len(selection.active))
492
+
493
+ everhour = EverhourClient(
494
+ api_key=env["EVERHOUR_API_KEY"],
495
+ project_id=env["EVERHOUR_PROJECT_ID"],
496
+ )
497
+ plans = build_plan(selection.active, estimates, everhour)
498
+ print_plan(plans, selection.skipped_completed, dry_run=args.dry_run)
499
+
500
+ if args.dry_run:
501
+ return 0
502
+
503
+ for plan in plans:
504
+ everhour.update_task_estimate(plan.everhour_task_id, plan.seconds)
505
+
506
+ for plan in plans:
507
+ update_basecamp_title(args.basecamp_bin, plan.todo.id, plan.new_basecamp_title)
508
+
509
+ print("Completed Everhour estimate updates and Basecamp title updates.")
510
+ return 0
511
+
512
+
513
+ if __name__ == "__main__":
514
+ try:
515
+ raise SystemExit(main())
516
+ except ScriptError as exc:
517
+ print(f"Error: {exc}", file=sys.stderr)
518
+ raise SystemExit(1)
@@ -0,0 +1,93 @@
1
+ import importlib.util
2
+ import sys
3
+ import unittest
4
+ from decimal import Decimal
5
+ from pathlib import Path
6
+ from unittest import mock
7
+
8
+
9
+ SCRIPT_PATH = (
10
+ Path(__file__).resolve().parents[1] / "scripts" / "update_estimates.py"
11
+ )
12
+ SPEC = importlib.util.spec_from_file_location("update_estimates", SCRIPT_PATH)
13
+ MODULE = importlib.util.module_from_spec(SPEC)
14
+ assert SPEC.loader is not None
15
+ sys.modules[SPEC.name] = MODULE
16
+ SPEC.loader.exec_module(MODULE)
17
+
18
+
19
+ class ParseHoursInputTest(unittest.TestCase):
20
+ def test_scalar_hours_are_reused_for_each_todo(self) -> None:
21
+ estimates = MODULE.parse_hours_input("2.5", 3)
22
+
23
+ self.assertEqual(
24
+ estimates,
25
+ [
26
+ (Decimal("2.5"), 9000),
27
+ (Decimal("2.5"), 9000),
28
+ (Decimal("2.5"), 9000),
29
+ ],
30
+ )
31
+
32
+ def test_json_array_hours_map_one_to_one(self) -> None:
33
+ estimates = MODULE.parse_hours_input("[1,2.5,\"4\"]", 3)
34
+
35
+ self.assertEqual(
36
+ estimates,
37
+ [
38
+ (Decimal("1"), 3600),
39
+ (Decimal("2.5"), 9000),
40
+ (Decimal("4"), 14400),
41
+ ],
42
+ )
43
+
44
+ def test_json_array_length_must_match_active_todos(self) -> None:
45
+ with self.assertRaises(MODULE.ScriptError) as caught:
46
+ MODULE.parse_hours_input("[1,2.5]", 3)
47
+
48
+ self.assertIn("expected 3, got 2", str(caught.exception))
49
+
50
+
51
+ class BuildPlanTest(unittest.TestCase):
52
+ def test_rejects_duplicate_normalized_basecamp_titles(self) -> None:
53
+ todos = [
54
+ MODULE.TodoItem(id="1", title="Ship estimate"),
55
+ MODULE.TodoItem(id="2", title="Ship estimate [1h]"),
56
+ ]
57
+ estimates = [
58
+ (Decimal("1"), 3600),
59
+ (Decimal("2"), 7200),
60
+ ]
61
+ everhour = mock.Mock()
62
+ everhour.match_task.return_value = {"id": "abc123", "name": "Ship estimate"}
63
+
64
+ with self.assertRaises(MODULE.ScriptError) as caught:
65
+ MODULE.build_plan(todos, estimates, everhour)
66
+
67
+ self.assertIn("duplicates todo 1", str(caught.exception))
68
+ everhour.match_task.assert_called_once_with("Ship estimate")
69
+
70
+ def test_rejects_duplicate_everhour_task_matches(self) -> None:
71
+ todos = [
72
+ MODULE.TodoItem(id="1", title="First task"),
73
+ MODULE.TodoItem(id="2", title="Second task"),
74
+ ]
75
+ estimates = [
76
+ (Decimal("1"), 3600),
77
+ (Decimal("2"), 7200),
78
+ ]
79
+ everhour = mock.Mock()
80
+ everhour.match_task.side_effect = [
81
+ {"id": "abc123", "name": "Shared task"},
82
+ {"id": "abc123", "name": "Shared task"},
83
+ ]
84
+
85
+ with self.assertRaises(MODULE.ScriptError) as caught:
86
+ MODULE.build_plan(todos, estimates, everhour)
87
+
88
+ self.assertIn("todo 1 and todo 2", str(caught.exception))
89
+ self.assertEqual(everhour.match_task.call_count, 2)
90
+
91
+
92
+ if __name__ == "__main__":
93
+ unittest.main()
@@ -0,0 +1,59 @@
1
+ ---
2
+ name: horizon-component-migration
3
+ description: Bundle Shopify Horizon components into a migration package for a different theme, including recursive dependencies across sections, snippets, blocks, assets, templates, and locale strings. Use when asked to port or extract a Horizon component with all required files and setup notes.
4
+ ---
5
+
6
+ # Horizon Component Migration
7
+
8
+ ## Workflow
9
+
10
+ 1. Scan the source theme before selecting files.
11
+ - Index all `.liquid`, `.js`, `.mjs`, `.css`, `.scss`, `.json` files.
12
+ - Inspect `assets/`, `locales/`, `config/`, `layout/`, `sections/`, `snippets/`, and `templates/`.
13
+
14
+ 2. Confirm the migration target with the developer.
15
+ - Ask for `Component Type` (template, section, snippet, block).
16
+ - Ask for `Component Name`.
17
+ - Stop and request clarification if either is missing.
18
+
19
+ 3. Resolve dependencies recursively from the target component.
20
+ - Follow Liquid dependencies (`render`, `include`, section/snippet usage, block references).
21
+ - Follow JS module imports and runtime dependencies.
22
+ - Follow CSS/SCSS dependencies required for correct rendering.
23
+ - Collect localization keys used by the component tree.
24
+ - Include static, private, and public theme blocks.
25
+ - Continue traversal until no new dependencies are discovered.
26
+
27
+ 4. Build a migration bundle under `migration/`.
28
+ - Output root: `migration/{component-name}/`
29
+ - Include subfolders:
30
+ - `sections/`
31
+ - `snippets/`
32
+ - `assets/`
33
+ - `blocks/`
34
+ - `locales/`
35
+ - `templates/`
36
+ - `docs/`
37
+
38
+ 5. Apply naming and rewrite rules while copying files.
39
+ - Prefix all new Liquid files with `td-`.
40
+ - For private blocks that start with `_`, convert `_name` to `_td-name`.
41
+ - If an import map is required, create `snippets/td-scripts.liquid`.
42
+ - Prefix new asset filenames with `td-` and update all references/imports accordingly.
43
+
44
+ 6. Scope CSS for safe portability.
45
+ - Scope rules with `td-` selectors to avoid collisions.
46
+ - Use an existing `{% stylesheet %}` tag when present; do not add a second stylesheet tag.
47
+ - Include only the minimum CSS required for component behavior.
48
+ - Place shared/global dependency rules at the highest sensible level (Section > Snippet > Block).
49
+ - Explicitly include rules that are not implicit globals (for example spacing or section-specific rules).
50
+ - CSS variables that are dependant on theme settings should have a fallback value to the current theme's value
51
+
52
+ 7. Document migration requirements.
53
+ - Create a dependency summary in `docs/`.
54
+ - Document required theme settings, schema fields, and variable assumptions in `docs/`.
55
+ - List any manual post-copy steps needed for the destination theme.
56
+
57
+ ## Output Contract
58
+
59
+ Produce a complete folder bundle ready to copy into another theme, with renamed files, rewritten references, scoped styles, and docs for settings/variables required by the migrated component.
@@ -0,0 +1,4 @@
1
+ interface:
2
+ display_name: "Horizon Migration"
3
+ short_description: "Bundle Horizon components for migration to another theme"
4
+ default_prompt: "Use $horizon-component-migration to create a complete migration bundle for a Horizon component."