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,420 @@
|
|
|
1
|
+
import fs from 'fs'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { spawn, ChildProcess } from 'child_process'
|
|
4
|
+
import chalk from 'chalk'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Monitor for running Loopwork instances in the background
|
|
8
|
+
*
|
|
9
|
+
* Features:
|
|
10
|
+
* - Start/stop loops by namespace
|
|
11
|
+
* - Track running processes
|
|
12
|
+
* - View logs and status
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
interface LoopProcess {
|
|
16
|
+
namespace: string
|
|
17
|
+
pid: number
|
|
18
|
+
startedAt: string
|
|
19
|
+
logFile: string
|
|
20
|
+
args: string[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface MonitorState {
|
|
24
|
+
processes: LoopProcess[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const MONITOR_STATE_FILE = '.loopwork-monitor-state.json'
|
|
28
|
+
|
|
29
|
+
export class LoopworkMonitor {
|
|
30
|
+
private stateFile: string
|
|
31
|
+
private projectRoot: string
|
|
32
|
+
|
|
33
|
+
constructor(projectRoot?: string) {
|
|
34
|
+
this.projectRoot = projectRoot || process.cwd()
|
|
35
|
+
this.stateFile = path.join(this.projectRoot, MONITOR_STATE_FILE)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Start a loop in the background
|
|
40
|
+
*/
|
|
41
|
+
async start(namespace: string, args: string[] = []): Promise<{ success: boolean; pid?: number; error?: string }> {
|
|
42
|
+
// Check if already running
|
|
43
|
+
const running = this.getRunningProcesses()
|
|
44
|
+
const existing = running.find(p => p.namespace === namespace)
|
|
45
|
+
if (existing) {
|
|
46
|
+
return { success: false, error: `Namespace '${namespace}' is already running (PID: ${existing.pid})` }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Create log directory
|
|
50
|
+
const logsDir = path.join(this.projectRoot, 'loopwork-runs', namespace, 'monitor-logs')
|
|
51
|
+
fs.mkdirSync(logsDir, { recursive: true })
|
|
52
|
+
|
|
53
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
|
|
54
|
+
const logFile = path.join(logsDir, `${timestamp}.log`)
|
|
55
|
+
|
|
56
|
+
// Build command args
|
|
57
|
+
const fullArgs = ['--namespace', namespace, '-y', ...args]
|
|
58
|
+
|
|
59
|
+
// Spawn background process
|
|
60
|
+
const logStream = fs.openSync(logFile, 'a')
|
|
61
|
+
|
|
62
|
+
const child: ChildProcess = spawn('bun', ['run', 'src/index.ts', ...fullArgs], {
|
|
63
|
+
cwd: path.join(this.projectRoot, 'packages/loopwork'),
|
|
64
|
+
detached: true,
|
|
65
|
+
stdio: ['ignore', logStream, logStream],
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
child.unref()
|
|
69
|
+
|
|
70
|
+
if (!child.pid) {
|
|
71
|
+
return { success: false, error: 'Failed to spawn process' }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Save to state
|
|
75
|
+
const state = this.loadState()
|
|
76
|
+
state.processes.push({
|
|
77
|
+
namespace,
|
|
78
|
+
pid: child.pid,
|
|
79
|
+
startedAt: new Date().toISOString(),
|
|
80
|
+
logFile,
|
|
81
|
+
args: fullArgs,
|
|
82
|
+
})
|
|
83
|
+
this.saveState(state)
|
|
84
|
+
|
|
85
|
+
return { success: true, pid: child.pid }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Stop a running loop by namespace
|
|
90
|
+
*/
|
|
91
|
+
stop(namespace: string): { success: boolean; error?: string } {
|
|
92
|
+
const state = this.loadState()
|
|
93
|
+
const proc = state.processes.find(p => p.namespace === namespace)
|
|
94
|
+
|
|
95
|
+
if (!proc) {
|
|
96
|
+
return { success: false, error: `No running loop found for namespace '${namespace}'` }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
process.kill(proc.pid, 'SIGTERM')
|
|
101
|
+
|
|
102
|
+
// Remove from state
|
|
103
|
+
state.processes = state.processes.filter(p => p.namespace !== namespace)
|
|
104
|
+
this.saveState(state)
|
|
105
|
+
|
|
106
|
+
return { success: true }
|
|
107
|
+
} catch (e: any) {
|
|
108
|
+
if (e.code === 'ESRCH') {
|
|
109
|
+
// Process already dead, clean up state
|
|
110
|
+
state.processes = state.processes.filter(p => p.namespace !== namespace)
|
|
111
|
+
this.saveState(state)
|
|
112
|
+
return { success: true }
|
|
113
|
+
}
|
|
114
|
+
return { success: false, error: e.message }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Stop all running loops
|
|
120
|
+
*/
|
|
121
|
+
stopAll(): { stopped: string[]; errors: string[] } {
|
|
122
|
+
const stopped: string[] = []
|
|
123
|
+
const errors: string[] = []
|
|
124
|
+
|
|
125
|
+
const running = this.getRunningProcesses()
|
|
126
|
+
for (const proc of running) {
|
|
127
|
+
const result = this.stop(proc.namespace)
|
|
128
|
+
if (result.success) {
|
|
129
|
+
stopped.push(proc.namespace)
|
|
130
|
+
} else {
|
|
131
|
+
errors.push(`${proc.namespace}: ${result.error}`)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { stopped, errors }
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Get list of running processes (with validation)
|
|
140
|
+
*/
|
|
141
|
+
getRunningProcesses(): LoopProcess[] {
|
|
142
|
+
const state = this.loadState()
|
|
143
|
+
const running: LoopProcess[] = []
|
|
144
|
+
const toRemove: string[] = []
|
|
145
|
+
|
|
146
|
+
for (const proc of state.processes) {
|
|
147
|
+
if (this.isProcessAlive(proc.pid)) {
|
|
148
|
+
running.push(proc)
|
|
149
|
+
} else {
|
|
150
|
+
toRemove.push(proc.namespace)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Clean up dead processes
|
|
155
|
+
if (toRemove.length > 0) {
|
|
156
|
+
state.processes = state.processes.filter(p => !toRemove.includes(p.namespace))
|
|
157
|
+
this.saveState(state)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return running
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Get status of all namespaces
|
|
165
|
+
*/
|
|
166
|
+
getStatus(): {
|
|
167
|
+
running: LoopProcess[]
|
|
168
|
+
namespaces: { name: string; status: 'running' | 'stopped'; lastRun?: string }[]
|
|
169
|
+
} {
|
|
170
|
+
const running = this.getRunningProcesses()
|
|
171
|
+
|
|
172
|
+
// Find all namespaces from directories
|
|
173
|
+
const runsDir = path.join(this.projectRoot, 'loopwork-runs')
|
|
174
|
+
const namespaces: { name: string; status: 'running' | 'stopped'; lastRun?: string }[] = []
|
|
175
|
+
|
|
176
|
+
if (fs.existsSync(runsDir)) {
|
|
177
|
+
const dirs = fs.readdirSync(runsDir, { withFileTypes: true })
|
|
178
|
+
.filter(d => d.isDirectory())
|
|
179
|
+
.map(d => d.name)
|
|
180
|
+
|
|
181
|
+
for (const name of dirs) {
|
|
182
|
+
const isRunning = running.some(p => p.namespace === name)
|
|
183
|
+
const nsDir = path.join(runsDir, name)
|
|
184
|
+
|
|
185
|
+
// Find last run timestamp
|
|
186
|
+
let lastRun: string | undefined
|
|
187
|
+
const runDirs = fs.readdirSync(nsDir, { withFileTypes: true })
|
|
188
|
+
.filter(d => d.isDirectory() && d.name !== 'monitor-logs')
|
|
189
|
+
.map(d => d.name)
|
|
190
|
+
.sort()
|
|
191
|
+
.reverse()
|
|
192
|
+
|
|
193
|
+
if (runDirs.length > 0) {
|
|
194
|
+
lastRun = runDirs[0]
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
namespaces.push({
|
|
198
|
+
name,
|
|
199
|
+
status: isRunning ? 'running' : 'stopped',
|
|
200
|
+
lastRun,
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return { running, namespaces }
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Get recent logs for a namespace
|
|
210
|
+
*/
|
|
211
|
+
getLogs(namespace: string, lines = 50): string[] {
|
|
212
|
+
const running = this.getRunningProcesses()
|
|
213
|
+
const proc = running.find(p => p.namespace === namespace)
|
|
214
|
+
|
|
215
|
+
let logFile: string | undefined
|
|
216
|
+
|
|
217
|
+
if (proc) {
|
|
218
|
+
logFile = proc.logFile
|
|
219
|
+
} else {
|
|
220
|
+
// Find most recent log file
|
|
221
|
+
const logsDir = path.join(this.projectRoot, 'loopwork-runs', namespace, 'monitor-logs')
|
|
222
|
+
if (fs.existsSync(logsDir)) {
|
|
223
|
+
const files = fs.readdirSync(logsDir)
|
|
224
|
+
.filter(f => f.endsWith('.log'))
|
|
225
|
+
.sort()
|
|
226
|
+
.reverse()
|
|
227
|
+
if (files.length > 0) {
|
|
228
|
+
logFile = path.join(logsDir, files[0])
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!logFile || !fs.existsSync(logFile)) {
|
|
234
|
+
return [`No logs found for namespace '${namespace}'`]
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const content = fs.readFileSync(logFile, 'utf-8')
|
|
238
|
+
const allLines = content.split('\n')
|
|
239
|
+
return allLines.slice(-lines)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Check if a process is still alive
|
|
244
|
+
*/
|
|
245
|
+
private isProcessAlive(pid: number): boolean {
|
|
246
|
+
try {
|
|
247
|
+
process.kill(pid, 0)
|
|
248
|
+
return true
|
|
249
|
+
} catch {
|
|
250
|
+
return false
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private loadState(): MonitorState {
|
|
255
|
+
try {
|
|
256
|
+
if (fs.existsSync(this.stateFile)) {
|
|
257
|
+
const content = fs.readFileSync(this.stateFile, 'utf-8')
|
|
258
|
+
return JSON.parse(content)
|
|
259
|
+
}
|
|
260
|
+
} catch {}
|
|
261
|
+
return { processes: [] }
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private saveState(state: MonitorState): void {
|
|
265
|
+
fs.writeFileSync(this.stateFile, JSON.stringify(state, null, 2))
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* CLI for the monitor
|
|
271
|
+
*/
|
|
272
|
+
async function main() {
|
|
273
|
+
const args = process.argv.slice(2)
|
|
274
|
+
const command = args[0]
|
|
275
|
+
const monitor = new LoopworkMonitor()
|
|
276
|
+
|
|
277
|
+
switch (command) {
|
|
278
|
+
case 'start': {
|
|
279
|
+
const namespace = args[1] || 'default'
|
|
280
|
+
const extraArgs = args.slice(2)
|
|
281
|
+
console.log(chalk.blue(`Starting loop in namespace '${namespace}'...`))
|
|
282
|
+
const result = await monitor.start(namespace, extraArgs)
|
|
283
|
+
if (result.success) {
|
|
284
|
+
console.log(chalk.green(`✓ Started (PID: ${result.pid})`))
|
|
285
|
+
console.log(chalk.gray(`View logs: bun run src/monitor.ts logs ${namespace}`))
|
|
286
|
+
} else {
|
|
287
|
+
console.log(chalk.red(`✗ ${result.error}`))
|
|
288
|
+
process.exit(1)
|
|
289
|
+
}
|
|
290
|
+
break
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
case 'stop': {
|
|
294
|
+
const namespace = args[1]
|
|
295
|
+
if (!namespace) {
|
|
296
|
+
console.log(chalk.yellow('Usage: monitor stop <namespace> | monitor stop --all'))
|
|
297
|
+
process.exit(1)
|
|
298
|
+
}
|
|
299
|
+
if (namespace === '--all') {
|
|
300
|
+
const result = monitor.stopAll()
|
|
301
|
+
if (result.stopped.length > 0) {
|
|
302
|
+
console.log(chalk.green(`✓ Stopped: ${result.stopped.join(', ')}`))
|
|
303
|
+
}
|
|
304
|
+
if (result.errors.length > 0) {
|
|
305
|
+
console.log(chalk.red(`✗ Errors:\n ${result.errors.join('\n ')}`))
|
|
306
|
+
}
|
|
307
|
+
if (result.stopped.length === 0 && result.errors.length === 0) {
|
|
308
|
+
console.log(chalk.gray('No running loops'))
|
|
309
|
+
}
|
|
310
|
+
} else {
|
|
311
|
+
const result = monitor.stop(namespace)
|
|
312
|
+
if (result.success) {
|
|
313
|
+
console.log(chalk.green(`✓ Stopped namespace '${namespace}'`))
|
|
314
|
+
} else {
|
|
315
|
+
console.log(chalk.red(`✗ ${result.error}`))
|
|
316
|
+
process.exit(1)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
break
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
case 'status': {
|
|
323
|
+
const { running, namespaces } = monitor.getStatus()
|
|
324
|
+
|
|
325
|
+
console.log(chalk.bold('\nLoopwork Monitor Status'))
|
|
326
|
+
console.log(chalk.gray('─'.repeat(50)))
|
|
327
|
+
|
|
328
|
+
if (running.length === 0) {
|
|
329
|
+
console.log(chalk.gray('No loops currently running\n'))
|
|
330
|
+
} else {
|
|
331
|
+
console.log(chalk.bold(`\nRunning (${running.length}):`))
|
|
332
|
+
for (const proc of running) {
|
|
333
|
+
const uptime = getUptime(proc.startedAt)
|
|
334
|
+
console.log(` ${chalk.green('●')} ${chalk.bold(proc.namespace)}`)
|
|
335
|
+
console.log(` PID: ${proc.pid} | Uptime: ${uptime}`)
|
|
336
|
+
console.log(` Log: ${proc.logFile}`)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (namespaces.length > 0) {
|
|
341
|
+
console.log(chalk.bold('\nAll namespaces:'))
|
|
342
|
+
for (const ns of namespaces) {
|
|
343
|
+
const icon = ns.status === 'running' ? chalk.green('●') : chalk.gray('○')
|
|
344
|
+
const lastRunStr = ns.lastRun ? chalk.gray(`(last: ${ns.lastRun})`) : ''
|
|
345
|
+
console.log(` ${icon} ${ns.name} ${lastRunStr}`)
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
console.log()
|
|
350
|
+
break
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
case 'logs': {
|
|
354
|
+
const namespace = args[1] || 'default'
|
|
355
|
+
const lines = parseInt(args[2], 10) || 50
|
|
356
|
+
const logLines = monitor.getLogs(namespace, lines)
|
|
357
|
+
console.log(logLines.join('\n'))
|
|
358
|
+
break
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
case 'tail': {
|
|
362
|
+
const namespace = args[1] || 'default'
|
|
363
|
+
const running = monitor.getRunningProcesses()
|
|
364
|
+
const proc = running.find(p => p.namespace === namespace)
|
|
365
|
+
|
|
366
|
+
if (!proc) {
|
|
367
|
+
console.log(chalk.red(`Namespace '${namespace}' is not running`))
|
|
368
|
+
process.exit(1)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
console.log(chalk.gray(`Tailing ${proc.logFile} (Ctrl+C to stop)\n`))
|
|
372
|
+
const tail = spawn('tail', ['-f', proc.logFile], { stdio: 'inherit' })
|
|
373
|
+
tail.on('close', () => process.exit(0))
|
|
374
|
+
break
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
default:
|
|
378
|
+
console.log(chalk.bold('\nLoopwork Monitor'))
|
|
379
|
+
console.log(chalk.gray('─'.repeat(30)))
|
|
380
|
+
console.log(`
|
|
381
|
+
Commands:
|
|
382
|
+
${chalk.cyan('start <namespace> [args...]')} Start a loop in background
|
|
383
|
+
${chalk.cyan('stop <namespace>')} Stop a running loop
|
|
384
|
+
${chalk.cyan('stop --all')} Stop all running loops
|
|
385
|
+
${chalk.cyan('status')} Show status of all loops
|
|
386
|
+
${chalk.cyan('logs <namespace> [lines]')} Show recent logs
|
|
387
|
+
${chalk.cyan('tail <namespace>')} Follow logs in real-time
|
|
388
|
+
|
|
389
|
+
Examples:
|
|
390
|
+
bun run src/monitor.ts start default --feature auth
|
|
391
|
+
bun run src/monitor.ts start feature-a --backend github
|
|
392
|
+
bun run src/monitor.ts status
|
|
393
|
+
bun run src/monitor.ts logs feature-a 100
|
|
394
|
+
bun run src/monitor.ts stop --all
|
|
395
|
+
`)
|
|
396
|
+
break
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function getUptime(startedAt: string): string {
|
|
401
|
+
const start = new Date(startedAt).getTime()
|
|
402
|
+
const now = Date.now()
|
|
403
|
+
const diff = now - start
|
|
404
|
+
|
|
405
|
+
const hours = Math.floor(diff / (1000 * 60 * 60))
|
|
406
|
+
const mins = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
|
|
407
|
+
|
|
408
|
+
if (hours > 0) {
|
|
409
|
+
return `${hours}h ${mins}m`
|
|
410
|
+
}
|
|
411
|
+
return `${mins}m`
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Only run main when executed directly, not when imported
|
|
415
|
+
if (import.meta.main) {
|
|
416
|
+
main().catch((err) => {
|
|
417
|
+
console.error(chalk.red(`Error: ${err.message}`))
|
|
418
|
+
process.exit(1)
|
|
419
|
+
})
|
|
420
|
+
}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asana Plugin for Loopwork
|
|
3
|
+
*
|
|
4
|
+
* Syncs task status with Asana projects.
|
|
5
|
+
* Tasks should have metadata.asanaGid set to the Asana task GID.
|
|
6
|
+
*
|
|
7
|
+
* Setup:
|
|
8
|
+
* 1. Get Personal Access Token from Asana Developer Console
|
|
9
|
+
* 2. Set ASANA_ACCESS_TOKEN env var
|
|
10
|
+
* 3. Set ASANA_PROJECT_ID env var (from project URL)
|
|
11
|
+
* 4. Add asanaGid to task metadata in your tasks file
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { LoopworkPlugin, PluginTask } from '../contracts'
|
|
15
|
+
|
|
16
|
+
export interface AsanaConfig {
|
|
17
|
+
accessToken?: string
|
|
18
|
+
projectId?: string
|
|
19
|
+
workspaceId?: string
|
|
20
|
+
/** Create Asana tasks for new Loopwork tasks */
|
|
21
|
+
autoCreate?: boolean
|
|
22
|
+
/** Sync status changes to Asana */
|
|
23
|
+
syncStatus?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface AsanaTask {
|
|
27
|
+
gid: string
|
|
28
|
+
name: string
|
|
29
|
+
completed: boolean
|
|
30
|
+
notes?: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface AsanaResponse<T> {
|
|
34
|
+
data: T
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class AsanaClient {
|
|
38
|
+
private baseUrl = 'https://app.asana.com/api/1.0'
|
|
39
|
+
private accessToken: string
|
|
40
|
+
|
|
41
|
+
constructor(accessToken: string) {
|
|
42
|
+
this.accessToken = accessToken
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private async request<T>(
|
|
46
|
+
method: string,
|
|
47
|
+
endpoint: string,
|
|
48
|
+
body?: Record<string, unknown>
|
|
49
|
+
): Promise<T> {
|
|
50
|
+
const url = `${this.baseUrl}${endpoint}`
|
|
51
|
+
const response = await fetch(url, {
|
|
52
|
+
method,
|
|
53
|
+
headers: {
|
|
54
|
+
'Authorization': `Bearer ${this.accessToken}`,
|
|
55
|
+
'Content-Type': 'application/json',
|
|
56
|
+
},
|
|
57
|
+
body: body ? JSON.stringify({ data: body }) : undefined,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
if (!response.ok) {
|
|
61
|
+
const error = await response.text()
|
|
62
|
+
throw new Error(`Asana API error: ${response.status} - ${error}`)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const result = await response.json() as AsanaResponse<T>
|
|
66
|
+
return result.data
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async getTask(taskGid: string): Promise<AsanaTask> {
|
|
70
|
+
return this.request('GET', `/tasks/${taskGid}`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async createTask(projectId: string, name: string, notes?: string): Promise<AsanaTask> {
|
|
74
|
+
return this.request('POST', '/tasks', {
|
|
75
|
+
name,
|
|
76
|
+
notes,
|
|
77
|
+
projects: [projectId],
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async updateTask(taskGid: string, updates: Partial<{ name: string; notes: string; completed: boolean }>): Promise<AsanaTask> {
|
|
82
|
+
return this.request('PUT', `/tasks/${taskGid}`, updates)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async completeTask(taskGid: string): Promise<AsanaTask> {
|
|
86
|
+
return this.updateTask(taskGid, { completed: true })
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async addComment(taskGid: string, text: string): Promise<void> {
|
|
90
|
+
await this.request('POST', `/tasks/${taskGid}/stories`, { text })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async getProjectTasks(projectId: string): Promise<AsanaTask[]> {
|
|
94
|
+
return this.request('GET', `/projects/${projectId}/tasks?opt_fields=gid,name,completed,notes`)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Create Asana plugin wrapper
|
|
100
|
+
*/
|
|
101
|
+
export function withAsana(config: AsanaConfig = {}) {
|
|
102
|
+
const accessToken = config.accessToken || process.env.ASANA_ACCESS_TOKEN
|
|
103
|
+
const projectId = config.projectId || process.env.ASANA_PROJECT_ID
|
|
104
|
+
|
|
105
|
+
return (baseConfig: any) => ({
|
|
106
|
+
...baseConfig,
|
|
107
|
+
asana: {
|
|
108
|
+
accessToken,
|
|
109
|
+
projectId,
|
|
110
|
+
autoCreate: config.autoCreate ?? false,
|
|
111
|
+
syncStatus: config.syncStatus ?? true,
|
|
112
|
+
},
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Helper to get Asana GID from task metadata */
|
|
117
|
+
function getAsanaGid(task: PluginTask): string | undefined {
|
|
118
|
+
return task.metadata?.asanaGid as string | undefined
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Create Asana hook plugin
|
|
123
|
+
*
|
|
124
|
+
* Tasks should have metadata.asanaGid set for Asana integration.
|
|
125
|
+
*/
|
|
126
|
+
export function createAsanaPlugin(config: AsanaConfig = {}): LoopworkPlugin {
|
|
127
|
+
const accessToken = config.accessToken || process.env.ASANA_ACCESS_TOKEN || ''
|
|
128
|
+
const projectId = config.projectId || process.env.ASANA_PROJECT_ID || ''
|
|
129
|
+
|
|
130
|
+
if (!accessToken || !projectId) {
|
|
131
|
+
return {
|
|
132
|
+
name: 'asana',
|
|
133
|
+
onConfigLoad: (cfg) => {
|
|
134
|
+
console.warn('Asana plugin: Missing ASANA_ACCESS_TOKEN or ASANA_PROJECT_ID')
|
|
135
|
+
return cfg
|
|
136
|
+
},
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const client = new AsanaClient(accessToken)
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
name: 'asana',
|
|
144
|
+
|
|
145
|
+
async onTaskStart(task) {
|
|
146
|
+
const asanaGid = getAsanaGid(task)
|
|
147
|
+
if (!asanaGid) return
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
await client.addComment(asanaGid, `🔄 Loopwork started working on this task`)
|
|
151
|
+
} catch (e: any) {
|
|
152
|
+
console.warn(`Asana: Failed to add comment: ${e.message}`)
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
|
|
156
|
+
async onTaskComplete(task, result) {
|
|
157
|
+
const asanaGid = getAsanaGid(task)
|
|
158
|
+
if (!asanaGid) return
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
if (config.syncStatus !== false) {
|
|
162
|
+
await client.completeTask(asanaGid)
|
|
163
|
+
}
|
|
164
|
+
await client.addComment(
|
|
165
|
+
asanaGid,
|
|
166
|
+
`✅ Completed by Loopwork in ${Math.round(result.duration)}s`
|
|
167
|
+
)
|
|
168
|
+
} catch (e: any) {
|
|
169
|
+
console.warn(`Asana: Failed to update task: ${e.message}`)
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
async onTaskFailed(task, error) {
|
|
174
|
+
const asanaGid = getAsanaGid(task)
|
|
175
|
+
if (!asanaGid) return
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
await client.addComment(
|
|
179
|
+
asanaGid,
|
|
180
|
+
`❌ Loopwork failed: ${error.slice(0, 200)}`
|
|
181
|
+
)
|
|
182
|
+
} catch (e: any) {
|
|
183
|
+
console.warn(`Asana: Failed to add comment: ${e.message}`)
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
|
|
187
|
+
async onLoopEnd(stats) {
|
|
188
|
+
// Could post a summary to a specific task or project
|
|
189
|
+
console.log(`📊 Asana sync: ${stats.completed} tasks synced`)
|
|
190
|
+
},
|
|
191
|
+
}
|
|
192
|
+
}
|