freeagent-cli 0.2.1__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.1
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.1"
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()
@@ -283,7 +287,151 @@ def recent(limit, days, all_users):
283
287
  pname = proj["name"] if isinstance(proj, dict) else "?"
284
288
  tname = task_["name"] if isinstance(task_, dict) else "?"
285
289
  comment = (s.get("comment") or "").replace("\n", " ")
286
- click.echo(f"{s.get('dated_on','?')}\t{format_hours(s.get('hours'))}\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"])
287
435
 
288
436
 
289
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.1"
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.1"
File without changes
File without changes