Skip to content

Commit 32a51cd

Browse files
authored
refactor: Implement WriteResult pattern for WriterService (#4)
* feat: Add webhook sender for time tracking entries (#3) * feat: Add webhook sender for time tracking entries - Introduce WriterService interface for pluggable entry writers - Add WebhookSender that POSTs entries to configurable endpoint - Support Bearer token authentication via TT_WEBHOOK_BEARER_TOKEN - Extend CsvEntryData with id and userEmail for consistent tracking - Refactor EventHook to iterate over multiple writers (CSV first, then webhook) Configuration: - TT_WEBHOOK_URL: Webhook endpoint (required to enable) - TT_WEBHOOK_BEARER_TOKEN: Optional authentication token * docs: add webhook documentation and bump version to 1.3.0 - Add CHANGELOG entry for v1.3.0 with webhook feature details - Document webhook configuration in README (TT_WEBHOOK_URL, TT_WEBHOOK_BEARER_TOKEN) - Add webhook payload example and WriterService extension guide - Update environment variables table with webhook variables - Bump version from 1.2.0 to 1.3.0 * fix(docs): correct webhook payload example to match actual implementation - Use snake_case field names (start_date, issue_key, etc.) - Add all 23 fields including token breakdown - Fix model format to include provider prefix * refactor: Implement WriteResult pattern for WriterService BREAKING CHANGE: WriterService.write() now returns Promise<WriteResult> instead of Promise<void> Changes: - Add WriteResult interface { writer, success, error? } - CsvWriter: Returns WriteResult with try/catch error handling - WebhookSender: Remove toast handler, return WriteResult - Plugin: Remove setToastHandler() call (no longer needed) - EventHook: Collect WriteResults, show combined status toast - track-time tool: Refactor to use WriterService architecture - Uses CsvWriter and WebhookSender directly - Manual entries have tokenUsage: { input: 0, ... } and cost: 0 - Response includes writers: WriteResult[] array Benefits: - Consistent error handling across all writers - Single combined toast shows all writer statuses - Tool responses include detailed writer results - Better separation of concerns (no toast logic in writers) * docs: Update CHANGELOG and README for WriteResult pattern - CHANGELOG: Document WriteResult interface and breaking change - README: Update WriterService interface documentation with WriteResult
1 parent e57802c commit 32a51cd

File tree

10 files changed

+572
-197
lines changed

10 files changed

+572
-197
lines changed

CHANGELOG.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [1.3.0] - 2026-03-05
9+
10+
### Added
11+
12+
- **Webhook support** for time tracking entries via `WebhookSender` service
13+
- `WriterService` interface for pluggable output writers (extensible architecture)
14+
- `WriteResult` interface: `{ writer: string, success: boolean, error?: string }`
15+
- New environment variables for webhook configuration:
16+
- `TT_WEBHOOK_URL` - Webhook endpoint URL (optional, webhook disabled if not set)
17+
- `TT_WEBHOOK_BEARER_TOKEN` - Bearer token for webhook authentication (optional)
18+
- Tool response now includes `writers: WriteResult[]` array for detailed status
19+
20+
### Changed
21+
22+
- **BREAKING:** `WriterService.write()` now returns `Promise<WriteResult>` instead of `Promise<void>`
23+
- `EventHook` now accepts an array of `WriterService` implementations
24+
- `EventHook` collects `WriteResult[]` and shows combined status toast
25+
- UUID is generated once in `EventHook` and passed to all writers (consistent ID across CSV and webhook)
26+
- `CsvWriter` refactored to implement `WriterService` interface, returns `WriteResult`
27+
- `WebhookSender` returns `WriteResult` (toast handler removed, consolidated in `EventHook`)
28+
- `CsvEntryData` extended with `id` and `userEmail` fields
29+
- `track-time` tool refactored to use `WriterService` architecture directly
30+
31+
### Technical
32+
33+
- Both CSV and webhook are triggered on each `session.idle` event
34+
- CSV is written first (as backup), then webhook is called
35+
- Consistent error handling across all writers
36+
- Single combined toast shows all writer statuses (e.g., "csv: ✓, webhook: ✗")
37+
- Webhook payload matches CSV entry structure (JSON format)
38+
839
## [1.2.0] - 2026-03-01
940

1041
### Added

README.md

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,83 @@ Without whitelist (default):
295295
id,start_date,end_date,user,ticket_name,issue_key,account_key,start_time,end_time,duration_seconds,tokens_used,tokens_remaining,story_points,description,notes
296296
```
297297

298+
## Webhook Integration
299+
300+
The plugin can send time tracking entries to a webhook endpoint in addition to writing them to CSV. This enables real-time integration with external systems.
301+
302+
### Configuration
303+
304+
Set the following environment variables in `.opencode/.env`:
305+
306+
| Variable | Required | Description |
307+
|----------|----------|-------------|
308+
| `TT_WEBHOOK_URL` | No | Webhook endpoint URL. If not set, webhook is disabled. |
309+
| `TT_WEBHOOK_BEARER_TOKEN` | No | Bearer token for webhook authentication. If set, adds `Authorization: Bearer <token>` header. |
310+
311+
Example `.opencode/.env`:
312+
313+
```env
314+
TT_WEBHOOK_URL=https://your-api.example.com/time-tracking
315+
TT_WEBHOOK_BEARER_TOKEN=your-secret-token
316+
```
317+
318+
### Behavior
319+
320+
- **Dual output:** Both CSV and webhook are triggered on each session idle event
321+
- **Order:** CSV is written first (as backup), then webhook is called
322+
- **Failure handling:** Webhook failures show a toast notification but don't block CSV writing
323+
- **Consistent ID:** The same UUID is used for both CSV entry and webhook payload
324+
325+
### Webhook Payload
326+
327+
The webhook receives a POST request with `Content-Type: application/json`. The payload matches the CSV entry structure (snake_case field names):
328+
329+
```json
330+
{
331+
"id": "550e8400-e29b-41d4-a716-446655440000",
332+
"start_date": "2026-03-05",
333+
"end_date": "2026-03-05",
334+
"user": "your@email.com",
335+
"ticket_name": "",
336+
"issue_key": "PROJ-123",
337+
"account_key": "TD_DEVELOPMENT",
338+
"start_time": "09:00:00",
339+
"end_time": "09:15:00",
340+
"duration_seconds": 900,
341+
"tokens_used": 2800,
342+
"tokens_remaining": "",
343+
"story_points": "",
344+
"description": "Implemented webhook sender service",
345+
"notes": "",
346+
"model": "anthropic/claude-sonnet-4-20250514",
347+
"agent": "@developer",
348+
"tokens_input": 1500,
349+
"tokens_output": 800,
350+
"tokens_reasoning": 0,
351+
"tokens_cache_read": 500,
352+
"tokens_cache_write": 0,
353+
"cost": 0.027
354+
}
355+
```
356+
357+
### Extending with Custom Writers
358+
359+
The plugin uses a `WriterService` interface for output. You can implement custom writers by following the interface:
360+
361+
```typescript
362+
interface WriteResult {
363+
writer: string; // e.g., "csv", "webhook"
364+
success: boolean;
365+
error?: string; // Only present if success === false
366+
}
367+
368+
interface WriterService {
369+
write(data: CsvEntryData): Promise<WriteResult>;
370+
}
371+
```
372+
373+
Each writer returns a `WriteResult` indicating success or failure. The `EventHook` collects all results and shows a combined toast notification (e.g., "Time tracked: 5 min, 1000 tokens for PROJ-123 (webhook: failed)").
374+
298375
## Sync Features
299376

300377
The plugin provides several sync commands to export time tracking data to external systems.
@@ -325,6 +402,8 @@ All sync-related secrets should be configured in `.opencode/.env` (loaded by `op
325402
| Variable | Required | Used by | Description |
326403
|----------|----------|---------|-------------|
327404
| `OPENCODE_USER_EMAIL` | Yes | All | User email for CSV entries and file naming |
405+
| `TT_WEBHOOK_URL` | No | Webhook | Webhook endpoint URL (disabled if not set) |
406+
| `TT_WEBHOOK_BEARER_TOKEN` | No | Webhook | Bearer token for webhook authentication |
328407
| `TT_SOURCE_CALENDAR_ID` | No | Booking-Proposal | Source calendar for meeting integration |
329408
| `TT_BOOKING_CALENDAR_ID` | For Calendar Sync | Calendar Sync | Target calendar for booking events |
330409
| `TT_DRIVE_FOLDER_ID` | For Drive Sync | Drive Sync | Google Drive folder ID for CSV upload |
@@ -336,6 +415,10 @@ Example `.opencode/.env`:
336415
```env
337416
OPENCODE_USER_EMAIL=t.wagner@techdivision.com
338417
418+
# Webhook Integration (optional)
419+
TT_WEBHOOK_URL=https://your-api.example.com/time-tracking
420+
TT_WEBHOOK_BEARER_TOKEN=your-secret-token
421+
339422
# Google Calendar Sync
340423
TT_SOURCE_CALENDAR_ID=t.wagner@techdivision.com
341424
TT_BOOKING_CALENDAR_ID=c_abc123@group.calendar.google.com

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@techdivision/opencode-plugin-time-tracking",
3-
"version": "1.2.0",
3+
"version": "1.3.0",
44
"description": "Automatic time tracking plugin for OpenCode. Tracks session duration and tool usage, writing entries to a CSV file compatible with Jira worklog sync incl. commands, skills and agents.",
55
"type": "module",
66
"main": "src/Plugin.ts",

src/Plugin.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,12 @@ import { CsvWriter } from "./services/CsvWriter"
1414
import { SessionManager } from "./services/SessionManager"
1515
import { TicketExtractor } from "./services/TicketExtractor"
1616
import { TicketResolver } from "./services/TicketResolver"
17+
import { WebhookSender } from "./services/WebhookSender"
1718
import { createEventHook } from "./hooks/EventHook"
1819
import { createToolExecuteAfterHook } from "./hooks/ToolExecuteAfterHook"
1920

21+
import type { WriterService } from "./types/WriterService"
22+
2023
/**
2124
* OpenCode Time Tracking Plugin
2225
*
@@ -61,9 +64,13 @@ export const plugin: Plugin = async ({
6164

6265
const sessionManager = new SessionManager()
6366
const csvWriter = new CsvWriter(config, directory)
67+
const webhookSender = new WebhookSender()
6468
const ticketExtractor = new TicketExtractor(client, config.valid_projects)
6569
const ticketResolver = new TicketResolver(config, ticketExtractor)
6670

71+
// Writers are called in order: CSV first (backup), then webhook
72+
const writers: WriterService[] = [csvWriter, webhookSender]
73+
6774
// Ensure CSV file has a valid header at startup
6875
await csvWriter.ensureHeader()
6976

@@ -72,7 +79,7 @@ export const plugin: Plugin = async ({
7279
sessionManager,
7380
ticketExtractor
7481
),
75-
event: createEventHook(sessionManager, csvWriter, client, ticketResolver, config),
82+
event: createEventHook(sessionManager, writers, client, ticketResolver, config),
7683
}
7784

7885
return hooks

src/hooks/EventHook.ts

Lines changed: 53 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@
22
* @fileoverview Event hook for session lifecycle and token tracking.
33
*/
44

5+
import { randomUUID } from "crypto"
6+
57
import type { AssistantMessage, Event, Message } from "@opencode-ai/sdk"
68

7-
import type { CsvWriter } from "../services/CsvWriter"
89
import type { SessionManager } from "../services/SessionManager"
910
import type { TicketResolver } from "../services/TicketResolver"
11+
import type { CsvEntryData } from "../types/CsvEntryData"
1012
import type { MessagePartUpdatedProperties } from "../types/MessagePartUpdatedProperties"
1113
import type { MessageWithParts } from "../types/MessageWithParts"
1214
import type { OpencodeClient } from "../types/OpencodeClient"
1315
import type { TimeTrackingConfig } from "../types/TimeTrackingConfig"
16+
import type { WriteResult, WriterService } from "../types/WriterService"
1417

1518
import { AgentMatcher } from "../utils/AgentMatcher"
1619
import { DescriptionGenerator } from "../utils/DescriptionGenerator"
@@ -65,27 +68,33 @@ async function extractSummaryTitle(
6568
* Creates the event hook for session lifecycle management.
6669
*
6770
* @param sessionManager - The session manager instance
68-
* @param csvWriter - The CSV writer instance
71+
* @param writers - Array of writer services to persist entries (e.g., CsvWriter, WebhookSender)
6972
* @param client - The OpenCode SDK client
73+
* @param ticketResolver - The ticket resolver instance
74+
* @param config - The time tracking configuration
7075
* @returns The event hook function
7176
*
7277
* @remarks
7378
* Handles three types of events:
7479
*
7580
* 1. **message.updated** - Tracks model from assistant messages
7681
* 2. **message.part.updated** - Tracks token usage from step-finish parts
77-
* 3. **session.idle** - Finalizes and exports the session
82+
* 3. **session.idle** - Finalizes and exports the session via all writers
83+
*
84+
* Writers are called in order. Each writer handles its own errors internally,
85+
* so a failure in one writer does not affect others.
7886
*
7987
* @example
8088
* ```typescript
89+
* const writers: WriterService[] = [csvWriter, webhookSender]
8190
* const hooks: Hooks = {
82-
* event: createEventHook(sessionManager, csvWriter, client),
91+
* event: createEventHook(sessionManager, writers, client, ticketResolver, config),
8392
* }
8493
* ```
8594
*/
8695
export function createEventHook(
8796
sessionManager: SessionManager,
88-
csvWriter: CsvWriter,
97+
writers: WriterService[],
8998
client: OpencodeClient,
9099
ticketResolver: TicketResolver,
91100
config: TimeTrackingConfig
@@ -219,37 +228,48 @@ export function createEventHook(
219228
// Resolve ticket and account key with fallback hierarchy
220229
const resolved = await ticketResolver.resolve(sessionID, agentString)
221230

222-
try {
223-
await csvWriter.write({
224-
ticket: resolved.ticket,
225-
accountKey: resolved.accountKey,
226-
startTime: session.startTime,
227-
endTime,
228-
durationSeconds,
229-
description,
230-
notes: `Auto-tracked: ${toolSummary}`,
231-
tokenUsage: session.tokenUsage,
232-
cost: session.cost,
233-
model: modelString,
234-
agent: resolved.primaryAgent ?? agentString,
235-
})
231+
// Build entry data once, shared across all writers
232+
const entryData: CsvEntryData = {
233+
id: randomUUID(),
234+
userEmail: config.user_email,
235+
ticket: resolved.ticket,
236+
accountKey: resolved.accountKey,
237+
startTime: session.startTime,
238+
endTime,
239+
durationSeconds,
240+
description,
241+
notes: `Auto-tracked: ${toolSummary}`,
242+
tokenUsage: session.tokenUsage,
243+
cost: session.cost,
244+
model: modelString,
245+
agent: resolved.primaryAgent ?? agentString,
246+
}
236247

237-
const minutes = Math.round(durationSeconds / 60)
248+
// Call all writers in order (CSV first, then webhook, etc.)
249+
// Collect results for combined status reporting
250+
const results: WriteResult[] = []
251+
for (const writer of writers) {
252+
const result = await writer.write(entryData)
253+
results.push(result)
254+
}
238255

239-
await client.tui.showToast({
240-
body: {
241-
message: `Time tracked: ${minutes} min, ${totalTokens} tokens${resolved.ticket ? ` for ${resolved.ticket}` : ""}`,
242-
variant: "success",
243-
},
244-
})
245-
} catch {
246-
await client.tui.showToast({
247-
body: {
248-
message: "Time Tracking: Failed to save entry",
249-
variant: "error",
250-
},
251-
})
256+
// Build combined toast message with writer status
257+
const minutes = Math.round(durationSeconds / 60)
258+
const failedWriters = results.filter((r) => !r.success)
259+
260+
let message = `Time tracked: ${minutes} min, ${totalTokens} tokens${resolved.ticket ? ` for ${resolved.ticket}` : ""}`
261+
262+
if (failedWriters.length > 0) {
263+
const failedNames = failedWriters.map((r) => r.writer).join(", ")
264+
message += ` (${failedNames}: failed)`
252265
}
266+
267+
await client.tui.showToast({
268+
body: {
269+
message,
270+
variant: failedWriters.length > 0 ? "warning" : "success",
271+
},
272+
})
253273
}
254274
}
255275
}

0 commit comments

Comments
 (0)