Skip to content

Commit 4ad313f

Browse files
authored
Merge pull request #3 from osodevops/feat/timer-api-alignment
feat: align timer API contract
2 parents 7581641 + 724b4a1 commit 4ad313f

9 files changed

Lines changed: 403 additions & 31 deletions

File tree

README.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ The SDK follows a resource-based architecture. Each API resource is accessed as
8787

8888
| Resource | Methods | Description |
8989
|----------|---------|-------------|
90-
| `client.timeEntries` | `list` `create` `update` `delete` | Log and manage billable time |
90+
| `client.timeEntries` | `list` `create` `startTimer` `stopTimer` `restartTimer` `update` `delete` | Log and manage billable time |
9191
| `client.expenses` | `list` `create` | Track expenses and compute costs |
9292
| `client.projects` | `list` | Browse projects |
9393
| `client.clients` | `list` `get` `create` `update` | Manage client records |
@@ -124,6 +124,20 @@ const entry = await client.timeEntries.create({
124124
billable: true,
125125
});
126126

127+
// Start a running timer. Omit started_time to start at the server's current time.
128+
const timer = await client.timeEntries.startTimer({
129+
project_id: 'proj_123',
130+
task_id: 'task_456',
131+
spent_date: new Date().toISOString().split('T')[0],
132+
source: 'agent',
133+
replace_running: true,
134+
});
135+
136+
// Stop a running timer. The API calculates elapsed duration server-side.
137+
await client.timeEntries.stopTimer(timer.id, {
138+
notes: 'Finished implementation',
139+
});
140+
127141
// Update a time entry
128142
await client.timeEntries.update('te_abc', {
129143
hours: 2.5,

openapi/openapi-v2.yaml

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ paths:
7979
schema: { type: string, format: date-time }
8080
- name: source
8181
in: query
82-
schema: { type: string, enum: [web, cli, api, agent] }
82+
schema: { type: string, enum: [web, cli, api, agent, calendar, desktop] }
8383
description: Filter by entry source. Comma-separated for multiple (e.g. `agent,cli`).
8484
responses:
8585
"200":
@@ -118,6 +118,12 @@ paths:
118118
"401": { $ref: "#/components/responses/Unauthorized" }
119119
"403": { $ref: "#/components/responses/Forbidden" }
120120
"404": { $ref: "#/components/responses/NotFound" }
121+
"409":
122+
description: Conflict — another timer is already running
123+
content:
124+
application/json:
125+
schema:
126+
$ref: "#/components/schemas/Error"
121127

122128
/api/v2/time_entries/{id}:
123129
parameters:
@@ -169,6 +175,72 @@ paths:
169175
schema:
170176
$ref: "#/components/schemas/Error"
171177

178+
/api/v2/time_entries/{id}/stop:
179+
parameters:
180+
- name: id
181+
in: path
182+
required: true
183+
schema: { type: string }
184+
description: Time entry CUID
185+
186+
patch:
187+
tags: [Time Entries]
188+
summary: Stop a running time entry
189+
operationId: stopTimeEntry
190+
description: Stops a running timer and calculates elapsed duration server-side.
191+
requestBody:
192+
required: false
193+
content:
194+
application/json:
195+
schema:
196+
$ref: "#/components/schemas/TimeEntryStop"
197+
responses:
198+
"200":
199+
description: Stopped time entry
200+
content:
201+
application/json:
202+
schema:
203+
$ref: "#/components/schemas/TimeEntry"
204+
"400": { $ref: "#/components/responses/BadRequest" }
205+
"403": { $ref: "#/components/responses/Forbidden" }
206+
"404": { $ref: "#/components/responses/NotFound" }
207+
208+
/api/v2/time_entries/{id}/restart:
209+
parameters:
210+
- name: id
211+
in: path
212+
required: true
213+
schema: { type: string }
214+
description: Time entry CUID
215+
216+
patch:
217+
tags: [Time Entries]
218+
summary: Restart a stopped time entry
219+
operationId: restartTimeEntry
220+
description: Restarts a stopped entry as the active timer. Pass `replace_running` to intentionally switch timers.
221+
requestBody:
222+
required: false
223+
content:
224+
application/json:
225+
schema:
226+
$ref: "#/components/schemas/TimeEntryRestart"
227+
responses:
228+
"200":
229+
description: Restarted time entry
230+
content:
231+
application/json:
232+
schema:
233+
$ref: "#/components/schemas/TimeEntry"
234+
"400": { $ref: "#/components/responses/BadRequest" }
235+
"403": { $ref: "#/components/responses/Forbidden" }
236+
"404": { $ref: "#/components/responses/NotFound" }
237+
"409":
238+
description: Conflict — another timer is already running
239+
content:
240+
application/json:
241+
schema:
242+
$ref: "#/components/schemas/Error"
243+
172244
# ─── EXPENSES ──────────────────────────────────────────────────
173245
/api/v2/expenses:
174246
get:
@@ -720,6 +792,7 @@ components:
720792
# ── Common ─────────────────────────────────────────────────
721793
Error:
722794
type: object
795+
additionalProperties: true
723796
properties:
724797
error:
725798
type: string
@@ -784,7 +857,7 @@ components:
784857
cost_rate: { type: number, nullable: true }
785858
source:
786859
type: string
787-
enum: [web, cli, api, agent]
860+
enum: [web, cli, api, agent, calendar, desktop]
788861
metadata: { $ref: "#/components/schemas/Metadata" }
789862
created_at: { type: string, format: date-time }
790863
updated_at: { type: string, format: date-time }
@@ -801,13 +874,29 @@ components:
801874
notes: { type: string }
802875
billable: { type: boolean }
803876
is_running: { type: boolean }
877+
replace_running:
878+
type: boolean
879+
description: When true with is_running, stop any current running timer before creating this one.
804880
started_time: { type: string, description: "HH:mm" }
805881
ended_time: { type: string, description: "HH:mm" }
806882
source:
807883
type: string
808-
enum: [web, cli, api, agent]
884+
enum: [web, cli, api, agent, calendar, desktop]
809885
metadata: { $ref: "#/components/schemas/Metadata" }
810886

887+
TimeEntryStop:
888+
type: object
889+
properties:
890+
notes:
891+
type: string
892+
nullable: true
893+
894+
TimeEntryRestart:
895+
type: object
896+
properties:
897+
replace_running:
898+
type: boolean
899+
811900
TimeEntryUpdate:
812901
type: object
813902
properties:

src/core/errors.ts

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,46 +10,69 @@ export class KeitoApiError extends KeitoError {
1010
readonly error: string;
1111
readonly error_description: string;
1212
readonly headers: Headers;
13+
readonly body: Record<string, unknown> | null;
1314

1415
constructor(
1516
status: number,
1617
error: string,
1718
error_description: string,
1819
headers: Headers,
20+
body: Record<string, unknown> | null = null,
1921
) {
2022
super(`${status} ${error}: ${error_description}`);
2123
this.name = 'KeitoApiError';
2224
this.status = status;
2325
this.error = error;
2426
this.error_description = error_description;
2527
this.headers = headers;
28+
this.body = body;
2629
}
2730
}
2831

2932
export class KeitoAuthError extends KeitoApiError {
30-
constructor(error: string, error_description: string, headers: Headers) {
31-
super(401, error, error_description, headers);
33+
constructor(
34+
error: string,
35+
error_description: string,
36+
headers: Headers,
37+
body: Record<string, unknown> | null = null,
38+
) {
39+
super(401, error, error_description, headers, body);
3240
this.name = 'KeitoAuthError';
3341
}
3442
}
3543

3644
export class KeitoForbiddenError extends KeitoApiError {
37-
constructor(error: string, error_description: string, headers: Headers) {
38-
super(403, error, error_description, headers);
45+
constructor(
46+
error: string,
47+
error_description: string,
48+
headers: Headers,
49+
body: Record<string, unknown> | null = null,
50+
) {
51+
super(403, error, error_description, headers, body);
3952
this.name = 'KeitoForbiddenError';
4053
}
4154
}
4255

4356
export class KeitoNotFoundError extends KeitoApiError {
44-
constructor(error: string, error_description: string, headers: Headers) {
45-
super(404, error, error_description, headers);
57+
constructor(
58+
error: string,
59+
error_description: string,
60+
headers: Headers,
61+
body: Record<string, unknown> | null = null,
62+
) {
63+
super(404, error, error_description, headers, body);
4664
this.name = 'KeitoNotFoundError';
4765
}
4866
}
4967

5068
export class KeitoConflictError extends KeitoApiError {
51-
constructor(error: string, error_description: string, headers: Headers) {
52-
super(409, error, error_description, headers);
69+
constructor(
70+
error: string,
71+
error_description: string,
72+
headers: Headers,
73+
body: Record<string, unknown> | null = null,
74+
) {
75+
super(409, error, error_description, headers, body);
5376
this.name = 'KeitoConflictError';
5477
}
5578
}
@@ -61,8 +84,9 @@ export class KeitoRateLimitError extends KeitoApiError {
6184
error: string,
6285
error_description: string,
6386
headers: Headers,
87+
body: Record<string, unknown> | null = null,
6488
) {
65-
super(429, error, error_description, headers);
89+
super(429, error, error_description, headers, body);
6690
this.name = 'KeitoRateLimitError';
6791
const ra = headers.get('retry-after');
6892
this.retryAfter = ra ? parseInt(ra, 10) : null;

src/core/http.ts

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -127,11 +127,19 @@ export class HttpClient {
127127
private async parseError(response: Response): Promise<KeitoApiError> {
128128
let error = 'unknown_error';
129129
let error_description = '';
130+
let body: Record<string, unknown> | null = null;
130131

131132
try {
132-
const body = await response.json();
133-
error = body.error ?? error;
134-
error_description = body.error_description ?? '';
133+
const parsed = await response.json();
134+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
135+
body = parsed as Record<string, unknown>;
136+
}
137+
if (typeof body?.error === 'string') {
138+
error = body.error;
139+
}
140+
if (typeof body?.error_description === 'string') {
141+
error_description = body.error_description;
142+
}
135143
} catch {
136144
// response body not JSON
137145
}
@@ -141,17 +149,17 @@ export class HttpClient {
141149

142150
switch (status) {
143151
case 401:
144-
return new KeitoAuthError(error, error_description, headers);
152+
return new KeitoAuthError(error, error_description, headers, body);
145153
case 403:
146-
return new KeitoForbiddenError(error, error_description, headers);
154+
return new KeitoForbiddenError(error, error_description, headers, body);
147155
case 404:
148-
return new KeitoNotFoundError(error, error_description, headers);
156+
return new KeitoNotFoundError(error, error_description, headers, body);
149157
case 409:
150-
return new KeitoConflictError(error, error_description, headers);
158+
return new KeitoConflictError(error, error_description, headers, body);
151159
case 429:
152-
return new KeitoRateLimitError(error, error_description, headers);
160+
return new KeitoRateLimitError(error, error_description, headers, body);
153161
default:
154-
return new KeitoApiError(status, error, error_description, headers);
162+
return new KeitoApiError(status, error, error_description, headers, body);
155163
}
156164
}
157165

0 commit comments

Comments
 (0)