td-ai-tools 1.2.2 → 1.2.3
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 +2 -0
- package/lib/installer.js +27 -17
- package/lib/project-hooks.js +171 -0
- package/package.json +2 -2
- package/skills/README.md +1 -3
- package/skills/shopify-lint/SKILL.md +4 -1
- package/skills/shopify-lint/hooks/claude-stop.sh +49 -0
- package/skills/shopify-lint/hooks/codex-stop.sh +49 -0
- package/skills/shopify-lint/hooks/manifest.json +34 -0
- package/skills/shopify-lint/scripts/__pycache__/shopify_lint.cpython-312.pyc +0 -0
- package/skills/shopify-lint/scripts/setup.sh +2 -0
- package/skills/shopify-lint/tests/__pycache__/test_shopify_lint.cpython-312.pyc +0 -0
- package/skills/car-ticket-generator/SKILL.md +0 -131
- package/skills/car-ticket-generator/agents/openai.yaml +0 -4
- package/skills/everhour-basecamp-estimates/.env.example +0 -2
- package/skills/everhour-basecamp-estimates/SKILL.md +0 -75
- package/skills/everhour-basecamp-estimates/agents/openai.yaml +0 -4
- package/skills/everhour-basecamp-estimates/scripts/update_estimates.py +0 -654
- package/skills/everhour-basecamp-estimates/tests/test_update_estimates.py +0 -286
- package/skills/playwright-cli/PRIMER.md +0 -122
- package/skills/visual-regression/scripts/__pycache__/visual_regression.cpython-312.pyc +0 -0
|
@@ -1,654 +0,0 @@
|
|
|
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
|
-
api_url: str | None = None
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
@dataclass(frozen=True)
|
|
34
|
-
class MatchPlan:
|
|
35
|
-
todo: TodoItem
|
|
36
|
-
hours: Decimal
|
|
37
|
-
seconds: int
|
|
38
|
-
everhour_task_id: str
|
|
39
|
-
everhour_task_name: str
|
|
40
|
-
new_basecamp_title: str
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
@dataclass(frozen=True)
|
|
44
|
-
class TodoSelection:
|
|
45
|
-
active: list[TodoItem]
|
|
46
|
-
skipped_completed: list[TodoItem]
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
class ScriptError(RuntimeError):
|
|
50
|
-
pass
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
def parse_args() -> argparse.Namespace:
|
|
54
|
-
parser = argparse.ArgumentParser(
|
|
55
|
-
description=(
|
|
56
|
-
"Update Everhour estimates for tasks matched from a Basecamp todo or "
|
|
57
|
-
"todolist URL, then append the estimate to the Basecamp todo titles."
|
|
58
|
-
)
|
|
59
|
-
)
|
|
60
|
-
parser.add_argument("basecamp_url", help="Basecamp todo or todolist URL")
|
|
61
|
-
parser.add_argument(
|
|
62
|
-
"hours",
|
|
63
|
-
help="Estimate in hours, or a JSON array of per-task estimates in hours",
|
|
64
|
-
)
|
|
65
|
-
parser.add_argument(
|
|
66
|
-
"--env-file",
|
|
67
|
-
default=str(DEFAULT_ENV_FILE),
|
|
68
|
-
help=f"Path to the skill-local .env file (default: {DEFAULT_ENV_FILE})",
|
|
69
|
-
)
|
|
70
|
-
parser.add_argument(
|
|
71
|
-
"--basecamp-bin",
|
|
72
|
-
default="basecamp",
|
|
73
|
-
help="Basecamp CLI executable name or path",
|
|
74
|
-
)
|
|
75
|
-
parser.add_argument(
|
|
76
|
-
"--dry-run",
|
|
77
|
-
action="store_true",
|
|
78
|
-
help="Print the planned changes without mutating Everhour or Basecamp",
|
|
79
|
-
)
|
|
80
|
-
return parser.parse_args()
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def load_env_file(path: Path) -> dict[str, str]:
|
|
84
|
-
if not path.exists():
|
|
85
|
-
raise ScriptError(
|
|
86
|
-
f"Missing env file: {path}. Copy {SKILL_ROOT / '.env.example'} to "
|
|
87
|
-
f"{path} and fill in the values."
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
values: dict[str, str] = {}
|
|
91
|
-
for line in path.read_text(encoding="utf-8").splitlines():
|
|
92
|
-
stripped = line.strip()
|
|
93
|
-
if not stripped or stripped.startswith("#"):
|
|
94
|
-
continue
|
|
95
|
-
if "=" not in stripped:
|
|
96
|
-
raise ScriptError(f"Invalid line in {path}: {line}")
|
|
97
|
-
key, raw_value = stripped.split("=", 1)
|
|
98
|
-
values[key.strip()] = raw_value.strip().strip("'").strip('"')
|
|
99
|
-
|
|
100
|
-
for key, value in os.environ.items():
|
|
101
|
-
if key.startswith("EVERHOUR_"):
|
|
102
|
-
values[key] = value
|
|
103
|
-
|
|
104
|
-
missing = [key for key in ("EVERHOUR_API_KEY", "EVERHOUR_PROJECT_ID") if not values.get(key)]
|
|
105
|
-
if missing:
|
|
106
|
-
raise ScriptError(f"Missing required environment values: {', '.join(missing)}")
|
|
107
|
-
|
|
108
|
-
return values
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def parse_hours_to_seconds(raw_hours: str) -> tuple[Decimal, int]:
|
|
112
|
-
return parse_hours_value(raw_hours)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def parse_hours_value(raw_hours: Any) -> tuple[Decimal, int]:
|
|
116
|
-
if isinstance(raw_hours, bool):
|
|
117
|
-
raise ScriptError(f"Invalid hours value: {raw_hours}")
|
|
118
|
-
|
|
119
|
-
if isinstance(raw_hours, Decimal):
|
|
120
|
-
hours = raw_hours
|
|
121
|
-
else:
|
|
122
|
-
try:
|
|
123
|
-
hours = Decimal(str(raw_hours))
|
|
124
|
-
except (InvalidOperation, ValueError) as exc:
|
|
125
|
-
raise ScriptError(f"Invalid hours value: {raw_hours}") from exc
|
|
126
|
-
|
|
127
|
-
if hours <= 0:
|
|
128
|
-
raise ScriptError("Hours must be greater than zero.")
|
|
129
|
-
|
|
130
|
-
seconds = int((hours * Decimal("3600")).quantize(Decimal("1"), rounding=ROUND_HALF_UP))
|
|
131
|
-
return hours, seconds
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def parse_hours_input(raw_hours: str, todo_count: int) -> list[tuple[Decimal, int]]:
|
|
135
|
-
stripped = raw_hours.strip()
|
|
136
|
-
if stripped.startswith("["):
|
|
137
|
-
try:
|
|
138
|
-
values = json.loads(stripped, parse_float=Decimal, parse_int=Decimal)
|
|
139
|
-
except json.JSONDecodeError as exc:
|
|
140
|
-
raise ScriptError(f"Invalid hours array: {raw_hours}") from exc
|
|
141
|
-
|
|
142
|
-
if not isinstance(values, list):
|
|
143
|
-
raise ScriptError("Hours array input must decode to a JSON array.")
|
|
144
|
-
if len(values) != todo_count:
|
|
145
|
-
raise ScriptError(
|
|
146
|
-
"Hours array length does not match active Basecamp todo count: "
|
|
147
|
-
f"expected {todo_count}, got {len(values)}."
|
|
148
|
-
)
|
|
149
|
-
return [parse_hours_value(value) for value in values]
|
|
150
|
-
|
|
151
|
-
hours, seconds = parse_hours_to_seconds(raw_hours)
|
|
152
|
-
return [(hours, seconds) for _ in range(todo_count)]
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
def format_hours(hours: Decimal) -> str:
|
|
156
|
-
rendered = format(hours.normalize(), "f")
|
|
157
|
-
return rendered.rstrip("0").rstrip(".") if "." in rendered else rendered
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def strip_estimate_suffix(title: str) -> str:
|
|
161
|
-
return TRAILING_ESTIMATE_RE.sub("", title).strip()
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
def normalize_title(title: str) -> str:
|
|
165
|
-
collapsed = WHITESPACE_RE.sub(" ", strip_estimate_suffix(title)).strip()
|
|
166
|
-
return collapsed.casefold()
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
def build_estimated_title(title: str, hours: Decimal) -> str:
|
|
170
|
-
return f"{strip_estimate_suffix(title)} [{format_hours(hours)}h]"
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def run_basecamp_json(basecamp_bin: str, *args: str) -> Any:
|
|
174
|
-
command = [basecamp_bin, *args, "--agent"]
|
|
175
|
-
completed = subprocess.run(command, capture_output=True, text=True)
|
|
176
|
-
if completed.returncode != 0:
|
|
177
|
-
stderr = completed.stderr.strip()
|
|
178
|
-
stdout = completed.stdout.strip()
|
|
179
|
-
raise ScriptError(
|
|
180
|
-
f"Basecamp command failed: {' '.join(command)}\n"
|
|
181
|
-
f"{stderr or stdout or 'No output returned.'}"
|
|
182
|
-
)
|
|
183
|
-
|
|
184
|
-
stdout = completed.stdout.strip()
|
|
185
|
-
if not stdout:
|
|
186
|
-
raise ScriptError(f"Basecamp command returned no JSON: {' '.join(command)}")
|
|
187
|
-
|
|
188
|
-
try:
|
|
189
|
-
return json.loads(stdout)
|
|
190
|
-
except json.JSONDecodeError as exc:
|
|
191
|
-
raise ScriptError(
|
|
192
|
-
f"Basecamp command returned invalid JSON: {' '.join(command)}\n{stdout}"
|
|
193
|
-
) from exc
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
def extract_title(record: dict[str, Any]) -> str:
|
|
197
|
-
for key in TITLE_KEYS:
|
|
198
|
-
value = record.get(key)
|
|
199
|
-
if isinstance(value, str) and value.strip():
|
|
200
|
-
return value.strip()
|
|
201
|
-
raise ScriptError(f"Could not determine a title from Basecamp payload: {record}")
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
def extract_api_url(record: dict[str, Any]) -> str | None:
|
|
205
|
-
value = record.get("url")
|
|
206
|
-
if isinstance(value, str) and value.strip():
|
|
207
|
-
return value.strip()
|
|
208
|
-
return None
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
def extract_todo_objects(payload: Any) -> list[dict[str, Any]]:
|
|
212
|
-
if isinstance(payload, list):
|
|
213
|
-
candidates = [item for item in payload if is_todo_like(item)]
|
|
214
|
-
if candidates:
|
|
215
|
-
return candidates
|
|
216
|
-
return []
|
|
217
|
-
|
|
218
|
-
if not isinstance(payload, dict):
|
|
219
|
-
return []
|
|
220
|
-
|
|
221
|
-
for key in ("todos", "items", "entries", "records"):
|
|
222
|
-
value = payload.get(key)
|
|
223
|
-
if isinstance(value, list):
|
|
224
|
-
candidates = [item for item in value if is_todo_like(item)]
|
|
225
|
-
if candidates:
|
|
226
|
-
return candidates
|
|
227
|
-
|
|
228
|
-
for key, value in payload.items():
|
|
229
|
-
if "todo" in key.lower():
|
|
230
|
-
candidates = extract_todo_objects(value)
|
|
231
|
-
if candidates:
|
|
232
|
-
return candidates
|
|
233
|
-
|
|
234
|
-
for value in payload.values():
|
|
235
|
-
candidates = extract_todo_objects(value)
|
|
236
|
-
if candidates:
|
|
237
|
-
return candidates
|
|
238
|
-
|
|
239
|
-
return []
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
def is_todo_like(value: Any) -> bool:
|
|
243
|
-
return (
|
|
244
|
-
isinstance(value, dict)
|
|
245
|
-
and "id" in value
|
|
246
|
-
and any(isinstance(value.get(key), str) and value.get(key).strip() for key in TITLE_KEYS)
|
|
247
|
-
)
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
def is_todo_completed(record: dict[str, Any]) -> bool:
|
|
251
|
-
if record.get("completed") is True:
|
|
252
|
-
return True
|
|
253
|
-
|
|
254
|
-
status = record.get("status")
|
|
255
|
-
if isinstance(status, str) and status.strip().lower() in {"complete", "completed", "done", "closed"}:
|
|
256
|
-
return True
|
|
257
|
-
|
|
258
|
-
for key in ("completedAt", "completed_at", "completedOn", "completed_on"):
|
|
259
|
-
value = record.get(key)
|
|
260
|
-
if isinstance(value, str) and value.strip():
|
|
261
|
-
return True
|
|
262
|
-
|
|
263
|
-
return False
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
def load_basecamp_todos(basecamp_bin: str, basecamp_url: str) -> TodoSelection:
|
|
267
|
-
parsed = run_basecamp_json(basecamp_bin, "url", "parse", basecamp_url)
|
|
268
|
-
resource_type = str(parsed.get("type", "")).strip().lower()
|
|
269
|
-
project_id = str(parsed.get("project_id", "")).strip()
|
|
270
|
-
recording_id = str(parsed.get("recording_id", "")).strip()
|
|
271
|
-
|
|
272
|
-
if resource_type in {"todo", "todos"}:
|
|
273
|
-
payload = run_basecamp_json(basecamp_bin, "todos", "show", basecamp_url)
|
|
274
|
-
todo = TodoItem(
|
|
275
|
-
id=str(payload["id"]),
|
|
276
|
-
title=extract_title(payload),
|
|
277
|
-
api_url=extract_api_url(payload),
|
|
278
|
-
)
|
|
279
|
-
if is_todo_completed(payload):
|
|
280
|
-
return TodoSelection(active=[], skipped_completed=[todo])
|
|
281
|
-
return TodoSelection(active=[todo], skipped_completed=[])
|
|
282
|
-
|
|
283
|
-
if resource_type in {"todolist", "todolists"}:
|
|
284
|
-
payload: Any = None
|
|
285
|
-
todo_objects: list[dict[str, Any]] = []
|
|
286
|
-
try:
|
|
287
|
-
payload = run_basecamp_json(basecamp_bin, "todolists", "show", basecamp_url)
|
|
288
|
-
except ScriptError:
|
|
289
|
-
payload = None
|
|
290
|
-
|
|
291
|
-
if payload is not None:
|
|
292
|
-
todo_objects = extract_todo_objects(payload)
|
|
293
|
-
|
|
294
|
-
# Some Basecamp CLI versions return an empty payload for todolists show even
|
|
295
|
-
# though the list exists. Fall back to listing todos scoped to the parsed
|
|
296
|
-
# project and todolist IDs.
|
|
297
|
-
if not todo_objects and project_id and recording_id:
|
|
298
|
-
fallback_payload = run_basecamp_json(
|
|
299
|
-
basecamp_bin,
|
|
300
|
-
"todos",
|
|
301
|
-
"list",
|
|
302
|
-
"--in",
|
|
303
|
-
project_id,
|
|
304
|
-
"--todolist",
|
|
305
|
-
recording_id,
|
|
306
|
-
)
|
|
307
|
-
todo_objects = extract_todo_objects(fallback_payload)
|
|
308
|
-
|
|
309
|
-
if not todo_objects:
|
|
310
|
-
raise ScriptError(
|
|
311
|
-
"The Basecamp todolist payload did not include any todo items. "
|
|
312
|
-
"Inspect the todolist manually to confirm the CLI output shape."
|
|
313
|
-
)
|
|
314
|
-
active: list[TodoItem] = []
|
|
315
|
-
skipped_completed: list[TodoItem] = []
|
|
316
|
-
for item in todo_objects:
|
|
317
|
-
todo = TodoItem(
|
|
318
|
-
id=str(item["id"]),
|
|
319
|
-
title=extract_title(item),
|
|
320
|
-
api_url=extract_api_url(item),
|
|
321
|
-
)
|
|
322
|
-
if is_todo_completed(item):
|
|
323
|
-
skipped_completed.append(todo)
|
|
324
|
-
else:
|
|
325
|
-
active.append(todo)
|
|
326
|
-
return TodoSelection(active=active, skipped_completed=skipped_completed)
|
|
327
|
-
|
|
328
|
-
raise ScriptError(
|
|
329
|
-
f"Unsupported Basecamp URL type: {resource_type or 'unknown'}. "
|
|
330
|
-
"Use a todo or todolist URL."
|
|
331
|
-
)
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
class EverhourClient:
|
|
335
|
-
def __init__(self, api_key: str, project_id: str) -> None:
|
|
336
|
-
self.api_key = api_key
|
|
337
|
-
self.project_id = project_id
|
|
338
|
-
self._open_tasks_by_title: dict[str, list[dict[str, Any]]] | None = None
|
|
339
|
-
|
|
340
|
-
def _request(self, method: str, path: str, payload: dict[str, Any] | None = None) -> Any:
|
|
341
|
-
request = Request(
|
|
342
|
-
url=f"{EVERHOUR_BASE_URL}{path}",
|
|
343
|
-
method=method,
|
|
344
|
-
headers={
|
|
345
|
-
"Accept": "application/json",
|
|
346
|
-
"Content-Type": "application/json",
|
|
347
|
-
"X-Api-Key": self.api_key,
|
|
348
|
-
},
|
|
349
|
-
data=None if payload is None else json.dumps(payload).encode("utf-8"),
|
|
350
|
-
)
|
|
351
|
-
|
|
352
|
-
try:
|
|
353
|
-
with urlopen(request) as response:
|
|
354
|
-
raw_body = response.read().decode("utf-8")
|
|
355
|
-
except HTTPError as exc:
|
|
356
|
-
body = exc.read().decode("utf-8", errors="replace")
|
|
357
|
-
raise ScriptError(f"Everhour API error {exc.code} for {method} {path}: {body}") from exc
|
|
358
|
-
except URLError as exc:
|
|
359
|
-
raise ScriptError(f"Everhour API request failed for {method} {path}: {exc.reason}") from exc
|
|
360
|
-
|
|
361
|
-
if not raw_body:
|
|
362
|
-
return None
|
|
363
|
-
|
|
364
|
-
try:
|
|
365
|
-
return json.loads(raw_body)
|
|
366
|
-
except json.JSONDecodeError as exc:
|
|
367
|
-
raise ScriptError(f"Everhour API returned invalid JSON for {method} {path}: {raw_body}") from exc
|
|
368
|
-
|
|
369
|
-
def _project_path(self, suffix: str) -> str:
|
|
370
|
-
return f"/projects/{quote(self.project_id, safe='')}{suffix}"
|
|
371
|
-
|
|
372
|
-
def _task_path(self, task_id: str, suffix: str = "") -> str:
|
|
373
|
-
return f"/tasks/{quote(task_id, safe='')}{suffix}"
|
|
374
|
-
|
|
375
|
-
def list_open_tasks(self) -> dict[str, list[dict[str, Any]]]:
|
|
376
|
-
if self._open_tasks_by_title is not None:
|
|
377
|
-
return self._open_tasks_by_title
|
|
378
|
-
|
|
379
|
-
tasks_by_title: dict[str, list[dict[str, Any]]] = {}
|
|
380
|
-
page = 1
|
|
381
|
-
limit = 100
|
|
382
|
-
|
|
383
|
-
while True:
|
|
384
|
-
query = urlencode({"limit": limit, "page": page})
|
|
385
|
-
tasks = self._request("GET", self._project_path(f"/tasks?{query}"))
|
|
386
|
-
if not isinstance(tasks, list):
|
|
387
|
-
raise ScriptError("Everhour project tasks response was not a list.")
|
|
388
|
-
|
|
389
|
-
for task in tasks:
|
|
390
|
-
if not isinstance(task, dict) or "id" not in task:
|
|
391
|
-
continue
|
|
392
|
-
tasks_by_title.setdefault(normalize_title(str(task.get("name", ""))), []).append(task)
|
|
393
|
-
|
|
394
|
-
if len(tasks) < limit:
|
|
395
|
-
break
|
|
396
|
-
page += 1
|
|
397
|
-
|
|
398
|
-
self._open_tasks_by_title = tasks_by_title
|
|
399
|
-
return tasks_by_title
|
|
400
|
-
|
|
401
|
-
def search_tasks(self, title: str) -> list[dict[str, Any]]:
|
|
402
|
-
query = urlencode(
|
|
403
|
-
{
|
|
404
|
-
"query": title,
|
|
405
|
-
"limit": 100,
|
|
406
|
-
"searchInClosed": "true",
|
|
407
|
-
}
|
|
408
|
-
)
|
|
409
|
-
tasks = self._request("GET", self._project_path(f"/tasks/search?{query}"))
|
|
410
|
-
if not isinstance(tasks, list):
|
|
411
|
-
raise ScriptError("Everhour task search response was not a list.")
|
|
412
|
-
return [task for task in tasks if isinstance(task, dict)]
|
|
413
|
-
|
|
414
|
-
def match_task(self, title: str, source_id: str) -> dict[str, Any]:
|
|
415
|
-
normalized = normalize_title(title)
|
|
416
|
-
open_matches = self.list_open_tasks().get(normalized, [])
|
|
417
|
-
open_match = select_single_task_match(title, open_matches, source_id)
|
|
418
|
-
if open_match is not None:
|
|
419
|
-
return open_match
|
|
420
|
-
|
|
421
|
-
search_matches = [
|
|
422
|
-
task
|
|
423
|
-
for task in self.search_tasks(strip_estimate_suffix(title))
|
|
424
|
-
if normalize_title(str(task.get("name", ""))) == normalized
|
|
425
|
-
]
|
|
426
|
-
if not search_matches:
|
|
427
|
-
raise ScriptError(format_missing_match_error(title, self.project_id))
|
|
428
|
-
search_match = select_single_task_match(title, search_matches, source_id)
|
|
429
|
-
if search_match is not None:
|
|
430
|
-
return search_match
|
|
431
|
-
|
|
432
|
-
raise ScriptError(format_ambiguous_match_error(title, search_matches, source_id))
|
|
433
|
-
|
|
434
|
-
def update_task_estimate(self, task_id: str, total_seconds: int) -> None:
|
|
435
|
-
self._request(
|
|
436
|
-
"PUT",
|
|
437
|
-
self._task_path(task_id, "/estimate"),
|
|
438
|
-
{"total": total_seconds, "type": "overall"},
|
|
439
|
-
)
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
def task_matches_source_id(task: dict[str, Any], source_id: str) -> bool:
|
|
443
|
-
normalized_source_id = str(source_id).strip()
|
|
444
|
-
if not normalized_source_id:
|
|
445
|
-
return False
|
|
446
|
-
|
|
447
|
-
task_id = str(task.get("id", "")).strip()
|
|
448
|
-
if task_id == normalized_source_id or task_id.endswith(f":{normalized_source_id}"):
|
|
449
|
-
return True
|
|
450
|
-
|
|
451
|
-
url = str(task.get("url", "")).strip().rstrip("/")
|
|
452
|
-
return bool(url) and url.endswith(f"/{normalized_source_id}")
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
def select_single_task_match(
|
|
456
|
-
title: str,
|
|
457
|
-
tasks: list[dict[str, Any]],
|
|
458
|
-
source_id: str,
|
|
459
|
-
) -> dict[str, Any] | None:
|
|
460
|
-
if len(tasks) == 1:
|
|
461
|
-
return tasks[0]
|
|
462
|
-
if len(tasks) == 0:
|
|
463
|
-
return None
|
|
464
|
-
|
|
465
|
-
source_matches = [task for task in tasks if task_matches_source_id(task, source_id)]
|
|
466
|
-
if len(source_matches) == 1:
|
|
467
|
-
return source_matches[0]
|
|
468
|
-
if len(source_matches) > 1:
|
|
469
|
-
raise ScriptError(format_ambiguous_match_error(title, source_matches, source_id))
|
|
470
|
-
return None
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
def format_ambiguous_match_error(
|
|
474
|
-
title: str,
|
|
475
|
-
matches: list[dict[str, Any]],
|
|
476
|
-
source_id: str,
|
|
477
|
-
) -> str:
|
|
478
|
-
match_ids = ", ".join(str(task.get("id")) for task in matches)
|
|
479
|
-
return (
|
|
480
|
-
f"Ambiguous Everhour matches for '{title}': {match_ids}. "
|
|
481
|
-
f"None could be uniquely matched to Basecamp todo {source_id}."
|
|
482
|
-
)
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
def format_missing_match_error(title: str, project_id: str) -> str:
|
|
486
|
-
return f"No Everhour task match found for Basecamp title '{title}' in project {project_id}."
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
def update_basecamp_title(basecamp_bin: str, todo: TodoItem, title: str) -> None:
|
|
490
|
-
if not todo.api_url:
|
|
491
|
-
raise ScriptError(f"Basecamp todo {todo.id} payload did not include an API URL.")
|
|
492
|
-
current = run_basecamp_json(basecamp_bin, "api", "get", todo.api_url)
|
|
493
|
-
if not isinstance(current, dict):
|
|
494
|
-
raise ScriptError(
|
|
495
|
-
f"Basecamp todo {todo.id} GET did not return a JSON object: {current!r}"
|
|
496
|
-
)
|
|
497
|
-
payload = build_basecamp_update_payload(current, title)
|
|
498
|
-
run_basecamp_json(
|
|
499
|
-
basecamp_bin,
|
|
500
|
-
"api",
|
|
501
|
-
"put",
|
|
502
|
-
todo.api_url,
|
|
503
|
-
"-d",
|
|
504
|
-
json.dumps(payload),
|
|
505
|
-
)
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
def build_basecamp_update_payload(current: dict[str, Any], title: str) -> dict[str, Any]:
|
|
509
|
-
# Basecamp's PUT to a todo replaces all writable fields, so any field omitted
|
|
510
|
-
# here gets cleared. Carry over the assignees, completion subscribers, notes
|
|
511
|
-
# (description), and dates from the current record so we only change `content`.
|
|
512
|
-
payload: dict[str, Any] = {"content": title}
|
|
513
|
-
|
|
514
|
-
description = current.get("description")
|
|
515
|
-
if isinstance(description, str) and description:
|
|
516
|
-
payload["description"] = description
|
|
517
|
-
|
|
518
|
-
assignee_ids = _extract_person_ids(current.get("assignees"))
|
|
519
|
-
if assignee_ids:
|
|
520
|
-
payload["assignee_ids"] = assignee_ids
|
|
521
|
-
|
|
522
|
-
subscriber_ids = _extract_person_ids(current.get("completion_subscribers"))
|
|
523
|
-
if subscriber_ids:
|
|
524
|
-
payload["completion_subscriber_ids"] = subscriber_ids
|
|
525
|
-
|
|
526
|
-
for key in ("due_on", "starts_on"):
|
|
527
|
-
value = current.get(key)
|
|
528
|
-
if isinstance(value, str) and value:
|
|
529
|
-
payload[key] = value
|
|
530
|
-
|
|
531
|
-
return payload
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
def _extract_person_ids(people: Any) -> list[Any]:
|
|
535
|
-
if not isinstance(people, list):
|
|
536
|
-
return []
|
|
537
|
-
ids: list[Any] = []
|
|
538
|
-
for person in people:
|
|
539
|
-
if isinstance(person, dict) and "id" in person:
|
|
540
|
-
ids.append(person["id"])
|
|
541
|
-
return ids
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
def build_plan(
|
|
545
|
-
todos: list[TodoItem],
|
|
546
|
-
estimates: list[tuple[Decimal, int]],
|
|
547
|
-
everhour: EverhourClient,
|
|
548
|
-
) -> list[MatchPlan]:
|
|
549
|
-
if len(todos) != len(estimates):
|
|
550
|
-
raise ScriptError(
|
|
551
|
-
"Estimate count does not match todo count: "
|
|
552
|
-
f"expected {len(todos)}, got {len(estimates)}."
|
|
553
|
-
)
|
|
554
|
-
|
|
555
|
-
plans: list[MatchPlan] = []
|
|
556
|
-
seen_titles: dict[str, TodoItem] = {}
|
|
557
|
-
seen_task_ids: dict[str, MatchPlan] = {}
|
|
558
|
-
for todo, (hours, seconds) in zip(todos, estimates):
|
|
559
|
-
normalized_title = normalize_title(todo.title)
|
|
560
|
-
prior_todo = seen_titles.get(normalized_title)
|
|
561
|
-
if prior_todo is not None:
|
|
562
|
-
raise ScriptError(
|
|
563
|
-
"Cannot map Basecamp todos one-to-one to Everhour tasks because "
|
|
564
|
-
f"'{todo.title}' duplicates todo {prior_todo.id} after normalization "
|
|
565
|
-
f"(conflicts with todo {todo.id})."
|
|
566
|
-
)
|
|
567
|
-
seen_titles[normalized_title] = todo
|
|
568
|
-
|
|
569
|
-
task = everhour.match_task(todo.title, source_id=todo.id)
|
|
570
|
-
task_id = str(task.get("id", "")).strip()
|
|
571
|
-
task_name = str(task.get("name", "")).strip()
|
|
572
|
-
if not task_id or not task_name:
|
|
573
|
-
raise ScriptError(f"Everhour task payload missing id or name: {task}")
|
|
574
|
-
plan = MatchPlan(
|
|
575
|
-
todo=todo,
|
|
576
|
-
hours=hours,
|
|
577
|
-
seconds=seconds,
|
|
578
|
-
everhour_task_id=task_id,
|
|
579
|
-
everhour_task_name=task_name,
|
|
580
|
-
new_basecamp_title=build_estimated_title(todo.title, hours),
|
|
581
|
-
)
|
|
582
|
-
prior_plan = seen_task_ids.get(task_id)
|
|
583
|
-
if prior_plan is not None:
|
|
584
|
-
raise ScriptError(
|
|
585
|
-
"Cannot update the same Everhour task for multiple Basecamp todos: "
|
|
586
|
-
f"todo {prior_plan.todo.id} and todo {todo.id} both matched Everhour "
|
|
587
|
-
f"task {task_id} ({task_name})."
|
|
588
|
-
)
|
|
589
|
-
seen_task_ids[task_id] = plan
|
|
590
|
-
plans.append(plan)
|
|
591
|
-
return plans
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
def print_plan(
|
|
595
|
-
plans: list[MatchPlan],
|
|
596
|
-
skipped_completed: list[TodoItem],
|
|
597
|
-
dry_run: bool,
|
|
598
|
-
) -> None:
|
|
599
|
-
mode = "DRY RUN" if dry_run else "PLAN"
|
|
600
|
-
distinct_estimates = {(plan.hours, plan.seconds) for plan in plans}
|
|
601
|
-
if len(distinct_estimates) == 1:
|
|
602
|
-
hours, seconds = next(iter(distinct_estimates))
|
|
603
|
-
print(f"{mode}: apply {format_hours(hours)}h ({seconds} seconds) to {len(plans)} task(s)")
|
|
604
|
-
else:
|
|
605
|
-
print(f"{mode}: apply per-task estimates to {len(plans)} task(s)")
|
|
606
|
-
if skipped_completed:
|
|
607
|
-
print(f"Skipping {len(skipped_completed)} completed Basecamp todo(s):")
|
|
608
|
-
for todo in skipped_completed:
|
|
609
|
-
print(f"- Basecamp todo {todo.id}: {todo.title}")
|
|
610
|
-
for plan in plans:
|
|
611
|
-
print(
|
|
612
|
-
f"- Basecamp todo {plan.todo.id}: {plan.todo.title}\n"
|
|
613
|
-
f" Everhour task {plan.everhour_task_id}: {plan.everhour_task_name}\n"
|
|
614
|
-
f" Estimate: {format_hours(plan.hours)}h ({plan.seconds} seconds)\n"
|
|
615
|
-
f" New Basecamp title: {plan.new_basecamp_title}"
|
|
616
|
-
)
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
def main() -> int:
|
|
620
|
-
args = parse_args()
|
|
621
|
-
env = load_env_file(Path(args.env_file))
|
|
622
|
-
selection = load_basecamp_todos(args.basecamp_bin, args.basecamp_url)
|
|
623
|
-
if not selection.active:
|
|
624
|
-
if selection.skipped_completed:
|
|
625
|
-
raise ScriptError("All matching Basecamp todos are already completed; nothing to update.")
|
|
626
|
-
raise ScriptError("No Basecamp todos were found for the provided URL.")
|
|
627
|
-
estimates = parse_hours_input(args.hours, len(selection.active))
|
|
628
|
-
|
|
629
|
-
everhour = EverhourClient(
|
|
630
|
-
api_key=env["EVERHOUR_API_KEY"],
|
|
631
|
-
project_id=env["EVERHOUR_PROJECT_ID"],
|
|
632
|
-
)
|
|
633
|
-
plans = build_plan(selection.active, estimates, everhour)
|
|
634
|
-
print_plan(plans, selection.skipped_completed, dry_run=args.dry_run)
|
|
635
|
-
|
|
636
|
-
if args.dry_run:
|
|
637
|
-
return 0
|
|
638
|
-
|
|
639
|
-
for plan in plans:
|
|
640
|
-
everhour.update_task_estimate(plan.everhour_task_id, plan.seconds)
|
|
641
|
-
|
|
642
|
-
for plan in plans:
|
|
643
|
-
update_basecamp_title(args.basecamp_bin, plan.todo, plan.new_basecamp_title)
|
|
644
|
-
|
|
645
|
-
print("Completed Everhour estimate updates and Basecamp title updates.")
|
|
646
|
-
return 0
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
if __name__ == "__main__":
|
|
650
|
-
try:
|
|
651
|
-
raise SystemExit(main())
|
|
652
|
-
except ScriptError as exc:
|
|
653
|
-
print(f"Error: {exc}", file=sys.stderr)
|
|
654
|
-
raise SystemExit(1)
|