agentopia 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.
Files changed (140) hide show
  1. package/.claude/settings.local.json +28 -0
  2. package/dist/app.d.ts +10 -0
  3. package/dist/app.d.ts.map +1 -0
  4. package/dist/app.js +121 -0
  5. package/dist/app.js.map +1 -0
  6. package/dist/config.d.ts +9 -0
  7. package/dist/config.d.ts.map +1 -0
  8. package/dist/config.js +19 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/db/database.d.ts +5 -0
  11. package/dist/db/database.d.ts.map +1 -0
  12. package/dist/db/database.js +39 -0
  13. package/dist/db/database.js.map +1 -0
  14. package/dist/db/schema.d.ts +3 -0
  15. package/dist/db/schema.d.ts.map +1 -0
  16. package/dist/db/schema.js +621 -0
  17. package/dist/db/schema.js.map +1 -0
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +49 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/logger.d.ts +4 -0
  23. package/dist/logger.d.ts.map +1 -0
  24. package/dist/logger.js +9 -0
  25. package/dist/logger.js.map +1 -0
  26. package/dist/middleware/auth.d.ts +13 -0
  27. package/dist/middleware/auth.d.ts.map +1 -0
  28. package/dist/middleware/auth.js +733 -0
  29. package/dist/middleware/auth.js.map +1 -0
  30. package/dist/routes/agents.d.ts +3 -0
  31. package/dist/routes/agents.d.ts.map +1 -0
  32. package/dist/routes/agents.js +1058 -0
  33. package/dist/routes/agents.js.map +1 -0
  34. package/dist/routes/issues.d.ts +4 -0
  35. package/dist/routes/issues.d.ts.map +1 -0
  36. package/dist/routes/issues.js +946 -0
  37. package/dist/routes/issues.js.map +1 -0
  38. package/dist/routes/knowledge.d.ts +3 -0
  39. package/dist/routes/knowledge.d.ts.map +1 -0
  40. package/dist/routes/knowledge.js +117 -0
  41. package/dist/routes/knowledge.js.map +1 -0
  42. package/dist/routes/memories.d.ts +3 -0
  43. package/dist/routes/memories.d.ts.map +1 -0
  44. package/dist/routes/memories.js +115 -0
  45. package/dist/routes/memories.js.map +1 -0
  46. package/dist/routes/messages.d.ts +3 -0
  47. package/dist/routes/messages.d.ts.map +1 -0
  48. package/dist/routes/messages.js +130 -0
  49. package/dist/routes/messages.js.map +1 -0
  50. package/dist/routes/projects.d.ts +3 -0
  51. package/dist/routes/projects.d.ts.map +1 -0
  52. package/dist/routes/projects.js +754 -0
  53. package/dist/routes/projects.js.map +1 -0
  54. package/dist/routes/templates.d.ts +3 -0
  55. package/dist/routes/templates.d.ts.map +1 -0
  56. package/dist/routes/templates.js +117 -0
  57. package/dist/routes/templates.js.map +1 -0
  58. package/dist/routes/ui.d.ts +3 -0
  59. package/dist/routes/ui.d.ts.map +1 -0
  60. package/dist/routes/ui.js +38 -0
  61. package/dist/routes/ui.js.map +1 -0
  62. package/dist/services/agent-hierarchy.d.ts +14 -0
  63. package/dist/services/agent-hierarchy.d.ts.map +1 -0
  64. package/dist/services/agent-hierarchy.js +58 -0
  65. package/dist/services/agent-hierarchy.js.map +1 -0
  66. package/dist/services/agent-issue-batch.d.ts +17 -0
  67. package/dist/services/agent-issue-batch.d.ts.map +1 -0
  68. package/dist/services/agent-issue-batch.js +57 -0
  69. package/dist/services/agent-issue-batch.js.map +1 -0
  70. package/dist/services/controller.d.ts +4 -0
  71. package/dist/services/controller.d.ts.map +1 -0
  72. package/dist/services/controller.js +237 -0
  73. package/dist/services/controller.js.map +1 -0
  74. package/dist/services/langgraph-runner.d.ts +33 -0
  75. package/dist/services/langgraph-runner.d.ts.map +1 -0
  76. package/dist/services/langgraph-runner.js +478 -0
  77. package/dist/services/langgraph-runner.js.map +1 -0
  78. package/dist/services/orchestrator.d.ts +9 -0
  79. package/dist/services/orchestrator.d.ts.map +1 -0
  80. package/dist/services/orchestrator.js +116 -0
  81. package/dist/services/orchestrator.js.map +1 -0
  82. package/dist/services/pre-controller.d.ts +7 -0
  83. package/dist/services/pre-controller.d.ts.map +1 -0
  84. package/dist/services/pre-controller.js +101 -0
  85. package/dist/services/pre-controller.js.map +1 -0
  86. package/dist/services/process-manager.d.ts +67 -0
  87. package/dist/services/process-manager.d.ts.map +1 -0
  88. package/dist/services/process-manager.js +938 -0
  89. package/dist/services/process-manager.js.map +1 -0
  90. package/dist/services/project-permissions.d.ts +84 -0
  91. package/dist/services/project-permissions.d.ts.map +1 -0
  92. package/dist/services/project-permissions.js +129 -0
  93. package/dist/services/project-permissions.js.map +1 -0
  94. package/dist/services/scheduler.d.ts +6 -0
  95. package/dist/services/scheduler.d.ts.map +1 -0
  96. package/dist/services/scheduler.js +300 -0
  97. package/dist/services/scheduler.js.map +1 -0
  98. package/dist/services/system-prompt.d.ts +3 -0
  99. package/dist/services/system-prompt.d.ts.map +1 -0
  100. package/dist/services/system-prompt.js +285 -0
  101. package/dist/services/system-prompt.js.map +1 -0
  102. package/dist/services/terminal.d.ts +18 -0
  103. package/dist/services/terminal.d.ts.map +1 -0
  104. package/dist/services/terminal.js +222 -0
  105. package/dist/services/terminal.js.map +1 -0
  106. package/dist/services/websocket.d.ts +15 -0
  107. package/dist/services/websocket.d.ts.map +1 -0
  108. package/dist/services/websocket.js +204 -0
  109. package/dist/services/websocket.js.map +1 -0
  110. package/dist/types.d.ts +108 -0
  111. package/dist/types.d.ts.map +1 -0
  112. package/dist/types.js +3 -0
  113. package/dist/types.js.map +1 -0
  114. package/env.ini +18 -0
  115. package/package.json +38 -0
  116. package/project_id +0 -0
  117. package/public/admin-users.html +188 -0
  118. package/public/agent.html +199 -0
  119. package/public/css/issues.css +275 -0
  120. package/public/css/style.css +1299 -0
  121. package/public/index.html +166 -0
  122. package/public/issue.html +76 -0
  123. package/public/js/agent.js +19 -0
  124. package/public/js/common.js +735 -0
  125. package/public/js/dashboard.js +772 -0
  126. package/public/js/files-panel.js +703 -0
  127. package/public/js/interactive-terminal.js +201 -0
  128. package/public/js/issue-renderer.js +559 -0
  129. package/public/js/issue.js +57 -0
  130. package/public/js/project.js +2425 -0
  131. package/public/js/terminal.js +564 -0
  132. package/public/project.html +430 -0
  133. package/public/terminal.html +67 -0
  134. package/public/vendor/marked.js +74 -0
  135. package/public/vendor/xterm-addon-fit.js +2 -0
  136. package/public/vendor/xterm.css +209 -0
  137. package/public/vendor/xterm.js +2 -0
  138. package/send_message_and_update_issue.js +65 -0
  139. package/tsconfig.json +19 -0
  140. package/update_round2_and_create_round3.js +284 -0
@@ -0,0 +1,946 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.clearPendingControllerTriggerTimers = clearPendingControllerTriggerTimers;
4
+ exports.registerIssueRoutes = registerIssueRoutes;
5
+ const uuid_1 = require("uuid");
6
+ const database_1 = require("../db/database");
7
+ const process_manager_1 = require("../services/process-manager");
8
+ const system_prompt_1 = require("../services/system-prompt");
9
+ const controller_1 = require("../services/controller");
10
+ const pre_controller_1 = require("../services/pre-controller");
11
+ const websocket_1 = require("../services/websocket");
12
+ const config_1 = require("../config");
13
+ const project_permissions_1 = require("../services/project-permissions");
14
+ // Track pending controller trigger timers so they can be cancelled during shutdown
15
+ const pendingControllerTriggerTimers = new Set();
16
+ function clearPendingControllerTriggerTimers() {
17
+ for (const timer of pendingControllerTriggerTimers)
18
+ clearTimeout(timer);
19
+ pendingControllerTriggerTimers.clear();
20
+ }
21
+ // Parse @agent-name mentions from text and auto-start mentioned agents
22
+ function parseMentionsAndStartAgents(text, projectId, issueId, issueNumber, issueTitle, authorId) {
23
+ if (!text)
24
+ return;
25
+ const mentionPattern = /@([\w-]+)/g;
26
+ const mentions = new Set();
27
+ let match;
28
+ while ((match = mentionPattern.exec(text)) !== null) {
29
+ mentions.add(match[1]);
30
+ }
31
+ if (mentions.size === 0)
32
+ return;
33
+ const db = (0, database_1.getDatabase)();
34
+ const agents = db.prepare('SELECT * FROM agents WHERE project_id = ?').all(projectId);
35
+ const eventStmt = db.prepare('INSERT INTO issue_comments (id, issue_id, author_id, body, event_type, meta) VALUES (?, ?, ?, ?, ?, ?)');
36
+ for (const agentName of mentions) {
37
+ const agent = agents.find(a => a.name === agentName);
38
+ if (!agent)
39
+ continue;
40
+ // Auto-start if idle and not paused
41
+ if (!agent.paused && agent.status !== 'running' && !(0, process_manager_1.isAgentRunning)(agent.id)) {
42
+ const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(projectId);
43
+ if (project) {
44
+ const prompt = `You were mentioned (@${agentName}) in issue #${issueNumber} "${issueTitle}". Review the issue and take action.\n\nContext: ${text.slice(0, 500)}`;
45
+ const commandTemplate = agent.command_template || project.command_template || config_1.config.defaultCommandTemplate;
46
+ const isRaw = /^\s*(bash|sh|zsh)\s+-c\b/.test(commandTemplate);
47
+ const systemPrompt = isRaw ? undefined : (0, system_prompt_1.buildSystemPrompt)(agent, project);
48
+ (0, process_manager_1.startAgentProcess)(agent, prompt, commandTemplate, systemPrompt);
49
+ // Record system event
50
+ eventStmt.run((0, uuid_1.v4)(), issueId, 'system', `auto-started ${agent.name} (mentioned by ${authorId === 'user' ? 'user' : nameOfAgent(authorId, agents)})`, 'status_change', JSON.stringify({ mention: agentName, agent_id: agent.id, triggered_by: authorId }));
51
+ }
52
+ }
53
+ }
54
+ }
55
+ function nameOfAgent(agentId, agents) {
56
+ const a = agents.find(x => x.id === agentId);
57
+ return a ? a.name : agentId;
58
+ }
59
+ // Trigger controller on-demand (wake-on-issue mode)
60
+ // actorId: skip triggering if the actor is the controller itself (avoid self-trigger loops)
61
+ function triggerControllerOnDemand(projectId, triggerIssueNumber, actorId) {
62
+ const db = (0, database_1.getDatabase)();
63
+ const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(projectId);
64
+ if (!project)
65
+ return;
66
+ // Pre-controller: 规则引擎拦截简单场景,避免不必要的 LLM 调用
67
+ if ((0, pre_controller_1.tryHandleWithoutLLM)(projectId, triggerIssueNumber))
68
+ return;
69
+ const controller = db.prepare('SELECT * FROM agents WHERE project_id = ? AND is_controller = 1').get(projectId);
70
+ if (!controller || controller.status === 'running' || controller.paused)
71
+ return;
72
+ // Skip if the action was performed by the controller itself to avoid self-trigger loops
73
+ if (actorId && actorId === controller.id)
74
+ return;
75
+ const timer = setTimeout(() => {
76
+ pendingControllerTriggerTimers.delete(timer);
77
+ try {
78
+ if ((0, database_1.isDatabaseOpen)())
79
+ (0, controller_1.triggerControllerAgent)(project, false, triggerIssueNumber);
80
+ }
81
+ catch { }
82
+ }, 1000);
83
+ pendingControllerTriggerTimers.add(timer);
84
+ }
85
+ function resolvePriority(createdBy, projectId) {
86
+ if (createdBy === 'user' || createdBy === 'system')
87
+ return 10;
88
+ const db = (0, database_1.getDatabase)();
89
+ const agent = db.prepare('SELECT is_controller FROM agents WHERE id = ? AND project_id = ?').get(createdBy, projectId);
90
+ if (agent?.is_controller)
91
+ return 5;
92
+ return 1;
93
+ }
94
+ function buildSqlPlaceholders(values) {
95
+ return values.map(() => '?').join(', ');
96
+ }
97
+ function registerIssueRoutes(fastify) {
98
+ // ─── Issues ───
99
+ // List issues (with search, sort, pagination)
100
+ fastify.get('/api/projects/:pid/issues', async (request, reply) => {
101
+ const db = (0, database_1.getDatabase)();
102
+ const access = (0, project_permissions_1.ensureProjectAccess)(db, request, reply, request.params.pid);
103
+ if (!access)
104
+ return;
105
+ const { status, assigned_to, label, q, sort, page, per_page, milestone_id } = request.query;
106
+ let sql = `SELECT issues.*, (SELECT COUNT(*) FROM issue_comments WHERE issue_id = issues.id AND event_type = 'comment') as comment_count, (SELECT COUNT(*) > 0 FROM issue_relations r JOIN issues blocker ON blocker.id = r.from_issue_id WHERE r.to_issue_id = issues.id AND r.relation_type = 'blocks' AND blocker.status NOT IN ('done', 'closed')) as is_blocked FROM issues WHERE project_id = ?`;
107
+ const params = [request.params.pid];
108
+ if (status) {
109
+ sql += ' AND status = ?';
110
+ params.push(status);
111
+ }
112
+ if (assigned_to) {
113
+ sql += ' AND assigned_to = ?';
114
+ params.push(assigned_to);
115
+ }
116
+ if (label) {
117
+ sql += " AND (',' || labels || ',') LIKE ?";
118
+ params.push(`%,${label},%`);
119
+ }
120
+ if (milestone_id) {
121
+ sql += ' AND milestone_id = ?';
122
+ params.push(milestone_id);
123
+ }
124
+ if (q) {
125
+ sql += ' AND (title LIKE ? OR body LIKE ?)';
126
+ params.push(`%${q}%`, `%${q}%`);
127
+ }
128
+ // Sort
129
+ const sortMap = {
130
+ 'newest': 'created_at DESC',
131
+ 'oldest': 'created_at ASC',
132
+ 'updated': 'updated_at DESC',
133
+ 'priority': 'priority DESC, created_at DESC',
134
+ 'comments': '(SELECT COUNT(*) FROM issue_comments WHERE issue_id = issues.id) DESC',
135
+ };
136
+ sql += ' ORDER BY ' + (sortMap[sort || ''] || 'created_at DESC');
137
+ // Pagination
138
+ const limit = Math.min(parseInt(per_page || '100'), 200);
139
+ const offset = (Math.max(parseInt(page || '1'), 1) - 1) * limit;
140
+ const countSql = sql.replace(/SELECT issues\.\*.*?FROM issues/, 'SELECT COUNT(*) as total FROM issues');
141
+ const total = db.prepare(countSql).get(...params)?.total || 0;
142
+ sql += ` LIMIT ${limit} OFFSET ${offset}`;
143
+ const issues = db.prepare(sql).all(...params).map((issue) => ({
144
+ ...issue,
145
+ is_blocked: !!issue.is_blocked,
146
+ }));
147
+ return { issues, total, page: Math.floor(offset / limit) + 1, per_page: limit, total_pages: Math.ceil(total / limit) };
148
+ });
149
+ // Issue counts by status (lightweight alternative to loading all issues)
150
+ fastify.get('/api/projects/:pid/issues/counts', async (request, reply) => {
151
+ const db = (0, database_1.getDatabase)();
152
+ const access = (0, project_permissions_1.ensureProjectAccess)(db, request, reply, request.params.pid);
153
+ if (!access)
154
+ return;
155
+ const rows = db.prepare(`SELECT status, COUNT(*) as count FROM issues WHERE project_id = ? GROUP BY status`).all(request.params.pid);
156
+ const counts = { open: 0, in_progress: 0, pending: 0, done: 0, closed: 0 };
157
+ let total = 0;
158
+ for (const row of rows) {
159
+ counts[row.status] = row.count;
160
+ total += row.count;
161
+ }
162
+ return { ...counts, total };
163
+ });
164
+ // Create issue
165
+ fastify.post('/api/projects/:pid/issues', async (request, reply) => {
166
+ const { title, body, created_by, assigned_to, labels, parent_id } = request.body;
167
+ if (!title || !created_by) {
168
+ return reply.code(400).send({ error: 'title and created_by are required' });
169
+ }
170
+ const db = (0, database_1.getDatabase)();
171
+ const access = (0, project_permissions_1.ensureProjectAccess)(db, request, reply, request.params.pid, true);
172
+ if (!access)
173
+ return;
174
+ const id = (0, uuid_1.v4)();
175
+ const priority = resolvePriority(created_by, request.params.pid);
176
+ // Auto-increment number per project
177
+ const last = db.prepare('SELECT MAX(number) as n FROM issues WHERE project_id = ?').get(request.params.pid);
178
+ const number = (last?.n || 0) + 1;
179
+ // Validate parent_id if provided
180
+ if (parent_id) {
181
+ const parent = db.prepare('SELECT id, project_id FROM issues WHERE id = ?').get(parent_id);
182
+ if (!parent)
183
+ return reply.code(400).send({ error: 'Parent issue not found' });
184
+ if (parent.project_id !== request.params.pid)
185
+ return reply.code(400).send({ error: 'Parent issue must be in the same project' });
186
+ }
187
+ db.prepare(`
188
+ INSERT INTO issues (id, project_id, number, title, body, created_by, assigned_to, priority, labels, parent_id, status)
189
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open')
190
+ `).run(id, request.params.pid, number, title, body || '', created_by, assigned_to || null, priority, labels || '', parent_id || null);
191
+ const created = db.prepare('SELECT * FROM issues WHERE id = ?').get(id);
192
+ // Auto-set parent issue to 'pending' when a child issue is created
193
+ if (parent_id) {
194
+ const parent = db.prepare('SELECT id, status FROM issues WHERE id = ?').get(parent_id);
195
+ if (parent && !['done', 'closed', 'pending'].includes(parent.status)) {
196
+ db.prepare("UPDATE issues SET status = 'pending', updated_at = datetime('now') WHERE id = ?").run(parent_id);
197
+ const eventStmt = db.prepare('INSERT INTO issue_comments (id, issue_id, author_id, body, event_type, meta) VALUES (?, ?, ?, ?, ?, ?)');
198
+ eventStmt.run((0, uuid_1.v4)(), parent_id, 'system', `changed status from ${parent.status} to pending (child issue #${number} created)`, 'status_change', JSON.stringify({ from: parent.status, to: 'pending', child_number: number }));
199
+ (0, websocket_1.broadcastToProject)(request.params.pid, {
200
+ type: 'issue_updated', projectId: request.params.pid,
201
+ data: { issue: db.prepare('SELECT * FROM issues WHERE id = ?').get(parent_id) },
202
+ });
203
+ }
204
+ else if (parent && parent.status === 'pending') {
205
+ const eventStmt = db.prepare('INSERT INTO issue_comments (id, issue_id, author_id, body, event_type, meta) VALUES (?, ?, ?, ?, ?, ?)');
206
+ eventStmt.run((0, uuid_1.v4)(), parent_id, 'system', `New child issue #${number} added`, 'status_change', JSON.stringify({ child_number: number }));
207
+ }
208
+ }
209
+ (0, websocket_1.broadcastToProject)(request.params.pid, {
210
+ type: 'issue_created', projectId: request.params.pid,
211
+ data: { issue: created },
212
+ });
213
+ // Reset auto-restart skip when new issue is assigned to an agent
214
+ if (assigned_to && assigned_to !== 'user' && assigned_to !== 'all') {
215
+ (0, process_manager_1.resetAutoRestartSkip)(assigned_to);
216
+ }
217
+ // Auto-start assigned agent when user creates issue
218
+ if (created_by === 'user' && assigned_to && assigned_to !== 'user' && assigned_to !== 'all') {
219
+ const agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(assigned_to);
220
+ if (agent && !agent.paused && agent.status !== 'running' && !(0, process_manager_1.isAgentRunning)(agent.id)) {
221
+ const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(request.params.pid);
222
+ if (project) {
223
+ const prompt = `New issue #${number} "${title}" has been assigned to you. Review and take action.\n\nDescription: ${(body || '').slice(0, 500)}`;
224
+ const commandTemplate = agent.command_template || project.command_template || config_1.config.defaultCommandTemplate;
225
+ const isRaw = /^\s*(bash|sh|zsh)\s+-c\b/.test(commandTemplate);
226
+ const systemPrompt = isRaw ? undefined : (0, system_prompt_1.buildSystemPrompt)(agent, project);
227
+ (0, process_manager_1.startAgentProcess)(agent, prompt, commandTemplate, systemPrompt);
228
+ }
229
+ }
230
+ }
231
+ else if (created_by === 'user' && (!assigned_to || assigned_to === 'all')) {
232
+ // Trigger controller for unassigned/broadcast issues
233
+ const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(request.params.pid);
234
+ if (project) {
235
+ const t = setTimeout(() => {
236
+ pendingControllerTriggerTimers.delete(t);
237
+ try {
238
+ if ((0, database_1.isDatabaseOpen)())
239
+ (0, controller_1.triggerControllerAgent)(project, false, number);
240
+ }
241
+ catch { }
242
+ }, 1000);
243
+ pendingControllerTriggerTimers.add(t);
244
+ }
245
+ }
246
+ // Parse @mentions in body and auto-start mentioned agents
247
+ if (body) {
248
+ parseMentionsAndStartAgents(body, request.params.pid, id, number, title, created_by);
249
+ }
250
+ // Wake-on-issue: trigger controller when any issue is created
251
+ triggerControllerOnDemand(request.params.pid, number, created_by);
252
+ return reply.code(201).send(created);
253
+ });
254
+ // Get issue detail (with comments + reactions + parent/children)
255
+ fastify.get('/api/issues/:id', async (request, reply) => {
256
+ const db = (0, database_1.getDatabase)();
257
+ const access = (0, project_permissions_1.ensureIssueAccess)(db, request, reply, request.params.id);
258
+ if (!access)
259
+ return;
260
+ const issue = db.prepare('SELECT * FROM issues WHERE id = ?').get(request.params.id);
261
+ if (!issue)
262
+ return reply.code(404).send({ error: 'Issue not found' });
263
+ const comments = db.prepare('SELECT * FROM issue_comments WHERE issue_id = ? ORDER BY created_at').all(request.params.id);
264
+ const reactions = db.prepare("SELECT * FROM reactions WHERE target_type = 'issue' AND target_id = ?").all(request.params.id);
265
+ // Batch-fetch all comment reactions in one query (fixes N+1)
266
+ const commentIds = comments.map(c => c.id);
267
+ let commentReactionsMap = {};
268
+ if (commentIds.length > 0) {
269
+ const placeholders = commentIds.map(() => '?').join(',');
270
+ const allReactions = db.prepare(`SELECT * FROM reactions WHERE target_type = 'comment' AND target_id IN (${placeholders})`).all(...commentIds);
271
+ for (const r of allReactions) {
272
+ (commentReactionsMap[r.target_id] ||= []).push(r);
273
+ }
274
+ }
275
+ const commentsWithReactions = comments.map(c => ({
276
+ ...c,
277
+ reactions: commentReactionsMap[c.id] || [],
278
+ }));
279
+ // Parent info
280
+ let parent_number = null;
281
+ let parent_title = null;
282
+ if (issue.parent_id) {
283
+ const parent = db.prepare('SELECT number, title FROM issues WHERE id = ?').get(issue.parent_id);
284
+ if (parent) {
285
+ parent_number = parent.number;
286
+ parent_title = parent.title;
287
+ }
288
+ }
289
+ // Children
290
+ const children = db.prepare('SELECT id, number, title, status, assigned_to FROM issues WHERE parent_id = ? ORDER BY number').all(request.params.id);
291
+ // Relations (blocks / blocked_by / related_to)
292
+ const blocksRaw = db.prepare(`
293
+ SELECT r.id as relation_id, r.relation_type, r.created_by, r.created_at,
294
+ i.id, i.number, i.title, i.status
295
+ FROM issue_relations r JOIN issues i ON i.id = r.to_issue_id
296
+ WHERE r.from_issue_id = ?
297
+ `).all(request.params.id);
298
+ const blockedByRaw = db.prepare(`
299
+ SELECT r.id as relation_id, r.relation_type, r.created_by, r.created_at,
300
+ i.id, i.number, i.title, i.status
301
+ FROM issue_relations r JOIN issues i ON i.id = r.from_issue_id
302
+ WHERE r.to_issue_id = ? AND r.relation_type = 'blocks'
303
+ `).all(request.params.id);
304
+ const relatedRaw = db.prepare(`
305
+ SELECT r.id as relation_id, r.relation_type, r.created_by, r.created_at,
306
+ i.id, i.number, i.title, i.status
307
+ FROM issue_relations r JOIN issues i ON i.id = r.to_issue_id
308
+ WHERE r.from_issue_id = ? AND r.relation_type = 'related_to'
309
+ UNION
310
+ SELECT r.id as relation_id, r.relation_type, r.created_by, r.created_at,
311
+ i.id, i.number, i.title, i.status
312
+ FROM issue_relations r JOIN issues i ON i.id = r.from_issue_id
313
+ WHERE r.to_issue_id = ? AND r.relation_type = 'related_to'
314
+ `).all(request.params.id, request.params.id);
315
+ const blocks = blocksRaw.filter(r => r.relation_type === 'blocks');
316
+ const blocked_by = blockedByRaw;
317
+ const related_to = relatedRaw;
318
+ // is_blocked: true if any blocker is not done/closed
319
+ const is_blocked = blocked_by.some((r) => !['done', 'closed'].includes(r.status));
320
+ return { ...issue, comments: commentsWithReactions, reactions, parent_number, parent_title, children, blocks, blocked_by, related_to, is_blocked };
321
+ });
322
+ // Update issue with timeline events
323
+ fastify.put('/api/issues/:id', async (request, reply) => {
324
+ const db = (0, database_1.getDatabase)();
325
+ const access = (0, project_permissions_1.ensureIssueAccess)(db, request, reply, request.params.id, true);
326
+ if (!access)
327
+ return;
328
+ const existing = db.prepare('SELECT * FROM issues WHERE id = ?').get(request.params.id);
329
+ if (!existing)
330
+ return reply.code(404).send({ error: 'Issue not found' });
331
+ const { status, assigned_to, title, body, labels, milestone_id, actor } = request.body;
332
+ const actorId = actor || 'user';
333
+ if (status && !['open', 'in_progress', 'pending', 'done', 'closed'].includes(status)) {
334
+ return reply.code(400).send({ error: 'Invalid status' });
335
+ }
336
+ // Record timeline events for changes
337
+ const eventStmt = db.prepare('INSERT INTO issue_comments (id, issue_id, author_id, body, event_type, meta) VALUES (?, ?, ?, ?, ?, ?)');
338
+ if (status && status !== existing.status) {
339
+ eventStmt.run((0, uuid_1.v4)(), request.params.id, actorId, `changed status from ${existing.status} to ${status}`, 'status_change', JSON.stringify({ from: existing.status, to: status }));
340
+ }
341
+ if (assigned_to !== undefined && assigned_to !== existing.assigned_to) {
342
+ const agentRow = assigned_to ? db.prepare('SELECT name FROM agents WHERE id = ?').get(assigned_to) : null;
343
+ const assigneeName = agentRow ? agentRow.name : (assigned_to || 'nobody');
344
+ eventStmt.run((0, uuid_1.v4)(), request.params.id, actorId, `assigned to ${assigneeName}`, 'assignment', JSON.stringify({ from: existing.assigned_to, to: assigned_to }));
345
+ // Reset auto-restart skip when new work is assigned to an agent
346
+ if (assigned_to && assigned_to !== 'user' && assigned_to !== 'all') {
347
+ (0, process_manager_1.resetAutoRestartSkip)(assigned_to);
348
+ }
349
+ }
350
+ if (labels !== undefined && labels !== existing.labels) {
351
+ eventStmt.run((0, uuid_1.v4)(), request.params.id, actorId, `changed labels`, 'label_change', JSON.stringify({ from: existing.labels, to: labels }));
352
+ }
353
+ // Reset acknowledged_at only when issue is reassigned TO user (needs attention)
354
+ // Status changes alone don't reset — user gets notified via comments instead
355
+ const resetAck = assigned_to !== undefined && assigned_to !== existing.assigned_to && assigned_to === 'user';
356
+ db.prepare(`
357
+ UPDATE issues SET
358
+ title = COALESCE(?, title),
359
+ body = COALESCE(?, body),
360
+ assigned_to = COALESCE(?, assigned_to),
361
+ status = COALESCE(?, status),
362
+ labels = COALESCE(?, labels),
363
+ milestone_id = COALESCE(?, milestone_id),
364
+ acknowledged_at = CASE WHEN ? THEN NULL ELSE acknowledged_at END,
365
+ updated_at = datetime('now')
366
+ WHERE id = ?
367
+ `).run(title ?? null, body ?? null, assigned_to ?? null, status ?? null, labels ?? null, milestone_id ?? null, resetAck ? 1 : 0, request.params.id);
368
+ const updated = db.prepare('SELECT * FROM issues WHERE id = ?').get(request.params.id);
369
+ (0, websocket_1.broadcastToProject)(updated.project_id, {
370
+ type: 'issue_updated', projectId: updated.project_id,
371
+ data: { issue: updated },
372
+ });
373
+ // Tail request avoidance: when a running agent marks an issue done/closed,
374
+ // check if it has any remaining open/in_progress issues. If none remain,
375
+ // signal that its task is complete so intra-session low-output detection
376
+ // can use a more aggressive threshold (kill after 1 low-output turn).
377
+ if ((status === 'done' || status === 'closed') && actorId !== 'user' && (0, process_manager_1.isAgentRunning)(actorId)) {
378
+ const remainingIssues = db.prepare("SELECT 1 FROM issues WHERE project_id = ? AND assigned_to = ? AND status IN ('open', 'in_progress') LIMIT 1").get(updated.project_id, actorId);
379
+ if (!remainingIssues) {
380
+ (0, process_manager_1.markAgentIssuesCompleted)(actorId);
381
+ }
382
+ }
383
+ // When child issue completed, check siblings and update parent
384
+ if ((status === 'done' || status === 'closed') && updated.parent_id) {
385
+ const siblings = db.prepare("SELECT COUNT(*) as total, SUM(CASE WHEN status IN ('done','closed') THEN 1 ELSE 0 END) as completed FROM issues WHERE parent_id = ?").get(updated.parent_id);
386
+ const parentIssue = db.prepare('SELECT * FROM issues WHERE id = ?').get(updated.parent_id);
387
+ const eventStmt2 = db.prepare('INSERT INTO issue_comments (id, issue_id, author_id, body, event_type, meta) VALUES (?, ?, ?, ?, ?, ?)');
388
+ if (siblings.total > 0 && siblings.total === siblings.completed && parentIssue) {
389
+ // All children done
390
+ eventStmt2.run((0, uuid_1.v4)(), updated.parent_id, 'system', `All ${siblings.total} child issues are now complete.`, 'status_change', JSON.stringify({ all_children_complete: true, child_count: siblings.total }));
391
+ // If parent was created by user, assign back to user for review
392
+ if (parentIssue.created_by === 'user') {
393
+ db.prepare("UPDATE issues SET status = 'done', assigned_to = 'user', acknowledged_at = NULL, updated_at = datetime('now') WHERE id = ?")
394
+ .run(updated.parent_id);
395
+ eventStmt2.run((0, uuid_1.v4)(), updated.parent_id, 'system', 'assigned to user for review (all child issues complete)', 'assignment', JSON.stringify({ from: parentIssue.assigned_to, to: 'user' }));
396
+ }
397
+ else {
398
+ db.prepare("UPDATE issues SET updated_at = datetime('now'), acknowledged_at = NULL WHERE id = ?")
399
+ .run(updated.parent_id);
400
+ // Trigger controller to review and close parent
401
+ triggerControllerOnDemand(updated.project_id, parentIssue.number, actorId);
402
+ }
403
+ // Broadcast parent update to frontend
404
+ const refreshedParent = db.prepare('SELECT * FROM issues WHERE id = ?').get(updated.parent_id);
405
+ (0, websocket_1.broadcastToProject)(updated.project_id, {
406
+ type: 'issue_updated', projectId: updated.project_id,
407
+ data: { issue: refreshedParent },
408
+ });
409
+ }
410
+ else if (parentIssue) {
411
+ // Partial progress — update parent timestamp so it's visible
412
+ eventStmt2.run((0, uuid_1.v4)(), updated.parent_id, 'system', `Child #${updated.number} completed (${siblings.completed}/${siblings.total} done).`, 'status_change', JSON.stringify({ child_number: updated.number, completed: siblings.completed, total: siblings.total }));
413
+ db.prepare("UPDATE issues SET updated_at = datetime('now') WHERE id = ?").run(updated.parent_id);
414
+ }
415
+ }
416
+ // System-level auto-assign back to user: when a user-created issue (without parent)
417
+ // is marked done by an agent, assign it back to user for review — no controller needed
418
+ if ((status === 'done' || status === 'closed') && !updated.parent_id
419
+ && existing.created_by === 'user' && actorId !== 'user'
420
+ && existing.assigned_to !== 'user') {
421
+ db.prepare("UPDATE issues SET assigned_to = 'user', acknowledged_at = NULL, updated_at = datetime('now') WHERE id = ?")
422
+ .run(request.params.id);
423
+ const returnEvt = db.prepare('INSERT INTO issue_comments (id, issue_id, author_id, body, event_type, meta) VALUES (?, ?, ?, ?, ?, ?)');
424
+ returnEvt.run((0, uuid_1.v4)(), request.params.id, 'system', 'assigned to user for review (task completed)', 'assignment', JSON.stringify({ from: existing.assigned_to, to: 'user' }));
425
+ }
426
+ // Wake-on-issue: trigger controller when issue status/assignment changes
427
+ triggerControllerOnDemand(updated.project_id, updated.number, actorId);
428
+ // Auto-start agent when user assigns an issue to them
429
+ if (actorId === 'user' && assigned_to && assigned_to !== existing.assigned_to && assigned_to !== 'user' && assigned_to !== 'all') {
430
+ const agent = db.prepare('SELECT * FROM agents WHERE id = ?').get(assigned_to);
431
+ if (agent && !agent.paused && agent.status !== 'running' && !(0, process_manager_1.isAgentRunning)(agent.id)) {
432
+ const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(updated.project_id);
433
+ if (project) {
434
+ const prompt = `You have been assigned issue #${updated.number} "${updated.title}". Review it and take action.\n\nDescription: ${updated.body?.slice(0, 500) || '(none)'}`;
435
+ const commandTemplate = agent.command_template || project.command_template || config_1.config.defaultCommandTemplate;
436
+ const isRaw = /^\s*(bash|sh|zsh)\s+-c\b/.test(commandTemplate);
437
+ const systemPrompt = isRaw ? undefined : (0, system_prompt_1.buildSystemPrompt)(agent, project);
438
+ (0, process_manager_1.startAgentProcess)(agent, prompt, commandTemplate, systemPrompt);
439
+ }
440
+ }
441
+ }
442
+ // Re-fetch to include any auto-assign changes
443
+ const finalIssue = db.prepare('SELECT * FROM issues WHERE id = ?').get(request.params.id);
444
+ return finalIssue;
445
+ });
446
+ // Delete issue (only open, no children)
447
+ fastify.delete('/api/issues/:id', async (request, reply) => {
448
+ const db = (0, database_1.getDatabase)();
449
+ const access = (0, project_permissions_1.ensureIssueAccess)(db, request, reply, request.params.id, true);
450
+ if (!access)
451
+ return;
452
+ const issue = db.prepare('SELECT * FROM issues WHERE id = ?').get(request.params.id);
453
+ if (!issue)
454
+ return reply.code(404).send({ error: 'Issue not found' });
455
+ if (issue.status !== 'open') {
456
+ return reply.code(409).send({ error: 'Only open issues can be deleted' });
457
+ }
458
+ const childCount = db.prepare('SELECT COUNT(*) as c FROM issues WHERE parent_id = ?').get(request.params.id).c;
459
+ if (childCount > 0) {
460
+ return reply.code(409).send({ error: `Cannot delete: issue has ${childCount} child issue(s)` });
461
+ }
462
+ db.prepare('DELETE FROM issues WHERE id = ?').run(request.params.id);
463
+ return { success: true };
464
+ });
465
+ // ─── Comments ───
466
+ // List comments
467
+ fastify.get('/api/issues/:id/comments', async (request, reply) => {
468
+ const db = (0, database_1.getDatabase)();
469
+ const access = (0, project_permissions_1.ensureIssueAccess)(db, request, reply, request.params.id);
470
+ if (!access)
471
+ return;
472
+ return db.prepare('SELECT * FROM issue_comments WHERE issue_id = ? ORDER BY created_at').all(request.params.id);
473
+ });
474
+ // User notifications — open issues assigned to user + recent comments on user's issues
475
+ fastify.get('/api/notifications', async (request) => {
476
+ const db = (0, database_1.getDatabase)();
477
+ const { user, localhostBypass } = (0, project_permissions_1.getProjectRequestContext)(request);
478
+ const projectIds = (0, project_permissions_1.listAccessibleProjectIds)(db, user, localhostBypass);
479
+ if (projectIds.length === 0) {
480
+ return { user_issues: [], recent_comments: [] };
481
+ }
482
+ const placeholders = buildSqlPlaceholders(projectIds);
483
+ const userIssues = db.prepare(`SELECT i.*, p.name as project_name
484
+ FROM issues i
485
+ JOIN projects p ON i.project_id = p.id
486
+ WHERE i.project_id IN (${placeholders})
487
+ AND (i.assigned_to = 'user' OR (i.acknowledged_at IS NOT NULL AND i.created_by = 'user'))
488
+ AND i.status IN ('open', 'in_progress', 'pending', 'done')
489
+ ORDER BY i.acknowledged_at IS NULL DESC, i.priority DESC, i.updated_at DESC`).all(...projectIds);
490
+ // Recent comments on any issue (last 50)
491
+ const recentComments = db.prepare(`SELECT c.*, i.title as issue_title, i.number as issue_number, i.project_id, p.name as project_name
492
+ FROM issue_comments c
493
+ JOIN issues i ON c.issue_id = i.id
494
+ JOIN projects p ON i.project_id = p.id
495
+ WHERE i.project_id IN (${placeholders})
496
+ AND c.author_id != 'user'
497
+ ORDER BY c.created_at DESC
498
+ LIMIT 50`).all(...projectIds);
499
+ return { user_issues: userIssues, recent_comments: recentComments };
500
+ });
501
+ // My Issues — all issues the user is involved in (assigned, created, or commented)
502
+ fastify.get('/api/my-issues', async (request) => {
503
+ const db = (0, database_1.getDatabase)();
504
+ const { user, localhostBypass } = (0, project_permissions_1.getProjectRequestContext)(request);
505
+ const projectIds = (0, project_permissions_1.listAccessibleProjectIds)(db, user, localhostBypass);
506
+ if (projectIds.length === 0)
507
+ return [];
508
+ const placeholders = buildSqlPlaceholders(projectIds);
509
+ return db.prepare(`
510
+ SELECT DISTINCT i.*, p.name as project_name FROM issues i
511
+ JOIN projects p ON i.project_id = p.id
512
+ WHERE i.project_id IN (${placeholders})
513
+ AND (
514
+ i.assigned_to = 'user'
515
+ OR i.created_by = 'user'
516
+ OR i.id IN (SELECT DISTINCT issue_id FROM issue_comments WHERE author_id = 'user')
517
+ )
518
+ ORDER BY i.updated_at DESC
519
+ LIMIT 100
520
+ `).all(...projectIds);
521
+ });
522
+ // Inbox search — search all issues by title, body, or number
523
+ fastify.get('/api/inbox/search', async (request) => {
524
+ const db = (0, database_1.getDatabase)();
525
+ const q = (request.query.q || '').trim();
526
+ if (!q)
527
+ return [];
528
+ const { user, localhostBypass } = (0, project_permissions_1.getProjectRequestContext)(request);
529
+ const projectIds = (0, project_permissions_1.listAccessibleProjectIds)(db, user, localhostBypass);
530
+ if (projectIds.length === 0)
531
+ return [];
532
+ const placeholders = buildSqlPlaceholders(projectIds);
533
+ const like = `%${q}%`;
534
+ return db.prepare(`SELECT i.*, p.name as project_name
535
+ FROM issues i
536
+ JOIN projects p ON i.project_id = p.id
537
+ WHERE i.project_id IN (${placeholders})
538
+ AND (i.title LIKE ? OR i.body LIKE ? OR CAST(i.number AS TEXT) LIKE ?)
539
+ ORDER BY i.updated_at DESC
540
+ LIMIT 200`).all(...projectIds, like, like, like);
541
+ });
542
+ // Acknowledge issue (mark as read — hides from notifications)
543
+ fastify.post('/api/issues/:id/acknowledge', async (request, reply) => {
544
+ const db = (0, database_1.getDatabase)();
545
+ const access = (0, project_permissions_1.ensureIssueAccess)(db, request, reply, request.params.id);
546
+ if (!access)
547
+ return;
548
+ const issue = db.prepare('SELECT * FROM issues WHERE id = ?').get(request.params.id);
549
+ if (!issue)
550
+ return reply.code(404).send({ error: 'Issue not found' });
551
+ db.prepare("UPDATE issues SET acknowledged_at = datetime('now') WHERE id = ?").run(request.params.id);
552
+ return db.prepare('SELECT * FROM issues WHERE id = ?').get(request.params.id);
553
+ });
554
+ // Unacknowledge issue (show again in notifications)
555
+ fastify.post('/api/issues/:id/unacknowledge', async (request, reply) => {
556
+ const db = (0, database_1.getDatabase)();
557
+ const access = (0, project_permissions_1.ensureIssueAccess)(db, request, reply, request.params.id);
558
+ if (!access)
559
+ return;
560
+ const issue = db.prepare('SELECT * FROM issues WHERE id = ?').get(request.params.id);
561
+ if (!issue)
562
+ return reply.code(404).send({ error: 'Issue not found' });
563
+ db.prepare("UPDATE issues SET acknowledged_at = NULL WHERE id = ?").run(request.params.id);
564
+ return db.prepare('SELECT * FROM issues WHERE id = ?').get(request.params.id);
565
+ });
566
+ // Add comment
567
+ fastify.post('/api/issues/:id/comments', async (request, reply) => {
568
+ const { author_id, body } = request.body;
569
+ if (!author_id || !body) {
570
+ return reply.code(400).send({ error: 'author_id and body are required' });
571
+ }
572
+ const db = (0, database_1.getDatabase)();
573
+ const access = (0, project_permissions_1.ensureIssueAccess)(db, request, reply, request.params.id, true);
574
+ if (!access)
575
+ return;
576
+ const issue = db.prepare('SELECT * FROM issues WHERE id = ?').get(request.params.id);
577
+ if (!issue)
578
+ return reply.code(404).send({ error: 'Issue not found' });
579
+ const id = (0, uuid_1.v4)();
580
+ db.prepare('INSERT INTO issue_comments (id, issue_id, author_id, body) VALUES (?, ?, ?, ?)')
581
+ .run(id, request.params.id, author_id, body);
582
+ // Update issue timestamp and reset acknowledged_at (new comment = new activity)
583
+ db.prepare("UPDATE issues SET updated_at = datetime('now'), acknowledged_at = NULL WHERE id = ?").run(request.params.id);
584
+ const comment = db.prepare('SELECT * FROM issue_comments WHERE id = ?').get(id);
585
+ const iss = issue;
586
+ (0, websocket_1.broadcastToProject)(iss.project_id, {
587
+ type: 'comment_added', projectId: iss.project_id,
588
+ data: { comment, issueId: request.params.id, issueNumber: iss.number },
589
+ });
590
+ // Parse @mentions in comment and auto-start mentioned agents
591
+ parseMentionsAndStartAgents(body, iss.project_id, request.params.id, iss.number, iss.title, author_id);
592
+ // Wake-on-issue: trigger controller when comment is added
593
+ triggerControllerOnDemand(iss.project_id, iss.number, author_id);
594
+ // If user commented, auto-reassign issue and start the target agent
595
+ if (author_id === 'user') {
596
+ // Auto-reassign: parse @mentions to find target agent, fallback to controller
597
+ const agents = db.prepare('SELECT * FROM agents WHERE project_id = ?').all(iss.project_id);
598
+ const mentionPattern = /@([\w-]+)/g;
599
+ let mentionMatch;
600
+ let targetAgent;
601
+ while ((mentionMatch = mentionPattern.exec(body)) !== null) {
602
+ targetAgent = agents.find(a => a.name === mentionMatch[1]);
603
+ if (targetAgent)
604
+ break; // Use first matched agent
605
+ }
606
+ const controllerAgent = agents.find(a => a.is_controller);
607
+ const controllerId = controllerAgent?.id || 'b9b6362c-2d59-40cd-9ffc-fd871a7e811e';
608
+ const newAssignee = targetAgent ? targetAgent.id : controllerId;
609
+ // Update assignment
610
+ db.prepare('UPDATE issues SET assigned_to = ? WHERE id = ?').run(newAssignee, request.params.id);
611
+ // Reopen if done/closed
612
+ if (iss.status === 'done' || iss.status === 'closed') {
613
+ db.prepare("UPDATE issues SET status = 'open' WHERE id = ?").run(request.params.id);
614
+ }
615
+ // Start the assigned agent
616
+ const agentToStart = targetAgent || controllerAgent;
617
+ if (agentToStart && !agentToStart.paused && agentToStart.status !== 'running' && !(0, process_manager_1.isAgentRunning)(agentToStart.id)) {
618
+ const project = db.prepare('SELECT * FROM projects WHERE id = ?').get(iss.project_id);
619
+ if (project) {
620
+ if (agentToStart.is_controller) {
621
+ const t = setTimeout(() => {
622
+ pendingControllerTriggerTimers.delete(t);
623
+ try {
624
+ if ((0, database_1.isDatabaseOpen)())
625
+ (0, controller_1.triggerControllerAgent)(project, false, iss.number);
626
+ }
627
+ catch { }
628
+ }, 1000);
629
+ pendingControllerTriggerTimers.add(t);
630
+ }
631
+ else {
632
+ const prompt = `User just commented on issue #${iss.number} "${iss.title}" assigned to you. Review the comment and respond.\n\nComment: ${body}`;
633
+ const commandTemplate = agentToStart.command_template || project.command_template || config_1.config.defaultCommandTemplate;
634
+ const isRawShell = /^\s*(bash|sh|zsh)\s+-c\b/.test(commandTemplate);
635
+ const systemPrompt = isRawShell ? undefined : (0, system_prompt_1.buildSystemPrompt)(agentToStart, project);
636
+ (0, process_manager_1.startAgentProcess)(agentToStart, prompt, commandTemplate, systemPrompt);
637
+ }
638
+ }
639
+ }
640
+ }
641
+ return reply.code(201).send(comment);
642
+ });
643
+ // Edit comment
644
+ fastify.put('/api/comments/:id', async (request, reply) => {
645
+ const db = (0, database_1.getDatabase)();
646
+ const access = (0, project_permissions_1.ensureCommentAccess)(db, request, reply, request.params.id, true);
647
+ if (!access)
648
+ return;
649
+ const existing = db.prepare('SELECT * FROM issue_comments WHERE id = ?').get(request.params.id);
650
+ if (!existing)
651
+ return reply.code(404).send({ error: 'Comment not found' });
652
+ db.prepare('UPDATE issue_comments SET body = ? WHERE id = ?').run(request.body.body, request.params.id);
653
+ return db.prepare('SELECT * FROM issue_comments WHERE id = ?').get(request.params.id);
654
+ });
655
+ // Delete comment
656
+ fastify.delete('/api/comments/:id', async (request, reply) => {
657
+ const db = (0, database_1.getDatabase)();
658
+ const access = (0, project_permissions_1.ensureCommentAccess)(db, request, reply, request.params.id, true);
659
+ if (!access)
660
+ return;
661
+ const existing = db.prepare('SELECT * FROM issue_comments WHERE id = ?').get(request.params.id);
662
+ if (!existing)
663
+ return reply.code(404).send({ error: 'Comment not found' });
664
+ db.prepare('DELETE FROM issue_comments WHERE id = ?').run(request.params.id);
665
+ return { success: true };
666
+ });
667
+ // Get issue by project ID + number (with reactions + parent/children)
668
+ fastify.get('/api/projects/:pid/issues/number/:num', async (request, reply) => {
669
+ const db = (0, database_1.getDatabase)();
670
+ const access = (0, project_permissions_1.ensureProjectAccess)(db, request, reply, request.params.pid);
671
+ if (!access)
672
+ return;
673
+ const issue = db.prepare('SELECT * FROM issues WHERE project_id = ? AND number = ?').get(request.params.pid, parseInt(request.params.num));
674
+ if (!issue)
675
+ return reply.code(404).send({ error: 'Issue not found' });
676
+ const comments = db.prepare('SELECT * FROM issue_comments WHERE issue_id = ? ORDER BY created_at').all(issue.id);
677
+ const reactions = db.prepare("SELECT * FROM reactions WHERE target_type = 'issue' AND target_id = ?").all(issue.id);
678
+ // Batch-fetch all comment reactions in one query (fixes N+1)
679
+ const cIds = comments.map(c => c.id);
680
+ let cReactionsMap = {};
681
+ if (cIds.length > 0) {
682
+ const ph = cIds.map(() => '?').join(',');
683
+ const allCReactions = db.prepare(`SELECT * FROM reactions WHERE target_type = 'comment' AND target_id IN (${ph})`).all(...cIds);
684
+ for (const r of allCReactions) {
685
+ (cReactionsMap[r.target_id] ||= []).push(r);
686
+ }
687
+ }
688
+ const commentsWithReactions = comments.map(c => ({
689
+ ...c,
690
+ reactions: cReactionsMap[c.id] || [],
691
+ }));
692
+ let parent_number = null;
693
+ let parent_title = null;
694
+ if (issue.parent_id) {
695
+ const parent = db.prepare('SELECT number, title FROM issues WHERE id = ?').get(issue.parent_id);
696
+ if (parent) {
697
+ parent_number = parent.number;
698
+ parent_title = parent.title;
699
+ }
700
+ }
701
+ const children = db.prepare('SELECT id, number, title, status, assigned_to FROM issues WHERE parent_id = ? ORDER BY number').all(issue.id);
702
+ // Relations for by-number endpoint
703
+ const blocks2 = db.prepare(`
704
+ SELECT r.id as relation_id, r.relation_type, r.created_by, r.created_at,
705
+ i.id, i.number, i.title, i.status
706
+ FROM issue_relations r JOIN issues i ON i.id = r.to_issue_id
707
+ WHERE r.from_issue_id = ? AND r.relation_type = 'blocks'
708
+ `).all(issue.id);
709
+ const blocked_by2 = db.prepare(`
710
+ SELECT r.id as relation_id, r.relation_type, r.created_by, r.created_at,
711
+ i.id, i.number, i.title, i.status
712
+ FROM issue_relations r JOIN issues i ON i.id = r.from_issue_id
713
+ WHERE r.to_issue_id = ? AND r.relation_type = 'blocks'
714
+ `).all(issue.id);
715
+ const related_to2 = db.prepare(`
716
+ SELECT r.id as relation_id, r.relation_type, r.created_by, r.created_at,
717
+ i.id, i.number, i.title, i.status
718
+ FROM issue_relations r JOIN issues i ON i.id = r.to_issue_id
719
+ WHERE r.from_issue_id = ? AND r.relation_type = 'related_to'
720
+ UNION
721
+ SELECT r.id as relation_id, r.relation_type, r.created_by, r.created_at,
722
+ i.id, i.number, i.title, i.status
723
+ FROM issue_relations r JOIN issues i ON i.id = r.from_issue_id
724
+ WHERE r.to_issue_id = ? AND r.relation_type = 'related_to'
725
+ `).all(issue.id, issue.id);
726
+ const is_blocked2 = blocked_by2.some((r) => !['done', 'closed'].includes(r.status));
727
+ return { ...issue, comments: commentsWithReactions, reactions, parent_number, parent_title, children, blocks: blocks2, blocked_by: blocked_by2, related_to: related_to2, is_blocked: is_blocked2 };
728
+ });
729
+ // Also update GET /api/issues/:id to include reactions
730
+ // (Override by adding reactions to response)
731
+ // ─── Reactions ───
732
+ fastify.post('/api/reactions/:type/:id', async (request, reply) => {
733
+ const db = (0, database_1.getDatabase)();
734
+ if (request.params.type === 'issue') {
735
+ const access = (0, project_permissions_1.ensureIssueAccess)(db, request, reply, request.params.id, true);
736
+ if (!access)
737
+ return;
738
+ }
739
+ else if (request.params.type === 'comment') {
740
+ const access = (0, project_permissions_1.ensureCommentAccess)(db, request, reply, request.params.id, true);
741
+ if (!access)
742
+ return;
743
+ }
744
+ else {
745
+ return reply.code(400).send({ error: 'Invalid reaction target type' });
746
+ }
747
+ const { user_id, emoji } = request.body;
748
+ if (!user_id || !emoji)
749
+ return reply.code(400).send({ error: 'user_id and emoji required' });
750
+ const id = (0, uuid_1.v4)();
751
+ try {
752
+ db.prepare('INSERT INTO reactions (id, target_type, target_id, user_id, emoji) VALUES (?, ?, ?, ?, ?)')
753
+ .run(id, request.params.type, request.params.id, user_id, emoji);
754
+ }
755
+ catch {
756
+ // Already exists, remove (toggle)
757
+ db.prepare('DELETE FROM reactions WHERE target_type = ? AND target_id = ? AND user_id = ? AND emoji = ?')
758
+ .run(request.params.type, request.params.id, user_id, emoji);
759
+ return { toggled: 'off' };
760
+ }
761
+ return reply.code(201).send({ toggled: 'on', id });
762
+ });
763
+ fastify.get('/api/reactions/:type/:id', async (request, reply) => {
764
+ const db = (0, database_1.getDatabase)();
765
+ if (request.params.type === 'issue') {
766
+ const access = (0, project_permissions_1.ensureIssueAccess)(db, request, reply, request.params.id);
767
+ if (!access)
768
+ return;
769
+ }
770
+ else if (request.params.type === 'comment') {
771
+ const access = (0, project_permissions_1.ensureCommentAccess)(db, request, reply, request.params.id);
772
+ if (!access)
773
+ return;
774
+ }
775
+ else {
776
+ return reply.code(400).send({ error: 'Invalid reaction target type' });
777
+ }
778
+ return db.prepare('SELECT emoji, COUNT(*) as count, GROUP_CONCAT(user_id) as users FROM reactions WHERE target_type = ? AND target_id = ? GROUP BY emoji')
779
+ .all(request.params.type, request.params.id);
780
+ });
781
+ // ─── Milestones ───
782
+ fastify.get('/api/projects/:pid/milestones', async (request, reply) => {
783
+ const db = (0, database_1.getDatabase)();
784
+ const access = (0, project_permissions_1.ensureProjectAccess)(db, request, reply, request.params.pid);
785
+ if (!access)
786
+ return;
787
+ const milestones = db.prepare('SELECT * FROM milestones WHERE project_id = ? ORDER BY created_at DESC').all(request.params.pid);
788
+ // Add progress
789
+ return milestones.map(m => {
790
+ const total = db.prepare('SELECT COUNT(*) as c FROM issues WHERE milestone_id = ?').get(m.id);
791
+ const closed = db.prepare("SELECT COUNT(*) as c FROM issues WHERE milestone_id = ? AND status IN ('done','closed')").get(m.id);
792
+ return { ...m, total_issues: total.c, closed_issues: closed.c, progress: total.c > 0 ? Math.round(closed.c / total.c * 100) : 0 };
793
+ });
794
+ });
795
+ fastify.post('/api/projects/:pid/milestones', async (request, reply) => {
796
+ const db = (0, database_1.getDatabase)();
797
+ const access = (0, project_permissions_1.ensureProjectAccess)(db, request, reply, request.params.pid, true);
798
+ if (!access)
799
+ return;
800
+ const { title, description, due_date } = request.body;
801
+ if (!title)
802
+ return reply.code(400).send({ error: 'title required' });
803
+ const id = (0, uuid_1.v4)();
804
+ db.prepare('INSERT INTO milestones (id, project_id, title, description, due_date) VALUES (?, ?, ?, ?, ?)')
805
+ .run(id, request.params.pid, title, description || '', due_date || null);
806
+ return reply.code(201).send(db.prepare('SELECT * FROM milestones WHERE id = ?').get(id));
807
+ });
808
+ fastify.put('/api/milestones/:id', async (request, reply) => {
809
+ const db = (0, database_1.getDatabase)();
810
+ const access = (0, project_permissions_1.ensureMilestoneAccess)(db, request, reply, request.params.id, true);
811
+ if (!access)
812
+ return;
813
+ const { title, description, due_date, status } = request.body;
814
+ db.prepare('UPDATE milestones SET title = COALESCE(?, title), description = COALESCE(?, description), due_date = COALESCE(?, due_date), status = COALESCE(?, status) WHERE id = ?')
815
+ .run(title ?? null, description ?? null, due_date ?? null, status ?? null, request.params.id);
816
+ return db.prepare('SELECT * FROM milestones WHERE id = ?').get(request.params.id);
817
+ });
818
+ fastify.delete('/api/milestones/:id', async (request, reply) => {
819
+ const db = (0, database_1.getDatabase)();
820
+ const access = (0, project_permissions_1.ensureMilestoneAccess)(db, request, reply, request.params.id, true);
821
+ if (!access)
822
+ return;
823
+ db.prepare('UPDATE issues SET milestone_id = NULL WHERE milestone_id = ?').run(request.params.id);
824
+ db.prepare('DELETE FROM milestones WHERE id = ?').run(request.params.id);
825
+ return { success: true };
826
+ });
827
+ // ─── Search ───
828
+ fastify.get('/api/projects/:pid/search', async (request, reply) => {
829
+ const db = (0, database_1.getDatabase)();
830
+ const access = (0, project_permissions_1.ensureProjectAccess)(db, request, reply, request.params.pid);
831
+ if (!access)
832
+ return;
833
+ const q = `%${request.query.q}%`;
834
+ const issues = db.prepare('SELECT * FROM issues WHERE project_id = ? AND (title LIKE ? OR body LIKE ?) ORDER BY updated_at DESC LIMIT 50')
835
+ .all(request.params.pid, q, q);
836
+ const comments = db.prepare("SELECT c.*, i.number as issue_number, i.title as issue_title FROM issue_comments c JOIN issues i ON c.issue_id = i.id WHERE i.project_id = ? AND c.body LIKE ? AND c.event_type = 'comment' ORDER BY c.created_at DESC LIMIT 20")
837
+ .all(request.params.pid, q);
838
+ return { issues, comments };
839
+ });
840
+ // ─── Issue Relations ───
841
+ // Add relation
842
+ fastify.post('/api/issues/:id/relations', async (request, reply) => {
843
+ const db = (0, database_1.getDatabase)();
844
+ const sourceAccess = (0, project_permissions_1.ensureIssueAccess)(db, request, reply, request.params.id, true);
845
+ if (!sourceAccess)
846
+ return;
847
+ const { id: fromId } = request.params;
848
+ const { type: relationType, target_issue_id: toId, actor } = request.body;
849
+ if (!relationType || !toId) {
850
+ return reply.code(400).send({ error: 'type and target_issue_id are required' });
851
+ }
852
+ if (!['blocks', 'related_to'].includes(relationType)) {
853
+ return reply.code(400).send({ error: 'type must be blocks or related_to' });
854
+ }
855
+ if (fromId === toId) {
856
+ return reply.code(400).send({ error: 'Cannot create relation to self' });
857
+ }
858
+ const fromIssue = db.prepare('SELECT * FROM issues WHERE id = ?').get(fromId);
859
+ const toIssue = db.prepare('SELECT * FROM issues WHERE id = ?').get(toId);
860
+ if (!fromIssue)
861
+ return reply.code(404).send({ error: 'Source issue not found' });
862
+ if (!toIssue)
863
+ return reply.code(404).send({ error: 'Target issue not found' });
864
+ if (toIssue.project_id !== fromIssue.project_id || toIssue.project_id !== sourceAccess.entity.project_id) {
865
+ return reply.code(400).send({ error: 'Target issue must belong to the same project' });
866
+ }
867
+ const relId = (0, uuid_1.v4)();
868
+ try {
869
+ db.prepare('INSERT INTO issue_relations (id, from_issue_id, to_issue_id, relation_type, created_by) VALUES (?, ?, ?, ?, ?)').run(relId, fromId, toId, relationType, actor || 'user');
870
+ }
871
+ catch {
872
+ return reply.code(409).send({ error: 'Relation already exists' });
873
+ }
874
+ // Record event on both issues
875
+ const eventStmt = db.prepare('INSERT INTO issue_comments (id, issue_id, author_id, body, event_type, meta) VALUES (?, ?, ?, ?, ?, ?)');
876
+ const actorId = actor || 'user';
877
+ if (relationType === 'blocks') {
878
+ eventStmt.run((0, uuid_1.v4)(), fromId, actorId, `added blocks dependency on #${toIssue.number}`, 'status_change', JSON.stringify({ relation: 'blocks', target: toId, target_number: toIssue.number }));
879
+ eventStmt.run((0, uuid_1.v4)(), toId, actorId, `marked as blocked by #${fromIssue.number}`, 'status_change', JSON.stringify({ relation: 'blocked_by', source: fromId, source_number: fromIssue.number }));
880
+ }
881
+ else {
882
+ eventStmt.run((0, uuid_1.v4)(), fromId, actorId, `linked as related to #${toIssue.number}`, 'status_change', JSON.stringify({ relation: 'related_to', target: toId, target_number: toIssue.number }));
883
+ }
884
+ (0, websocket_1.broadcastToProject)(fromIssue.project_id, {
885
+ type: 'issue_updated', projectId: fromIssue.project_id,
886
+ data: { issue: db.prepare('SELECT * FROM issues WHERE id = ?').get(fromId) },
887
+ });
888
+ const relation = db.prepare('SELECT * FROM issue_relations WHERE id = ?').get(relId);
889
+ return reply.code(201).send(relation);
890
+ });
891
+ // Delete relation
892
+ fastify.delete('/api/issues/:id/relations/:relationId', async (request, reply) => {
893
+ const db = (0, database_1.getDatabase)();
894
+ const issueAccess = (0, project_permissions_1.ensureIssueAccess)(db, request, reply, request.params.id, true);
895
+ if (!issueAccess)
896
+ return;
897
+ const relationAccess = (0, project_permissions_1.ensureRelationAccess)(db, request, reply, request.params.relationId, true);
898
+ if (!relationAccess)
899
+ return;
900
+ if (relationAccess.entity.from_issue_id !== request.params.id && relationAccess.entity.to_issue_id !== request.params.id) {
901
+ return reply.code(404).send({ error: 'Relation not found' });
902
+ }
903
+ const relation = db.prepare('SELECT * FROM issue_relations WHERE id = ?').get(request.params.relationId);
904
+ if (!relation)
905
+ return reply.code(404).send({ error: 'Relation not found' });
906
+ db.prepare('DELETE FROM issue_relations WHERE id = ?').run(request.params.relationId);
907
+ const fromIssue = db.prepare('SELECT * FROM issues WHERE id = ?').get(relation.from_issue_id);
908
+ if (fromIssue) {
909
+ (0, websocket_1.broadcastToProject)(fromIssue.project_id, {
910
+ type: 'issue_updated', projectId: fromIssue.project_id,
911
+ data: { issue: fromIssue },
912
+ });
913
+ }
914
+ return { success: true };
915
+ });
916
+ // List relations for an issue
917
+ fastify.get('/api/issues/:id/relations', async (request, reply) => {
918
+ const db = (0, database_1.getDatabase)();
919
+ const access = (0, project_permissions_1.ensureIssueAccess)(db, request, reply, request.params.id);
920
+ if (!access)
921
+ return;
922
+ const issueId = request.params.id;
923
+ const blocks = db.prepare(`
924
+ SELECT r.*, i.number as target_number, i.title as target_title, i.status as target_status
925
+ FROM issue_relations r JOIN issues i ON i.id = r.to_issue_id
926
+ WHERE r.from_issue_id = ? AND r.relation_type = 'blocks'
927
+ `).all(issueId);
928
+ const blocked_by = db.prepare(`
929
+ SELECT r.*, i.number as source_number, i.title as source_title, i.status as source_status
930
+ FROM issue_relations r JOIN issues i ON i.id = r.from_issue_id
931
+ WHERE r.to_issue_id = ? AND r.relation_type = 'blocks'
932
+ `).all(issueId);
933
+ const related_to = db.prepare(`
934
+ SELECT r.*, i.number as other_number, i.title as other_title, i.status as other_status
935
+ FROM issue_relations r JOIN issues i ON i.id = r.to_issue_id
936
+ WHERE r.from_issue_id = ? AND r.relation_type = 'related_to'
937
+ UNION
938
+ SELECT r.*, i.number as other_number, i.title as other_title, i.status as other_status
939
+ FROM issue_relations r JOIN issues i ON i.id = r.from_issue_id
940
+ WHERE r.to_issue_id = ? AND r.relation_type = 'related_to'
941
+ `).all(issueId, issueId);
942
+ const is_blocked = blocked_by.some(r => !['done', 'closed'].includes(r.source_status));
943
+ return { blocks, blocked_by, related_to, is_blocked };
944
+ });
945
+ }
946
+ //# sourceMappingURL=issues.js.map