@m0xoo/openboard 1.0.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/README.md +99 -0
- package/bin/openboard.js +93 -0
- package/package.json +33 -0
- package/packages/client/dist/assets/index-CAahrBYB.css +1 -0
- package/packages/client/dist/assets/index-CvvU24UX.js +145 -0
- package/packages/client/dist/index.html +15 -0
- package/packages/server/dist/agents/agent-queue.js +175 -0
- package/packages/server/dist/agents/agent-runner.js +18 -0
- package/packages/server/dist/agents/agent.interface.js +1 -0
- package/packages/server/dist/agents/codereview.agent.js +132 -0
- package/packages/server/dist/agents/dummy.agent.js +49 -0
- package/packages/server/dist/agents/opencode.agent.js +214 -0
- package/packages/server/dist/agents/opencode.events.js +398 -0
- package/packages/server/dist/db/database.js +159 -0
- package/packages/server/dist/index.js +66 -0
- package/packages/server/dist/repositories/board.repository.js +56 -0
- package/packages/server/dist/repositories/column-config.repository.js +30 -0
- package/packages/server/dist/repositories/column.repository.js +36 -0
- package/packages/server/dist/repositories/comment.repository.js +35 -0
- package/packages/server/dist/repositories/ticket.repository.js +158 -0
- package/packages/server/dist/routes/boards.router.js +33 -0
- package/packages/server/dist/routes/column-config.router.js +42 -0
- package/packages/server/dist/routes/columns.router.js +45 -0
- package/packages/server/dist/routes/tickets.router.js +88 -0
- package/packages/server/dist/sse.js +43 -0
- package/packages/server/dist/types.js +2 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { getDb } from '../db/database.js';
|
|
3
|
+
export const boardRepository = {
|
|
4
|
+
findAll() {
|
|
5
|
+
const db = getDb();
|
|
6
|
+
const boards = db.prepare('SELECT * FROM boards ORDER BY created_at ASC').all();
|
|
7
|
+
return boards.map(board => {
|
|
8
|
+
const workspaces = db.prepare('SELECT * FROM board_workspaces WHERE board_id = ?').all(board.id);
|
|
9
|
+
return {
|
|
10
|
+
...board,
|
|
11
|
+
workspaces
|
|
12
|
+
};
|
|
13
|
+
});
|
|
14
|
+
},
|
|
15
|
+
findById(id) {
|
|
16
|
+
const db = getDb();
|
|
17
|
+
const board = db.prepare('SELECT * FROM boards WHERE id = ?').get(id);
|
|
18
|
+
if (!board)
|
|
19
|
+
return undefined;
|
|
20
|
+
const workspaces = db.prepare('SELECT * FROM board_workspaces WHERE board_id = ?').all(id);
|
|
21
|
+
return {
|
|
22
|
+
...board,
|
|
23
|
+
workspaces
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
create(name, workspaces = []) {
|
|
27
|
+
const boardId = randomUUID();
|
|
28
|
+
const db = getDb();
|
|
29
|
+
db.transaction(() => {
|
|
30
|
+
db.prepare('INSERT INTO boards (id, name) VALUES (?, ?)').run(boardId, name);
|
|
31
|
+
for (const ws of workspaces) {
|
|
32
|
+
db.prepare('INSERT INTO board_workspaces (id, board_id, type, path) VALUES (?, ?, ?, ?)')
|
|
33
|
+
.run(randomUUID(), boardId, ws.type, ws.path);
|
|
34
|
+
}
|
|
35
|
+
})();
|
|
36
|
+
return this.findById(boardId);
|
|
37
|
+
},
|
|
38
|
+
update(id, name, workspaces) {
|
|
39
|
+
const db = getDb();
|
|
40
|
+
db.transaction(() => {
|
|
41
|
+
db.prepare('UPDATE boards SET name = ? WHERE id = ?').run(name, id);
|
|
42
|
+
if (workspaces) {
|
|
43
|
+
// Simple sync: delete all and re-create
|
|
44
|
+
db.prepare('DELETE FROM board_workspaces WHERE board_id = ?').run(id);
|
|
45
|
+
for (const ws of workspaces) {
|
|
46
|
+
db.prepare('INSERT INTO board_workspaces (id, board_id, type, path) VALUES (?, ?, ?, ?)')
|
|
47
|
+
.run(randomUUID(), id, ws.type, ws.path);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
})();
|
|
51
|
+
return this.findById(id);
|
|
52
|
+
},
|
|
53
|
+
delete(id) {
|
|
54
|
+
getDb().prepare('DELETE FROM boards WHERE id = ?').run(id);
|
|
55
|
+
},
|
|
56
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { getDb } from '../db/database.js';
|
|
2
|
+
export const columnConfigRepository = {
|
|
3
|
+
findByColumnId(columnId) {
|
|
4
|
+
return getDb()
|
|
5
|
+
.prepare('SELECT * FROM column_configs WHERE column_id = ?')
|
|
6
|
+
.get(columnId);
|
|
7
|
+
},
|
|
8
|
+
findByBoardId(boardId) {
|
|
9
|
+
// Since board_id is not in column_configs, we join with columns
|
|
10
|
+
return getDb()
|
|
11
|
+
.prepare(`
|
|
12
|
+
SELECT cf.* FROM column_configs cf
|
|
13
|
+
JOIN columns c ON cf.column_id = c.id
|
|
14
|
+
WHERE c.board_id = ?
|
|
15
|
+
`)
|
|
16
|
+
.all(boardId);
|
|
17
|
+
},
|
|
18
|
+
upsert(data) {
|
|
19
|
+
getDb()
|
|
20
|
+
.prepare(`INSERT OR REPLACE INTO column_configs (column_id, agent_type, agent_model, max_agents, on_finish_column_id, on_reject_column_id)
|
|
21
|
+
VALUES (?, ?, ?, ?, ?, ?)`)
|
|
22
|
+
.run(data.columnId, data.agentType, data.agentModel ?? null, data.maxAgents ?? 1, data.onFinishColumnId ?? null, data.onRejectColumnId ?? null);
|
|
23
|
+
return this.findByColumnId(data.columnId);
|
|
24
|
+
},
|
|
25
|
+
delete(columnId) {
|
|
26
|
+
getDb()
|
|
27
|
+
.prepare('DELETE FROM column_configs WHERE column_id = ?')
|
|
28
|
+
.run(columnId);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { getDb } from '../db/database.js';
|
|
3
|
+
export const columnRepository = {
|
|
4
|
+
findByBoardId(boardId) {
|
|
5
|
+
return getDb()
|
|
6
|
+
.prepare('SELECT * FROM columns WHERE board_id = ? ORDER BY position ASC')
|
|
7
|
+
.all(boardId);
|
|
8
|
+
},
|
|
9
|
+
findById(id) {
|
|
10
|
+
return getDb().prepare('SELECT * FROM columns WHERE id = ?').get(id);
|
|
11
|
+
},
|
|
12
|
+
create(boardId, name) {
|
|
13
|
+
const id = randomUUID();
|
|
14
|
+
const maxPos = getDb()
|
|
15
|
+
.prepare('SELECT COALESCE(MAX(position), -1) as m FROM columns WHERE board_id = ?')
|
|
16
|
+
.get(boardId).m;
|
|
17
|
+
getDb()
|
|
18
|
+
.prepare('INSERT INTO columns (id, board_id, name, position) VALUES (?, ?, ?, ?)')
|
|
19
|
+
.run(id, boardId, name, maxPos + 1);
|
|
20
|
+
return this.findById(id);
|
|
21
|
+
},
|
|
22
|
+
update(id, name) {
|
|
23
|
+
getDb().prepare('UPDATE columns SET name = ? WHERE id = ?').run(name, id);
|
|
24
|
+
return this.findById(id);
|
|
25
|
+
},
|
|
26
|
+
reorder(boardId, orderedIds) {
|
|
27
|
+
const update = getDb().prepare('UPDATE columns SET position = ? WHERE id = ? AND board_id = ?');
|
|
28
|
+
const transaction = getDb().transaction(() => {
|
|
29
|
+
orderedIds.forEach((id, index) => update.run(index, id, boardId));
|
|
30
|
+
});
|
|
31
|
+
transaction();
|
|
32
|
+
},
|
|
33
|
+
delete(id) {
|
|
34
|
+
getDb().prepare('DELETE FROM columns WHERE id = ?').run(id);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { getDb } from '../db/database.js';
|
|
3
|
+
import { sseManager } from '../sse.js';
|
|
4
|
+
export const commentRepository = {
|
|
5
|
+
findByTicketId(ticketId) {
|
|
6
|
+
const db = getDb();
|
|
7
|
+
return db.prepare('SELECT * FROM comments WHERE ticket_id = ? ORDER BY created_at ASC').all(ticketId);
|
|
8
|
+
},
|
|
9
|
+
create(data) {
|
|
10
|
+
const id = randomUUID();
|
|
11
|
+
const db = getDb();
|
|
12
|
+
db.prepare('INSERT INTO comments (id, ticket_id, author, content) VALUES (?, ?, ?, ?)')
|
|
13
|
+
.run(id, data.ticketId, data.author ?? 'agent', data.content);
|
|
14
|
+
const comment = db.prepare('SELECT * FROM comments WHERE id = ?').get(id);
|
|
15
|
+
if (comment) {
|
|
16
|
+
sseManager.emit(data.ticketId, 'comment:added', {
|
|
17
|
+
ticketId: data.ticketId,
|
|
18
|
+
comment,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
return comment;
|
|
22
|
+
},
|
|
23
|
+
update(id, content) {
|
|
24
|
+
const db = getDb();
|
|
25
|
+
db.prepare('UPDATE comments SET content = ? WHERE id = ?').run(content, id);
|
|
26
|
+
const comment = db.prepare('SELECT * FROM comments WHERE id = ?').get(id);
|
|
27
|
+
if (comment) {
|
|
28
|
+
sseManager.emit(comment.ticket_id, 'comment:updated', {
|
|
29
|
+
ticketId: comment.ticket_id,
|
|
30
|
+
comment,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
return comment;
|
|
34
|
+
},
|
|
35
|
+
};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { getDb } from '../db/database.js';
|
|
3
|
+
import { sseManager } from '../sse.js';
|
|
4
|
+
function parseTicket(row) {
|
|
5
|
+
let agent_sessions = [];
|
|
6
|
+
if (row.agent_sessions) {
|
|
7
|
+
try {
|
|
8
|
+
agent_sessions = JSON.parse(row.agent_sessions);
|
|
9
|
+
}
|
|
10
|
+
catch { }
|
|
11
|
+
}
|
|
12
|
+
return {
|
|
13
|
+
...row,
|
|
14
|
+
agent_sessions
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export const ticketRepository = {
|
|
18
|
+
findByColumnId(columnId) {
|
|
19
|
+
const rows = getDb()
|
|
20
|
+
.prepare('SELECT * FROM tickets WHERE column_id = ? ORDER BY position ASC')
|
|
21
|
+
.all(columnId);
|
|
22
|
+
return rows.map(parseTicket);
|
|
23
|
+
},
|
|
24
|
+
findByBoardId(boardId) {
|
|
25
|
+
const rows = getDb()
|
|
26
|
+
.prepare('SELECT * FROM tickets WHERE board_id = ? ORDER BY position ASC')
|
|
27
|
+
.all(boardId);
|
|
28
|
+
return rows.map(parseTicket);
|
|
29
|
+
},
|
|
30
|
+
findById(id) {
|
|
31
|
+
const row = getDb().prepare('SELECT * FROM tickets WHERE id = ?').get(id);
|
|
32
|
+
if (!row)
|
|
33
|
+
return undefined;
|
|
34
|
+
return parseTicket(row);
|
|
35
|
+
},
|
|
36
|
+
create(data) {
|
|
37
|
+
const id = randomUUID();
|
|
38
|
+
const maxPos = getDb()
|
|
39
|
+
.prepare('SELECT COALESCE(MAX(position), -1) as m FROM tickets WHERE column_id = ?')
|
|
40
|
+
.get(data.columnId).m;
|
|
41
|
+
getDb()
|
|
42
|
+
.prepare(`INSERT INTO tickets (id, column_id, board_id, title, description, priority, position)
|
|
43
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
|
44
|
+
.run(id, data.columnId, data.boardId, data.title, data.description ?? '', data.priority ?? 'medium', maxPos + 1);
|
|
45
|
+
const savedTicket = this.findById(id);
|
|
46
|
+
sseManager.emit(savedTicket.board_id, 'ticket:updated', savedTicket);
|
|
47
|
+
return savedTicket;
|
|
48
|
+
},
|
|
49
|
+
update(id, data) {
|
|
50
|
+
const ticket = this.findById(id);
|
|
51
|
+
if (!ticket)
|
|
52
|
+
return undefined;
|
|
53
|
+
const updated = { ...ticket, ...data, updated_at: new Date().toISOString() };
|
|
54
|
+
getDb()
|
|
55
|
+
.prepare(`UPDATE tickets SET title = ?, description = ?, priority = ?, updated_at = ? WHERE id = ?`)
|
|
56
|
+
.run(updated.title, updated.description, updated.priority, updated.updated_at, id);
|
|
57
|
+
const savedTicket = this.findById(id);
|
|
58
|
+
if (savedTicket) {
|
|
59
|
+
sseManager.emit(savedTicket.board_id, 'ticket:updated', savedTicket);
|
|
60
|
+
}
|
|
61
|
+
return savedTicket;
|
|
62
|
+
},
|
|
63
|
+
move(id, toColumnId, position) {
|
|
64
|
+
const db = getDb();
|
|
65
|
+
const existing = db.prepare('SELECT column_id, position FROM tickets WHERE id = ?').get(id);
|
|
66
|
+
if (!existing)
|
|
67
|
+
return undefined;
|
|
68
|
+
const fromColumnId = existing.column_id;
|
|
69
|
+
const fromPosition = existing.position;
|
|
70
|
+
db.transaction(() => {
|
|
71
|
+
if (fromColumnId === toColumnId) {
|
|
72
|
+
// Moving within same column
|
|
73
|
+
if (fromPosition === position)
|
|
74
|
+
return; // No-op
|
|
75
|
+
if (fromPosition < position) {
|
|
76
|
+
// Moving down: shift items between old and new position up
|
|
77
|
+
db.prepare('UPDATE tickets SET position = position - 1 WHERE column_id = ? AND position > ? AND position <= ?').run(toColumnId, fromPosition, position);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
// Moving up: shift items between new and old position down
|
|
81
|
+
db.prepare('UPDATE tickets SET position = position + 1 WHERE column_id = ? AND position >= ? AND position < ?').run(toColumnId, position, fromPosition);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
// Moving to different column
|
|
86
|
+
// 1. Shift items in destination column down to make room
|
|
87
|
+
db.prepare('UPDATE tickets SET position = position + 1 WHERE column_id = ? AND position >= ?').run(toColumnId, position);
|
|
88
|
+
// 2. Clear gaps in source column
|
|
89
|
+
db.prepare('UPDATE tickets SET position = position - 1 WHERE column_id = ? AND position > ?').run(fromColumnId, fromPosition);
|
|
90
|
+
}
|
|
91
|
+
// Finally, update the target ticket (agent history remains intact)
|
|
92
|
+
db.prepare(`UPDATE tickets SET column_id = ?, position = ?, updated_at = datetime('now') WHERE id = ?`).run(toColumnId, position, id);
|
|
93
|
+
})();
|
|
94
|
+
const updatedTicket = this.findById(id);
|
|
95
|
+
if (updatedTicket) {
|
|
96
|
+
sseManager.emit(updatedTicket.board_id, 'ticket:updated', updatedTicket);
|
|
97
|
+
}
|
|
98
|
+
return updatedTicket;
|
|
99
|
+
},
|
|
100
|
+
updateAgentSession(id, sessionData) {
|
|
101
|
+
const ticket = this.findById(id);
|
|
102
|
+
if (!ticket)
|
|
103
|
+
return undefined;
|
|
104
|
+
const sessions = [...ticket.agent_sessions];
|
|
105
|
+
// Find the index of the most recent session for this column
|
|
106
|
+
let existingIdx = -1;
|
|
107
|
+
for (let i = sessions.length - 1; i >= 0; i--) {
|
|
108
|
+
if (sessions[i].column_id === sessionData.column_id) {
|
|
109
|
+
existingIdx = i;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const isTerminalStatus = (s) => s === 'done' || s === 'blocked';
|
|
114
|
+
let isNewSession = false;
|
|
115
|
+
if (existingIdx === -1) {
|
|
116
|
+
// No session ever Existed for this column
|
|
117
|
+
isNewSession = true;
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
const existingSession = sessions[existingIdx];
|
|
121
|
+
// If the existing session is already in a terminal state (done or blocked)
|
|
122
|
+
// AND we are trying to start a new 'processing' session, then we append.
|
|
123
|
+
if (isTerminalStatus(existingSession.status) && sessionData.status === 'processing') {
|
|
124
|
+
isNewSession = true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (isNewSession) {
|
|
128
|
+
const newSession = {
|
|
129
|
+
...sessionData,
|
|
130
|
+
started_at: new Date().toISOString(),
|
|
131
|
+
// If it starts terminal (which would be weird but possible), set finished_at
|
|
132
|
+
finished_at: isTerminalStatus(sessionData.status) ? new Date().toISOString() : undefined
|
|
133
|
+
};
|
|
134
|
+
sessions.push(newSession);
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
// Update the existing (active) session
|
|
138
|
+
const newSession = {
|
|
139
|
+
...sessions[existingIdx], // Preserve started_at and any missing fields
|
|
140
|
+
...sessionData,
|
|
141
|
+
finished_at: isTerminalStatus(sessionData.status) ? new Date().toISOString() : undefined
|
|
142
|
+
};
|
|
143
|
+
sessions[existingIdx] = newSession;
|
|
144
|
+
}
|
|
145
|
+
const updated = { ...ticket, agent_sessions: sessions, updated_at: new Date().toISOString() };
|
|
146
|
+
getDb()
|
|
147
|
+
.prepare('UPDATE tickets SET agent_sessions = ?, updated_at = ? WHERE id = ?')
|
|
148
|
+
.run(JSON.stringify(sessions), updated.updated_at, id);
|
|
149
|
+
const savedTicket = this.findById(id);
|
|
150
|
+
if (savedTicket) {
|
|
151
|
+
sseManager.emit(savedTicket.board_id, 'ticket:updated', savedTicket);
|
|
152
|
+
}
|
|
153
|
+
return savedTicket;
|
|
154
|
+
},
|
|
155
|
+
delete(id) {
|
|
156
|
+
getDb().prepare('DELETE FROM tickets WHERE id = ?').run(id);
|
|
157
|
+
},
|
|
158
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { boardRepository } from '../repositories/board.repository.js';
|
|
3
|
+
import { sseManager } from '../sse.js';
|
|
4
|
+
const router = Router();
|
|
5
|
+
router.get('/', (_req, res) => {
|
|
6
|
+
res.json(boardRepository.findAll());
|
|
7
|
+
});
|
|
8
|
+
router.post('/', (req, res) => {
|
|
9
|
+
const { name, workspaces } = req.body;
|
|
10
|
+
if (!name?.trim()) {
|
|
11
|
+
res.status(400).json({ error: 'Name is required' });
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const board = boardRepository.create(name.trim(), workspaces);
|
|
15
|
+
sseManager.emitGlobal('board:created', board);
|
|
16
|
+
res.status(201).json(board);
|
|
17
|
+
});
|
|
18
|
+
router.patch('/:id', (req, res) => {
|
|
19
|
+
const { name, workspaces } = req.body;
|
|
20
|
+
const board = boardRepository.update(req.params.id, name?.trim(), workspaces);
|
|
21
|
+
if (!board) {
|
|
22
|
+
res.status(404).json({ error: 'Board not found' });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
sseManager.emitGlobal('board:updated', board);
|
|
26
|
+
res.json(board);
|
|
27
|
+
});
|
|
28
|
+
router.delete('/:id', (req, res) => {
|
|
29
|
+
boardRepository.delete(req.params.id);
|
|
30
|
+
sseManager.emitGlobal('board:deleted', { id: req.params.id });
|
|
31
|
+
res.status(204).end();
|
|
32
|
+
});
|
|
33
|
+
export { router as boardsRouter };
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { columnConfigRepository } from '../repositories/column-config.repository.js';
|
|
3
|
+
import { sseManager } from '../sse.js';
|
|
4
|
+
const router = Router({ mergeParams: true });
|
|
5
|
+
// GET /api/boards/:boardId/columns/configs
|
|
6
|
+
// Fetches all configs for all columns in this board
|
|
7
|
+
router.get('/configs', (req, res) => {
|
|
8
|
+
const configs = columnConfigRepository.findByBoardId(req.params.boardId);
|
|
9
|
+
res.json(configs);
|
|
10
|
+
});
|
|
11
|
+
// GET /api/boards/:boardId/columns/:id/config
|
|
12
|
+
router.get('/:id/config', (req, res) => {
|
|
13
|
+
const config = columnConfigRepository.findByColumnId(req.params.id);
|
|
14
|
+
if (!config) {
|
|
15
|
+
res.json({ column_id: req.params.id, agent_type: 'none', on_finish_column_id: null });
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
res.json(config);
|
|
19
|
+
});
|
|
20
|
+
// PUT /api/boards/:boardId/columns/:id/config
|
|
21
|
+
router.put('/:id/config', (req, res) => {
|
|
22
|
+
const { agentType, onFinishColumnId, onRejectColumnId } = req.body;
|
|
23
|
+
if (!agentType) {
|
|
24
|
+
res.status(400).json({ error: 'agentType is required' });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const config = columnConfigRepository.upsert({
|
|
28
|
+
columnId: req.params.id,
|
|
29
|
+
agentType,
|
|
30
|
+
onFinishColumnId,
|
|
31
|
+
onRejectColumnId,
|
|
32
|
+
});
|
|
33
|
+
sseManager.emit(req.params.boardId, 'column:config:updated', config);
|
|
34
|
+
res.json(config);
|
|
35
|
+
});
|
|
36
|
+
// DELETE /api/boards/:boardId/columns/:id/config
|
|
37
|
+
router.delete('/:id/config', (req, res) => {
|
|
38
|
+
columnConfigRepository.delete(req.params.id);
|
|
39
|
+
sseManager.emit(req.params.boardId, 'column:config:deleted', { columnId: req.params.id });
|
|
40
|
+
res.status(204).end();
|
|
41
|
+
});
|
|
42
|
+
export { router as columnConfigRouter };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { columnRepository } from '../repositories/column.repository.js';
|
|
3
|
+
import { sseManager } from '../sse.js';
|
|
4
|
+
const router = Router({ mergeParams: true });
|
|
5
|
+
// GET /api/boards/:boardId/columns
|
|
6
|
+
router.get('/', (req, res) => {
|
|
7
|
+
res.json(columnRepository.findByBoardId(req.params.boardId));
|
|
8
|
+
});
|
|
9
|
+
// POST /api/boards/:boardId/columns
|
|
10
|
+
router.post('/', (req, res) => {
|
|
11
|
+
const { name } = req.body;
|
|
12
|
+
if (!name?.trim()) {
|
|
13
|
+
res.status(400).json({ error: 'Name is required' });
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const column = columnRepository.create(req.params.boardId, name.trim());
|
|
17
|
+
sseManager.emit(req.params.boardId, 'column:created', column);
|
|
18
|
+
res.status(201).json(column);
|
|
19
|
+
});
|
|
20
|
+
// PATCH /api/boards/:boardId/columns/:id
|
|
21
|
+
router.patch('/:id', (req, res) => {
|
|
22
|
+
const { name } = req.body;
|
|
23
|
+
const column = columnRepository.update(req.params.id, name.trim());
|
|
24
|
+
if (!column) {
|
|
25
|
+
res.status(404).json({ error: 'Column not found' });
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
sseManager.emit(req.params.boardId, 'column:updated', column);
|
|
29
|
+
res.json(column);
|
|
30
|
+
});
|
|
31
|
+
// PUT /api/boards/:boardId/columns/reorder
|
|
32
|
+
router.put('/reorder', (req, res) => {
|
|
33
|
+
const { orderedIds } = req.body;
|
|
34
|
+
columnRepository.reorder(req.params.boardId, orderedIds);
|
|
35
|
+
const columns = columnRepository.findByBoardId(req.params.boardId);
|
|
36
|
+
sseManager.emit(req.params.boardId, 'columns:reordered', columns);
|
|
37
|
+
res.status(204).end();
|
|
38
|
+
});
|
|
39
|
+
// DELETE /api/boards/:boardId/columns/:id
|
|
40
|
+
router.delete('/:id', (req, res) => {
|
|
41
|
+
columnRepository.delete(req.params.id);
|
|
42
|
+
sseManager.emit(req.params.boardId, 'column:deleted', { id: req.params.id });
|
|
43
|
+
res.status(204).end();
|
|
44
|
+
});
|
|
45
|
+
export { router as columnsRouter };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { ticketRepository } from '../repositories/ticket.repository.js';
|
|
3
|
+
import { commentRepository } from '../repositories/comment.repository.js';
|
|
4
|
+
import { triggerAgent } from '../agents/agent-runner.js';
|
|
5
|
+
import { agentQueue } from '../agents/agent-queue.js';
|
|
6
|
+
const router = Router({ mergeParams: true });
|
|
7
|
+
// GET /api/boards/:boardId/tickets
|
|
8
|
+
router.get('/', (req, res) => {
|
|
9
|
+
res.json(ticketRepository.findByBoardId(req.params.boardId));
|
|
10
|
+
});
|
|
11
|
+
// POST /api/boards/:boardId/tickets
|
|
12
|
+
router.post('/', (req, res) => {
|
|
13
|
+
const { title, description, priority, columnId } = req.body;
|
|
14
|
+
if (!title?.trim() || !columnId) {
|
|
15
|
+
res.status(400).json({ error: 'title and columnId are required' });
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const ticket = ticketRepository.create({
|
|
19
|
+
boardId: req.params.boardId,
|
|
20
|
+
columnId,
|
|
21
|
+
title: title.trim(),
|
|
22
|
+
description,
|
|
23
|
+
priority,
|
|
24
|
+
});
|
|
25
|
+
triggerAgent(ticket);
|
|
26
|
+
// Return latest DB state (might have agent_status: 'processing')
|
|
27
|
+
const latest = ticketRepository.findById(ticket.id) || ticket;
|
|
28
|
+
res.status(201).json(latest);
|
|
29
|
+
});
|
|
30
|
+
// PATCH /api/boards/:boardId/tickets/:id
|
|
31
|
+
router.patch('/:id', (req, res) => {
|
|
32
|
+
const { title, description, priority } = req.body;
|
|
33
|
+
const ticket = ticketRepository.update(req.params.id, { title, description, priority });
|
|
34
|
+
if (!ticket) {
|
|
35
|
+
res.status(404).json({ error: 'Ticket not found' });
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
res.json(ticket);
|
|
39
|
+
});
|
|
40
|
+
// PUT /api/boards/:boardId/tickets/:id/move
|
|
41
|
+
router.put('/:id/move', (req, res) => {
|
|
42
|
+
const { toColumnId, position } = req.body;
|
|
43
|
+
const ticket = ticketRepository.move(req.params.id, toColumnId, position);
|
|
44
|
+
if (!ticket) {
|
|
45
|
+
res.status(404).json({ error: 'Ticket not found' });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
triggerAgent(ticket);
|
|
49
|
+
res.json(ticket);
|
|
50
|
+
});
|
|
51
|
+
// POST /api/boards/:boardId/tickets/:id/retry
|
|
52
|
+
router.post('/:id/retry', (req, res) => {
|
|
53
|
+
const ticket = ticketRepository.findById(req.params.id);
|
|
54
|
+
if (!ticket) {
|
|
55
|
+
res.status(404).json({ error: 'Ticket not found' });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
// Only retry if it failed or hasn't started
|
|
59
|
+
// We pass force=true so that if it is in 'blocked' state, it gets cleared.
|
|
60
|
+
triggerAgent(ticket, true);
|
|
61
|
+
res.status(202).json({ status: 'retrying' });
|
|
62
|
+
});
|
|
63
|
+
// DELETE /api/boards/:boardId/tickets/:id
|
|
64
|
+
router.delete('/:id', (req, res) => {
|
|
65
|
+
ticketRepository.delete(req.params.id);
|
|
66
|
+
agentQueue.ping();
|
|
67
|
+
res.status(204).end();
|
|
68
|
+
});
|
|
69
|
+
// GET /api/boards/:boardId/tickets/:id/comments
|
|
70
|
+
router.get('/:id/comments', (req, res) => {
|
|
71
|
+
const comments = commentRepository.findByTicketId(req.params.id);
|
|
72
|
+
res.json(comments);
|
|
73
|
+
});
|
|
74
|
+
// POST /api/boards/:boardId/tickets/:id/comments
|
|
75
|
+
router.post('/:id/comments', (req, res) => {
|
|
76
|
+
const { content, author } = req.body;
|
|
77
|
+
if (!content?.trim()) {
|
|
78
|
+
res.status(400).json({ error: 'content is required' });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const comment = commentRepository.create({
|
|
82
|
+
ticketId: req.params.id,
|
|
83
|
+
author,
|
|
84
|
+
content: content.trim(),
|
|
85
|
+
});
|
|
86
|
+
res.status(201).json(comment);
|
|
87
|
+
});
|
|
88
|
+
export { router as ticketsRouter };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
class SseManager {
|
|
2
|
+
// boardId -> set of connected SSE clients
|
|
3
|
+
// Use '*' for board-level events (created/deleted) that all clients should see
|
|
4
|
+
clients = new Map();
|
|
5
|
+
subscribe(boardId, res) {
|
|
6
|
+
if (!this.clients.has(boardId)) {
|
|
7
|
+
this.clients.set(boardId, new Set());
|
|
8
|
+
}
|
|
9
|
+
this.clients.get(boardId).add(res);
|
|
10
|
+
}
|
|
11
|
+
unsubscribe(boardId, res) {
|
|
12
|
+
this.clients.get(boardId)?.delete(res);
|
|
13
|
+
}
|
|
14
|
+
emit(boardId, event, data) {
|
|
15
|
+
const targets = new Set();
|
|
16
|
+
// Collect subscribers for the specific board
|
|
17
|
+
this.clients.get(boardId)?.forEach(c => targets.add(c));
|
|
18
|
+
// Always also broadcast to '*' channel (global subscribers)
|
|
19
|
+
this.clients.get('*')?.forEach(c => targets.add(c));
|
|
20
|
+
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
21
|
+
targets.forEach(client => {
|
|
22
|
+
try {
|
|
23
|
+
client.write(payload);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// Client disconnected — cleanup handled by 'close' event
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
/** Broadcast to ALL connected clients (used for board-level events). */
|
|
31
|
+
emitGlobal(event, data) {
|
|
32
|
+
const payload = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
33
|
+
this.clients.forEach(set => {
|
|
34
|
+
set.forEach(client => {
|
|
35
|
+
try {
|
|
36
|
+
client.write(payload);
|
|
37
|
+
}
|
|
38
|
+
catch { /* ignore */ }
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export const sseManager = new SseManager();
|