td-ai-tools 1.0.2 → 1.0.4
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/package.json +1 -1
- package/skills/basecamp/SKILL.md +651 -0
- package/skills/everhour-basecamp-estimates/SKILL.md +2 -1
- package/skills/everhour-basecamp-estimates/scripts/update_estimates.py +157 -21
- package/skills/everhour-basecamp-estimates/tests/test_update_estimates.py +194 -1
- package/skills/playwright-cli/CODEX-CONFIG.md +79 -0
- package/skills/playwright-cli/SKILL.md +278 -0
- package/skills/playwright-cli/agents/openai.yaml +4 -0
- package/skills/playwright-cli/config.toml.template +32 -0
- package/skills/playwright-cli/references/request-mocking.md +87 -0
- package/skills/playwright-cli/references/running-code.md +232 -0
- package/skills/playwright-cli/references/session-management.md +169 -0
- package/skills/playwright-cli/references/storage-state.md +275 -0
- package/skills/playwright-cli/references/test-generation.md +88 -0
- package/skills/playwright-cli/references/tracing.md +139 -0
- package/skills/playwright-cli/references/video-recording.md +43 -0
- package/skills/playwright-cli/setup-codex.sh +29 -0
- package/skills/pull-request/SKILL.md +1 -1
- package/skills/pull-request-statamic/SKILL.md +216 -0
- package/skills/pull-request-statamic/agents/openai.yaml +4 -0
|
@@ -27,6 +27,7 @@ WHITESPACE_RE = re.compile(r"\s+")
|
|
|
27
27
|
class TodoItem:
|
|
28
28
|
id: str
|
|
29
29
|
title: str
|
|
30
|
+
api_url: str | None = None
|
|
30
31
|
|
|
31
32
|
|
|
32
33
|
@dataclass(frozen=True)
|
|
@@ -200,6 +201,13 @@ def extract_title(record: dict[str, Any]) -> str:
|
|
|
200
201
|
raise ScriptError(f"Could not determine a title from Basecamp payload: {record}")
|
|
201
202
|
|
|
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
|
+
|
|
203
211
|
def extract_todo_objects(payload: Any) -> list[dict[str, Any]]:
|
|
204
212
|
if isinstance(payload, list):
|
|
205
213
|
candidates = [item for item in payload if is_todo_like(item)]
|
|
@@ -258,17 +266,46 @@ def is_todo_completed(record: dict[str, Any]) -> bool:
|
|
|
258
266
|
def load_basecamp_todos(basecamp_bin: str, basecamp_url: str) -> TodoSelection:
|
|
259
267
|
parsed = run_basecamp_json(basecamp_bin, "url", "parse", basecamp_url)
|
|
260
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()
|
|
261
271
|
|
|
262
272
|
if resource_type in {"todo", "todos"}:
|
|
263
273
|
payload = run_basecamp_json(basecamp_bin, "todos", "show", basecamp_url)
|
|
264
|
-
todo = TodoItem(
|
|
274
|
+
todo = TodoItem(
|
|
275
|
+
id=str(payload["id"]),
|
|
276
|
+
title=extract_title(payload),
|
|
277
|
+
api_url=extract_api_url(payload),
|
|
278
|
+
)
|
|
265
279
|
if is_todo_completed(payload):
|
|
266
280
|
return TodoSelection(active=[], skipped_completed=[todo])
|
|
267
281
|
return TodoSelection(active=[todo], skipped_completed=[])
|
|
268
282
|
|
|
269
283
|
if resource_type in {"todolist", "todolists"}:
|
|
270
|
-
payload =
|
|
271
|
-
todo_objects =
|
|
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
|
+
|
|
272
309
|
if not todo_objects:
|
|
273
310
|
raise ScriptError(
|
|
274
311
|
"The Basecamp todolist payload did not include any todo items. "
|
|
@@ -277,7 +314,11 @@ def load_basecamp_todos(basecamp_bin: str, basecamp_url: str) -> TodoSelection:
|
|
|
277
314
|
active: list[TodoItem] = []
|
|
278
315
|
skipped_completed: list[TodoItem] = []
|
|
279
316
|
for item in todo_objects:
|
|
280
|
-
todo = TodoItem(
|
|
317
|
+
todo = TodoItem(
|
|
318
|
+
id=str(item["id"]),
|
|
319
|
+
title=extract_title(item),
|
|
320
|
+
api_url=extract_api_url(item),
|
|
321
|
+
)
|
|
281
322
|
if is_todo_completed(item):
|
|
282
323
|
skipped_completed.append(todo)
|
|
283
324
|
else:
|
|
@@ -370,14 +411,12 @@ class EverhourClient:
|
|
|
370
411
|
raise ScriptError("Everhour task search response was not a list.")
|
|
371
412
|
return [task for task in tasks if isinstance(task, dict)]
|
|
372
413
|
|
|
373
|
-
def match_task(self, title: str) -> dict[str, Any]:
|
|
414
|
+
def match_task(self, title: str, source_id: str) -> dict[str, Any]:
|
|
374
415
|
normalized = normalize_title(title)
|
|
375
416
|
open_matches = self.list_open_tasks().get(normalized, [])
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
matches = ", ".join(str(task.get("id")) for task in open_matches)
|
|
380
|
-
raise ScriptError(f"Ambiguous Everhour matches for '{title}': {matches}")
|
|
417
|
+
open_match = select_single_task_match(title, open_matches, source_id)
|
|
418
|
+
if open_match is not None:
|
|
419
|
+
return open_match
|
|
381
420
|
|
|
382
421
|
search_matches = [
|
|
383
422
|
task
|
|
@@ -385,13 +424,12 @@ class EverhourClient:
|
|
|
385
424
|
if normalize_title(str(task.get("name", ""))) == normalized
|
|
386
425
|
]
|
|
387
426
|
if not search_matches:
|
|
388
|
-
raise ScriptError(
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
return search_matches[0]
|
|
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))
|
|
395
433
|
|
|
396
434
|
def update_task_estimate(self, task_id: str, total_seconds: int) -> None:
|
|
397
435
|
self._request(
|
|
@@ -401,8 +439,106 @@ class EverhourClient:
|
|
|
401
439
|
)
|
|
402
440
|
|
|
403
441
|
|
|
404
|
-
def
|
|
405
|
-
|
|
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
|
|
406
542
|
|
|
407
543
|
|
|
408
544
|
def build_plan(
|
|
@@ -430,7 +566,7 @@ def build_plan(
|
|
|
430
566
|
)
|
|
431
567
|
seen_titles[normalized_title] = todo
|
|
432
568
|
|
|
433
|
-
task = everhour.match_task(todo.title)
|
|
569
|
+
task = everhour.match_task(todo.title, source_id=todo.id)
|
|
434
570
|
task_id = str(task.get("id", "")).strip()
|
|
435
571
|
task_name = str(task.get("name", "")).strip()
|
|
436
572
|
if not task_id or not task_name:
|
|
@@ -504,7 +640,7 @@ def main() -> int:
|
|
|
504
640
|
everhour.update_task_estimate(plan.everhour_task_id, plan.seconds)
|
|
505
641
|
|
|
506
642
|
for plan in plans:
|
|
507
|
-
update_basecamp_title(args.basecamp_bin, plan.todo
|
|
643
|
+
update_basecamp_title(args.basecamp_bin, plan.todo, plan.new_basecamp_title)
|
|
508
644
|
|
|
509
645
|
print("Completed Everhour estimate updates and Basecamp title updates.")
|
|
510
646
|
return 0
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import importlib.util
|
|
2
|
+
import json
|
|
2
3
|
import sys
|
|
3
4
|
import unittest
|
|
4
5
|
from decimal import Decimal
|
|
@@ -65,7 +66,7 @@ class BuildPlanTest(unittest.TestCase):
|
|
|
65
66
|
MODULE.build_plan(todos, estimates, everhour)
|
|
66
67
|
|
|
67
68
|
self.assertIn("duplicates todo 1", str(caught.exception))
|
|
68
|
-
everhour.match_task.assert_called_once_with("Ship estimate")
|
|
69
|
+
everhour.match_task.assert_called_once_with("Ship estimate", source_id="1")
|
|
69
70
|
|
|
70
71
|
def test_rejects_duplicate_everhour_task_matches(self) -> None:
|
|
71
72
|
todos = [
|
|
@@ -88,6 +89,198 @@ class BuildPlanTest(unittest.TestCase):
|
|
|
88
89
|
self.assertIn("todo 1 and todo 2", str(caught.exception))
|
|
89
90
|
self.assertEqual(everhour.match_task.call_count, 2)
|
|
90
91
|
|
|
92
|
+
def test_passes_basecamp_todo_id_to_everhour_matcher(self) -> None:
|
|
93
|
+
todos = [MODULE.TodoItem(id="1", title="Ship estimate")]
|
|
94
|
+
estimates = [(Decimal("1"), 3600)]
|
|
95
|
+
everhour = mock.Mock()
|
|
96
|
+
everhour.match_task.return_value = {
|
|
97
|
+
"id": "abc123",
|
|
98
|
+
"name": "Ship estimate",
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
plans = MODULE.build_plan(todos, estimates, everhour)
|
|
102
|
+
|
|
103
|
+
everhour.match_task.assert_called_once_with("Ship estimate", source_id="1")
|
|
104
|
+
self.assertEqual(plans[0].everhour_task_id, "abc123")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class EverhourClientMatchTaskTest(unittest.TestCase):
|
|
108
|
+
def test_duplicate_open_matches_are_resolved_by_basecamp_todo_id(self) -> None:
|
|
109
|
+
client = MODULE.EverhourClient(api_key="key", project_id="project")
|
|
110
|
+
client.list_open_tasks = mock.Mock(
|
|
111
|
+
return_value={
|
|
112
|
+
"ship estimate": [
|
|
113
|
+
{"id": "b3:1", "name": "Ship estimate"},
|
|
114
|
+
{"id": "b3:2", "name": "Ship estimate"},
|
|
115
|
+
],
|
|
116
|
+
}
|
|
117
|
+
)
|
|
118
|
+
client.search_tasks = mock.Mock(return_value=[])
|
|
119
|
+
|
|
120
|
+
task = client.match_task("Ship estimate", source_id="2")
|
|
121
|
+
|
|
122
|
+
self.assertEqual(task["id"], "b3:2")
|
|
123
|
+
client.search_tasks.assert_not_called()
|
|
124
|
+
|
|
125
|
+
def test_duplicate_open_matches_still_fail_without_todo_id_match(self) -> None:
|
|
126
|
+
client = MODULE.EverhourClient(api_key="key", project_id="project")
|
|
127
|
+
client.list_open_tasks = mock.Mock(
|
|
128
|
+
return_value={
|
|
129
|
+
"ship estimate": [
|
|
130
|
+
{"id": "b3:1", "name": "Ship estimate"},
|
|
131
|
+
{"id": "b3:2", "name": "Ship estimate"},
|
|
132
|
+
],
|
|
133
|
+
}
|
|
134
|
+
)
|
|
135
|
+
client.search_tasks = mock.Mock(
|
|
136
|
+
return_value=[
|
|
137
|
+
{"id": "b3:1", "name": "Ship estimate"},
|
|
138
|
+
{"id": "b3:2", "name": "Ship estimate"},
|
|
139
|
+
]
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
with self.assertRaises(MODULE.ScriptError) as caught:
|
|
143
|
+
client.match_task("Ship estimate", source_id="3")
|
|
144
|
+
|
|
145
|
+
self.assertIn("Basecamp todo 3", str(caught.exception))
|
|
146
|
+
|
|
147
|
+
def test_duplicate_search_matches_are_resolved_by_basecamp_todo_id(self) -> None:
|
|
148
|
+
client = MODULE.EverhourClient(api_key="key", project_id="project")
|
|
149
|
+
client.list_open_tasks = mock.Mock(return_value={})
|
|
150
|
+
client.search_tasks = mock.Mock(
|
|
151
|
+
return_value=[
|
|
152
|
+
{"id": "b3:1", "name": "Ship estimate"},
|
|
153
|
+
{"id": "b3:2", "name": "Ship estimate"},
|
|
154
|
+
]
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
task = client.match_task("Ship estimate", source_id="2")
|
|
158
|
+
|
|
159
|
+
self.assertEqual(task["id"], "b3:2")
|
|
160
|
+
client.search_tasks.assert_called_once_with("Ship estimate")
|
|
161
|
+
|
|
162
|
+
def test_basecamp_todo_id_can_match_task_url(self) -> None:
|
|
163
|
+
task = {
|
|
164
|
+
"id": "opaque",
|
|
165
|
+
"name": "Ship estimate",
|
|
166
|
+
"url": "https://3.basecamp.com/1/buckets/2/todos/123",
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
self.assertTrue(MODULE.task_matches_source_id(task, "123"))
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class UpdateBasecampTitleTest(unittest.TestCase):
|
|
173
|
+
def test_fetches_current_todo_then_puts_with_only_changed_content(self) -> None:
|
|
174
|
+
todo = MODULE.TodoItem(
|
|
175
|
+
id="123",
|
|
176
|
+
title="Ship estimate",
|
|
177
|
+
api_url="https://3.basecampapi.com/1/buckets/2/todos/123.json",
|
|
178
|
+
)
|
|
179
|
+
current_payload = {
|
|
180
|
+
"id": 123,
|
|
181
|
+
"content": "Ship estimate",
|
|
182
|
+
"description": "<div>Original notes with image</div>",
|
|
183
|
+
"assignees": [{"id": 7}, {"id": 8}],
|
|
184
|
+
"completion_subscribers": [{"id": 9}],
|
|
185
|
+
"due_on": "2026-05-01",
|
|
186
|
+
"starts_on": None,
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
with mock.patch.object(MODULE, "run_basecamp_json") as run_basecamp_json:
|
|
190
|
+
run_basecamp_json.side_effect = [current_payload, {}]
|
|
191
|
+
MODULE.update_basecamp_title("basecamp", todo, "Ship estimate [2h]")
|
|
192
|
+
|
|
193
|
+
self.assertEqual(run_basecamp_json.call_count, 2)
|
|
194
|
+
get_call, put_call = run_basecamp_json.call_args_list
|
|
195
|
+
|
|
196
|
+
self.assertEqual(
|
|
197
|
+
get_call.args,
|
|
198
|
+
(
|
|
199
|
+
"basecamp",
|
|
200
|
+
"api",
|
|
201
|
+
"get",
|
|
202
|
+
"https://3.basecampapi.com/1/buckets/2/todos/123.json",
|
|
203
|
+
),
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
self.assertEqual(put_call.args[:5], (
|
|
207
|
+
"basecamp",
|
|
208
|
+
"api",
|
|
209
|
+
"put",
|
|
210
|
+
"https://3.basecampapi.com/1/buckets/2/todos/123.json",
|
|
211
|
+
"-d",
|
|
212
|
+
))
|
|
213
|
+
body = json.loads(put_call.args[5])
|
|
214
|
+
self.assertEqual(body["content"], "Ship estimate [2h]")
|
|
215
|
+
self.assertEqual(body["description"], "<div>Original notes with image</div>")
|
|
216
|
+
self.assertEqual(body["assignee_ids"], [7, 8])
|
|
217
|
+
self.assertEqual(body["completion_subscriber_ids"], [9])
|
|
218
|
+
self.assertEqual(body["due_on"], "2026-05-01")
|
|
219
|
+
self.assertNotIn("starts_on", body)
|
|
220
|
+
|
|
221
|
+
def test_omits_fields_absent_from_current_todo(self) -> None:
|
|
222
|
+
todo = MODULE.TodoItem(
|
|
223
|
+
id="123",
|
|
224
|
+
title="Ship estimate",
|
|
225
|
+
api_url="https://3.basecampapi.com/1/buckets/2/todos/123.json",
|
|
226
|
+
)
|
|
227
|
+
current_payload = {
|
|
228
|
+
"id": 123,
|
|
229
|
+
"content": "Ship estimate",
|
|
230
|
+
"description": "",
|
|
231
|
+
"assignees": [],
|
|
232
|
+
"completion_subscribers": [],
|
|
233
|
+
"due_on": None,
|
|
234
|
+
"starts_on": None,
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
with mock.patch.object(MODULE, "run_basecamp_json") as run_basecamp_json:
|
|
238
|
+
run_basecamp_json.side_effect = [current_payload, {}]
|
|
239
|
+
MODULE.update_basecamp_title("basecamp", todo, "Ship estimate [2h]")
|
|
240
|
+
|
|
241
|
+
put_call = run_basecamp_json.call_args_list[1]
|
|
242
|
+
body = json.loads(put_call.args[5])
|
|
243
|
+
self.assertEqual(body, {"content": "Ship estimate [2h]"})
|
|
244
|
+
|
|
245
|
+
def test_rejects_missing_basecamp_api_url(self) -> None:
|
|
246
|
+
todo = MODULE.TodoItem(id="123", title="Ship estimate")
|
|
247
|
+
|
|
248
|
+
with self.assertRaises(MODULE.ScriptError) as caught:
|
|
249
|
+
MODULE.update_basecamp_title("basecamp", todo, "Ship estimate [2h]")
|
|
250
|
+
|
|
251
|
+
self.assertIn("did not include an API URL", str(caught.exception))
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class BuildBasecampUpdatePayloadTest(unittest.TestCase):
|
|
255
|
+
def test_extracts_assignee_and_subscriber_ids_and_preserves_dates(self) -> None:
|
|
256
|
+
current = {
|
|
257
|
+
"content": "Old",
|
|
258
|
+
"description": "<p>note</p>",
|
|
259
|
+
"assignees": [{"id": 1}, {"id": 2}],
|
|
260
|
+
"completion_subscribers": [{"id": 3}],
|
|
261
|
+
"due_on": "2026-05-01",
|
|
262
|
+
"starts_on": "2026-04-01",
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
payload = MODULE.build_basecamp_update_payload(current, "New title")
|
|
266
|
+
|
|
267
|
+
self.assertEqual(
|
|
268
|
+
payload,
|
|
269
|
+
{
|
|
270
|
+
"content": "New title",
|
|
271
|
+
"description": "<p>note</p>",
|
|
272
|
+
"assignee_ids": [1, 2],
|
|
273
|
+
"completion_subscriber_ids": [3],
|
|
274
|
+
"due_on": "2026-05-01",
|
|
275
|
+
"starts_on": "2026-04-01",
|
|
276
|
+
},
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def test_skips_empty_or_missing_fields(self) -> None:
|
|
280
|
+
payload = MODULE.build_basecamp_update_payload({"content": "Old"}, "New title")
|
|
281
|
+
|
|
282
|
+
self.assertEqual(payload, {"content": "New title"})
|
|
283
|
+
|
|
91
284
|
|
|
92
285
|
if __name__ == "__main__":
|
|
93
286
|
unittest.main()
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Codex sandbox config bootstrap
|
|
2
|
+
|
|
3
|
+
This directory contains a template + generator that produces `<repo>/.codex/config.toml`
|
|
4
|
+
on demand, so the file does not need to be committed with hardcoded paths tied to one
|
|
5
|
+
machine or one project clone location.
|
|
6
|
+
|
|
7
|
+
## Files
|
|
8
|
+
|
|
9
|
+
- `config.toml.template` — the source of truth. Uses two placeholders:
|
|
10
|
+
- `__PROJECT_ROOT__` — replaced with the absolute path to the repo root
|
|
11
|
+
- `__CHROME_PATH__` — replaced with the absolute path to the Chrome/Chromium binary
|
|
12
|
+
- `setup-codex.sh` — reads the template, substitutes the placeholders, writes
|
|
13
|
+
`<repo>/.codex/config.toml`.
|
|
14
|
+
|
|
15
|
+
The generated `.codex/` directory is gitignored, so each clone gets its own copy.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
Run once after cloning the repo (or after moving the clone to a new path):
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
.agents/skills/playwright-cli/setup-codex.sh
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The script prints the values it resolved, e.g.:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
wrote /home/you/code/lio/.codex/config.toml
|
|
29
|
+
PROJECT_ROOT=/home/you/code/lio
|
|
30
|
+
CHROME_PATH=/usr/bin/google-chrome
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Re-run it any time the template changes or you move the repo.
|
|
34
|
+
|
|
35
|
+
## How paths are resolved
|
|
36
|
+
|
|
37
|
+
**`PROJECT_ROOT`** — derived in this order:
|
|
38
|
+
|
|
39
|
+
1. `git rev-parse --show-toplevel` from the script's directory (works for any clone
|
|
40
|
+
location, on any machine, regardless of username)
|
|
41
|
+
2. Falls back to walking three directories up from the script
|
|
42
|
+
(`.agents/skills/playwright-cli` → repo root) if the repo isn't a git checkout
|
|
43
|
+
|
|
44
|
+
**`CHROME_PATH`** — derived in this order:
|
|
45
|
+
|
|
46
|
+
1. The `CHROME_PATH` environment variable, if set
|
|
47
|
+
2. First match from `PATH` of: `google-chrome`, `google-chrome-stable`, `chromium`,
|
|
48
|
+
`chromium-browser`
|
|
49
|
+
3. Falls back to `/usr/bin/google-chrome` with a warning if nothing is found
|
|
50
|
+
|
|
51
|
+
Override the Chrome binary explicitly when needed:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
CHROME_PATH=/opt/google/chrome/chrome .agents/skills/playwright-cli/setup-codex.sh
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Why a generator instead of variable expansion
|
|
58
|
+
|
|
59
|
+
Codex CLI parses `config.toml` as static TOML and does not expand `~`, `$HOME`, or
|
|
60
|
+
`${VAR}` in path values — they would be treated as literal strings. Generating the
|
|
61
|
+
file at setup time is the simplest way to keep the template portable while still
|
|
62
|
+
producing the absolute paths Codex expects at runtime.
|
|
63
|
+
|
|
64
|
+
## What the generated config does
|
|
65
|
+
|
|
66
|
+
The output `.codex/config.toml` configures Codex's sandbox so Playwright can run
|
|
67
|
+
inside it:
|
|
68
|
+
|
|
69
|
+
- `sandbox_workspace_write.writable_roots` — directories Playwright is allowed to
|
|
70
|
+
write to (browser cache, artifacts, test reports, daemon sockets)
|
|
71
|
+
- `shell_environment_policy.set` — pins `PLAYWRIGHT_*`, `XDG_*`, and `TMPDIR`-family
|
|
72
|
+
env vars to repo-local paths so Playwright stores its browser, cache, and runtime
|
|
73
|
+
state under the project rather than the user's home directory
|
|
74
|
+
|
|
75
|
+
## Editing
|
|
76
|
+
|
|
77
|
+
Edit `config.toml.template` (not the generated file) and re-run `setup-codex.sh`.
|
|
78
|
+
Changes to `.codex/config.toml` directly will be overwritten the next time the
|
|
79
|
+
generator runs.
|