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,426 @@
|
|
|
1
|
+
import { $ } from 'bun'
|
|
2
|
+
import type { TaskBackend, Task, FindTaskOptions, UpdateResult, BackendConfig } from './types'
|
|
3
|
+
import { LABELS } from '../contracts/task'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Patterns for parsing dependencies and parent references from issue body
|
|
7
|
+
*/
|
|
8
|
+
const PARENT_PATTERN = /(?:^|\n)\s*(?:Parent|parent):\s*(?:#?(\d+)|([A-Z]+-\d+-\d+[a-z]?))/i
|
|
9
|
+
const DEPENDS_PATTERN = /(?:^|\n)\s*(?:Depends on|depends on|Dependencies|dependencies):\s*(.+?)(?:\n|$)/i
|
|
10
|
+
|
|
11
|
+
interface GitHubIssue {
|
|
12
|
+
number: number
|
|
13
|
+
title: string
|
|
14
|
+
body: string
|
|
15
|
+
state: 'open' | 'closed'
|
|
16
|
+
labels: { name: string }[]
|
|
17
|
+
url: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* GitHub Issues Adapter
|
|
22
|
+
*
|
|
23
|
+
* Adapts GitHub Issues API (via gh CLI) to the TaskBackend interface.
|
|
24
|
+
*/
|
|
25
|
+
export class GitHubTaskAdapter implements TaskBackend {
|
|
26
|
+
readonly name = 'github'
|
|
27
|
+
private repo?: string
|
|
28
|
+
private maxRetries = 3
|
|
29
|
+
private baseDelayMs = 1000
|
|
30
|
+
|
|
31
|
+
constructor(config: BackendConfig) {
|
|
32
|
+
this.repo = config.repo
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private repoFlag(): string {
|
|
36
|
+
return this.repo ? `--repo ${this.repo}` : ''
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private async withRetry<T>(fn: () => Promise<T>, retries = this.maxRetries): Promise<T> {
|
|
40
|
+
let lastError: Error | null = null
|
|
41
|
+
|
|
42
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
43
|
+
try {
|
|
44
|
+
return await fn()
|
|
45
|
+
} catch (e: any) {
|
|
46
|
+
lastError = e
|
|
47
|
+
const isRetryable = this.isRetryableError(e)
|
|
48
|
+
if (!isRetryable || attempt === retries) throw e
|
|
49
|
+
const delay = this.baseDelayMs * Math.pow(2, attempt)
|
|
50
|
+
await new Promise(r => setTimeout(r, delay))
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
throw lastError || new Error('Retry failed')
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private isRetryableError(error: any): boolean {
|
|
58
|
+
const message = String(error?.message || error || '').toLowerCase()
|
|
59
|
+
if (message.includes('network') || message.includes('timeout')) return true
|
|
60
|
+
if (message.includes('econnreset') || message.includes('econnrefused')) return true
|
|
61
|
+
if (message.includes('socket hang up')) return true
|
|
62
|
+
if (message.includes('rate limit') || message.includes('429')) return true
|
|
63
|
+
if (message.includes('502') || message.includes('503') || message.includes('504')) return true
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private extractIssueNumber(taskId: string): number | null {
|
|
68
|
+
if (taskId.startsWith('GH-')) return parseInt(taskId.slice(3), 10)
|
|
69
|
+
const num = parseInt(taskId, 10)
|
|
70
|
+
if (!isNaN(num)) return num
|
|
71
|
+
const hashMatch = taskId.match(/#(\d+)/)
|
|
72
|
+
if (hashMatch) return parseInt(hashMatch[1], 10)
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async findNextTask(options?: FindTaskOptions): Promise<Task | null> {
|
|
77
|
+
const tasks = await this.listPendingTasks(options)
|
|
78
|
+
return tasks[0] || null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async getTask(taskId: string): Promise<Task | null> {
|
|
82
|
+
const issueNumber = this.extractIssueNumber(taskId)
|
|
83
|
+
if (!issueNumber) return null
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
return await this.withRetry(async () => {
|
|
87
|
+
const result = await $`gh issue view ${issueNumber} ${this.repoFlag()} --json number,title,body,labels,url,state`.quiet()
|
|
88
|
+
const issue: GitHubIssue = JSON.parse(result.stdout.toString())
|
|
89
|
+
return this.adaptIssue(issue)
|
|
90
|
+
})
|
|
91
|
+
} catch {
|
|
92
|
+
return null
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async listPendingTasks(options?: FindTaskOptions): Promise<Task[]> {
|
|
97
|
+
const labels = [LABELS.LOOPWORK_TASK, LABELS.STATUS_PENDING]
|
|
98
|
+
if (options?.feature) labels.push(`feat:${options.feature}`)
|
|
99
|
+
if (options?.priority) labels.push(`priority:${options.priority}`)
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
return await this.withRetry(async () => {
|
|
103
|
+
const labelArg = labels.join(',')
|
|
104
|
+
const result = await $`gh issue list ${this.repoFlag()} --label "${labelArg}" --state open --json number,title,body,labels,url --limit 100`.quiet()
|
|
105
|
+
const issues: GitHubIssue[] = JSON.parse(result.stdout.toString())
|
|
106
|
+
return issues.map(issue => this.adaptIssue(issue))
|
|
107
|
+
})
|
|
108
|
+
} catch {
|
|
109
|
+
return []
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async countPending(options?: FindTaskOptions): Promise<number> {
|
|
114
|
+
const tasks = await this.listPendingTasks(options)
|
|
115
|
+
return tasks.length
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async markInProgress(taskId: string): Promise<UpdateResult> {
|
|
119
|
+
const issueNumber = this.extractIssueNumber(taskId)
|
|
120
|
+
if (!issueNumber) return { success: false, error: 'Invalid task ID' }
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
await this.withRetry(async () => {
|
|
124
|
+
await $`gh issue edit ${issueNumber} ${this.repoFlag()} --remove-label "${LABELS.STATUS_PENDING}"`.quiet().nothrow()
|
|
125
|
+
await $`gh issue edit ${issueNumber} ${this.repoFlag()} --remove-label "${LABELS.STATUS_FAILED}"`.quiet().nothrow()
|
|
126
|
+
await $`gh issue edit ${issueNumber} ${this.repoFlag()} --add-label "${LABELS.STATUS_IN_PROGRESS}"`.quiet()
|
|
127
|
+
})
|
|
128
|
+
return { success: true }
|
|
129
|
+
} catch (e: any) {
|
|
130
|
+
return { success: false, error: e.message }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async markCompleted(taskId: string, comment?: string): Promise<UpdateResult> {
|
|
135
|
+
const issueNumber = this.extractIssueNumber(taskId)
|
|
136
|
+
if (!issueNumber) return { success: false, error: 'Invalid task ID' }
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
await this.withRetry(async () => {
|
|
140
|
+
const msg = comment || 'Completed by Loopwork'
|
|
141
|
+
await $`gh issue close ${issueNumber} ${this.repoFlag()} --comment "${msg}"`.quiet()
|
|
142
|
+
})
|
|
143
|
+
return { success: true }
|
|
144
|
+
} catch (e: any) {
|
|
145
|
+
return { success: false, error: e.message }
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async markFailed(taskId: string, error: string): Promise<UpdateResult> {
|
|
150
|
+
const issueNumber = this.extractIssueNumber(taskId)
|
|
151
|
+
if (!issueNumber) return { success: false, error: 'Invalid task ID' }
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
await this.withRetry(async () => {
|
|
155
|
+
await $`gh issue edit ${issueNumber} ${this.repoFlag()} --remove-label "${LABELS.STATUS_IN_PROGRESS}"`.quiet().nothrow()
|
|
156
|
+
await $`gh issue edit ${issueNumber} ${this.repoFlag()} --add-label "${LABELS.STATUS_FAILED}"`.quiet()
|
|
157
|
+
const commentText = `**Loopwork Failed**\n\n\`\`\`\n${error}\n\`\`\``
|
|
158
|
+
await $`gh issue comment ${issueNumber} ${this.repoFlag()} --body "${commentText}"`.quiet()
|
|
159
|
+
})
|
|
160
|
+
return { success: true }
|
|
161
|
+
} catch (e: any) {
|
|
162
|
+
return { success: false, error: e.message }
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async resetToPending(taskId: string): Promise<UpdateResult> {
|
|
167
|
+
const issueNumber = this.extractIssueNumber(taskId)
|
|
168
|
+
if (!issueNumber) return { success: false, error: 'Invalid task ID' }
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
await this.withRetry(async () => {
|
|
172
|
+
await $`gh issue edit ${issueNumber} ${this.repoFlag()} --remove-label "${LABELS.STATUS_FAILED}"`.quiet().nothrow()
|
|
173
|
+
await $`gh issue edit ${issueNumber} ${this.repoFlag()} --remove-label "${LABELS.STATUS_IN_PROGRESS}"`.quiet().nothrow()
|
|
174
|
+
await $`gh issue edit ${issueNumber} ${this.repoFlag()} --add-label "${LABELS.STATUS_PENDING}"`.quiet()
|
|
175
|
+
})
|
|
176
|
+
return { success: true }
|
|
177
|
+
} catch (e: any) {
|
|
178
|
+
return { success: false, error: e.message }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async addComment(taskId: string, comment: string): Promise<UpdateResult> {
|
|
183
|
+
const issueNumber = this.extractIssueNumber(taskId)
|
|
184
|
+
if (!issueNumber) return { success: false, error: 'Invalid task ID' }
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
await this.withRetry(async () => {
|
|
188
|
+
await $`gh issue comment ${issueNumber} ${this.repoFlag()} --body "${comment}"`.quiet()
|
|
189
|
+
})
|
|
190
|
+
return { success: true }
|
|
191
|
+
} catch (e: any) {
|
|
192
|
+
return { success: false, error: e.message }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async ping(): Promise<{ ok: boolean; latencyMs: number; error?: string }> {
|
|
197
|
+
const start = Date.now()
|
|
198
|
+
try {
|
|
199
|
+
const result = await $`gh auth status`.quiet()
|
|
200
|
+
return { ok: result.exitCode === 0, latencyMs: Date.now() - start }
|
|
201
|
+
} catch (e: any) {
|
|
202
|
+
return { ok: false, latencyMs: Date.now() - start, error: e.message }
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async getSubTasks(taskId: string): Promise<Task[]> {
|
|
207
|
+
const allTasks = await this.listAllTasks()
|
|
208
|
+
const parentIssueNum = this.extractIssueNumber(taskId)
|
|
209
|
+
if (!parentIssueNum) return []
|
|
210
|
+
|
|
211
|
+
return allTasks.filter(task => {
|
|
212
|
+
if (!task.parentId) return false
|
|
213
|
+
const parentNum = this.extractIssueNumber(task.parentId)
|
|
214
|
+
return parentNum === parentIssueNum || task.parentId === taskId
|
|
215
|
+
})
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async getDependencies(taskId: string): Promise<Task[]> {
|
|
219
|
+
const task = await this.getTask(taskId)
|
|
220
|
+
if (!task || !task.dependsOn || task.dependsOn.length === 0) return []
|
|
221
|
+
|
|
222
|
+
const deps: Task[] = []
|
|
223
|
+
for (const depId of task.dependsOn) {
|
|
224
|
+
const depTask = await this.getTask(depId)
|
|
225
|
+
if (depTask) deps.push(depTask)
|
|
226
|
+
}
|
|
227
|
+
return deps
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async getDependents(taskId: string): Promise<Task[]> {
|
|
231
|
+
const allTasks = await this.listAllTasks()
|
|
232
|
+
return allTasks.filter(task => task.dependsOn?.includes(taskId))
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async areDependenciesMet(taskId: string): Promise<boolean> {
|
|
236
|
+
const deps = await this.getDependencies(taskId)
|
|
237
|
+
return deps.every(dep => dep.status === 'completed')
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async createSubTask(parentId: string, task: Omit<Task, 'id' | 'parentId' | 'status'>): Promise<Task> {
|
|
241
|
+
const parentNum = this.extractIssueNumber(parentId)
|
|
242
|
+
if (!parentNum) throw new Error('Invalid parent task ID')
|
|
243
|
+
|
|
244
|
+
const body = `Parent: #${parentNum}\n\n${task.description}`
|
|
245
|
+
const labels = [
|
|
246
|
+
LABELS.LOOPWORK_TASK,
|
|
247
|
+
LABELS.STATUS_PENDING,
|
|
248
|
+
LABELS.SUB_TASK,
|
|
249
|
+
`priority:${task.priority}`,
|
|
250
|
+
]
|
|
251
|
+
if (task.feature) labels.push(`feat:${task.feature}`)
|
|
252
|
+
|
|
253
|
+
const result = await this.withRetry(async () => {
|
|
254
|
+
const r = await $`gh issue create ${this.repoFlag()} --title "${task.title}" --body "${body}" --label "${labels.join(',')}" --json number,title,body,labels,url`.quiet()
|
|
255
|
+
return JSON.parse(r.stdout.toString())
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
return this.adaptIssue(result)
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
async addDependency(taskId: string, dependsOnId: string): Promise<UpdateResult> {
|
|
262
|
+
const task = await this.getTask(taskId)
|
|
263
|
+
if (!task) return { success: false, error: 'Task not found' }
|
|
264
|
+
|
|
265
|
+
const issueNumber = this.extractIssueNumber(taskId)
|
|
266
|
+
if (!issueNumber) return { success: false, error: 'Invalid task ID' }
|
|
267
|
+
|
|
268
|
+
const currentDeps = task.dependsOn || []
|
|
269
|
+
if (currentDeps.includes(dependsOnId)) return { success: true }
|
|
270
|
+
|
|
271
|
+
const newDeps = [...currentDeps, dependsOnId]
|
|
272
|
+
const depsLine = `Depends on: ${newDeps.join(', ')}`
|
|
273
|
+
|
|
274
|
+
let newBody = task.description
|
|
275
|
+
if (DEPENDS_PATTERN.test(newBody)) {
|
|
276
|
+
newBody = newBody.replace(DEPENDS_PATTERN, `\nDepends on: ${newDeps.join(', ')}\n`)
|
|
277
|
+
} else {
|
|
278
|
+
newBody = `${depsLine}\n\n${newBody}`
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
await this.withRetry(async () => {
|
|
283
|
+
await $`gh issue edit ${issueNumber} ${this.repoFlag()} --body "${newBody}"`.quiet()
|
|
284
|
+
})
|
|
285
|
+
return { success: true }
|
|
286
|
+
} catch (e: any) {
|
|
287
|
+
return { success: false, error: e.message }
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async removeDependency(taskId: string, dependsOnId: string): Promise<UpdateResult> {
|
|
292
|
+
const task = await this.getTask(taskId)
|
|
293
|
+
if (!task) return { success: false, error: 'Task not found' }
|
|
294
|
+
|
|
295
|
+
const issueNumber = this.extractIssueNumber(taskId)
|
|
296
|
+
if (!issueNumber) return { success: false, error: 'Invalid task ID' }
|
|
297
|
+
|
|
298
|
+
const currentDeps = task.dependsOn || []
|
|
299
|
+
const newDeps = currentDeps.filter(d => d !== dependsOnId)
|
|
300
|
+
|
|
301
|
+
let newBody = task.description
|
|
302
|
+
if (newDeps.length > 0) {
|
|
303
|
+
newBody = newBody.replace(DEPENDS_PATTERN, `\nDepends on: ${newDeps.join(', ')}\n`)
|
|
304
|
+
} else {
|
|
305
|
+
newBody = newBody.replace(DEPENDS_PATTERN, '\n')
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
await this.withRetry(async () => {
|
|
310
|
+
await $`gh issue edit ${issueNumber} ${this.repoFlag()} --body "${newBody}"`.quiet()
|
|
311
|
+
})
|
|
312
|
+
return { success: true }
|
|
313
|
+
} catch (e: any) {
|
|
314
|
+
return { success: false, error: e.message }
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Create a new task (GitHub issue)
|
|
320
|
+
*/
|
|
321
|
+
async createTask(task: Omit<Task, 'id' | 'status'>): Promise<Task> {
|
|
322
|
+
const labels = [
|
|
323
|
+
LABELS.LOOPWORK_TASK,
|
|
324
|
+
LABELS.STATUS_PENDING,
|
|
325
|
+
`priority:${task.priority}`,
|
|
326
|
+
]
|
|
327
|
+
if (task.feature) labels.push(`feat:${task.feature}`)
|
|
328
|
+
|
|
329
|
+
// Add dependencies to body if present
|
|
330
|
+
let body = task.description
|
|
331
|
+
if (task.dependsOn && task.dependsOn.length > 0) {
|
|
332
|
+
body = `Depends on: ${task.dependsOn.join(', ')}\n\n${body}`
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const result = await this.withRetry(async () => {
|
|
336
|
+
const r = await $`gh issue create ${this.repoFlag()} --title "${task.title}" --body "${body}" --label "${labels.join(',')}" --json number,title,body,labels,url`.quiet()
|
|
337
|
+
return JSON.parse(r.stdout.toString())
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
return this.adaptIssue(result)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Set task priority by updating labels
|
|
345
|
+
*/
|
|
346
|
+
async setPriority(taskId: string, priority: Task['priority']): Promise<UpdateResult> {
|
|
347
|
+
const issueNumber = this.extractIssueNumber(taskId)
|
|
348
|
+
if (!issueNumber) return { success: false, error: 'Invalid task ID' }
|
|
349
|
+
|
|
350
|
+
try {
|
|
351
|
+
await this.withRetry(async () => {
|
|
352
|
+
// Remove existing priority labels
|
|
353
|
+
await $`gh issue edit ${issueNumber} ${this.repoFlag()} --remove-label "${LABELS.PRIORITY_HIGH}"`.quiet().nothrow()
|
|
354
|
+
await $`gh issue edit ${issueNumber} ${this.repoFlag()} --remove-label "${LABELS.PRIORITY_MEDIUM}"`.quiet().nothrow()
|
|
355
|
+
await $`gh issue edit ${issueNumber} ${this.repoFlag()} --remove-label "${LABELS.PRIORITY_LOW}"`.quiet().nothrow()
|
|
356
|
+
|
|
357
|
+
// Add new priority label
|
|
358
|
+
const priorityLabel = `priority:${priority}`
|
|
359
|
+
await $`gh issue edit ${issueNumber} ${this.repoFlag()} --add-label "${priorityLabel}"`.quiet()
|
|
360
|
+
})
|
|
361
|
+
return { success: true }
|
|
362
|
+
} catch (e: any) {
|
|
363
|
+
return { success: false, error: e.message }
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private async listAllTasks(): Promise<Task[]> {
|
|
368
|
+
try {
|
|
369
|
+
return await this.withRetry(async () => {
|
|
370
|
+
const result = await $`gh issue list ${this.repoFlag()} --label "${LABELS.LOOPWORK_TASK}" --state open --json number,title,body,labels,url --limit 200`.quiet()
|
|
371
|
+
const issues: GitHubIssue[] = JSON.parse(result.stdout.toString())
|
|
372
|
+
return issues.map(issue => this.adaptIssue(issue))
|
|
373
|
+
})
|
|
374
|
+
} catch {
|
|
375
|
+
return []
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
private adaptIssue(issue: GitHubIssue): Task {
|
|
380
|
+
const labels = issue.labels.map(l => l.name)
|
|
381
|
+
const body = issue.body || ''
|
|
382
|
+
|
|
383
|
+
const taskIdMatch = issue.title.match(/TASK-\d+-\d+[a-z]?/i)
|
|
384
|
+
const id = taskIdMatch ? taskIdMatch[0].toUpperCase() : `GH-${issue.number}`
|
|
385
|
+
|
|
386
|
+
let status: Task['status'] = 'pending'
|
|
387
|
+
if (labels.includes(LABELS.STATUS_IN_PROGRESS)) status = 'in-progress'
|
|
388
|
+
else if (labels.includes(LABELS.STATUS_FAILED)) status = 'failed'
|
|
389
|
+
else if (issue.state === 'closed') status = 'completed'
|
|
390
|
+
|
|
391
|
+
let priority: Task['priority'] = 'medium'
|
|
392
|
+
if (labels.includes(LABELS.PRIORITY_HIGH)) priority = 'high'
|
|
393
|
+
else if (labels.includes(LABELS.PRIORITY_LOW)) priority = 'low'
|
|
394
|
+
|
|
395
|
+
const featureLabel = labels.find(l => l.startsWith('feat:'))
|
|
396
|
+
const feature = featureLabel?.replace('feat:', '')
|
|
397
|
+
|
|
398
|
+
let parentId: string | undefined
|
|
399
|
+
const parentMatch = body.match(PARENT_PATTERN)
|
|
400
|
+
if (parentMatch) {
|
|
401
|
+
parentId = parentMatch[1] ? `GH-${parentMatch[1]}` : parentMatch[2]
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
let dependsOn: string[] | undefined
|
|
405
|
+
const depsMatch = body.match(DEPENDS_PATTERN)
|
|
406
|
+
if (depsMatch) {
|
|
407
|
+
const depsStr = depsMatch[1]
|
|
408
|
+
dependsOn = depsStr.split(/[,\s]+/).filter(Boolean).map(d => {
|
|
409
|
+
const num = d.replace('#', '')
|
|
410
|
+
return /^\d+$/.test(num) ? `GH-${num}` : d
|
|
411
|
+
})
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
return {
|
|
415
|
+
id,
|
|
416
|
+
title: issue.title,
|
|
417
|
+
description: body,
|
|
418
|
+
status,
|
|
419
|
+
priority,
|
|
420
|
+
feature,
|
|
421
|
+
parentId,
|
|
422
|
+
dependsOn,
|
|
423
|
+
metadata: { issueNumber: issue.number, url: issue.url, labels },
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Task Backend Module
|
|
3
|
+
*
|
|
4
|
+
* Provides adapter pattern for multiple task sources.
|
|
5
|
+
* Use createBackend() factory to instantiate the appropriate adapter.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export * from './types'
|
|
9
|
+
export { GitHubTaskAdapter } from './github'
|
|
10
|
+
export { JsonTaskAdapter } from './json'
|
|
11
|
+
|
|
12
|
+
import type { TaskBackend, BackendConfig } from './types'
|
|
13
|
+
import { GitHubTaskAdapter } from './github'
|
|
14
|
+
import { JsonTaskAdapter } from './json'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create a task backend based on configuration
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* // GitHub Issues backend
|
|
21
|
+
* const backend = createBackend({ type: 'github', repo: 'owner/repo' })
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* // JSON file backend
|
|
25
|
+
* const backend = createBackend({
|
|
26
|
+
* type: 'json',
|
|
27
|
+
* tasksFile: '.specs/tasks/tasks.json'
|
|
28
|
+
* })
|
|
29
|
+
*/
|
|
30
|
+
export function createBackend(config: BackendConfig): TaskBackend {
|
|
31
|
+
switch (config.type) {
|
|
32
|
+
case 'github':
|
|
33
|
+
return new GitHubTaskAdapter(config)
|
|
34
|
+
|
|
35
|
+
case 'json':
|
|
36
|
+
return new JsonTaskAdapter(config)
|
|
37
|
+
|
|
38
|
+
default:
|
|
39
|
+
throw new Error(`Unknown backend type: ${(config as any).type}`)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Auto-detect backend from environment/files
|
|
45
|
+
*
|
|
46
|
+
* Detection order:
|
|
47
|
+
* 1. If LOOPWORK_BACKEND env var is set, use that
|
|
48
|
+
* 2. If .specs/tasks/tasks.json exists, use json
|
|
49
|
+
* 3. Default to github
|
|
50
|
+
*/
|
|
51
|
+
export function detectBackend(projectRoot: string): BackendConfig {
|
|
52
|
+
const envBackend = process.env.LOOPWORK_BACKEND
|
|
53
|
+
|
|
54
|
+
if (envBackend === 'json') {
|
|
55
|
+
return {
|
|
56
|
+
type: 'json',
|
|
57
|
+
tasksFile: `${projectRoot}/.specs/tasks/tasks.json`,
|
|
58
|
+
tasksDir: `${projectRoot}/.specs/tasks`,
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (envBackend === 'github') {
|
|
63
|
+
return {
|
|
64
|
+
type: 'github',
|
|
65
|
+
repo: process.env.LOOPWORK_REPO,
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Auto-detect
|
|
70
|
+
const fs = require('fs')
|
|
71
|
+
const jsonTasksFile = `${projectRoot}/.specs/tasks/tasks.json`
|
|
72
|
+
|
|
73
|
+
if (fs.existsSync(jsonTasksFile)) {
|
|
74
|
+
return {
|
|
75
|
+
type: 'json',
|
|
76
|
+
tasksFile: jsonTasksFile,
|
|
77
|
+
tasksDir: `${projectRoot}/.specs/tasks`,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Default to GitHub
|
|
82
|
+
return {
|
|
83
|
+
type: 'github',
|
|
84
|
+
repo: process.env.LOOPWORK_REPO,
|
|
85
|
+
}
|
|
86
|
+
}
|