freeagent-cli 0.2.0__tar.gz → 0.3.0__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: freeagent-cli
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: CLI for submitting FreeAgent timeslips
5
5
  Project-URL: Homepage, https://github.com/tomdyson/freeagent-cli
6
6
  Project-URL: Repository, https://github.com/tomdyson/freeagent-cli
@@ -67,9 +67,9 @@ Credentials are stored at `~/Library/Application Support/freeagent-cli/config.js
67
67
 
68
68
  ```
69
69
  freeagent-cli --help # canonical flow
70
- freeagent-cli projects # projects + tasks in one call
70
+ freeagent-cli recent # what you've already logged (run this first to avoid duplicates)
71
71
  freeagent-cli log <project> <duration> [comment...] # submit a timeslip
72
- freeagent-cli recent # last few timeslips
72
+ freeagent-cli projects # first-time / discovery: projects + tasks in one call
73
73
  ```
74
74
 
75
75
  Examples:
@@ -40,9 +40,9 @@ Credentials are stored at `~/Library/Application Support/freeagent-cli/config.js
40
40
 
41
41
  ```
42
42
  freeagent-cli --help # canonical flow
43
- freeagent-cli projects # projects + tasks in one call
43
+ freeagent-cli recent # what you've already logged (run this first to avoid duplicates)
44
44
  freeagent-cli log <project> <duration> [comment...] # submit a timeslip
45
- freeagent-cli recent # last few timeslips
45
+ freeagent-cli projects # first-time / discovery: projects + tasks in one call
46
46
  ```
47
47
 
48
48
  Examples:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "freeagent-cli"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "CLI for submitting FreeAgent timeslips"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -40,3 +40,8 @@ build-backend = "hatchling.build"
40
40
 
41
41
  [tool.hatch.build.targets.wheel]
42
42
  packages = ["src/freeagent_cli"]
43
+
44
+ [dependency-groups]
45
+ dev = [
46
+ "pytest>=9.0.3",
47
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -71,3 +71,20 @@ class FreeAgent:
71
71
  if comment:
72
72
  body["timeslip"]["comment"] = comment
73
73
  return self.post("/v2/timeslips", body)
74
+
75
+ def delete(self, path: str) -> dict:
76
+ with self._client() as c:
77
+ r = c.delete(path)
78
+ r.raise_for_status()
79
+ return r.json()
80
+
81
+ def delete_timeslip(self, timeslip_id: str) -> dict:
82
+ try:
83
+ return self.delete(f"/v2/timeslips/{timeslip_id}")
84
+ except httpx.HTTPStatusError as e:
85
+ if e.response.status_code == 404:
86
+ return {"deleted": True, "id": timeslip_id, "already_deleted": True}
87
+ raise
88
+
89
+ def get_timeslip(self, timeslip_id: str) -> dict:
90
+ return self.get(f"/v2/timeslips/{timeslip_id}", nested="true")["timeslip"]
@@ -19,6 +19,10 @@ def _api() -> FreeAgent:
19
19
  return FreeAgent(c)
20
20
 
21
21
 
22
+ def _extract_id(query: str) -> str:
23
+ return query.rsplit("/", 1)[-1] if "/" in query else query
24
+
25
+
22
26
  def parse_hours(s: str) -> float:
23
27
  """Parse a duration: 1.5, 90m, 1h, 1h30m, 1:30."""
24
28
  s = s.strip().lower()
@@ -37,6 +41,22 @@ def parse_hours(s: str) -> float:
37
41
  raise ValueError(f"Cannot parse duration {s!r}; try 1.5, 90m, 1h30m, or 1:30")
38
42
 
39
43
 
44
+ def format_hours(value) -> str:
45
+ """Format a decimal-hours value as 40m / 1h / 1h30m, rounding to the nearest minute."""
46
+ try:
47
+ total_min = round(float(value) * 60)
48
+ except (TypeError, ValueError):
49
+ return str(value)
50
+ if total_min == 0:
51
+ return "0m"
52
+ h, m = divmod(total_min, 60)
53
+ if h == 0:
54
+ return f"{m}m"
55
+ if m == 0:
56
+ return f"{h}h"
57
+ return f"{h}h{m}m"
58
+
59
+
40
60
  def _resolve(items: list[dict], query: str, label: str) -> dict:
41
61
  if query.startswith("http"):
42
62
  match = [i for i in items if i["url"] == query]
@@ -73,9 +93,9 @@ def _pick_task(tasks: list[dict], task_q: str | None, project_name: str) -> dict
73
93
  @click.group(epilog="""
74
94
  \b
75
95
  Typical flow:
76
- freeagent-cli projects # projects + tasks in one call
96
+ freeagent-cli recent # check what you've already logged (avoids duplicates)
77
97
  freeagent-cli log <project> <duration> [comment] [--task <name>] [--dry-run]
78
- freeagent-cli recent # last few timeslips
98
+ freeagent-cli projects # first-time / discovery: projects + tasks
79
99
 
80
100
  \b
81
101
  Examples:
@@ -209,6 +229,8 @@ def log(project_q, duration, comment_parts, task_q, date_, dry_run):
209
229
  freeagent-cli log Acme 1h30m "fixed the thing"
210
230
  freeagent-cli log Acme 90m fixed the thing # comment without quotes
211
231
  freeagent-cli log "Big Co" 1.5 --task Coding --date 2026-05-01
232
+
233
+ Tip: run `freeagent-cli recent` first to avoid duplicate entries.
212
234
  """
213
235
  try:
214
236
  hours = parse_hours(duration)
@@ -222,7 +244,7 @@ def log(project_q, duration, comment_parts, task_q, date_, dry_run):
222
244
  dated_on = date_ or _dt.date.today().isoformat()
223
245
 
224
246
  if dry_run:
225
- click.echo(f"DRY RUN — would submit {hours}h on {dated_on} → {project['name']} / {task['name']}")
247
+ click.echo(f"DRY RUN — would submit {format_hours(hours)} on {dated_on} → {project['name']} / {task['name']}")
226
248
  click.echo(f" project: {project['url']}")
227
249
  click.echo(f" task: {task['url']}")
228
250
  if comment:
@@ -235,7 +257,7 @@ def log(project_q, duration, comment_parts, task_q, date_, dry_run):
235
257
  dated_on=dated_on, hours=hours, comment=comment,
236
258
  )
237
259
  ts = result["timeslips"][0] if "timeslips" in result else result.get("timeslip", result)
238
- click.echo(f"Submitted {hours}h on {dated_on} → {project['name']} / {task['name']}")
260
+ click.echo(f"Submitted {format_hours(hours)} on {dated_on} → {project['name']} / {task['name']}")
239
261
  if isinstance(ts, dict) and "url" in ts:
240
262
  click.echo(ts["url"])
241
263
 
@@ -265,7 +287,151 @@ def recent(limit, days, all_users):
265
287
  pname = proj["name"] if isinstance(proj, dict) else "?"
266
288
  tname = task_["name"] if isinstance(task_, dict) else "?"
267
289
  comment = (s.get("comment") or "").replace("\n", " ")
268
- click.echo(f"{s.get('dated_on','?')}\t{s.get('hours','?')}h\t{pname}\t{tname}\t{comment}")
290
+ click.echo(f"{s.get('dated_on','?')}\t{format_hours(s.get('hours'))}\t{pname}\t{tname}\t{comment}\t{s.get('url','')}")
291
+
292
+
293
+ # -- delete --------------------------------------------------------------
294
+
295
+ @main.command()
296
+ @click.argument("timeslip_q", metavar="TIMESLIP_ID_OR_URL")
297
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation.")
298
+ def delete(timeslip_q, yes):
299
+ """Delete a timeslip by numeric ID or full URL."""
300
+ api = _api()
301
+ tid = _extract_id(timeslip_q)
302
+ try:
303
+ ts = api.get_timeslip(tid)
304
+ except Exception:
305
+ raise click.UsageError(f"Timeslip {tid!r} not found.")
306
+ proj = ts.get("project")
307
+ task_ = ts.get("task")
308
+ pname = proj["name"] if isinstance(proj, dict) else "?"
309
+ tname = task_["name"] if isinstance(task_, dict) else "?"
310
+ comment = (ts.get("comment") or "")[:80]
311
+ click.echo(f"{ts.get('dated_on','?')} {format_hours(ts.get('hours'))} {pname} / {tname}")
312
+ if comment:
313
+ click.echo(f" {comment}")
314
+ if not yes:
315
+ click.confirm("Delete this timeslip?", abort=True)
316
+ result = api.delete_timeslip(tid)
317
+ if result.get("already_deleted"):
318
+ click.echo("Timeslip already deleted.")
319
+ else:
320
+ click.echo("Deleted.")
321
+ click.echo(ts.get("url", ""))
322
+
323
+
324
+ # -- edit ----------------------------------------------------------------
325
+
326
+ @main.command()
327
+ @click.argument("timeslip_q", metavar="TIMESLIP_ID_OR_URL")
328
+ @click.option("--project", "project_q", default=None,
329
+ help="New project (name substring, id, or URL).")
330
+ @click.option("--task", "task_q", default=None,
331
+ help="New task (name substring, id, or URL).")
332
+ @click.option("--duration", "duration_str", default=None,
333
+ help="New duration (1.5, 90m, 1h30m, 1:30).")
334
+ @click.option("--date", "date_", default=None, help="New date YYYY-MM-DD.")
335
+ @click.option("--comment", "comment", default=None, help="New comment.")
336
+ @click.option("--dry-run", is_flag=True, help="Resolve and preview, but don't submit.")
337
+ @click.option("--yes", "-y", is_flag=True, help="Skip confirmation.")
338
+ def edit(timeslip_q, project_q, task_q, duration_str, date_, comment, dry_run, yes):
339
+ """Edit a timeslip: fetch, apply changes, delete old, create new.
340
+
341
+ \b
342
+ Only the options you pass are changed; everything else stays the same.
343
+ If you change --project, --task defaults to a matching task name in the new
344
+ project if one exists, otherwise you must pass --task explicitly.
345
+
346
+ \b
347
+ Examples:
348
+ freeagent-cli edit 123456 --duration 2h
349
+ freeagent-cli edit https://api.freeagent.com/v2/timeslips/123456 --comment "done"
350
+ freeagent-cli edit 123456 --project "Big Co" --task Coding --dry-run
351
+ """
352
+ api = _api()
353
+ tid = _extract_id(timeslip_q)
354
+ try:
355
+ old = api.get_timeslip(tid)
356
+ except Exception:
357
+ raise click.UsageError(f"Timeslip {tid!r} not found.")
358
+
359
+ if not any([project_q, task_q, duration_str, date_, comment is not None]):
360
+ raise click.UsageError("Nothing to change. Use --help to see options.")
361
+
362
+ old_proj = old.get("project")
363
+ old_task = old.get("task")
364
+
365
+ new_project_url = old_proj["url"] if isinstance(old_proj, dict) else ""
366
+ new_task_url = old_task["url"] if isinstance(old_task, dict) else ""
367
+ new_hours = float(old.get("hours", 0))
368
+ new_date = old.get("dated_on", "")
369
+ new_comment = old.get("comment") or ""
370
+
371
+ old_pname = old_proj["name"] if isinstance(old_proj, dict) else "?"
372
+ old_tname = old_task["name"] if isinstance(old_task, dict) else "?"
373
+ new_pname = old_pname
374
+ new_tname = old_tname
375
+
376
+ if project_q:
377
+ new_proj = _resolve(api.projects(view="active"), project_q, "project")
378
+ new_project_url = new_proj["url"]
379
+ new_pname = new_proj["name"]
380
+ if not task_q:
381
+ new_tasks = api.tasks(new_project_url)
382
+ matches = [t for t in new_tasks if t["name"].lower() == old_tname.lower()]
383
+ if len(matches) == 1:
384
+ new_task_url = matches[0]["url"]
385
+ new_tname = matches[0]["name"]
386
+ else:
387
+ names = ", ".join(t["name"] for t in new_tasks) or "(none)"
388
+ raise click.UsageError(
389
+ f"Project changed; --task required. Available in {new_proj['name']!r}: {names}"
390
+ )
391
+
392
+ if task_q:
393
+ proj_tasks = api.tasks(new_project_url)
394
+ new_task = _resolve(proj_tasks, task_q, "task")
395
+ new_task_url = new_task["url"]
396
+ new_tname = new_task["name"]
397
+
398
+ if duration_str:
399
+ try:
400
+ new_hours = parse_hours(duration_str)
401
+ except ValueError as e:
402
+ raise click.UsageError(str(e))
403
+
404
+ if date_:
405
+ new_date = date_
406
+
407
+ if comment is not None:
408
+ new_comment = comment
409
+
410
+ lines = []
411
+ lines.append("Before → After")
412
+ lines.append(f" Project: {old_pname} → {new_pname}")
413
+ lines.append(f" Task: {old_tname} → {new_tname}")
414
+ lines.append(f" Date: {old.get('dated_on','?')} → {new_date}")
415
+ lines.append(f" Duration: {format_hours(old.get('hours'))} → {format_hours(new_hours)}")
416
+ lines.append(f" Comment: {repr(old.get('comment') or '')} → {repr(new_comment)}")
417
+ click.echo("\n".join(lines))
418
+
419
+ if dry_run:
420
+ return
421
+
422
+ if not yes:
423
+ click.confirm("Proceed with edit?", abort=True)
424
+
425
+ user_url = api.me()["url"]
426
+ result = api.create_timeslip(
427
+ user=user_url, project=new_project_url, task=new_task_url,
428
+ dated_on=new_date, hours=new_hours, comment=new_comment or None,
429
+ )
430
+ ts = result["timeslips"][0] if "timeslips" in result else result.get("timeslip", result)
431
+ api.delete_timeslip(tid)
432
+ click.echo(f"Edited → {new_pname} / {new_tname}{' --dry-run' if dry_run else ''}")
433
+ if isinstance(ts, dict) and "url" in ts:
434
+ click.echo(ts["url"])
269
435
 
270
436
 
271
437
  if __name__ == "__main__":
File without changes
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ import httpx
6
+ import pytest
7
+
8
+ from freeagent_cli.api import FreeAgent
9
+ from freeagent_cli.config import Config
10
+
11
+
12
+ @pytest.fixture
13
+ def config():
14
+ c = Config()
15
+ c.client_id = "test-id"
16
+ c.client_secret = "test-secret"
17
+ c.refresh_token = "test-refresh"
18
+ c.access_token = "test-access"
19
+ c.access_token_expires_at = 9999999999.0
20
+ return c
21
+
22
+
23
+ @pytest.fixture
24
+ def api(config):
25
+ return FreeAgent(config)
26
+
27
+
28
+ class TestDeleteTimeslip:
29
+ def test_delete_success(self, api):
30
+ mock_client = MagicMock()
31
+ mock_response = MagicMock()
32
+ mock_response.status_code = 200
33
+ mock_response.raise_for_status.return_value = None
34
+ mock_response.json.return_value = {"timeslip": {"url": "/v2/timeslips/42"}}
35
+ mock_client.__enter__.return_value = mock_client
36
+ mock_client.delete.return_value = mock_response
37
+
38
+ with patch.object(api, "_client", return_value=mock_client):
39
+ result = api.delete_timeslip("42")
40
+ assert result == {"timeslip": {"url": "/v2/timeslips/42"}}
41
+ mock_client.delete.assert_called_once_with("/v2/timeslips/42")
42
+
43
+ def test_delete_404_returns_already_deleted(self, api):
44
+ mock_client = MagicMock()
45
+ mock_response = MagicMock()
46
+ mock_response.status_code = 404
47
+ mock_client.__enter__.return_value = mock_client
48
+
49
+ error_response = MagicMock()
50
+ error_response.status_code = 404
51
+ error_response.text = "Not found"
52
+ error = httpx.HTTPStatusError("404 Not Found", request=MagicMock(), response=error_response)
53
+ mock_client.delete.side_effect = error
54
+
55
+ with patch.object(api, "_client", return_value=mock_client):
56
+ result = api.delete_timeslip("42")
57
+ assert result == {"deleted": True, "id": "42", "already_deleted": True}
58
+
59
+ def test_delete_500_reraises(self, api):
60
+ mock_client = MagicMock()
61
+ mock_client.__enter__.return_value = mock_client
62
+
63
+ error_response = MagicMock()
64
+ error_response.status_code = 500
65
+ error = httpx.HTTPStatusError("500 Server Error", request=MagicMock(), response=error_response)
66
+ mock_client.delete.side_effect = error
67
+
68
+ with patch.object(api, "_client", return_value=mock_client):
69
+ with pytest.raises(httpx.HTTPStatusError):
70
+ api.delete_timeslip("42")
71
+
72
+
73
+ class TestGetTimeslip:
74
+ def test_get_timeslip(self, api):
75
+ mock_client = MagicMock()
76
+ mock_response = MagicMock()
77
+ mock_response.raise_for_status.return_value = None
78
+ mock_response.json.return_value = {
79
+ "timeslip": {
80
+ "url": "/v2/timeslips/42",
81
+ "hours": "2.5",
82
+ "dated_on": "2026-05-15",
83
+ "comment": "test work",
84
+ "project": {"name": "Acme", "url": "/v2/projects/1"},
85
+ "task": {"name": "Coding", "url": "/v2/tasks/1"},
86
+ }
87
+ }
88
+ mock_client.__enter__.return_value = mock_client
89
+ mock_client.get.return_value = mock_response
90
+
91
+ with patch.object(api, "_client", return_value=mock_client):
92
+ result = api.get_timeslip("42")
93
+ assert result["url"] == "/v2/timeslips/42"
94
+ assert result["hours"] == "2.5"
95
+ mock_client.get.assert_called_once_with("/v2/timeslips/42", params={"nested": "true"})
96
+
97
+
98
+ class TestDelete:
99
+ def test_delete_generic(self, api):
100
+ mock_client = MagicMock()
101
+ mock_response = MagicMock()
102
+ mock_response.status_code = 200
103
+ mock_response.raise_for_status.return_value = None
104
+ mock_response.json.return_value = {"status": "ok"}
105
+ mock_client.__enter__.return_value = mock_client
106
+ mock_client.delete.return_value = mock_response
107
+
108
+ with patch.object(api, "_client", return_value=mock_client):
109
+ result = api.delete("/v2/some/resource")
110
+ assert result == {"status": "ok"}
111
+ mock_client.delete.assert_called_once_with("/v2/some/resource")
@@ -0,0 +1,281 @@
1
+ from __future__ import annotations
2
+
3
+ from unittest.mock import MagicMock, patch
4
+
5
+ import pytest
6
+ from click.testing import CliRunner
7
+
8
+ from freeagent_cli.cli import main
9
+ from freeagent_cli.config import Config
10
+
11
+
12
+ @pytest.fixture
13
+ def runner():
14
+ return CliRunner()
15
+
16
+
17
+ @pytest.fixture
18
+ def mock_config():
19
+ c = Config()
20
+ c.client_id = "test-id"
21
+ c.client_secret = "test-secret"
22
+ c.refresh_token = "test-refresh"
23
+ c.access_token = "test-access"
24
+ c.access_token_expires_at = 9999999999.0
25
+ return c
26
+
27
+
28
+ @pytest.fixture
29
+ def mock_api():
30
+ return MagicMock()
31
+
32
+
33
+ def _mock_api_and_config(api_mock, config):
34
+ api_mock.me.return_value = {"url": "/v2/users/me"}
35
+ api_mock.projects.return_value = [
36
+ {"name": "Acme", "url": "/v2/projects/1"},
37
+ {"name": "Big Co", "url": "/v2/projects/2"},
38
+ ]
39
+ api_mock.tasks.return_value = [
40
+ {"name": "Coding", "url": "/v2/tasks/1"},
41
+ {"name": "Design", "url": "/v2/tasks/2"},
42
+ ]
43
+
44
+
45
+ class TestDelete:
46
+ def test_delete_confirmed(self, runner, mock_config, mock_api):
47
+ _mock_api_and_config(mock_api, mock_config)
48
+ mock_api.get_timeslip.return_value = {
49
+ "url": "/v2/timeslips/42",
50
+ "hours": "2.5",
51
+ "dated_on": "2026-05-15",
52
+ "comment": "test work",
53
+ "project": {"name": "Acme", "url": "/v2/projects/1"},
54
+ "task": {"name": "Coding", "url": "/v2/tasks/1"},
55
+ }
56
+ mock_api.delete_timeslip.return_value = {"deleted": True, "id": "42", "already_deleted": False}
57
+
58
+ with patch("freeagent_cli.cli.cfg.load", return_value=mock_config), \
59
+ patch("freeagent_cli.cli.FreeAgent", return_value=mock_api):
60
+ result = runner.invoke(main, ["delete", "42"], input="y\n")
61
+ assert result.exit_code == 0
62
+ assert "Acme / Coding" in result.output
63
+ assert "2026-05-15" in result.output
64
+ assert "Deleted" in result.output
65
+
66
+ def test_delete_aborted(self, runner, mock_config, mock_api):
67
+ _mock_api_and_config(mock_api, mock_config)
68
+ mock_api.get_timeslip.return_value = {
69
+ "url": "/v2/timeslips/42",
70
+ "hours": "2.5",
71
+ "dated_on": "2026-05-15",
72
+ "comment": "test work",
73
+ "project": {"name": "Acme", "url": "/v2/projects/1"},
74
+ "task": {"name": "Coding", "url": "/v2/tasks/1"},
75
+ }
76
+
77
+ with patch("freeagent_cli.cli.cfg.load", return_value=mock_config), \
78
+ patch("freeagent_cli.cli.FreeAgent", return_value=mock_api):
79
+ result = runner.invoke(main, ["delete", "42"], input="n\n")
80
+ assert result.exit_code == 1
81
+
82
+ def test_delete_yes_flag_skips_confirm(self, runner, mock_config, mock_api):
83
+ _mock_api_and_config(mock_api, mock_config)
84
+ mock_api.get_timeslip.return_value = {
85
+ "url": "/v2/timeslips/42",
86
+ "hours": "2.5",
87
+ "dated_on": "2026-05-15",
88
+ "comment": "test work",
89
+ "project": {"name": "Acme", "url": "/v2/projects/1"},
90
+ "task": {"name": "Coding", "url": "/v2/tasks/1"},
91
+ }
92
+ mock_api.delete_timeslip.return_value = {"deleted": True, "id": "42", "already_deleted": False}
93
+
94
+ with patch("freeagent_cli.cli.cfg.load", return_value=mock_config), \
95
+ patch("freeagent_cli.cli.FreeAgent", return_value=mock_api):
96
+ result = runner.invoke(main, ["delete", "42", "--yes"])
97
+ assert result.exit_code == 0
98
+ assert "Deleted" in result.output
99
+
100
+ def test_delete_by_url(self, runner, mock_config, mock_api):
101
+ _mock_api_and_config(mock_api, mock_config)
102
+ mock_api.get_timeslip.return_value = {
103
+ "url": "/v2/timeslips/42",
104
+ "hours": "1.0",
105
+ "dated_on": "2026-05-14",
106
+ "comment": "",
107
+ "project": {"name": "Big Co", "url": "/v2/projects/2"},
108
+ "task": {"name": "Design", "url": "/v2/tasks/2"},
109
+ }
110
+ mock_api.delete_timeslip.return_value = {"deleted": True, "id": "42", "already_deleted": False}
111
+
112
+ with patch("freeagent_cli.cli.cfg.load", return_value=mock_config), \
113
+ patch("freeagent_cli.cli.FreeAgent", return_value=mock_api):
114
+ result = runner.invoke(main, ["delete", "https://api.freeagent.com/v2/timeslips/42", "--yes"])
115
+ assert result.exit_code == 0
116
+ mock_api.get_timeslip.assert_called_once_with("42")
117
+ mock_api.delete_timeslip.assert_called_once_with("42")
118
+
119
+ def test_delete_already_deleted(self, runner, mock_config, mock_api):
120
+ _mock_api_and_config(mock_api, mock_config)
121
+ mock_api.get_timeslip.side_effect = Exception("not found")
122
+
123
+ with patch("freeagent_cli.cli.cfg.load", return_value=mock_config), \
124
+ patch("freeagent_cli.cli.FreeAgent", return_value=mock_api):
125
+ result = runner.invoke(main, ["delete", "999", "--yes"])
126
+ assert result.exit_code == 2
127
+ assert "not found" in result.output
128
+
129
+
130
+ class TestEdit:
131
+ def _old_timeslip(self):
132
+ return {
133
+ "url": "/v2/timeslips/42",
134
+ "hours": "2.5",
135
+ "dated_on": "2026-05-15",
136
+ "comment": "test work",
137
+ "project": {"name": "Acme", "url": "/v2/projects/1"},
138
+ "task": {"name": "Coding", "url": "/v2/tasks/1"},
139
+ }
140
+
141
+ def test_edit_duration(self, runner, mock_config, mock_api):
142
+ _mock_api_and_config(mock_api, mock_config)
143
+ mock_api.get_timeslip.return_value = self._old_timeslip()
144
+ mock_api.create_timeslip.return_value = {
145
+ "timeslip": {"url": "/v2/timeslips/43", "hours": "1.0", "dated_on": "2026-05-15"}
146
+ }
147
+ mock_api.delete_timeslip.return_value = {"deleted": True, "id": "42", "already_deleted": False}
148
+
149
+ with patch("freeagent_cli.cli.cfg.load", return_value=mock_config), \
150
+ patch("freeagent_cli.cli.FreeAgent", return_value=mock_api):
151
+ result = runner.invoke(main, ["edit", "42", "--duration", "1h", "--yes"])
152
+ assert result.exit_code == 0
153
+ assert "2h30m → 1h" in result.output
154
+ mock_api.create_timeslip.assert_called_once()
155
+ mock_api.delete_timeslip.assert_called_once_with("42")
156
+
157
+ def test_edit_dry_run(self, runner, mock_config, mock_api):
158
+ _mock_api_and_config(mock_api, mock_config)
159
+ mock_api.get_timeslip.return_value = self._old_timeslip()
160
+
161
+ with patch("freeagent_cli.cli.cfg.load", return_value=mock_config), \
162
+ patch("freeagent_cli.cli.FreeAgent", return_value=mock_api):
163
+ result = runner.invoke(main, ["edit", "42", "--duration", "1h", "--dry-run"])
164
+ assert result.exit_code == 0
165
+ assert "2h30m → 1h" in result.output
166
+ mock_api.create_timeslip.assert_not_called()
167
+ mock_api.delete_timeslip.assert_not_called()
168
+
169
+ def test_edit_no_changes(self, runner, mock_config, mock_api):
170
+ _mock_api_and_config(mock_api, mock_config)
171
+ mock_api.get_timeslip.return_value = self._old_timeslip()
172
+
173
+ with patch("freeagent_cli.cli.cfg.load", return_value=mock_config), \
174
+ patch("freeagent_cli.cli.FreeAgent", return_value=mock_api):
175
+ result = runner.invoke(main, ["edit", "42"])
176
+ assert result.exit_code == 2
177
+ assert "Nothing to change" in result.output
178
+
179
+ def test_edit_project_with_matching_task(self, runner, mock_config, mock_api):
180
+ _mock_api_and_config(mock_api, mock_config)
181
+ mock_api.get_timeslip.return_value = self._old_timeslip()
182
+
183
+ def _tasks(project_url):
184
+ if "/projects/2" in project_url:
185
+ return [
186
+ {"name": "Coding", "url": "/v2/tasks/3"},
187
+ {"name": "Testing", "url": "/v2/tasks/4"},
188
+ ]
189
+ return [
190
+ {"name": "Coding", "url": "/v2/tasks/1"},
191
+ {"name": "Design", "url": "/v2/tasks/2"},
192
+ ]
193
+
194
+ mock_api.tasks.side_effect = _tasks
195
+ mock_api.create_timeslip.return_value = {
196
+ "timeslip": {"url": "/v2/timeslips/43", "hours": "2.5", "dated_on": "2026-05-15"}
197
+ }
198
+ mock_api.delete_timeslip.return_value = {"deleted": True, "id": "42", "already_deleted": False}
199
+
200
+ with patch("freeagent_cli.cli.cfg.load", return_value=mock_config), \
201
+ patch("freeagent_cli.cli.FreeAgent", return_value=mock_api):
202
+ result = runner.invoke(main, ["edit", "42", "--project", "Big Co", "--yes"])
203
+ assert result.exit_code == 0
204
+ assert "Acme → Big Co" in result.output
205
+
206
+ def test_edit_project_task_not_found_requires_flag(self, runner, mock_config, mock_api):
207
+ _mock_api_and_config(mock_api, mock_config)
208
+ mock_api.get_timeslip.return_value = self._old_timeslip()
209
+
210
+ def _tasks(project_url):
211
+ if "/projects/2" in project_url:
212
+ return [
213
+ {"name": "Testing", "url": "/v2/tasks/4"},
214
+ ]
215
+ return [
216
+ {"name": "Coding", "url": "/v2/tasks/1"},
217
+ ]
218
+
219
+ mock_api.tasks.side_effect = _tasks
220
+
221
+ with patch("freeagent_cli.cli.cfg.load", return_value=mock_config), \
222
+ patch("freeagent_cli.cli.FreeAgent", return_value=mock_api):
223
+ result = runner.invoke(main, ["edit", "42", "--project", "Big Co", "--yes"])
224
+ assert result.exit_code == 2
225
+ assert "--task required" in result.output
226
+
227
+ def test_edit_aborted(self, runner, mock_config, mock_api):
228
+ _mock_api_and_config(mock_api, mock_config)
229
+ mock_api.get_timeslip.return_value = self._old_timeslip()
230
+
231
+ with patch("freeagent_cli.cli.cfg.load", return_value=mock_config), \
232
+ patch("freeagent_cli.cli.FreeAgent", return_value=mock_api):
233
+ result = runner.invoke(main, ["edit", "42", "--duration", "3h"], input="n\n")
234
+ assert result.exit_code == 1
235
+ mock_api.create_timeslip.assert_not_called()
236
+
237
+ def test_edit_timeslip_not_found(self, runner, mock_config, mock_api):
238
+ _mock_api_and_config(mock_api, mock_config)
239
+ mock_api.get_timeslip.side_effect = Exception("not found")
240
+
241
+ with patch("freeagent_cli.cli.cfg.load", return_value=mock_config), \
242
+ patch("freeagent_cli.cli.FreeAgent", return_value=mock_api):
243
+ result = runner.invoke(main, ["edit", "999", "--duration", "2h"])
244
+ assert result.exit_code == 2
245
+ assert "not found" in result.output
246
+
247
+ def test_edit_comment_blank(self, runner, mock_config, mock_api):
248
+ _mock_api_and_config(mock_api, mock_config)
249
+ mock_api.get_timeslip.return_value = self._old_timeslip()
250
+ mock_api.create_timeslip.return_value = {
251
+ "timeslip": {"url": "/v2/timeslips/43"}
252
+ }
253
+ mock_api.delete_timeslip.return_value = {"deleted": True, "id": "42", "already_deleted": False}
254
+
255
+ with patch("freeagent_cli.cli.cfg.load", return_value=mock_config), \
256
+ patch("freeagent_cli.cli.FreeAgent", return_value=mock_api):
257
+ result = runner.invoke(main, ["edit", "42", "--comment", "", "--yes"])
258
+ assert result.exit_code == 0
259
+ assert "'test work' → ''" in result.output
260
+
261
+
262
+ class TestRecent:
263
+ def test_recent_includes_url(self, runner, mock_config, mock_api):
264
+ _mock_api_and_config(mock_api, mock_config)
265
+ mock_api.list_timeslips.return_value = [
266
+ {
267
+ "url": "/v2/timeslips/42",
268
+ "hours": "2.5",
269
+ "dated_on": "2026-05-15",
270
+ "created_at": "2026-05-15T10:00:00Z",
271
+ "comment": "test work",
272
+ "project": {"name": "Acme"},
273
+ "task": {"name": "Coding"},
274
+ }
275
+ ]
276
+
277
+ with patch("freeagent_cli.cli.cfg.load", return_value=mock_config), \
278
+ patch("freeagent_cli.cli.FreeAgent", return_value=mock_api):
279
+ result = runner.invoke(main, ["recent"])
280
+ assert result.exit_code == 0
281
+ assert "/v2/timeslips/42" in result.output
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ import pytest
4
+
5
+ from freeagent_cli.cli import _extract_id, format_hours, parse_hours
6
+
7
+
8
+ class TestParseHours:
9
+ def test_decimal(self):
10
+ assert parse_hours("1.5") == 1.5
11
+ assert parse_hours("0.25") == 0.25
12
+
13
+ def test_minutes_only(self):
14
+ assert parse_hours("90m") == 1.5
15
+ assert parse_hours("30m") == 0.5
16
+ assert parse_hours("0m") == 0.0
17
+
18
+ def test_hours_only(self):
19
+ assert parse_hours("1h") == 1.0
20
+ assert parse_hours("2h") == 2.0
21
+
22
+ def test_hours_and_minutes(self):
23
+ assert parse_hours("1h30m") == 1.5
24
+ assert parse_hours("2h15m") == 2.25
25
+ assert parse_hours("0h45m") == 0.75
26
+
27
+ def test_colon_format(self):
28
+ assert parse_hours("1:30") == 1.5
29
+ assert parse_hours("0:45") == 0.75
30
+ assert parse_hours("2:00") == 2.0
31
+
32
+ def test_case_insensitive(self):
33
+ assert parse_hours("1H30M") == 1.5
34
+ assert parse_hours("90M") == 1.5
35
+
36
+ def test_whitespace(self):
37
+ assert parse_hours(" 1.5 ") == 1.5
38
+
39
+ def test_invalid(self):
40
+ with pytest.raises(ValueError, match="Cannot parse duration"):
41
+ parse_hours("abc")
42
+ with pytest.raises(ValueError, match="Cannot parse duration"):
43
+ parse_hours("")
44
+
45
+ def test_fractional_hours_with_minutes(self):
46
+ assert parse_hours("1.5h30m") == 2.0
47
+
48
+
49
+ class TestFormatHours:
50
+ def test_minutes_only(self):
51
+ assert format_hours(0.5) == "30m"
52
+ assert format_hours(0.25) == "15m"
53
+ assert format_hours(0) == "0m"
54
+
55
+ def test_hours_only(self):
56
+ assert format_hours(1.0) == "1h"
57
+ assert format_hours(2.0) == "2h"
58
+
59
+ def test_hours_and_minutes(self):
60
+ assert format_hours(1.5) == "1h30m"
61
+ assert format_hours(2.25) == "2h15m"
62
+ assert format_hours(0.75) == "45m"
63
+
64
+ def test_rounding(self):
65
+ assert format_hours(0.505) == "30m"
66
+ assert format_hours(0.495) == "30m"
67
+
68
+ def test_bad_input(self):
69
+ assert format_hours(None) == "None"
70
+
71
+
72
+ class TestExtractId:
73
+ def test_numeric_id(self):
74
+ assert _extract_id("123456") == "123456"
75
+
76
+ def test_full_url(self):
77
+ assert _extract_id("https://api.freeagent.com/v2/timeslips/123456") == "123456"
78
+
79
+ def test_sandbox_url(self):
80
+ assert _extract_id("https://api.sandbox.freeagent.com/v2/timeslips/789") == "789"
81
+
82
+ def test_url_with_query(self):
83
+ assert _extract_id("https://api.freeagent.com/v2/timeslips/42?nested=true") == "42?nested=true"
@@ -47,7 +47,7 @@ wheels = [
47
47
 
48
48
  [[package]]
49
49
  name = "freeagent-cli"
50
- version = "0.2.0"
50
+ version = "0.3.0"
51
51
  source = { editable = "." }
52
52
  dependencies = [
53
53
  { name = "click" },
@@ -55,6 +55,11 @@ dependencies = [
55
55
  { name = "platformdirs" },
56
56
  ]
57
57
 
58
+ [package.dev-dependencies]
59
+ dev = [
60
+ { name = "pytest" },
61
+ ]
62
+
58
63
  [package.metadata]
59
64
  requires-dist = [
60
65
  { name = "click", specifier = ">=8.1" },
@@ -62,6 +67,9 @@ requires-dist = [
62
67
  { name = "platformdirs", specifier = ">=4.0" },
63
68
  ]
64
69
 
70
+ [package.metadata.requires-dev]
71
+ dev = [{ name = "pytest", specifier = ">=9.0.3" }]
72
+
65
73
  [[package]]
66
74
  name = "h11"
67
75
  version = "0.16.0"
@@ -108,6 +116,24 @@ wheels = [
108
116
  { url = "https://files.pythonhosted.org/packages/6c/3c/3f62dee257eb3d6b2c1ef2a09d36d9793c7111156a73b5654d2c2305e5ce/idna-3.14-py3-none-any.whl", hash = "sha256:e677eaf072e290f7b725f9acf0b3a2bd55f9fd6f7c70abe5f0e34823d0accf69", size = 72184, upload-time = "2026-05-10T20:32:14.295Z" },
109
117
  ]
110
118
 
119
+ [[package]]
120
+ name = "iniconfig"
121
+ version = "2.3.0"
122
+ source = { registry = "https://pypi.org/simple" }
123
+ sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
124
+ wheels = [
125
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
126
+ ]
127
+
128
+ [[package]]
129
+ name = "packaging"
130
+ version = "26.2"
131
+ source = { registry = "https://pypi.org/simple" }
132
+ sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
133
+ wheels = [
134
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
135
+ ]
136
+
111
137
  [[package]]
112
138
  name = "platformdirs"
113
139
  version = "4.9.6"
@@ -117,6 +143,40 @@ wheels = [
117
143
  { url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
118
144
  ]
119
145
 
146
+ [[package]]
147
+ name = "pluggy"
148
+ version = "1.6.0"
149
+ source = { registry = "https://pypi.org/simple" }
150
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
151
+ wheels = [
152
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
153
+ ]
154
+
155
+ [[package]]
156
+ name = "pygments"
157
+ version = "2.20.0"
158
+ source = { registry = "https://pypi.org/simple" }
159
+ sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
160
+ wheels = [
161
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
162
+ ]
163
+
164
+ [[package]]
165
+ name = "pytest"
166
+ version = "9.0.3"
167
+ source = { registry = "https://pypi.org/simple" }
168
+ dependencies = [
169
+ { name = "colorama", marker = "sys_platform == 'win32'" },
170
+ { name = "iniconfig" },
171
+ { name = "packaging" },
172
+ { name = "pluggy" },
173
+ { name = "pygments" },
174
+ ]
175
+ sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
176
+ wheels = [
177
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
178
+ ]
179
+
120
180
  [[package]]
121
181
  name = "typing-extensions"
122
182
  version = "4.15.0"
@@ -1 +0,0 @@
1
- __version__ = "0.2.0"
File without changes
File without changes