kachow 0.1.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 +77 -0
- package/_server/dist/app.js +130 -0
- package/_server/dist/db/index.js +50 -0
- package/_server/dist/db/schema.js +247 -0
- package/_server/dist/queues/ingestQueue.js +49 -0
- package/_server/dist/queues/redis.js +58 -0
- package/_server/dist/routes/agents.js +162 -0
- package/_server/dist/routes/architecture.js +88 -0
- package/_server/dist/routes/config.js +24 -0
- package/_server/dist/routes/github.js +158 -0
- package/_server/dist/routes/graph.js +112 -0
- package/_server/dist/routes/healing.js +137 -0
- package/_server/dist/routes/impact.js +100 -0
- package/_server/dist/routes/ingest.js +182 -0
- package/_server/dist/routes/manager.js +179 -0
- package/_server/dist/routes/notifications.js +85 -0
- package/_server/dist/routes/qa.js +68 -0
- package/_server/dist/routes/scanner.js +221 -0
- package/_server/dist/routes/stream.js +179 -0
- package/_server/dist/routes/webhooks.js +168 -0
- package/_server/dist/server.js +46 -0
- package/_server/dist/services/agentService.js +715 -0
- package/_server/dist/services/architectureService.js +172 -0
- package/_server/dist/services/demoSeed.js +181 -0
- package/_server/dist/services/graphLayout.js +102 -0
- package/_server/dist/services/graphService.js +532 -0
- package/_server/dist/services/healingService.js +253 -0
- package/_server/dist/services/impactService.js +304 -0
- package/_server/dist/services/ingestService.js +129 -0
- package/_server/dist/services/managerService.js +260 -0
- package/_server/dist/services/notificationService.js +283 -0
- package/_server/dist/services/qaService.js +413 -0
- package/_server/dist/services/scannerService.js +748 -0
- package/_server/dist/services/seedService.js +215 -0
- package/_server/dist/sse/sseManager.js +101 -0
- package/_server/dist/types/index.js +38 -0
- package/_server/dist/workers/ingestWorker.js +274 -0
- package/_server/public/assets/index-BTkbB_YF.js +4546 -0
- package/_server/public/assets/index-Bmh3jWBm.css +1 -0
- package/_server/public/favicon.ico +0 -0
- package/_server/public/images/glass-waves-bg.png +0 -0
- package/_server/public/index.html +29 -0
- package/_server/public/placeholder.svg +1 -0
- package/_server/public/robots.txt +14 -0
- package/dist/config.js +133 -0
- package/dist/index.js +510 -0
- package/dist/setup.js +223 -0
- package/package.json +62 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Manager intelligence service — Phase 8.
|
|
4
|
+
* Provides VP-level health summaries via OpenAI, team breakdowns,
|
|
5
|
+
* ADR decision history, and role-based onboarding paths.
|
|
6
|
+
*/
|
|
7
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
8
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
9
|
+
};
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.getManagerPulse = getManagerPulse;
|
|
12
|
+
exports.getManagerTeams = getManagerTeams;
|
|
13
|
+
exports.getManagerDecisions = getManagerDecisions;
|
|
14
|
+
exports.getOnboardingPath = getOnboardingPath;
|
|
15
|
+
exports.getTeams = getTeams;
|
|
16
|
+
exports.createTeam = createTeam;
|
|
17
|
+
exports.deleteTeam = deleteTeam;
|
|
18
|
+
exports.regenTeamCode = regenTeamCode;
|
|
19
|
+
exports.joinTeamByCode = joinTeamByCode;
|
|
20
|
+
exports.addTeamMember = addTeamMember;
|
|
21
|
+
const openai_1 = __importDefault(require("openai"));
|
|
22
|
+
const crypto_1 = require("crypto");
|
|
23
|
+
const index_js_1 = require("../db/index.js");
|
|
24
|
+
const graphService_js_1 = require("./graphService.js");
|
|
25
|
+
/**
|
|
26
|
+
* Returns a VP-level pulse summary using OpenAI for the plain-English description.
|
|
27
|
+
*/
|
|
28
|
+
async function getManagerPulse() {
|
|
29
|
+
const health = (0, graphService_js_1.getSystemHealth)();
|
|
30
|
+
const nodes = (0, graphService_js_1.getNodes)();
|
|
31
|
+
const db = (0, index_js_1.getDb)();
|
|
32
|
+
const criticalNames = nodes.filter(n => n.healthTier === 'critical').map(n => n.name);
|
|
33
|
+
const warningNames = nodes.filter(n => n.healthTier === 'warning').map(n => n.name);
|
|
34
|
+
let plainEnglishSummary = `System health is at ${health.overallScore}/100 with ${health.critical} critical and ${health.warning} warning services.`;
|
|
35
|
+
const apiKey = process.env.OPENAI_API_KEY;
|
|
36
|
+
if (apiKey) {
|
|
37
|
+
try {
|
|
38
|
+
const openai = new openai_1.default({ apiKey });
|
|
39
|
+
const completion = await openai.chat.completions.create({
|
|
40
|
+
model: 'gpt-4o',
|
|
41
|
+
messages: [{
|
|
42
|
+
role: 'user',
|
|
43
|
+
content: `You are briefing a VP of Engineering. In exactly 2-3 sentences, summarize: Overall score ${health.overallScore}/100. Services: ${health.totalServices} total, ${health.critical} critical (${criticalNames.join(', ') || 'none'}), ${health.warning} warning (${warningNames.join(', ') || 'none'}), ${health.healthy} healthy. Be direct and action-oriented.`,
|
|
44
|
+
}],
|
|
45
|
+
temperature: 0.2,
|
|
46
|
+
max_tokens: 150,
|
|
47
|
+
});
|
|
48
|
+
plainEnglishSummary = completion.choices[0]?.message?.content?.trim() ?? plainEnglishSummary;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Fallback to static summary
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const teams = db.prepare(`
|
|
55
|
+
SELECT team,
|
|
56
|
+
COUNT(*) AS svc_count,
|
|
57
|
+
ROUND(AVG(health_score), 0) AS avg_health,
|
|
58
|
+
SUM(CASE WHEN health_tier = 'critical' THEN 1 ELSE 0 END) AS critical_count
|
|
59
|
+
FROM services WHERE is_external = 0
|
|
60
|
+
GROUP BY team
|
|
61
|
+
ORDER BY avg_health ASC
|
|
62
|
+
`).all();
|
|
63
|
+
const topIssues = nodes
|
|
64
|
+
.filter(n => n.healthTier !== 'healthy')
|
|
65
|
+
.sort((a, b) => a.healthScore - b.healthScore)
|
|
66
|
+
.slice(0, 5)
|
|
67
|
+
.map(n => ({
|
|
68
|
+
serviceId: n.id,
|
|
69
|
+
serviceName: n.name,
|
|
70
|
+
healthScore: n.healthScore,
|
|
71
|
+
healthTier: n.healthTier,
|
|
72
|
+
}));
|
|
73
|
+
return {
|
|
74
|
+
overallScore: health.overallScore,
|
|
75
|
+
plainEnglishSummary,
|
|
76
|
+
topIssues,
|
|
77
|
+
teamBreakdown: teams.map(t => ({
|
|
78
|
+
team: t.team ?? 'unassigned',
|
|
79
|
+
serviceCount: t.svc_count,
|
|
80
|
+
avgHealth: t.avg_health,
|
|
81
|
+
criticalCount: t.critical_count,
|
|
82
|
+
})),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
// ── Teams ─────────────────────────────────────────────────────────────────────
|
|
86
|
+
function getManagerTeams() {
|
|
87
|
+
const db = (0, index_js_1.getDb)();
|
|
88
|
+
return db.prepare(`
|
|
89
|
+
SELECT team,
|
|
90
|
+
COUNT(*) AS svc_count,
|
|
91
|
+
ROUND(AVG(health_score), 0) AS avg_health,
|
|
92
|
+
SUM(CASE WHEN health_tier = 'critical' THEN 1 ELSE 0 END) AS critical_count
|
|
93
|
+
FROM services WHERE is_external = 0
|
|
94
|
+
GROUP BY team ORDER BY avg_health ASC
|
|
95
|
+
`).all()
|
|
96
|
+
.map(t => ({
|
|
97
|
+
team: t.team ?? 'unassigned',
|
|
98
|
+
serviceCount: t.svc_count,
|
|
99
|
+
avgHealth: t.avg_health,
|
|
100
|
+
criticalCount: t.critical_count,
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
// ── Decisions (ADRs) ──────────────────────────────────────────────────────────
|
|
104
|
+
function getManagerDecisions() {
|
|
105
|
+
const db = (0, index_js_1.getDb)();
|
|
106
|
+
return db.prepare(`
|
|
107
|
+
SELECT a.id, a.title, a.status, a.created_at,
|
|
108
|
+
ia.trigger_ref, ia.risk_score, ia.trigger_type
|
|
109
|
+
FROM adrs a
|
|
110
|
+
JOIN impact_analyses ia ON ia.id = a.analysis_id
|
|
111
|
+
ORDER BY a.created_at DESC LIMIT 20
|
|
112
|
+
`).all().map(a => ({
|
|
113
|
+
id: a.id,
|
|
114
|
+
title: a.title,
|
|
115
|
+
status: a.status,
|
|
116
|
+
riskScore: a.risk_score,
|
|
117
|
+
triggerRef: a.trigger_ref,
|
|
118
|
+
triggerType: a.trigger_type,
|
|
119
|
+
createdAt: a.created_at,
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
// ── Onboarding path ───────────────────────────────────────────────────────────
|
|
123
|
+
/**
|
|
124
|
+
* Generates a role-specific onboarding learning path.
|
|
125
|
+
* Orders services based on the engineer's role focus.
|
|
126
|
+
*/
|
|
127
|
+
function getOnboardingPath(role) {
|
|
128
|
+
const db = (0, index_js_1.getDb)();
|
|
129
|
+
const nodes = (0, graphService_js_1.getNodes)();
|
|
130
|
+
const edges = (0, graphService_js_1.getEdges)();
|
|
131
|
+
const outDegree = {};
|
|
132
|
+
for (const e of edges) {
|
|
133
|
+
outDegree[e.sourceId] = (outDegree[e.sourceId] ?? 0) + 1;
|
|
134
|
+
}
|
|
135
|
+
let ordered = [...nodes];
|
|
136
|
+
if (role === 'backend-engineer') {
|
|
137
|
+
// High out-degree first (most connected / most impactful)
|
|
138
|
+
ordered = ordered.sort((a, b) => (outDegree[b.id] ?? 0) - (outDegree[a.id] ?? 0));
|
|
139
|
+
}
|
|
140
|
+
else if (role === 'sre') {
|
|
141
|
+
// Worst health first — focus on fires
|
|
142
|
+
ordered = ordered.sort((a, b) => a.healthScore - b.healthScore);
|
|
143
|
+
}
|
|
144
|
+
else if (role === 'engineering-manager') {
|
|
145
|
+
// By team, then by health
|
|
146
|
+
ordered = ordered.sort((a, b) => {
|
|
147
|
+
const teamCmp = (a.team ?? 'zzz').localeCompare(b.team ?? 'zzz');
|
|
148
|
+
return teamCmp !== 0 ? teamCmp : a.healthScore - b.healthScore;
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
role,
|
|
153
|
+
steps: ordered.slice(0, 8).map((n, i) => {
|
|
154
|
+
const svcRow = db.prepare('SELECT repo_url FROM services WHERE id = ?').get(n.id);
|
|
155
|
+
const endpoints = db.prepare('SELECT method, path FROM endpoints WHERE service_id = ? LIMIT 5').all(n.id);
|
|
156
|
+
const deps = edges.filter(e => e.sourceId === n.id).length;
|
|
157
|
+
return {
|
|
158
|
+
step: i + 1,
|
|
159
|
+
serviceId: n.id,
|
|
160
|
+
title: `Understand ${n.name}`,
|
|
161
|
+
description: `${n.name} is a ${n.language ?? 'service'}${n.team ? ` owned by the ${n.team} team` : ''}. Health: ${n.healthScore}/100 (${n.healthTier}). Makes ${deps} downstream call(s).`,
|
|
162
|
+
keyFiles: [
|
|
163
|
+
`${n.name}/src/index.${n.language === 'python' ? 'py' : 'ts'}`,
|
|
164
|
+
`${n.name}/src/routes.${n.language === 'python' ? 'py' : 'ts'}`,
|
|
165
|
+
`${n.name}/README.md`,
|
|
166
|
+
],
|
|
167
|
+
starterTasks: [
|
|
168
|
+
`Read the ${n.name} README`,
|
|
169
|
+
...(endpoints.length > 0 ? [`Trace a request to ${endpoints[0].method} ${endpoints[0].path}`] : []),
|
|
170
|
+
`Run the ${n.name} test suite`,
|
|
171
|
+
...(svcRow?.repo_url ? [`Clone and build ${n.name} locally`] : []),
|
|
172
|
+
].slice(0, 3),
|
|
173
|
+
repoUrl: svcRow?.repo_url ?? null,
|
|
174
|
+
estimatedMinutes: 30 + deps * 5,
|
|
175
|
+
};
|
|
176
|
+
}),
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function generateJoinCode() {
|
|
180
|
+
return String(Math.floor(100000 + Math.random() * 900000));
|
|
181
|
+
}
|
|
182
|
+
function rowToTeam(row, members) {
|
|
183
|
+
return {
|
|
184
|
+
id: row.id,
|
|
185
|
+
name: row.name,
|
|
186
|
+
repo: row.repo,
|
|
187
|
+
joinCode: row.join_code,
|
|
188
|
+
description: row.description,
|
|
189
|
+
color: row.color,
|
|
190
|
+
createdAt: row.created_at,
|
|
191
|
+
members,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
function membersForTeam(teamId) {
|
|
195
|
+
const db = (0, index_js_1.getDb)();
|
|
196
|
+
return db.prepare(`SELECT * FROM team_members WHERE team_id = ? ORDER BY joined_at ASC`).all(teamId)
|
|
197
|
+
.map(m => ({
|
|
198
|
+
id: m.id,
|
|
199
|
+
name: m.name,
|
|
200
|
+
role: m.role,
|
|
201
|
+
online: m.online === 1,
|
|
202
|
+
progress: m.progress,
|
|
203
|
+
joinedAt: m.joined_at,
|
|
204
|
+
}));
|
|
205
|
+
}
|
|
206
|
+
/** Return all teams with their members. */
|
|
207
|
+
function getTeams() {
|
|
208
|
+
const db = (0, index_js_1.getDb)();
|
|
209
|
+
const rows = db.prepare(`SELECT * FROM teams ORDER BY created_at ASC`).all();
|
|
210
|
+
return rows.map(r => rowToTeam(r, membersForTeam(r.id)));
|
|
211
|
+
}
|
|
212
|
+
/** Create a new team and return it. */
|
|
213
|
+
function createTeam(input) {
|
|
214
|
+
const db = (0, index_js_1.getDb)();
|
|
215
|
+
// Ensure unique join code
|
|
216
|
+
let joinCode = generateJoinCode();
|
|
217
|
+
while (db.prepare(`SELECT 1 FROM teams WHERE join_code = ?`).get(joinCode)) {
|
|
218
|
+
joinCode = generateJoinCode();
|
|
219
|
+
}
|
|
220
|
+
const COLORS = ['#6366F1', '#16A34A', '#D97706', '#0052CC', '#DC2626', '#7C3AED', '#0891B2'];
|
|
221
|
+
const count = db.prepare(`SELECT COUNT(*) as c FROM teams`).get().c;
|
|
222
|
+
const color = input.color ?? COLORS[count % COLORS.length];
|
|
223
|
+
const id = (0, crypto_1.randomUUID)();
|
|
224
|
+
db.prepare(`
|
|
225
|
+
INSERT INTO teams (id, name, repo, join_code, description, color)
|
|
226
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
227
|
+
`).run(id, input.name, input.repo ?? '—', joinCode, input.description ?? '', color);
|
|
228
|
+
return rowToTeam(db.prepare(`SELECT * FROM teams WHERE id = ?`).get(id), []);
|
|
229
|
+
}
|
|
230
|
+
/** Delete a team (cascades to members). */
|
|
231
|
+
function deleteTeam(teamId) {
|
|
232
|
+
const db = (0, index_js_1.getDb)();
|
|
233
|
+
db.prepare(`DELETE FROM teams WHERE id = ?`).run(teamId);
|
|
234
|
+
}
|
|
235
|
+
/** Regenerate a team's join code and return the new code. */
|
|
236
|
+
function regenTeamCode(teamId) {
|
|
237
|
+
const db = (0, index_js_1.getDb)();
|
|
238
|
+
let joinCode = generateJoinCode();
|
|
239
|
+
while (db.prepare(`SELECT 1 FROM teams WHERE join_code = ? AND id != ?`).get(joinCode, teamId)) {
|
|
240
|
+
joinCode = generateJoinCode();
|
|
241
|
+
}
|
|
242
|
+
db.prepare(`UPDATE teams SET join_code = ?, updated_at = datetime('now') WHERE id = ?`).run(joinCode, teamId);
|
|
243
|
+
return joinCode;
|
|
244
|
+
}
|
|
245
|
+
/** Validate a 6-digit code and return the matching team (for engineers). */
|
|
246
|
+
function joinTeamByCode(joinCode) {
|
|
247
|
+
const db = (0, index_js_1.getDb)();
|
|
248
|
+
const row = db.prepare(`SELECT * FROM teams WHERE join_code = ?`).get(joinCode);
|
|
249
|
+
if (!row)
|
|
250
|
+
return null;
|
|
251
|
+
return rowToTeam(row, membersForTeam(row.id));
|
|
252
|
+
}
|
|
253
|
+
/** Add a member to a team (called after an engineer enters a valid code). */
|
|
254
|
+
function addTeamMember(teamId, input) {
|
|
255
|
+
const db = (0, index_js_1.getDb)();
|
|
256
|
+
const id = (0, crypto_1.randomUUID)();
|
|
257
|
+
db.prepare(`INSERT INTO team_members (id, team_id, name, role) VALUES (?, ?, ?, ?)`).run(id, teamId, input.name, input.role ?? 'Engineer');
|
|
258
|
+
return db.prepare(`SELECT * FROM team_members WHERE id = ?`).get(id);
|
|
259
|
+
}
|
|
260
|
+
//# sourceMappingURL=managerService.js.map
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Notification service — Slack & Jira integrations.
|
|
4
|
+
*
|
|
5
|
+
* Sends notifications for critical issues:
|
|
6
|
+
* - Slack: Bot Token + chat.postMessage Web API
|
|
7
|
+
* - Jira: REST API v3 → add comment or create issue
|
|
8
|
+
*
|
|
9
|
+
* Env vars:
|
|
10
|
+
* SLACK_BOT_TOKEN — Slack Bot User OAuth Token (xoxb-…)
|
|
11
|
+
* JIRA_BASE_URL — e.g. https://your-org.atlassian.net
|
|
12
|
+
* JIRA_EMAIL — Atlassian account email
|
|
13
|
+
* JIRA_API_TOKEN — Atlassian API token
|
|
14
|
+
* JIRA_PROJECT_KEY — e.g. "KACHOW" (defaults to KACHOW)
|
|
15
|
+
*/
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.sendSlackNotification = sendSlackNotification;
|
|
18
|
+
exports.commentOrCreateJira = commentOrCreateJira;
|
|
19
|
+
const index_js_1 = require("../db/index.js");
|
|
20
|
+
const crypto_1 = require("crypto");
|
|
21
|
+
function resolveIssueContext(issueId) {
|
|
22
|
+
const db = (0, index_js_1.getDb)();
|
|
23
|
+
// Agent-sourced issues have IDs like "agent-hi-...", "agent-sh-..."
|
|
24
|
+
// Healing issues have IDs like "heal-<uuid>"
|
|
25
|
+
// Incident issues have IDs like "inc-<uuid>"
|
|
26
|
+
if (issueId.startsWith('heal-')) {
|
|
27
|
+
const hid = issueId.replace('heal-', '');
|
|
28
|
+
const row = db.prepare(`
|
|
29
|
+
SELECT hp.service_id, hp.issue_type, hp.explanation, hp.status,
|
|
30
|
+
s.name AS service_name
|
|
31
|
+
FROM healing_prs hp JOIN services s ON s.id = hp.service_id
|
|
32
|
+
WHERE hp.id = ?
|
|
33
|
+
`).get(hid);
|
|
34
|
+
if (!row)
|
|
35
|
+
return null;
|
|
36
|
+
return {
|
|
37
|
+
serviceId: row.service_id,
|
|
38
|
+
serviceName: row.service_name,
|
|
39
|
+
severity: 'warning',
|
|
40
|
+
title: row.issue_type,
|
|
41
|
+
description: row.explanation,
|
|
42
|
+
category: 'quality',
|
|
43
|
+
file: null, line: null,
|
|
44
|
+
suggestedFix: null,
|
|
45
|
+
source: 'healing',
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
if (issueId.startsWith('inc-')) {
|
|
49
|
+
const iid = issueId.replace('inc-', '');
|
|
50
|
+
const row = db.prepare(`
|
|
51
|
+
SELECT i.service_id, i.severity, i.title, i.status, i.occurred_at,
|
|
52
|
+
s.name AS service_name
|
|
53
|
+
FROM incidents i JOIN services s ON s.id = i.service_id
|
|
54
|
+
WHERE i.id = ?
|
|
55
|
+
`).get(iid);
|
|
56
|
+
if (!row)
|
|
57
|
+
return null;
|
|
58
|
+
return {
|
|
59
|
+
serviceId: row.service_id,
|
|
60
|
+
serviceName: row.service_name,
|
|
61
|
+
severity: row.severity,
|
|
62
|
+
title: row.title,
|
|
63
|
+
description: `${row.title} — ${row.status} since ${row.occurred_at}`,
|
|
64
|
+
category: 'reliability',
|
|
65
|
+
file: null, line: null,
|
|
66
|
+
suggestedFix: null,
|
|
67
|
+
source: 'incident',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
// For agent-sourced issues, we need to look them up from the KG
|
|
71
|
+
// These are ephemeral IDs generated by getCriticalIssues(), so we
|
|
72
|
+
// accept the full issue payload from the frontend instead.
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
// ── Slack ─────────────────────────────────────────────────────────────────────
|
|
76
|
+
async function sendSlackNotification(payload, issueOverride) {
|
|
77
|
+
const botToken = process.env.SLACK_BOT_TOKEN;
|
|
78
|
+
const issue = issueOverride ?? resolveIssueContext(payload.issueId);
|
|
79
|
+
const severityEmoji = {
|
|
80
|
+
critical: '🔴', warning: '🟡', info: '🔵',
|
|
81
|
+
};
|
|
82
|
+
const emoji = issue ? severityEmoji[issue.severity] ?? '⚪' : '⚪';
|
|
83
|
+
const blocks = [
|
|
84
|
+
{
|
|
85
|
+
type: 'header',
|
|
86
|
+
text: { type: 'plain_text', text: `${emoji} KA-CHOW Critical Issue Detected`, emoji: true },
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
type: 'section',
|
|
90
|
+
fields: [
|
|
91
|
+
{ type: 'mrkdwn', text: `*Service:*\n${issue?.serviceName ?? 'Unknown'}` },
|
|
92
|
+
{ type: 'mrkdwn', text: `*Severity:*\n${issue?.severity?.toUpperCase() ?? 'UNKNOWN'}` },
|
|
93
|
+
{ type: 'mrkdwn', text: `*Category:*\n${issue?.category ?? 'N/A'}` },
|
|
94
|
+
{ type: 'mrkdwn', text: `*Source:*\n${issue?.source ?? 'N/A'}` },
|
|
95
|
+
],
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
type: 'section',
|
|
99
|
+
text: { type: 'mrkdwn', text: `*Issue:*\n${issue?.description ?? payload.issueId}` },
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
if (issue?.file) {
|
|
103
|
+
blocks.push({
|
|
104
|
+
type: 'section',
|
|
105
|
+
text: { type: 'mrkdwn', text: `*File:* \`${issue.file}${issue.line ? `:${issue.line}` : ''}\`` },
|
|
106
|
+
fields: undefined,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
if (issue?.suggestedFix) {
|
|
110
|
+
blocks.push({
|
|
111
|
+
type: 'section',
|
|
112
|
+
text: { type: 'mrkdwn', text: `*🔧 Suggested Fix:*\n${issue.suggestedFix}` },
|
|
113
|
+
fields: undefined,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
if (payload.customMessage) {
|
|
117
|
+
blocks.push({
|
|
118
|
+
type: 'section',
|
|
119
|
+
text: { type: 'mrkdwn', text: payload.customMessage },
|
|
120
|
+
fields: undefined,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
const slackBody = {
|
|
124
|
+
channel: payload.channel,
|
|
125
|
+
text: `${emoji} [KA-CHOW] ${issue?.severity?.toUpperCase()}: ${issue?.title ?? payload.issueId} in ${issue?.serviceName ?? 'unknown service'}`,
|
|
126
|
+
blocks,
|
|
127
|
+
};
|
|
128
|
+
// Log to activity feed regardless of webhook config
|
|
129
|
+
const db = (0, index_js_1.getDb)();
|
|
130
|
+
db.prepare(`
|
|
131
|
+
INSERT INTO activity_feed (id, type, title, detail, severity, service_id)
|
|
132
|
+
VALUES (?, 'SLACK_SENT', ?, ?, ?, ?)
|
|
133
|
+
`).run((0, crypto_1.randomUUID)(), `Slack notification: ${issue?.title ?? payload.issueId}`, `Sent to ${payload.channel ?? 'default channel'}`, issue?.severity ?? 'info', issue?.serviceId ?? null);
|
|
134
|
+
if (!botToken) {
|
|
135
|
+
// No bot token configured — return mock success so the frontend can still work
|
|
136
|
+
return {
|
|
137
|
+
sent: false,
|
|
138
|
+
channel: payload.channel ?? '#general',
|
|
139
|
+
message: slackBody.text,
|
|
140
|
+
webhookConfigured: false,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const res = await fetch('https://slack.com/api/chat.postMessage', {
|
|
145
|
+
method: 'POST',
|
|
146
|
+
headers: {
|
|
147
|
+
'Authorization': `Bearer ${botToken}`,
|
|
148
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
149
|
+
},
|
|
150
|
+
body: JSON.stringify({
|
|
151
|
+
channel: payload.channel ?? '#general',
|
|
152
|
+
text: slackBody.text,
|
|
153
|
+
blocks: slackBody.blocks,
|
|
154
|
+
}),
|
|
155
|
+
});
|
|
156
|
+
const data = await res.json();
|
|
157
|
+
if (!data.ok) {
|
|
158
|
+
console.error('[Slack] API error:', data.error);
|
|
159
|
+
}
|
|
160
|
+
return {
|
|
161
|
+
sent: !!data.ok,
|
|
162
|
+
channel: payload.channel ?? '#general',
|
|
163
|
+
message: slackBody.text,
|
|
164
|
+
webhookConfigured: true,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
catch (e) {
|
|
168
|
+
console.error('[Slack] Send failed:', e);
|
|
169
|
+
return {
|
|
170
|
+
sent: false,
|
|
171
|
+
channel: payload.channel ?? '#general',
|
|
172
|
+
message: slackBody.text,
|
|
173
|
+
webhookConfigured: true,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
// ── Jira ──────────────────────────────────────────────────────────────────────
|
|
178
|
+
async function commentOrCreateJira(payload, issueOverride) {
|
|
179
|
+
const baseUrl = process.env.JIRA_BASE_URL;
|
|
180
|
+
const email = process.env.JIRA_EMAIL;
|
|
181
|
+
const token = process.env.JIRA_API_TOKEN;
|
|
182
|
+
const projectKey = process.env.JIRA_PROJECT_KEY ?? 'SCRUM';
|
|
183
|
+
const issue = issueOverride ?? resolveIssueContext(payload.issueId);
|
|
184
|
+
const db = (0, index_js_1.getDb)();
|
|
185
|
+
const title = issue?.title ?? payload.issueId;
|
|
186
|
+
const description = [
|
|
187
|
+
`*Service:* ${issue?.serviceName ?? 'Unknown'}`,
|
|
188
|
+
`*Severity:* ${issue?.severity?.toUpperCase() ?? 'UNKNOWN'}`,
|
|
189
|
+
`*Category:* ${issue?.category ?? 'N/A'}`,
|
|
190
|
+
`*Source:* ${issue?.source ?? 'N/A'}`,
|
|
191
|
+
'',
|
|
192
|
+
`*Description:*`,
|
|
193
|
+
issue?.description ?? 'No description available',
|
|
194
|
+
'',
|
|
195
|
+
issue?.file ? `*File:* ${issue.file}${issue.line ? `:${issue.line}` : ''}` : '',
|
|
196
|
+
issue?.suggestedFix ? `*Suggested Fix:* ${issue.suggestedFix}` : '',
|
|
197
|
+
'',
|
|
198
|
+
'_Auto-generated by KA-CHOW_',
|
|
199
|
+
].filter(Boolean).join('\n');
|
|
200
|
+
// Log to activity feed regardless of Jira config
|
|
201
|
+
db.prepare(`
|
|
202
|
+
INSERT INTO activity_feed (id, type, title, detail, severity, service_id)
|
|
203
|
+
VALUES (?, 'JIRA_COMMENT', ?, ?, ?, ?)
|
|
204
|
+
`).run((0, crypto_1.randomUUID)(), `Jira ${payload.createNewTicket ? 'ticket' : 'comment'}: ${title}`, payload.jiraIssueKey ?? 'new ticket', issue?.severity ?? 'info', issue?.serviceId ?? null);
|
|
205
|
+
if (!baseUrl || !email || !token) {
|
|
206
|
+
// No Jira configured — return mock
|
|
207
|
+
const mockKey = payload.jiraIssueKey ?? `${projectKey}-${Math.floor(Math.random() * 999)}`;
|
|
208
|
+
return {
|
|
209
|
+
success: false,
|
|
210
|
+
jiraIssueKey: mockKey,
|
|
211
|
+
action: payload.createNewTicket ? 'created' : 'commented',
|
|
212
|
+
summary: `[KA-CHOW] ${issue?.severity?.toUpperCase()}: ${title}`,
|
|
213
|
+
url: null,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
const headers = {
|
|
217
|
+
'Authorization': `Basic ${Buffer.from(`${email}:${token}`).toString('base64')}`,
|
|
218
|
+
'Content-Type': 'application/json',
|
|
219
|
+
'Accept': 'application/json',
|
|
220
|
+
};
|
|
221
|
+
try {
|
|
222
|
+
if (payload.createNewTicket || !payload.jiraIssueKey) {
|
|
223
|
+
// Create new issue
|
|
224
|
+
// Team-managed (next-gen) projects use Task; classic projects may have Bug.
|
|
225
|
+
// We try Task as the safe default since it's available in both project types.
|
|
226
|
+
const body = {
|
|
227
|
+
fields: {
|
|
228
|
+
project: { key: projectKey },
|
|
229
|
+
summary: `[KA-CHOW] ${issue?.severity?.toUpperCase()}: ${title} in ${issue?.serviceName ?? 'unknown'}`,
|
|
230
|
+
description: {
|
|
231
|
+
type: 'doc', version: 1,
|
|
232
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: description }] }],
|
|
233
|
+
},
|
|
234
|
+
issuetype: { name: 'Task' },
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
const res = await fetch(`${baseUrl}/rest/api/3/issue`, {
|
|
238
|
+
method: 'POST', headers, body: JSON.stringify(body),
|
|
239
|
+
});
|
|
240
|
+
const data = await res.json();
|
|
241
|
+
if (!res.ok) {
|
|
242
|
+
console.error('[Jira] Create issue failed:', res.status, JSON.stringify(data));
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
success: res.ok,
|
|
246
|
+
jiraIssueKey: data.key ?? `${projectKey}-?`,
|
|
247
|
+
action: 'created',
|
|
248
|
+
summary: body.fields.summary,
|
|
249
|
+
url: data.key ? `${baseUrl}/browse/${data.key}` : null,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
// Comment on existing issue
|
|
254
|
+
const commentBody = {
|
|
255
|
+
body: {
|
|
256
|
+
type: 'doc', version: 1,
|
|
257
|
+
content: [{ type: 'paragraph', content: [{ type: 'text', text: description }] }],
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
const res = await fetch(`${baseUrl}/rest/api/3/issue/${payload.jiraIssueKey}/comment`, {
|
|
261
|
+
method: 'POST', headers, body: JSON.stringify(commentBody),
|
|
262
|
+
});
|
|
263
|
+
return {
|
|
264
|
+
success: res.ok,
|
|
265
|
+
jiraIssueKey: payload.jiraIssueKey,
|
|
266
|
+
action: 'commented',
|
|
267
|
+
summary: `Comment added to ${payload.jiraIssueKey}`,
|
|
268
|
+
url: `${baseUrl}/browse/${payload.jiraIssueKey}`,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
catch (e) {
|
|
273
|
+
console.error('[Jira] API call failed:', e);
|
|
274
|
+
return {
|
|
275
|
+
success: false,
|
|
276
|
+
jiraIssueKey: payload.jiraIssueKey ?? `${projectKey}-?`,
|
|
277
|
+
action: payload.createNewTicket ? 'created' : 'commented',
|
|
278
|
+
summary: `Failed: ${e.message}`,
|
|
279
|
+
url: null,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
//# sourceMappingURL=notificationService.js.map
|