loopwork 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/README.md +528 -0
- package/bin/loopwork +0 -0
- package/examples/README.md +70 -0
- package/examples/basic-json-backend/.specs/tasks/TASK-001.md +22 -0
- package/examples/basic-json-backend/.specs/tasks/TASK-002.md +23 -0
- package/examples/basic-json-backend/.specs/tasks/TASK-003.md +37 -0
- package/examples/basic-json-backend/.specs/tasks/tasks.json +19 -0
- package/examples/basic-json-backend/README.md +32 -0
- package/examples/basic-json-backend/TESTING.md +184 -0
- package/examples/basic-json-backend/hello.test.ts +9 -0
- package/examples/basic-json-backend/hello.ts +3 -0
- package/examples/basic-json-backend/loopwork.config.js +35 -0
- package/examples/basic-json-backend/math.test.ts +29 -0
- package/examples/basic-json-backend/math.ts +3 -0
- package/examples/basic-json-backend/package.json +15 -0
- package/examples/basic-json-backend/quick-start.sh +80 -0
- package/loopwork.config.ts +164 -0
- package/package.json +26 -0
- package/src/backends/github.ts +426 -0
- package/src/backends/index.ts +86 -0
- package/src/backends/json.ts +598 -0
- package/src/backends/plugin.ts +317 -0
- package/src/backends/types.ts +19 -0
- package/src/commands/init.ts +100 -0
- package/src/commands/run.ts +365 -0
- package/src/contracts/backend.ts +127 -0
- package/src/contracts/config.ts +129 -0
- package/src/contracts/index.ts +43 -0
- package/src/contracts/plugin.ts +82 -0
- package/src/contracts/task.ts +78 -0
- package/src/core/cli.ts +275 -0
- package/src/core/config.ts +165 -0
- package/src/core/state.ts +154 -0
- package/src/core/utils.ts +125 -0
- package/src/dashboard/cli.ts +449 -0
- package/src/dashboard/index.ts +6 -0
- package/src/dashboard/kanban.tsx +226 -0
- package/src/dashboard/tui.tsx +372 -0
- package/src/index.ts +19 -0
- package/src/mcp/server.ts +451 -0
- package/src/monitor/index.ts +420 -0
- package/src/plugins/asana.ts +192 -0
- package/src/plugins/cost-tracking.ts +402 -0
- package/src/plugins/discord.ts +269 -0
- package/src/plugins/everhour.ts +335 -0
- package/src/plugins/index.ts +253 -0
- package/src/plugins/telegram/bot.ts +517 -0
- package/src/plugins/telegram/index.ts +6 -0
- package/src/plugins/telegram/notifications.ts +198 -0
- package/src/plugins/todoist.ts +261 -0
- package/test/backends.test.ts +929 -0
- package/test/cli.test.ts +145 -0
- package/test/config.test.ts +90 -0
- package/test/e2e.test.ts +458 -0
- package/test/github-tasks.test.ts +191 -0
- package/test/loopwork-config-types.test.ts +288 -0
- package/test/monitor.test.ts +123 -0
- package/test/plugins.test.ts +1175 -0
- package/test/state.test.ts +295 -0
- package/test/utils.test.ts +60 -0
- package/tsconfig.json +20 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Todoist Plugin for Loopwork
|
|
3
|
+
*
|
|
4
|
+
* Syncs task status with Todoist projects.
|
|
5
|
+
* Tasks should have metadata.todoistId set to the Todoist task ID.
|
|
6
|
+
*
|
|
7
|
+
* Setup:
|
|
8
|
+
* 1. Get API token from Todoist Settings > Integrations > Developer
|
|
9
|
+
* 2. Set TODOIST_API_TOKEN env var
|
|
10
|
+
* 3. Add todoistId to task metadata in your tasks file
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { LoopworkPlugin, PluginTask } from '../contracts'
|
|
14
|
+
|
|
15
|
+
export interface TodoistConfig {
|
|
16
|
+
apiToken?: string
|
|
17
|
+
projectId?: string
|
|
18
|
+
/** Sync status changes to Todoist (complete tasks) */
|
|
19
|
+
syncStatus?: boolean
|
|
20
|
+
/** Add comments to tasks on events */
|
|
21
|
+
addComments?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface TodoistTask {
|
|
25
|
+
id: string
|
|
26
|
+
content: string
|
|
27
|
+
description: string
|
|
28
|
+
is_completed: boolean
|
|
29
|
+
project_id: string
|
|
30
|
+
priority: number
|
|
31
|
+
labels: string[]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface TodoistComment {
|
|
35
|
+
id: string
|
|
36
|
+
task_id: string
|
|
37
|
+
content: string
|
|
38
|
+
posted_at: string
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class TodoistClient {
|
|
42
|
+
private baseUrl = 'https://api.todoist.com/rest/v2'
|
|
43
|
+
private apiToken: string
|
|
44
|
+
|
|
45
|
+
constructor(apiToken: string) {
|
|
46
|
+
this.apiToken = apiToken
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private async request<T>(
|
|
50
|
+
method: string,
|
|
51
|
+
endpoint: string,
|
|
52
|
+
body?: Record<string, unknown>
|
|
53
|
+
): Promise<T> {
|
|
54
|
+
const url = `${this.baseUrl}${endpoint}`
|
|
55
|
+
const response = await fetch(url, {
|
|
56
|
+
method,
|
|
57
|
+
headers: {
|
|
58
|
+
'Authorization': `Bearer ${this.apiToken}`,
|
|
59
|
+
'Content-Type': 'application/json',
|
|
60
|
+
},
|
|
61
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
const error = await response.text()
|
|
66
|
+
throw new Error(`Todoist API error: ${response.status} - ${error}`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Some endpoints return empty response (204)
|
|
70
|
+
if (response.status === 204) {
|
|
71
|
+
return {} as T
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return response.json() as Promise<T>
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Get a task by ID
|
|
79
|
+
*/
|
|
80
|
+
async getTask(taskId: string): Promise<TodoistTask> {
|
|
81
|
+
return this.request('GET', `/tasks/${taskId}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Create a new task
|
|
86
|
+
*/
|
|
87
|
+
async createTask(content: string, options?: {
|
|
88
|
+
description?: string
|
|
89
|
+
projectId?: string
|
|
90
|
+
priority?: 1 | 2 | 3 | 4
|
|
91
|
+
labels?: string[]
|
|
92
|
+
}): Promise<TodoistTask> {
|
|
93
|
+
return this.request('POST', '/tasks', {
|
|
94
|
+
content,
|
|
95
|
+
description: options?.description,
|
|
96
|
+
project_id: options?.projectId,
|
|
97
|
+
priority: options?.priority,
|
|
98
|
+
labels: options?.labels,
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Update a task
|
|
104
|
+
*/
|
|
105
|
+
async updateTask(taskId: string, updates: {
|
|
106
|
+
content?: string
|
|
107
|
+
description?: string
|
|
108
|
+
priority?: 1 | 2 | 3 | 4
|
|
109
|
+
labels?: string[]
|
|
110
|
+
}): Promise<TodoistTask> {
|
|
111
|
+
return this.request('POST', `/tasks/${taskId}`, updates)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Complete (close) a task
|
|
116
|
+
*/
|
|
117
|
+
async completeTask(taskId: string): Promise<void> {
|
|
118
|
+
await this.request('POST', `/tasks/${taskId}/close`)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Reopen a completed task
|
|
123
|
+
*/
|
|
124
|
+
async reopenTask(taskId: string): Promise<void> {
|
|
125
|
+
await this.request('POST', `/tasks/${taskId}/reopen`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Delete a task
|
|
130
|
+
*/
|
|
131
|
+
async deleteTask(taskId: string): Promise<void> {
|
|
132
|
+
await this.request('DELETE', `/tasks/${taskId}`)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get all tasks in a project
|
|
137
|
+
*/
|
|
138
|
+
async getProjectTasks(projectId: string): Promise<TodoistTask[]> {
|
|
139
|
+
return this.request('GET', `/tasks?project_id=${projectId}`)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Add a comment to a task
|
|
144
|
+
*/
|
|
145
|
+
async addComment(taskId: string, content: string): Promise<TodoistComment> {
|
|
146
|
+
return this.request('POST', '/comments', {
|
|
147
|
+
task_id: taskId,
|
|
148
|
+
content,
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Get comments for a task
|
|
154
|
+
*/
|
|
155
|
+
async getComments(taskId: string): Promise<TodoistComment[]> {
|
|
156
|
+
return this.request('GET', `/comments?task_id=${taskId}`)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Get all projects
|
|
161
|
+
*/
|
|
162
|
+
async getProjects(): Promise<Array<{ id: string; name: string }>> {
|
|
163
|
+
return this.request('GET', '/projects')
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Create Todoist plugin wrapper
|
|
169
|
+
*/
|
|
170
|
+
export function withTodoist(config: TodoistConfig = {}) {
|
|
171
|
+
const apiToken = config.apiToken || process.env.TODOIST_API_TOKEN
|
|
172
|
+
|
|
173
|
+
return (baseConfig: any) => ({
|
|
174
|
+
...baseConfig,
|
|
175
|
+
todoist: {
|
|
176
|
+
apiToken,
|
|
177
|
+
projectId: config.projectId || process.env.TODOIST_PROJECT_ID,
|
|
178
|
+
syncStatus: config.syncStatus ?? true,
|
|
179
|
+
addComments: config.addComments ?? true,
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Helper to get Todoist ID from task metadata */
|
|
185
|
+
function getTodoistId(task: PluginTask): string | undefined {
|
|
186
|
+
return task.metadata?.todoistId as string | undefined
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Create Todoist hook plugin
|
|
191
|
+
*
|
|
192
|
+
* Tasks should have metadata.todoistId set for Todoist integration.
|
|
193
|
+
*/
|
|
194
|
+
export function createTodoistPlugin(config: TodoistConfig = {}): LoopworkPlugin {
|
|
195
|
+
const apiToken = config.apiToken || process.env.TODOIST_API_TOKEN || ''
|
|
196
|
+
const addComments = config.addComments ?? true
|
|
197
|
+
|
|
198
|
+
if (!apiToken) {
|
|
199
|
+
return {
|
|
200
|
+
name: 'todoist',
|
|
201
|
+
onConfigLoad: (cfg) => {
|
|
202
|
+
console.warn('Todoist plugin: Missing TODOIST_API_TOKEN')
|
|
203
|
+
return cfg
|
|
204
|
+
},
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const client = new TodoistClient(apiToken)
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
name: 'todoist',
|
|
212
|
+
|
|
213
|
+
async onTaskStart(task) {
|
|
214
|
+
const todoistId = getTodoistId(task)
|
|
215
|
+
if (!todoistId || !addComments) return
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
await client.addComment(todoistId, `🔄 Loopwork started working on this task`)
|
|
219
|
+
} catch (e: any) {
|
|
220
|
+
console.warn(`Todoist: Failed to add comment: ${e.message}`)
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
async onTaskComplete(task, result) {
|
|
225
|
+
const todoistId = getTodoistId(task)
|
|
226
|
+
if (!todoistId) return
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
if (config.syncStatus !== false) {
|
|
230
|
+
await client.completeTask(todoistId)
|
|
231
|
+
}
|
|
232
|
+
if (addComments) {
|
|
233
|
+
await client.addComment(
|
|
234
|
+
todoistId,
|
|
235
|
+
`✅ Completed by Loopwork in ${Math.round(result.duration)}s`
|
|
236
|
+
)
|
|
237
|
+
}
|
|
238
|
+
} catch (e: any) {
|
|
239
|
+
console.warn(`Todoist: Failed to update task: ${e.message}`)
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
async onTaskFailed(task, error) {
|
|
244
|
+
const todoistId = getTodoistId(task)
|
|
245
|
+
if (!todoistId || !addComments) return
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
await client.addComment(
|
|
249
|
+
todoistId,
|
|
250
|
+
`❌ Loopwork failed: ${error.slice(0, 200)}`
|
|
251
|
+
)
|
|
252
|
+
} catch (e: any) {
|
|
253
|
+
console.warn(`Todoist: Failed to add comment: ${e.message}`)
|
|
254
|
+
}
|
|
255
|
+
},
|
|
256
|
+
|
|
257
|
+
async onLoopEnd(stats) {
|
|
258
|
+
console.log(`📋 Todoist sync: ${stats.completed} tasks synced`)
|
|
259
|
+
},
|
|
260
|
+
}
|
|
261
|
+
}
|