@calltelemetry/openclaw-linear 0.7.0 → 0.7.1
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/LICENSE +21 -0
- package/index.ts +39 -0
- package/openclaw.plugin.json +3 -3
- package/package.json +2 -1
- package/src/api/linear-api.test.ts +494 -0
- package/src/api/linear-api.ts +14 -11
- package/src/gateway/dispatch-methods.ts +243 -0
- package/src/infra/cli.ts +97 -29
- package/src/infra/codex-worktree.ts +83 -0
- package/src/infra/commands.ts +156 -0
- package/src/infra/file-lock.test.ts +61 -0
- package/src/infra/file-lock.ts +49 -0
- package/src/infra/multi-repo.ts +85 -0
- package/src/infra/notify.ts +115 -15
- package/src/infra/observability.ts +48 -0
- package/src/infra/resilience.test.ts +94 -0
- package/src/infra/resilience.ts +101 -0
- package/src/pipeline/artifacts.ts +38 -2
- package/src/pipeline/dag-dispatch.test.ts +553 -0
- package/src/pipeline/dag-dispatch.ts +390 -0
- package/src/pipeline/dispatch-service.ts +48 -1
- package/src/pipeline/dispatch-state.ts +2 -42
- package/src/pipeline/pipeline.ts +91 -17
- package/src/pipeline/planner.ts +6 -1
- package/src/pipeline/planning-state.ts +2 -40
- package/src/pipeline/tier-assess.test.ts +175 -0
- package/src/pipeline/webhook.ts +21 -0
- package/src/tools/dispatch-history-tool.ts +201 -0
- package/src/tools/orchestration-tools.test.ts +158 -0
|
@@ -0,0 +1,553 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from "vitest";
|
|
2
|
+
import { mkdtempSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import {
|
|
6
|
+
buildDispatchQueue,
|
|
7
|
+
getReadyIssues,
|
|
8
|
+
getActiveCount,
|
|
9
|
+
isProjectDispatchComplete,
|
|
10
|
+
isProjectStuck,
|
|
11
|
+
readProjectDispatch,
|
|
12
|
+
writeProjectDispatch,
|
|
13
|
+
onProjectIssueCompleted,
|
|
14
|
+
onProjectIssueStuck,
|
|
15
|
+
type ProjectIssueStatus,
|
|
16
|
+
type ProjectDispatchState,
|
|
17
|
+
} from "./dag-dispatch.js";
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// Helpers
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
function tmpStatePath(): string {
|
|
24
|
+
const dir = mkdtempSync(join(tmpdir(), "claw-dag-"));
|
|
25
|
+
return join(dir, "state.json");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Create a minimal issue shape for buildDispatchQueue */
|
|
29
|
+
function makeIssue(
|
|
30
|
+
identifier: string,
|
|
31
|
+
opts?: {
|
|
32
|
+
labels?: string[];
|
|
33
|
+
relations?: Array<{ type: string; relatedIssue: { identifier: string } }>;
|
|
34
|
+
},
|
|
35
|
+
) {
|
|
36
|
+
return {
|
|
37
|
+
id: `id-${identifier}`,
|
|
38
|
+
identifier,
|
|
39
|
+
labels: {
|
|
40
|
+
nodes: (opts?.labels ?? []).map((name) => ({ name })),
|
|
41
|
+
},
|
|
42
|
+
relations: {
|
|
43
|
+
nodes: (opts?.relations ?? []).map((r) => ({
|
|
44
|
+
type: r.type,
|
|
45
|
+
relatedIssue: r.relatedIssue,
|
|
46
|
+
})),
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function makeIssueStatus(
|
|
52
|
+
identifier: string,
|
|
53
|
+
overrides?: Partial<ProjectIssueStatus>,
|
|
54
|
+
): ProjectIssueStatus {
|
|
55
|
+
return {
|
|
56
|
+
identifier,
|
|
57
|
+
issueId: `id-${identifier}`,
|
|
58
|
+
dependsOn: [],
|
|
59
|
+
unblocks: [],
|
|
60
|
+
dispatchStatus: "pending",
|
|
61
|
+
...overrides,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function makeProjectDispatch(
|
|
66
|
+
overrides?: Partial<ProjectDispatchState>,
|
|
67
|
+
): ProjectDispatchState {
|
|
68
|
+
return {
|
|
69
|
+
projectId: "proj-1",
|
|
70
|
+
projectName: "Test Project",
|
|
71
|
+
rootIdentifier: "PROJ-1",
|
|
72
|
+
status: "dispatching",
|
|
73
|
+
startedAt: new Date().toISOString(),
|
|
74
|
+
maxConcurrent: 3,
|
|
75
|
+
issues: {},
|
|
76
|
+
...overrides,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function makeHookCtx(configPath: string) {
|
|
81
|
+
return {
|
|
82
|
+
api: {
|
|
83
|
+
logger: {
|
|
84
|
+
info: vi.fn(),
|
|
85
|
+
warn: vi.fn(),
|
|
86
|
+
error: vi.fn(),
|
|
87
|
+
},
|
|
88
|
+
} as any,
|
|
89
|
+
linearApi: {} as any,
|
|
90
|
+
notify: vi.fn().mockResolvedValue(undefined),
|
|
91
|
+
pluginConfig: { planningStatePath: configPath },
|
|
92
|
+
configPath,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// buildDispatchQueue
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
describe("buildDispatchQueue", () => {
|
|
101
|
+
it("creates entries for all issues", () => {
|
|
102
|
+
const issues = [makeIssue("PROJ-1"), makeIssue("PROJ-2"), makeIssue("PROJ-3")];
|
|
103
|
+
const queue = buildDispatchQueue(issues);
|
|
104
|
+
expect(Object.keys(queue)).toHaveLength(3);
|
|
105
|
+
expect(queue["PROJ-1"].dispatchStatus).toBe("pending");
|
|
106
|
+
expect(queue["PROJ-2"].dispatchStatus).toBe("pending");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("marks epic-labeled issues as skipped", () => {
|
|
110
|
+
const issues = [
|
|
111
|
+
makeIssue("PROJ-1", { labels: ["Epic"] }),
|
|
112
|
+
makeIssue("PROJ-2"),
|
|
113
|
+
];
|
|
114
|
+
const queue = buildDispatchQueue(issues);
|
|
115
|
+
expect(queue["PROJ-1"].dispatchStatus).toBe("skipped");
|
|
116
|
+
expect(queue["PROJ-2"].dispatchStatus).toBe("pending");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("case-insensitive epic detection", () => {
|
|
120
|
+
const issues = [makeIssue("PROJ-1", { labels: ["epic-feature"] })];
|
|
121
|
+
const queue = buildDispatchQueue(issues);
|
|
122
|
+
expect(queue["PROJ-1"].dispatchStatus).toBe("skipped");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("parses blocks relations correctly", () => {
|
|
126
|
+
const issues = [
|
|
127
|
+
makeIssue("PROJ-1", {
|
|
128
|
+
relations: [{ type: "blocks", relatedIssue: { identifier: "PROJ-2" } }],
|
|
129
|
+
}),
|
|
130
|
+
makeIssue("PROJ-2"),
|
|
131
|
+
];
|
|
132
|
+
const queue = buildDispatchQueue(issues);
|
|
133
|
+
expect(queue["PROJ-1"].unblocks).toContain("PROJ-2");
|
|
134
|
+
expect(queue["PROJ-2"].dependsOn).toContain("PROJ-1");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("parses blocked_by relations correctly", () => {
|
|
138
|
+
const issues = [
|
|
139
|
+
makeIssue("PROJ-1"),
|
|
140
|
+
makeIssue("PROJ-2", {
|
|
141
|
+
relations: [{ type: "blocked_by", relatedIssue: { identifier: "PROJ-1" } }],
|
|
142
|
+
}),
|
|
143
|
+
];
|
|
144
|
+
const queue = buildDispatchQueue(issues);
|
|
145
|
+
expect(queue["PROJ-2"].dependsOn).toContain("PROJ-1");
|
|
146
|
+
expect(queue["PROJ-1"].unblocks).toContain("PROJ-2");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("ignores relations to issues outside the project", () => {
|
|
150
|
+
const issues = [
|
|
151
|
+
makeIssue("PROJ-1", {
|
|
152
|
+
relations: [{ type: "blocks", relatedIssue: { identifier: "OTHER-1" } }],
|
|
153
|
+
}),
|
|
154
|
+
];
|
|
155
|
+
const queue = buildDispatchQueue(issues);
|
|
156
|
+
expect(queue["PROJ-1"].unblocks).toHaveLength(0);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("filters skipped issues from dependency lists", () => {
|
|
160
|
+
const issues = [
|
|
161
|
+
makeIssue("PROJ-1", { labels: ["Epic"] }),
|
|
162
|
+
makeIssue("PROJ-2", {
|
|
163
|
+
relations: [{ type: "blocked_by", relatedIssue: { identifier: "PROJ-1" } }],
|
|
164
|
+
}),
|
|
165
|
+
];
|
|
166
|
+
const queue = buildDispatchQueue(issues);
|
|
167
|
+
// PROJ-2 should not depend on the epic
|
|
168
|
+
expect(queue["PROJ-2"].dependsOn).toHaveLength(0);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("handles empty issue list", () => {
|
|
172
|
+
const queue = buildDispatchQueue([]);
|
|
173
|
+
expect(Object.keys(queue)).toHaveLength(0);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("handles diamond dependency graph", () => {
|
|
177
|
+
// A
|
|
178
|
+
// / \
|
|
179
|
+
// B C
|
|
180
|
+
// \ /
|
|
181
|
+
// D
|
|
182
|
+
const issues = [
|
|
183
|
+
makeIssue("A", {
|
|
184
|
+
relations: [
|
|
185
|
+
{ type: "blocks", relatedIssue: { identifier: "B" } },
|
|
186
|
+
{ type: "blocks", relatedIssue: { identifier: "C" } },
|
|
187
|
+
],
|
|
188
|
+
}),
|
|
189
|
+
makeIssue("B", {
|
|
190
|
+
relations: [{ type: "blocks", relatedIssue: { identifier: "D" } }],
|
|
191
|
+
}),
|
|
192
|
+
makeIssue("C", {
|
|
193
|
+
relations: [{ type: "blocks", relatedIssue: { identifier: "D" } }],
|
|
194
|
+
}),
|
|
195
|
+
makeIssue("D"),
|
|
196
|
+
];
|
|
197
|
+
const queue = buildDispatchQueue(issues);
|
|
198
|
+
expect(queue["A"].dependsOn).toHaveLength(0);
|
|
199
|
+
expect(queue["B"].dependsOn).toEqual(["A"]);
|
|
200
|
+
expect(queue["C"].dependsOn).toEqual(["A"]);
|
|
201
|
+
expect(queue["D"].dependsOn).toEqual(expect.arrayContaining(["B", "C"]));
|
|
202
|
+
expect(queue["D"].dependsOn).toHaveLength(2);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// getReadyIssues
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
describe("getReadyIssues", () => {
|
|
211
|
+
it("returns pending issues with no dependencies", () => {
|
|
212
|
+
const issues: Record<string, ProjectIssueStatus> = {
|
|
213
|
+
A: makeIssueStatus("A"),
|
|
214
|
+
B: makeIssueStatus("B", { dependsOn: ["A"] }),
|
|
215
|
+
};
|
|
216
|
+
const ready = getReadyIssues(issues);
|
|
217
|
+
expect(ready).toHaveLength(1);
|
|
218
|
+
expect(ready[0].identifier).toBe("A");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("returns pending issues whose deps are all done", () => {
|
|
222
|
+
const issues: Record<string, ProjectIssueStatus> = {
|
|
223
|
+
A: makeIssueStatus("A", { dispatchStatus: "done" }),
|
|
224
|
+
B: makeIssueStatus("B", { dependsOn: ["A"] }),
|
|
225
|
+
};
|
|
226
|
+
const ready = getReadyIssues(issues);
|
|
227
|
+
expect(ready).toHaveLength(1);
|
|
228
|
+
expect(ready[0].identifier).toBe("B");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("excludes dispatched issues", () => {
|
|
232
|
+
const issues: Record<string, ProjectIssueStatus> = {
|
|
233
|
+
A: makeIssueStatus("A", { dispatchStatus: "dispatched" }),
|
|
234
|
+
};
|
|
235
|
+
expect(getReadyIssues(issues)).toHaveLength(0);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("excludes issues with incomplete deps", () => {
|
|
239
|
+
const issues: Record<string, ProjectIssueStatus> = {
|
|
240
|
+
A: makeIssueStatus("A", { dispatchStatus: "dispatched" }),
|
|
241
|
+
B: makeIssueStatus("B", { dependsOn: ["A"] }),
|
|
242
|
+
};
|
|
243
|
+
expect(getReadyIssues(issues)).toHaveLength(0);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("returns multiple ready issues", () => {
|
|
247
|
+
const issues: Record<string, ProjectIssueStatus> = {
|
|
248
|
+
A: makeIssueStatus("A"),
|
|
249
|
+
B: makeIssueStatus("B"),
|
|
250
|
+
C: makeIssueStatus("C", { dependsOn: ["A"] }),
|
|
251
|
+
};
|
|
252
|
+
const ready = getReadyIssues(issues);
|
|
253
|
+
expect(ready).toHaveLength(2);
|
|
254
|
+
expect(ready.map((r) => r.identifier).sort()).toEqual(["A", "B"]);
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// getActiveCount
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
describe("getActiveCount", () => {
|
|
263
|
+
it("counts dispatched issues", () => {
|
|
264
|
+
const issues: Record<string, ProjectIssueStatus> = {
|
|
265
|
+
A: makeIssueStatus("A", { dispatchStatus: "dispatched" }),
|
|
266
|
+
B: makeIssueStatus("B", { dispatchStatus: "dispatched" }),
|
|
267
|
+
C: makeIssueStatus("C", { dispatchStatus: "done" }),
|
|
268
|
+
D: makeIssueStatus("D"),
|
|
269
|
+
};
|
|
270
|
+
expect(getActiveCount(issues)).toBe(2);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("returns 0 when none dispatched", () => {
|
|
274
|
+
const issues: Record<string, ProjectIssueStatus> = {
|
|
275
|
+
A: makeIssueStatus("A"),
|
|
276
|
+
B: makeIssueStatus("B", { dispatchStatus: "done" }),
|
|
277
|
+
};
|
|
278
|
+
expect(getActiveCount(issues)).toBe(0);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// isProjectDispatchComplete
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
describe("isProjectDispatchComplete", () => {
|
|
287
|
+
it("true when all done/skipped/stuck", () => {
|
|
288
|
+
const issues: Record<string, ProjectIssueStatus> = {
|
|
289
|
+
A: makeIssueStatus("A", { dispatchStatus: "done" }),
|
|
290
|
+
B: makeIssueStatus("B", { dispatchStatus: "stuck" }),
|
|
291
|
+
C: makeIssueStatus("C", { dispatchStatus: "skipped" }),
|
|
292
|
+
};
|
|
293
|
+
expect(isProjectDispatchComplete(issues)).toBe(true);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("false when pending issues remain", () => {
|
|
297
|
+
const issues: Record<string, ProjectIssueStatus> = {
|
|
298
|
+
A: makeIssueStatus("A", { dispatchStatus: "done" }),
|
|
299
|
+
B: makeIssueStatus("B"),
|
|
300
|
+
};
|
|
301
|
+
expect(isProjectDispatchComplete(issues)).toBe(false);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("false when dispatched issues remain", () => {
|
|
305
|
+
const issues: Record<string, ProjectIssueStatus> = {
|
|
306
|
+
A: makeIssueStatus("A", { dispatchStatus: "dispatched" }),
|
|
307
|
+
};
|
|
308
|
+
expect(isProjectDispatchComplete(issues)).toBe(false);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it("true for empty issues", () => {
|
|
312
|
+
expect(isProjectDispatchComplete({})).toBe(true);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
// isProjectStuck
|
|
318
|
+
// ---------------------------------------------------------------------------
|
|
319
|
+
|
|
320
|
+
describe("isProjectStuck", () => {
|
|
321
|
+
it("true when stuck issues block all pending", () => {
|
|
322
|
+
const issues: Record<string, ProjectIssueStatus> = {
|
|
323
|
+
A: makeIssueStatus("A", { dispatchStatus: "stuck" }),
|
|
324
|
+
B: makeIssueStatus("B", { dependsOn: ["A"] }),
|
|
325
|
+
};
|
|
326
|
+
expect(isProjectStuck(issues)).toBe(true);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("false when no issues are stuck", () => {
|
|
330
|
+
const issues: Record<string, ProjectIssueStatus> = {
|
|
331
|
+
A: makeIssueStatus("A", { dispatchStatus: "done" }),
|
|
332
|
+
B: makeIssueStatus("B"),
|
|
333
|
+
};
|
|
334
|
+
expect(isProjectStuck(issues)).toBe(false);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("false when active issues still in flight", () => {
|
|
338
|
+
const issues: Record<string, ProjectIssueStatus> = {
|
|
339
|
+
A: makeIssueStatus("A", { dispatchStatus: "stuck" }),
|
|
340
|
+
B: makeIssueStatus("B", { dispatchStatus: "dispatched" }),
|
|
341
|
+
};
|
|
342
|
+
expect(isProjectStuck(issues)).toBe(false);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("false when some pending issues can still be dispatched", () => {
|
|
346
|
+
const issues: Record<string, ProjectIssueStatus> = {
|
|
347
|
+
A: makeIssueStatus("A", { dispatchStatus: "stuck" }),
|
|
348
|
+
B: makeIssueStatus("B"), // no deps — still ready
|
|
349
|
+
};
|
|
350
|
+
expect(isProjectStuck(issues)).toBe(false);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// State persistence
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
describe("readProjectDispatch / writeProjectDispatch", () => {
|
|
359
|
+
it("returns null for non-existent project", async () => {
|
|
360
|
+
const p = tmpStatePath();
|
|
361
|
+
const result = await readProjectDispatch("proj-1", p);
|
|
362
|
+
expect(result).toBeNull();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("round-trips project dispatch state", async () => {
|
|
366
|
+
const p = tmpStatePath();
|
|
367
|
+
const pd = makeProjectDispatch({
|
|
368
|
+
issues: {
|
|
369
|
+
"PROJ-1": makeIssueStatus("PROJ-1", { dispatchStatus: "done" }),
|
|
370
|
+
"PROJ-2": makeIssueStatus("PROJ-2"),
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
await writeProjectDispatch(pd, p);
|
|
374
|
+
const result = await readProjectDispatch("proj-1", p);
|
|
375
|
+
expect(result).not.toBeNull();
|
|
376
|
+
expect(result!.projectName).toBe("Test Project");
|
|
377
|
+
expect(Object.keys(result!.issues)).toHaveLength(2);
|
|
378
|
+
expect(result!.issues["PROJ-1"].dispatchStatus).toBe("done");
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it("can store multiple project dispatches", async () => {
|
|
382
|
+
const p = tmpStatePath();
|
|
383
|
+
const pd1 = makeProjectDispatch({ projectId: "proj-1", projectName: "P1" });
|
|
384
|
+
const pd2 = makeProjectDispatch({ projectId: "proj-2", projectName: "P2" });
|
|
385
|
+
await writeProjectDispatch(pd1, p);
|
|
386
|
+
await writeProjectDispatch(pd2, p);
|
|
387
|
+
|
|
388
|
+
const r1 = await readProjectDispatch("proj-1", p);
|
|
389
|
+
const r2 = await readProjectDispatch("proj-2", p);
|
|
390
|
+
expect(r1!.projectName).toBe("P1");
|
|
391
|
+
expect(r2!.projectName).toBe("P2");
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// ---------------------------------------------------------------------------
|
|
396
|
+
// onProjectIssueCompleted
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
describe("onProjectIssueCompleted", () => {
|
|
400
|
+
it("marks issue as done and saves state", async () => {
|
|
401
|
+
const p = tmpStatePath();
|
|
402
|
+
const pd = makeProjectDispatch({
|
|
403
|
+
issues: {
|
|
404
|
+
"PROJ-1": makeIssueStatus("PROJ-1", { dispatchStatus: "dispatched" }),
|
|
405
|
+
"PROJ-2": makeIssueStatus("PROJ-2", { dependsOn: ["PROJ-1"] }),
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
await writeProjectDispatch(pd, p);
|
|
409
|
+
|
|
410
|
+
const hookCtx = makeHookCtx(p);
|
|
411
|
+
await onProjectIssueCompleted(hookCtx, "proj-1", "PROJ-1");
|
|
412
|
+
|
|
413
|
+
const result = await readProjectDispatch("proj-1", p);
|
|
414
|
+
expect(result!.issues["PROJ-1"].dispatchStatus).toBe("done");
|
|
415
|
+
expect(result!.issues["PROJ-1"].completedAt).toBeDefined();
|
|
416
|
+
expect(hookCtx.notify).toHaveBeenCalledWith("project_progress", expect.any(Object));
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it("marks project as completed when all issues done", async () => {
|
|
420
|
+
const p = tmpStatePath();
|
|
421
|
+
const pd = makeProjectDispatch({
|
|
422
|
+
issues: {
|
|
423
|
+
"PROJ-1": makeIssueStatus("PROJ-1", { dispatchStatus: "done" }),
|
|
424
|
+
"PROJ-2": makeIssueStatus("PROJ-2", { dispatchStatus: "dispatched" }),
|
|
425
|
+
},
|
|
426
|
+
});
|
|
427
|
+
await writeProjectDispatch(pd, p);
|
|
428
|
+
|
|
429
|
+
const hookCtx = makeHookCtx(p);
|
|
430
|
+
await onProjectIssueCompleted(hookCtx, "proj-1", "PROJ-2");
|
|
431
|
+
|
|
432
|
+
const result = await readProjectDispatch("proj-1", p);
|
|
433
|
+
expect(result!.status).toBe("completed");
|
|
434
|
+
expect(hookCtx.notify).toHaveBeenCalledWith("project_complete", expect.any(Object));
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("does nothing if project is not dispatching", async () => {
|
|
438
|
+
const p = tmpStatePath();
|
|
439
|
+
const pd = makeProjectDispatch({ status: "completed" });
|
|
440
|
+
await writeProjectDispatch(pd, p);
|
|
441
|
+
|
|
442
|
+
const hookCtx = makeHookCtx(p);
|
|
443
|
+
await onProjectIssueCompleted(hookCtx, "proj-1", "PROJ-1");
|
|
444
|
+
|
|
445
|
+
expect(hookCtx.notify).not.toHaveBeenCalled();
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("does nothing if issue not found in dispatch", async () => {
|
|
449
|
+
const p = tmpStatePath();
|
|
450
|
+
const pd = makeProjectDispatch({
|
|
451
|
+
issues: { "PROJ-1": makeIssueStatus("PROJ-1") },
|
|
452
|
+
});
|
|
453
|
+
await writeProjectDispatch(pd, p);
|
|
454
|
+
|
|
455
|
+
const hookCtx = makeHookCtx(p);
|
|
456
|
+
await onProjectIssueCompleted(hookCtx, "proj-1", "PROJ-999");
|
|
457
|
+
|
|
458
|
+
expect(hookCtx.api.logger.warn).toHaveBeenCalled();
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("detects stuck project after completion", async () => {
|
|
462
|
+
const p = tmpStatePath();
|
|
463
|
+
// PROJ-1 is stuck, PROJ-2 is dispatched (about to complete),
|
|
464
|
+
// PROJ-3 depends on PROJ-1 (stuck) — no more progress possible after PROJ-2 done
|
|
465
|
+
const pd = makeProjectDispatch({
|
|
466
|
+
issues: {
|
|
467
|
+
"PROJ-1": makeIssueStatus("PROJ-1", { dispatchStatus: "stuck" }),
|
|
468
|
+
"PROJ-2": makeIssueStatus("PROJ-2", { dispatchStatus: "dispatched" }),
|
|
469
|
+
"PROJ-3": makeIssueStatus("PROJ-3", { dependsOn: ["PROJ-1"] }),
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
await writeProjectDispatch(pd, p);
|
|
473
|
+
|
|
474
|
+
const hookCtx = makeHookCtx(p);
|
|
475
|
+
// PROJ-2 completes, but PROJ-3 still depends on stuck PROJ-1
|
|
476
|
+
await onProjectIssueCompleted(hookCtx, "proj-1", "PROJ-2");
|
|
477
|
+
|
|
478
|
+
const result = await readProjectDispatch("proj-1", p);
|
|
479
|
+
expect(result!.status).toBe("stuck");
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
// ---------------------------------------------------------------------------
|
|
484
|
+
// onProjectIssueStuck
|
|
485
|
+
// ---------------------------------------------------------------------------
|
|
486
|
+
|
|
487
|
+
describe("onProjectIssueStuck", () => {
|
|
488
|
+
it("marks issue as stuck", async () => {
|
|
489
|
+
const p = tmpStatePath();
|
|
490
|
+
const pd = makeProjectDispatch({
|
|
491
|
+
issues: {
|
|
492
|
+
"PROJ-1": makeIssueStatus("PROJ-1", { dispatchStatus: "dispatched" }),
|
|
493
|
+
"PROJ-2": makeIssueStatus("PROJ-2", { dependsOn: ["PROJ-1"] }),
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
await writeProjectDispatch(pd, p);
|
|
497
|
+
|
|
498
|
+
const hookCtx = makeHookCtx(p);
|
|
499
|
+
await onProjectIssueStuck(hookCtx, "proj-1", "PROJ-1");
|
|
500
|
+
|
|
501
|
+
const result = await readProjectDispatch("proj-1", p);
|
|
502
|
+
expect(result!.issues["PROJ-1"].dispatchStatus).toBe("stuck");
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
it("marks project as stuck when all pending depend on stuck", async () => {
|
|
506
|
+
const p = tmpStatePath();
|
|
507
|
+
const pd = makeProjectDispatch({
|
|
508
|
+
issues: {
|
|
509
|
+
"PROJ-1": makeIssueStatus("PROJ-1", { dispatchStatus: "dispatched" }),
|
|
510
|
+
"PROJ-2": makeIssueStatus("PROJ-2", { dependsOn: ["PROJ-1"] }),
|
|
511
|
+
},
|
|
512
|
+
});
|
|
513
|
+
await writeProjectDispatch(pd, p);
|
|
514
|
+
|
|
515
|
+
const hookCtx = makeHookCtx(p);
|
|
516
|
+
await onProjectIssueStuck(hookCtx, "proj-1", "PROJ-1");
|
|
517
|
+
|
|
518
|
+
const result = await readProjectDispatch("proj-1", p);
|
|
519
|
+
expect(result!.status).toBe("stuck");
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("does not mark project stuck if other issues can still progress", async () => {
|
|
523
|
+
const p = tmpStatePath();
|
|
524
|
+
const pd = makeProjectDispatch({
|
|
525
|
+
issues: {
|
|
526
|
+
"PROJ-1": makeIssueStatus("PROJ-1", { dispatchStatus: "dispatched" }),
|
|
527
|
+
"PROJ-2": makeIssueStatus("PROJ-2"), // no deps, can still dispatch
|
|
528
|
+
"PROJ-3": makeIssueStatus("PROJ-3", { dependsOn: ["PROJ-1"] }),
|
|
529
|
+
},
|
|
530
|
+
});
|
|
531
|
+
await writeProjectDispatch(pd, p);
|
|
532
|
+
|
|
533
|
+
const hookCtx = makeHookCtx(p);
|
|
534
|
+
await onProjectIssueStuck(hookCtx, "proj-1", "PROJ-1");
|
|
535
|
+
|
|
536
|
+
const result = await readProjectDispatch("proj-1", p);
|
|
537
|
+
expect(result!.issues["PROJ-1"].dispatchStatus).toBe("stuck");
|
|
538
|
+
expect(result!.status).toBe("dispatching"); // not stuck overall
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
it("does nothing if project is not dispatching", async () => {
|
|
542
|
+
const p = tmpStatePath();
|
|
543
|
+
const pd = makeProjectDispatch({ status: "completed" });
|
|
544
|
+
await writeProjectDispatch(pd, p);
|
|
545
|
+
|
|
546
|
+
const hookCtx = makeHookCtx(p);
|
|
547
|
+
await onProjectIssueStuck(hookCtx, "proj-1", "PROJ-1");
|
|
548
|
+
|
|
549
|
+
// No crash, no state change
|
|
550
|
+
const result = await readProjectDispatch("proj-1", p);
|
|
551
|
+
expect(result!.status).toBe("completed");
|
|
552
|
+
});
|
|
553
|
+
});
|