@cliangdev/flux-plugin 0.2.0-dev.dc5e2c4 → 0.2.0-dev.f718bcf
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/README.md +3 -3
- package/agents/coder.md +150 -25
- package/commands/breakdown.md +47 -10
- package/commands/flux.md +92 -12
- package/commands/implement.md +166 -17
- package/commands/linear.md +6 -5
- package/commands/prd.md +996 -82
- package/manifest.json +2 -1
- package/package.json +4 -2
- package/skills/flux-orchestrator/SKILL.md +11 -3
- package/skills/prd-writer/SKILL.md +761 -0
- package/skills/ux-ui-design/SKILL.md +346 -0
- package/skills/ux-ui-design/references/design-tokens.md +359 -0
- package/src/dashboard/__tests__/api.test.ts +211 -0
- package/src/dashboard/browser.ts +35 -0
- package/src/dashboard/public/app.js +869 -0
- package/src/dashboard/public/index.html +90 -0
- package/src/dashboard/public/styles.css +807 -0
- package/src/dashboard/public/vendor/highlight.css +10 -0
- package/src/dashboard/public/vendor/highlight.min.js +8422 -0
- package/src/dashboard/public/vendor/marked.min.js +2210 -0
- package/src/dashboard/server.ts +296 -0
- package/src/dashboard/watchers.ts +83 -0
- package/src/server/adapters/__tests__/dependency-ops.test.ts +52 -18
- package/src/server/adapters/linear/adapter.ts +19 -14
- package/src/server/adapters/local-adapter.ts +48 -7
- package/src/server/db/__tests__/queries.test.ts +2 -1
- package/src/server/db/schema.ts +9 -0
- package/src/server/index.ts +0 -2
- package/src/server/tools/__tests__/crud.test.ts +111 -1
- package/src/server/tools/__tests__/mcp-interface.test.ts +100 -9
- package/src/server/tools/__tests__/query.test.ts +73 -21
- package/src/server/tools/__tests__/z-configure-linear.test.ts +1 -1
- package/src/server/tools/__tests__/z-get-linear-url.test.ts +1 -1
- package/src/server/tools/create-epic.ts +11 -2
- package/src/server/tools/create-prd.ts +11 -2
- package/src/server/tools/create-task.ts +11 -2
- package/src/server/tools/dependencies.ts +2 -2
- package/src/server/tools/get-entity.ts +12 -10
- package/src/server/tools/index.ts +53 -9
- package/src/server/tools/init-project.ts +1 -1
- package/src/server/tools/render-status.ts +38 -20
- package/src/status-line/__tests__/status-line.test.ts +1 -1
- package/src/utils/status-renderer.ts +32 -6
- package/skills/prd-template/SKILL.md +0 -242
- package/src/server/tools/get-project-context.ts +0 -33
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Flux Dashboard Server
|
|
3
|
+
*
|
|
4
|
+
* HTTP + WebSocket server for the Flux Dashboard.
|
|
5
|
+
* Serves static files and API endpoints for viewing PRD/Epic/Task data.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getAdapter } from "../server/adapters/index.js";
|
|
9
|
+
import type { Epic, Prd, Task } from "../server/adapters/types.js";
|
|
10
|
+
import { initDb } from "../server/db/index.js";
|
|
11
|
+
import { openBrowser } from "./browser.js";
|
|
12
|
+
import { startWatchers, stopWatchers } from "./watchers.js";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_PORT = 3333;
|
|
15
|
+
const MAX_PORT_ATTEMPTS = 10;
|
|
16
|
+
|
|
17
|
+
interface DashboardServer {
|
|
18
|
+
server: ReturnType<typeof Bun.serve>;
|
|
19
|
+
port: number;
|
|
20
|
+
stop: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getPort(): number {
|
|
24
|
+
const envPort = process.env.FLUX_DASHBOARD_PORT;
|
|
25
|
+
if (envPort) {
|
|
26
|
+
const parsed = Number.parseInt(envPort, 10);
|
|
27
|
+
if (!Number.isNaN(parsed) && parsed > 0 && parsed < 65536) {
|
|
28
|
+
return parsed;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return DEFAULT_PORT;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function findAvailablePort(startPort: number): Promise<number> {
|
|
35
|
+
for (let port = startPort; port < startPort + MAX_PORT_ATTEMPTS; port++) {
|
|
36
|
+
try {
|
|
37
|
+
const testServer = Bun.serve({
|
|
38
|
+
port,
|
|
39
|
+
fetch: () => new Response("test"),
|
|
40
|
+
});
|
|
41
|
+
testServer.stop();
|
|
42
|
+
return port;
|
|
43
|
+
} catch {}
|
|
44
|
+
}
|
|
45
|
+
throw new Error(
|
|
46
|
+
`No available port found between ${startPort} and ${startPort + MAX_PORT_ATTEMPTS - 1}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getContentType(path: string): string {
|
|
51
|
+
if (path.endsWith(".html")) return "text/html";
|
|
52
|
+
if (path.endsWith(".css")) return "text/css";
|
|
53
|
+
if (path.endsWith(".js")) return "application/javascript";
|
|
54
|
+
if (path.endsWith(".json")) return "application/json";
|
|
55
|
+
if (path.endsWith(".svg")) return "image/svg+xml";
|
|
56
|
+
return "application/octet-stream";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function startDashboard(): Promise<DashboardServer> {
|
|
60
|
+
const preferredPort = getPort();
|
|
61
|
+
const port = await findAvailablePort(preferredPort);
|
|
62
|
+
|
|
63
|
+
const publicDir = new URL("./public/", import.meta.url).pathname;
|
|
64
|
+
|
|
65
|
+
const server = Bun.serve({
|
|
66
|
+
port,
|
|
67
|
+
|
|
68
|
+
async fetch(req, server) {
|
|
69
|
+
const url = new URL(req.url);
|
|
70
|
+
const path = url.pathname;
|
|
71
|
+
|
|
72
|
+
// WebSocket upgrade
|
|
73
|
+
if (path === "/ws") {
|
|
74
|
+
const upgraded = server.upgrade(req);
|
|
75
|
+
return upgraded
|
|
76
|
+
? undefined
|
|
77
|
+
: new Response("WebSocket upgrade failed", { status: 400 });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// API endpoints
|
|
81
|
+
if (path.startsWith("/api/")) {
|
|
82
|
+
return handleApiRequest(path, url);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Static files
|
|
86
|
+
const filePath = path === "/" ? "/index.html" : path;
|
|
87
|
+
const fullPath = `${publicDir}${filePath}`;
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const file = Bun.file(fullPath);
|
|
91
|
+
if (await file.exists()) {
|
|
92
|
+
return new Response(file, {
|
|
93
|
+
headers: { "Content-Type": getContentType(fullPath) },
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
} catch {}
|
|
97
|
+
|
|
98
|
+
return new Response("Not found", { status: 404 });
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
websocket: {
|
|
102
|
+
open(ws) {
|
|
103
|
+
ws.subscribe("updates");
|
|
104
|
+
},
|
|
105
|
+
message(_ws, _message) {
|
|
106
|
+
// Client messages not needed for read-only dashboard
|
|
107
|
+
},
|
|
108
|
+
close(ws) {
|
|
109
|
+
ws.unsubscribe("updates");
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Start file watchers
|
|
115
|
+
startWatchers(server);
|
|
116
|
+
|
|
117
|
+
// Graceful shutdown
|
|
118
|
+
const cleanup = () => {
|
|
119
|
+
console.log("\nShutting down dashboard...");
|
|
120
|
+
stopWatchers();
|
|
121
|
+
server.stop();
|
|
122
|
+
process.exit(0);
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
process.on("SIGINT", cleanup);
|
|
126
|
+
process.on("SIGTERM", cleanup);
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
server,
|
|
130
|
+
port,
|
|
131
|
+
stop: () => {
|
|
132
|
+
stopWatchers();
|
|
133
|
+
server.stop();
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
interface TreePrd extends Prd {
|
|
139
|
+
epics: TreeEpic[];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
interface TreeEpic extends Epic {
|
|
143
|
+
tasks: Task[];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function handleApiRequest(path: string, _url: URL): Promise<Response> {
|
|
147
|
+
try {
|
|
148
|
+
const adapter = getAdapter();
|
|
149
|
+
|
|
150
|
+
// GET /api/tree
|
|
151
|
+
if (path === "/api/tree") {
|
|
152
|
+
const prds = await adapter.listPrds({}, { limit: 100, offset: 0 });
|
|
153
|
+
const tree: TreePrd[] = await Promise.all(
|
|
154
|
+
prds.items.map(async (prd) => {
|
|
155
|
+
const epics = await adapter.listEpics(
|
|
156
|
+
{ prdRef: prd.ref },
|
|
157
|
+
{ limit: 100, offset: 0 },
|
|
158
|
+
);
|
|
159
|
+
const epicsWithTasks: TreeEpic[] = await Promise.all(
|
|
160
|
+
epics.items.map(async (epic) => {
|
|
161
|
+
const tasks = await adapter.listTasks(
|
|
162
|
+
{ epicRef: epic.ref },
|
|
163
|
+
{ limit: 100, offset: 0 },
|
|
164
|
+
);
|
|
165
|
+
return { ...epic, tasks: tasks.items };
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
return { ...prd, epics: epicsWithTasks };
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
return Response.json(tree);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// GET /api/prd/:ref
|
|
175
|
+
if (path.startsWith("/api/prd/")) {
|
|
176
|
+
const ref = path.slice("/api/prd/".length);
|
|
177
|
+
const prd = await adapter.getPrd(ref);
|
|
178
|
+
if (!prd) {
|
|
179
|
+
return Response.json({ error: "PRD not found" }, { status: 404 });
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
let markdown = prd.description || "";
|
|
183
|
+
if (prd.folderPath) {
|
|
184
|
+
try {
|
|
185
|
+
const prdPath = `${prd.folderPath}/prd.md`;
|
|
186
|
+
const file = Bun.file(prdPath);
|
|
187
|
+
if (await file.exists()) {
|
|
188
|
+
markdown = await file.text();
|
|
189
|
+
}
|
|
190
|
+
} catch {}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return Response.json({ ...prd, markdown });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// GET /api/epic/:ref
|
|
197
|
+
if (path.startsWith("/api/epic/")) {
|
|
198
|
+
const ref = path.slice("/api/epic/".length);
|
|
199
|
+
const epic = await adapter.getEpic(ref);
|
|
200
|
+
if (!epic) {
|
|
201
|
+
return Response.json({ error: "Epic not found" }, { status: 404 });
|
|
202
|
+
}
|
|
203
|
+
const criteria = await adapter.getCriteria(ref);
|
|
204
|
+
return Response.json({ ...epic, criteria });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// GET /api/task/:ref
|
|
208
|
+
if (path.startsWith("/api/task/")) {
|
|
209
|
+
const ref = path.slice("/api/task/".length);
|
|
210
|
+
const task = await adapter.getTask(ref);
|
|
211
|
+
if (!task) {
|
|
212
|
+
return Response.json({ error: "Task not found" }, { status: 404 });
|
|
213
|
+
}
|
|
214
|
+
const criteria = await adapter.getCriteria(ref);
|
|
215
|
+
const deps = await adapter.getDependencies(ref);
|
|
216
|
+
return Response.json({ ...task, criteria, dependencies: deps });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// GET /api/tags
|
|
220
|
+
if (path === "/api/tags") {
|
|
221
|
+
const prds = await adapter.listPrds({}, { limit: 1000, offset: 0 });
|
|
222
|
+
const tagCounts = new Map<string, number>();
|
|
223
|
+
let total = 0;
|
|
224
|
+
|
|
225
|
+
for (const prd of prds.items) {
|
|
226
|
+
total++;
|
|
227
|
+
if (prd.tag) {
|
|
228
|
+
tagCounts.set(prd.tag, (tagCounts.get(prd.tag) || 0) + 1);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const tags = [
|
|
233
|
+
{ tag: "All", count: total },
|
|
234
|
+
...Array.from(tagCounts.entries())
|
|
235
|
+
.map(([tag, count]) => ({ tag, count }))
|
|
236
|
+
.sort((a, b) => a.tag.localeCompare(b.tag)),
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
return Response.json(tags);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// GET /api/dependencies
|
|
243
|
+
if (path === "/api/dependencies") {
|
|
244
|
+
const edges: Array<{ from: string; to: string; type: string }> = [];
|
|
245
|
+
|
|
246
|
+
const prds = await adapter.listPrds({}, { limit: 1000, offset: 0 });
|
|
247
|
+
for (const prd of prds.items) {
|
|
248
|
+
const deps = await adapter.getDependencies(prd.ref);
|
|
249
|
+
for (const depRef of deps) {
|
|
250
|
+
edges.push({ from: prd.ref, to: depRef, type: "prd" });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const epics = await adapter.listEpics({}, { limit: 1000, offset: 0 });
|
|
255
|
+
for (const epic of epics.items) {
|
|
256
|
+
const deps = await adapter.getDependencies(epic.ref);
|
|
257
|
+
for (const depRef of deps) {
|
|
258
|
+
edges.push({ from: epic.ref, to: depRef, type: "epic" });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const tasks = await adapter.listTasks({}, { limit: 1000, offset: 0 });
|
|
263
|
+
for (const task of tasks.items) {
|
|
264
|
+
const deps = await adapter.getDependencies(task.ref);
|
|
265
|
+
for (const depRef of deps) {
|
|
266
|
+
edges.push({ from: task.ref, to: depRef, type: "task" });
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return Response.json({ edges });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return Response.json({ error: "Unknown endpoint" }, { status: 404 });
|
|
274
|
+
} catch (error) {
|
|
275
|
+
console.error("API error:", error);
|
|
276
|
+
return Response.json(
|
|
277
|
+
{ error: error instanceof Error ? error.message : "Internal error" },
|
|
278
|
+
{ status: 500 },
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Main entry point
|
|
284
|
+
if (import.meta.main) {
|
|
285
|
+
try {
|
|
286
|
+
// Initialize local database (no-op if using external adapter like Linear)
|
|
287
|
+
initDb();
|
|
288
|
+
const { port } = await startDashboard();
|
|
289
|
+
const url = `http://localhost:${port}`;
|
|
290
|
+
console.log(`\n Flux Dashboard running at ${url}\n`);
|
|
291
|
+
await openBrowser(url);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
console.error("Failed to start dashboard:", error);
|
|
294
|
+
process.exit(1);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Watchers for Real-time Updates
|
|
3
|
+
*
|
|
4
|
+
* Watches .flux/flux.db and .flux/prds/ for changes and broadcasts
|
|
5
|
+
* updates to connected WebSocket clients.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { type FSWatcher, watch } from "node:fs";
|
|
9
|
+
import { resolve } from "node:path";
|
|
10
|
+
|
|
11
|
+
let dbWatcher: FSWatcher | null = null;
|
|
12
|
+
let prdsWatcher: FSWatcher | null = null;
|
|
13
|
+
let debounceTimer: Timer | null = null;
|
|
14
|
+
|
|
15
|
+
const DEBOUNCE_MS = 500;
|
|
16
|
+
|
|
17
|
+
export function startWatchers(server: ReturnType<typeof Bun.serve>): void {
|
|
18
|
+
const projectRoot = process.env.FLUX_PROJECT_ROOT || process.cwd();
|
|
19
|
+
const fluxDir = resolve(projectRoot, ".flux");
|
|
20
|
+
const dbPath = resolve(fluxDir, "flux.db");
|
|
21
|
+
const prdsDir = resolve(fluxDir, "prds");
|
|
22
|
+
|
|
23
|
+
const broadcast = (type: "db" | "file", path?: string) => {
|
|
24
|
+
if (debounceTimer) {
|
|
25
|
+
clearTimeout(debounceTimer);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
debounceTimer = setTimeout(() => {
|
|
29
|
+
server.publish(
|
|
30
|
+
"updates",
|
|
31
|
+
JSON.stringify({
|
|
32
|
+
type: "update",
|
|
33
|
+
source: type,
|
|
34
|
+
path,
|
|
35
|
+
timestamp: Date.now(),
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
}, DEBOUNCE_MS);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// Watch database file
|
|
42
|
+
try {
|
|
43
|
+
dbWatcher = watch(dbPath, (eventType) => {
|
|
44
|
+
if (eventType === "change") {
|
|
45
|
+
broadcast("db");
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
} catch (error) {
|
|
49
|
+
console.warn("Could not watch database file:", error);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Watch PRDs directory recursively
|
|
53
|
+
try {
|
|
54
|
+
prdsWatcher = watch(
|
|
55
|
+
prdsDir,
|
|
56
|
+
{ recursive: true },
|
|
57
|
+
(_eventType, filename) => {
|
|
58
|
+
if (filename?.endsWith(".md")) {
|
|
59
|
+
broadcast("file", filename);
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
} catch (error) {
|
|
64
|
+
console.warn("Could not watch PRDs directory:", error);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function stopWatchers(): void {
|
|
69
|
+
if (debounceTimer) {
|
|
70
|
+
clearTimeout(debounceTimer);
|
|
71
|
+
debounceTimer = null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (dbWatcher) {
|
|
75
|
+
dbWatcher.close();
|
|
76
|
+
dbWatcher = null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (prdsWatcher) {
|
|
80
|
+
prdsWatcher.close();
|
|
81
|
+
prdsWatcher = null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -194,8 +194,12 @@ describe("LinearAdapter - Dependency Operations", () => {
|
|
|
194
194
|
const mockRelation = {
|
|
195
195
|
id: "relation_1",
|
|
196
196
|
type: "blocks",
|
|
197
|
-
|
|
198
|
-
|
|
197
|
+
// Linear SDK returns promises for lazy-loaded related objects
|
|
198
|
+
issue: Promise.resolve({ id: "issue_epic_1", identifier: "ENG-42" }),
|
|
199
|
+
relatedIssue: Promise.resolve({
|
|
200
|
+
id: "issue_epic_2",
|
|
201
|
+
identifier: "ENG-43",
|
|
202
|
+
}),
|
|
199
203
|
delete: mockDelete,
|
|
200
204
|
};
|
|
201
205
|
|
|
@@ -208,7 +212,7 @@ describe("LinearAdapter - Dependency Operations", () => {
|
|
|
208
212
|
id: "issue_epic_2",
|
|
209
213
|
identifier: "ENG-43",
|
|
210
214
|
_raw: {
|
|
211
|
-
|
|
215
|
+
inverseRelations: mock(async () => ({
|
|
212
216
|
nodes: [mockRelation],
|
|
213
217
|
})),
|
|
214
218
|
},
|
|
@@ -235,7 +239,7 @@ describe("LinearAdapter - Dependency Operations", () => {
|
|
|
235
239
|
id: "issue_epic_1",
|
|
236
240
|
identifier: "ENG-42",
|
|
237
241
|
_raw: {
|
|
238
|
-
|
|
242
|
+
inverseRelations: mock(async () => ({ nodes: [] })),
|
|
239
243
|
},
|
|
240
244
|
});
|
|
241
245
|
|
|
@@ -265,7 +269,7 @@ describe("LinearAdapter - Dependency Operations", () => {
|
|
|
265
269
|
id: "issue_epic_2",
|
|
266
270
|
identifier: "ENG-43",
|
|
267
271
|
_raw: {
|
|
268
|
-
|
|
272
|
+
inverseRelations: mock(async () => ({
|
|
269
273
|
nodes: [], // No relations
|
|
270
274
|
})),
|
|
271
275
|
},
|
|
@@ -294,19 +298,31 @@ describe("LinearAdapter - Dependency Operations", () => {
|
|
|
294
298
|
id: "issue_epic_1",
|
|
295
299
|
identifier: "ENG-43",
|
|
296
300
|
_raw: {
|
|
297
|
-
|
|
301
|
+
inverseRelations: mock(async () => ({
|
|
298
302
|
nodes: [
|
|
299
303
|
{
|
|
300
304
|
id: "relation_1",
|
|
301
305
|
type: "blocks",
|
|
302
|
-
issue: {
|
|
303
|
-
|
|
306
|
+
issue: Promise.resolve({
|
|
307
|
+
id: "issue_epic_2",
|
|
308
|
+
identifier: "ENG-42",
|
|
309
|
+
}),
|
|
310
|
+
relatedIssue: Promise.resolve({
|
|
311
|
+
id: "issue_epic_1",
|
|
312
|
+
identifier: "ENG-43",
|
|
313
|
+
}),
|
|
304
314
|
},
|
|
305
315
|
{
|
|
306
316
|
id: "relation_2",
|
|
307
317
|
type: "blocks",
|
|
308
|
-
issue: {
|
|
309
|
-
|
|
318
|
+
issue: Promise.resolve({
|
|
319
|
+
id: "issue_epic_3",
|
|
320
|
+
identifier: "ENG-44",
|
|
321
|
+
}),
|
|
322
|
+
relatedIssue: Promise.resolve({
|
|
323
|
+
id: "issue_epic_1",
|
|
324
|
+
identifier: "ENG-43",
|
|
325
|
+
}),
|
|
310
326
|
},
|
|
311
327
|
],
|
|
312
328
|
})),
|
|
@@ -328,7 +344,7 @@ describe("LinearAdapter - Dependency Operations", () => {
|
|
|
328
344
|
id: "issue_epic_1",
|
|
329
345
|
identifier: "ENG-42",
|
|
330
346
|
_raw: {
|
|
331
|
-
|
|
347
|
+
inverseRelations: mock(async () => ({
|
|
332
348
|
nodes: [],
|
|
333
349
|
})),
|
|
334
350
|
},
|
|
@@ -349,25 +365,43 @@ describe("LinearAdapter - Dependency Operations", () => {
|
|
|
349
365
|
id: "issue_epic_1",
|
|
350
366
|
identifier: "ENG-43",
|
|
351
367
|
_raw: {
|
|
352
|
-
|
|
368
|
+
inverseRelations: mock(async () => ({
|
|
353
369
|
nodes: [
|
|
354
370
|
{
|
|
355
371
|
id: "relation_1",
|
|
356
372
|
type: "blocks",
|
|
357
|
-
issue: {
|
|
358
|
-
|
|
373
|
+
issue: Promise.resolve({
|
|
374
|
+
id: "issue_epic_2",
|
|
375
|
+
identifier: "ENG-42",
|
|
376
|
+
}),
|
|
377
|
+
relatedIssue: Promise.resolve({
|
|
378
|
+
id: "issue_epic_1",
|
|
379
|
+
identifier: "ENG-43",
|
|
380
|
+
}),
|
|
359
381
|
},
|
|
360
382
|
{
|
|
361
383
|
id: "relation_2",
|
|
362
384
|
type: "duplicate",
|
|
363
|
-
issue: {
|
|
364
|
-
|
|
385
|
+
issue: Promise.resolve({
|
|
386
|
+
id: "issue_epic_3",
|
|
387
|
+
identifier: "ENG-44",
|
|
388
|
+
}),
|
|
389
|
+
relatedIssue: Promise.resolve({
|
|
390
|
+
id: "issue_epic_1",
|
|
391
|
+
identifier: "ENG-43",
|
|
392
|
+
}),
|
|
365
393
|
},
|
|
366
394
|
{
|
|
367
395
|
id: "relation_3",
|
|
368
396
|
type: "related",
|
|
369
|
-
issue: {
|
|
370
|
-
|
|
397
|
+
issue: Promise.resolve({
|
|
398
|
+
id: "issue_epic_4",
|
|
399
|
+
identifier: "ENG-45",
|
|
400
|
+
}),
|
|
401
|
+
relatedIssue: Promise.resolve({
|
|
402
|
+
id: "issue_epic_1",
|
|
403
|
+
identifier: "ENG-43",
|
|
404
|
+
}),
|
|
371
405
|
},
|
|
372
406
|
],
|
|
373
407
|
})),
|
|
@@ -887,16 +887,22 @@ export class LinearAdapter implements BackendAdapter {
|
|
|
887
887
|
if (!blockedIssue) throw new Error(`Issue not found: ${ref}`);
|
|
888
888
|
if (!blockerIssue) throw new Error(`Issue not found: ${dependsOnRef}`);
|
|
889
889
|
|
|
890
|
+
// Use inverseRelations to find relations where blockedIssue is the target
|
|
890
891
|
const relations = await this.client.execute<any>(() =>
|
|
891
|
-
blockedIssue._raw.
|
|
892
|
+
blockedIssue._raw.inverseRelations(),
|
|
892
893
|
);
|
|
893
894
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
895
|
+
// Find the relation to delete - need to await rel.issue since it's lazy-loaded
|
|
896
|
+
let relationToDelete: any = null;
|
|
897
|
+
for (const rel of relations.nodes) {
|
|
898
|
+
if (rel.type === "blocks") {
|
|
899
|
+
const relIssue = await rel.issue;
|
|
900
|
+
if (relIssue?.id === blockerIssue.id) {
|
|
901
|
+
relationToDelete = rel;
|
|
902
|
+
break;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
}
|
|
900
906
|
|
|
901
907
|
if (!relationToDelete) {
|
|
902
908
|
throw new Error(
|
|
@@ -911,18 +917,17 @@ export class LinearAdapter implements BackendAdapter {
|
|
|
911
917
|
const issue = await this.fetchIssue(ref);
|
|
912
918
|
if (!issue) throw new Error(`Issue not found: ${ref}`);
|
|
913
919
|
|
|
920
|
+
// Use inverseRelations to get relations where this issue is the target (relatedIssueId)
|
|
921
|
+
// This finds issues that block this one
|
|
914
922
|
const relations = await this.client.execute<any>(() =>
|
|
915
|
-
issue._raw.
|
|
923
|
+
issue._raw.inverseRelations(),
|
|
916
924
|
);
|
|
917
925
|
|
|
918
926
|
const blockingRefs: string[] = [];
|
|
919
927
|
for (const rel of relations.nodes) {
|
|
920
|
-
if (
|
|
921
|
-
|
|
922
|
-
rel.
|
|
923
|
-
(rel.relatedIssue as any).id === issue.id
|
|
924
|
-
) {
|
|
925
|
-
const blockerIssue = rel.issue as any;
|
|
928
|
+
if (rel.type === "blocks") {
|
|
929
|
+
// Linear SDK returns lazy-loaded promises for related objects
|
|
930
|
+
const blockerIssue = await rel.issue;
|
|
926
931
|
if (blockerIssue?.identifier) {
|
|
927
932
|
blockingRefs.push(blockerIssue.identifier);
|
|
928
933
|
}
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
rmSync,
|
|
13
13
|
writeFileSync,
|
|
14
14
|
} from "node:fs";
|
|
15
|
-
import { join } from "node:path";
|
|
15
|
+
import { isAbsolute, join } from "node:path";
|
|
16
16
|
import { config } from "../config.js";
|
|
17
17
|
import {
|
|
18
18
|
count,
|
|
@@ -108,6 +108,12 @@ interface ProjectRow {
|
|
|
108
108
|
// =============================================================================
|
|
109
109
|
|
|
110
110
|
function toPrd(row: PrdRow): Prd {
|
|
111
|
+
// Normalize folderPath to absolute path for consistency
|
|
112
|
+
let folderPath = row.folder_path ?? undefined;
|
|
113
|
+
if (folderPath && !isAbsolute(folderPath)) {
|
|
114
|
+
folderPath = join(config.projectRoot, folderPath);
|
|
115
|
+
}
|
|
116
|
+
|
|
111
117
|
return {
|
|
112
118
|
id: row.id,
|
|
113
119
|
projectId: row.project_id,
|
|
@@ -116,7 +122,7 @@ function toPrd(row: PrdRow): Prd {
|
|
|
116
122
|
description: row.description ?? undefined,
|
|
117
123
|
status: row.status as Prd["status"],
|
|
118
124
|
tag: row.tag ?? undefined,
|
|
119
|
-
folderPath
|
|
125
|
+
folderPath,
|
|
120
126
|
createdAt: row.created_at,
|
|
121
127
|
updatedAt: row.updated_at,
|
|
122
128
|
};
|
|
@@ -312,6 +318,14 @@ export class LocalAdapter implements BackendAdapter {
|
|
|
312
318
|
cascade.epics++;
|
|
313
319
|
}
|
|
314
320
|
|
|
321
|
+
// Delete PRD dependencies
|
|
322
|
+
const deletedDeps = db
|
|
323
|
+
.query(
|
|
324
|
+
"DELETE FROM prd_dependencies WHERE prd_id = ? OR depends_on_prd_id = ?",
|
|
325
|
+
)
|
|
326
|
+
.run(prd.id, prd.id);
|
|
327
|
+
cascade.dependencies += deletedDeps.changes;
|
|
328
|
+
|
|
315
329
|
remove(db, "prds", prd.id);
|
|
316
330
|
|
|
317
331
|
return { deleted: ref, cascade };
|
|
@@ -710,7 +724,15 @@ export class LocalAdapter implements BackendAdapter {
|
|
|
710
724
|
throw new Error("Dependencies must be between entities of the same type");
|
|
711
725
|
}
|
|
712
726
|
|
|
713
|
-
if (entityType === "
|
|
727
|
+
if (entityType === "P") {
|
|
728
|
+
const prd = findByRef<PrdRow>(db, "prds", ref);
|
|
729
|
+
const dependsOnPrd = findByRef<PrdRow>(db, "prds", dependsOnRef);
|
|
730
|
+
if (!prd || !dependsOnPrd) throw new Error("PRD not found");
|
|
731
|
+
|
|
732
|
+
db.query(
|
|
733
|
+
"INSERT OR IGNORE INTO prd_dependencies (prd_id, depends_on_prd_id) VALUES (?, ?)",
|
|
734
|
+
).run(prd.id, dependsOnPrd.id);
|
|
735
|
+
} else if (entityType === "E") {
|
|
714
736
|
const epic = findByRef<EpicRow>(db, "epics", ref);
|
|
715
737
|
const dependsOnEpic = findByRef<EpicRow>(db, "epics", dependsOnRef);
|
|
716
738
|
if (!epic || !dependsOnEpic) throw new Error("Epic not found");
|
|
@@ -726,8 +748,6 @@ export class LocalAdapter implements BackendAdapter {
|
|
|
726
748
|
db.query(
|
|
727
749
|
"INSERT OR IGNORE INTO task_dependencies (task_id, depends_on_task_id) VALUES (?, ?)",
|
|
728
750
|
).run(task.id, dependsOnTask.id);
|
|
729
|
-
} else {
|
|
730
|
-
throw new Error("Dependencies can only be added to Epics or Tasks");
|
|
731
751
|
}
|
|
732
752
|
}
|
|
733
753
|
|
|
@@ -735,7 +755,15 @@ export class LocalAdapter implements BackendAdapter {
|
|
|
735
755
|
const db = getDb();
|
|
736
756
|
const entityType = getEntityType(ref);
|
|
737
757
|
|
|
738
|
-
if (entityType === "
|
|
758
|
+
if (entityType === "P") {
|
|
759
|
+
const prd = findByRef<PrdRow>(db, "prds", ref);
|
|
760
|
+
const dependsOnPrd = findByRef<PrdRow>(db, "prds", dependsOnRef);
|
|
761
|
+
if (!prd || !dependsOnPrd) throw new Error("PRD not found");
|
|
762
|
+
|
|
763
|
+
db.query(
|
|
764
|
+
"DELETE FROM prd_dependencies WHERE prd_id = ? AND depends_on_prd_id = ?",
|
|
765
|
+
).run(prd.id, dependsOnPrd.id);
|
|
766
|
+
} else if (entityType === "E") {
|
|
739
767
|
const epic = findByRef<EpicRow>(db, "epics", ref);
|
|
740
768
|
const dependsOnEpic = findByRef<EpicRow>(db, "epics", dependsOnRef);
|
|
741
769
|
if (!epic || !dependsOnEpic) throw new Error("Epic not found");
|
|
@@ -758,7 +786,20 @@ export class LocalAdapter implements BackendAdapter {
|
|
|
758
786
|
const db = getDb();
|
|
759
787
|
const entityType = getEntityType(ref);
|
|
760
788
|
|
|
761
|
-
if (entityType === "
|
|
789
|
+
if (entityType === "P") {
|
|
790
|
+
const prd = findByRef<PrdRow>(db, "prds", ref);
|
|
791
|
+
if (!prd) return [];
|
|
792
|
+
|
|
793
|
+
const deps = db
|
|
794
|
+
.query(
|
|
795
|
+
`SELECT p.ref FROM prd_dependencies pd
|
|
796
|
+
JOIN prds p ON pd.depends_on_prd_id = p.id
|
|
797
|
+
WHERE pd.prd_id = ?`,
|
|
798
|
+
)
|
|
799
|
+
.all(prd.id) as { ref: string }[];
|
|
800
|
+
|
|
801
|
+
return deps.map((d) => d.ref);
|
|
802
|
+
} else if (entityType === "E") {
|
|
762
803
|
const epic = findByRef<EpicRow>(db, "epics", ref);
|
|
763
804
|
if (!epic) return [];
|
|
764
805
|
|
|
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
|
|
5
5
|
// Set up test environment BEFORE any imports
|
|
6
|
-
const TEST_DIR = `/tmp/flux-test-db-${Date.now()}`;
|
|
6
|
+
const TEST_DIR = `/tmp/flux-test-db-${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
7
7
|
const FLUX_DIR = join(TEST_DIR, ".flux");
|
|
8
8
|
process.env.FLUX_PROJECT_ROOT = TEST_DIR;
|
|
9
9
|
|
|
@@ -28,6 +28,7 @@ describe("Database Queries", () => {
|
|
|
28
28
|
let projectId: string;
|
|
29
29
|
|
|
30
30
|
beforeEach(() => {
|
|
31
|
+
closeDb();
|
|
31
32
|
config.clearCache();
|
|
32
33
|
process.env.FLUX_PROJECT_ROOT = TEST_DIR;
|
|
33
34
|
|