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.
- package/README.md +49 -0
- package/agents/README.md +10 -0
- package/agents/horizon-component-library/AGENTS.md +184 -0
- package/agents/horizon-component-library/README.md +10 -0
- package/agents/horizon-component-library/scripts/td-guard.sh +49 -0
- package/bin/cli.js +209 -0
- package/package.json +23 -0
- package/scripts/smoke-install.sh +56 -0
- package/skills/README.md +15 -0
- package/skills/cache-reset/SKILL.md +18 -0
- package/skills/cache-reset/agents/openai.yaml +4 -0
- package/skills/car-ticket-generator/SKILL.md +130 -0
- package/skills/car-ticket-generator/agents/openai.yaml +4 -0
- package/skills/everhour-basecamp-estimates/.env.example +2 -0
- package/skills/everhour-basecamp-estimates/SKILL.md +73 -0
- package/skills/everhour-basecamp-estimates/agents/openai.yaml +4 -0
- package/skills/everhour-basecamp-estimates/scripts/update_estimates.py +518 -0
- package/skills/everhour-basecamp-estimates/tests/test_update_estimates.py +93 -0
- package/skills/horizon-component-migration/SKILL.md +59 -0
- package/skills/horizon-component-migration/agents/openai.yaml +4 -0
- package/skills/pr-solver/SKILL.md +50 -0
- package/skills/pr-solver/agents/openai.yaml +4 -0
- package/skills/pr-solver/references/github-pr-reviewthreads-graphql.md +63 -0
- package/skills/pr-solver/scripts/list_unresolved_threads.py +307 -0
- package/skills/pull-request/SKILL.md +216 -0
- package/skills/pull-request/agents/openai.yaml +4 -0
- package/skills/record-changes/SKILL.md +75 -0
- package/skills/record-changes/agents/openai.yaml +4 -0
- package/skills/record-changes/scripts/branch_diff_context.py +190 -0
- package/skills/stylesheet-migration/SKILL.md +36 -0
- package/skills/stylesheet-migration/agents/openai.yaml +6 -0
- package/skills/stylesheet-migration/scripts/__pycache__/liquid_stylesheet_migrator.cpython-312.pyc +0 -0
- package/skills/stylesheet-migration/scripts/__pycache__/test_liquid_stylesheet_migrator.cpython-312.pyc +0 -0
- package/skills/stylesheet-migration/scripts/liquid_stylesheet_migrator.py +204 -0
- package/skills/stylesheet-migration/scripts/migrate_stylesheet_tags.py +172 -0
- package/skills/stylesheet-migration/scripts/test_liquid_stylesheet_migrator.py +254 -0
- package/skills/td-js-vanilla-rules/SKILL.md +70 -0
- package/skills/td-js-vanilla-rules/agents/openai.yaml +3 -0
- package/skills/td-review/SKILL.md +122 -0
- package/skills/td-review/agents/openai.yaml +4 -0
- 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.
|