husteno-o

[email protected]
2026-09-10 17:49:11 +0600 committed NaN years ago
9fbbdd0
commit 9fbbdd0
11 files changed
+589 -99
β–Όapps/api/src/server.ts
+7-0
3
3
import { formatBytes } from "@devpulse/core";
4
4
import { db, users } from "@devpulse/database";
5
5
import { logger } from "@devpulse/logger";
6
+import { metricsCollector } from "@devpulse/monitoring";
6
7
import { encryptSecret } from "@devpulse/security";
7
8
import { eq, sql } from "drizzle-orm";
8
9
import Fastify, { type FastifyBaseLogger, type FastifyInstance } from "fastify";
114
115
: {}),
115
116
nodeVersion: process.version,
116
117
platform: process.platform,
118
+ detailed: metricsCollector.getSnapshot(),
117
119
};
118
120
});
119
121
122
+ server.get("/metrics/prometheus", async (_req, reply) => {
123
+ reply.header("Content-Type", "text/plain; version=0.0.4; charset=utf-8");
124
+ return metricsCollector.getPrometheusMetrics();
125
+ });
126
+
120
127
// --------------------------------------------------------------------------
121
128
// GitHub OAuth Endpoints
122
129
// --------------------------------------------------------------------------
123
130
β–Όapps/bot/src/commands/actions.ts
+77-3
9
9
data: new SlashCommandBuilder()
10
10
.setName("actions")
11
11
.setDescription(
12
- "GitHub Actions Control Center: inspect CI/CD workflows, runs, and trigger reruns",
12
+ "GitHub Actions Control Center: inspect CI/CD workflows, runs, logs, and trigger reruns",
13
13
)
14
14
.addSubcommand((sub) =>
15
15
sub
27
27
opt.setName("repo").setDescription("Repository format 'owner/repo'").setRequired(true),
28
28
),
29
29
)
30
+ .addSubcommand((sub) =>
31
+ sub
32
+ .setName("logs")
33
+ .setDescription("View jobs and step details for a workflow run")
34
+ .addStringOption((opt) =>
35
+ opt.setName("repo").setDescription("Repository format 'owner/repo'").setRequired(true),
36
+ )
37
+ .addIntegerOption((opt) =>
38
+ opt.setName("run_id").setDescription("Workflow run ID to inspect").setRequired(true),
39
+ ),
40
+ )
30
41
.addSubcommand((sub) =>
31
42
sub
32
43
.setName("rerun")
68
79
const lines = runs.map((r) => {
69
80
const status =
70
81
r.conclusion === "success" ? "βœ…" : r.conclusion === "failure" ? "❌" : "🟑";
71
- return `β€’ [\`#${r.runNumber}\`](${r.htmlUrl}) **${r.name}** β€” ${status} \`${r.conclusion || r.status}\` on \`${r.headBranch}\` (\`${r.headSha}\`)`;
82
+ return `β€’ [\`#${r.runNumber}\`](${r.htmlUrl}) **${r.name}** β€” ${status} \`${r.conclusion || r.status}\` on \`${r.headBranch}\` (\`${r.headSha.slice(0, 7)}\`)`;
72
83
});
73
84
74
85
const embed = createBaseEmbed(
78
89
return;
79
90
}
80
91
92
+ if (subcommand === "logs") {
93
+ const runId = interaction.options.getInteger("run_id", true);
94
+ const jobs = await githubClient.getWorkflowRunJobs(repoInput, runId);
95
+
96
+ if (jobs.length === 0) {
97
+ await interaction.editReply({
98
+ embeds: [createBaseEmbed(`${NF.warning} No Jobs Found`).setDescription(
99
+ `No jobs found for workflow run \`#${runId}\`.`,
100
+ )],
101
+ });
102
+ return;
103
+ }
104
+
105
+ const jobLines: string[] = [];
106
+ let failedStep = "";
107
+
108
+ for (const job of jobs) {
109
+ const icon =
110
+ job.conclusion === "success"
111
+ ? "βœ…"
112
+ : job.conclusion === "failure"
113
+ ? "❌"
114
+ : job.conclusion === "cancelled"
115
+ ? "βšͺ"
116
+ : "🟑";
117
+
118
+ jobLines.push(`**${icon} ${job.name}** β€” \`${job.conclusion || job.status}\``);
119
+
120
+ if (job.conclusion === "failure" && job.steps) {
121
+ for (const step of job.steps) {
122
+ if (step.conclusion === "failure") {
123
+ failedStep = `${job.name} β†’ Step ${step.number}: ${step.name}`;
124
+ jobLines.push(` ↳ ❌ **${step.name}** (Step #${step.number})`);
125
+ }
126
+ }
127
+ }
128
+ }
129
+
130
+ const embed = createBaseEmbed(`${NF.robot} CI Logs: ${repoInput} #${runId}`)
131
+ .setDescription(jobLines.join("\n"));
132
+
133
+ if (failedStep) {
134
+ embed.addFields({
135
+ name: `${NF.warning} Failed Step`,
136
+ value: `\`${failedStep}\``,
137
+ inline: false,
138
+ });
139
+ }
140
+
141
+ const failedRun = jobs.find((j) => j.conclusion === "failure");
142
+ if (failedRun) {
143
+ embed.addFields({
144
+ name: `${NF.bug} Diagnosis`,
145
+ value: `**${failedRun.name}** failed. Check the [full logs on GitHub](https://github.com/${repoInput}/actions/runs/${runId}) for step-level details.`,
146
+ inline: false,
147
+ });
148
+ }
149
+
150
+ embed.setFooter({ text: `GITBOT CI Log Viewer β€’ ${jobs.length} job(s)` });
151
+ await interaction.editReply({ embeds: [embed] });
152
+ return;
153
+ }
154
+
81
155
if (subcommand === "rerun") {
82
156
const runId = interaction.options.getInteger("run_id", true);
83
157
await githubClient.rerunWorkflow(repoInput, runId);
107
181
});
108
182
}
109
183
},
110
-};
184
+};
111
185
β–Όapps/bot/src/events/interactionCreate.ts
+4-0
3
3
import { RateLimitError } from "@devpulse/core";
4
4
import { computeRepoDashboardMetrics, githubClient } from "@devpulse/github";
5
5
import { logger } from "@devpulse/logger";
6
+import { metricsCollector } from "@devpulse/monitoring";
6
7
import {
7
8
type ButtonInteraction,
8
9
type ChatInputCommandInteraction,
82
83
83
84
log.info("Executing slash command");
84
85
86
+ const startTime = Date.now();
85
87
try {
86
88
await command.execute(interaction);
89
+ metricsCollector.recordCommand(interaction.commandName, Date.now() - startTime, true);
87
90
} catch (err: unknown) {
91
+ metricsCollector.recordCommand(interaction.commandName, Date.now() - startTime, false);
88
92
log.error({ err }, "Error during command execution");
89
93
const errorEmbed = createErrorEmbed(err instanceof Error ? err : new Error(String(err)));
90
94
91
95
β–Όapps/bot/src/events/ready.ts
+37-1
2
2
import { initDatabase } from "@devpulse/database";
3
3
import { logger } from "@devpulse/logger";
4
4
import type { MonitorCheckOutcome } from "@devpulse/monitoring";
5
-import { type DueReminder, type WatchNotificationEvent, scheduler } from "@devpulse/scheduler";
5
+import { type DependencyAlert, type DueReminder, type WatchNotificationEvent, scheduler } from "@devpulse/scheduler";
6
6
import {
7
7
ActivityType,
8
8
ApplicationIntegrationType,
148
148
}
149
149
});
150
150
151
+ scheduler.setDependencyAlertHandler(async (alerts: DependencyAlert[]) => {
152
+ try {
153
+ for (const alert of alerts) {
154
+ const activeSubs = await db
155
+ .select()
156
+ .from(notifications)
157
+ .where(and(eq(notifications.target, alert.repoFullName), eq(notifications.isEnabled, true)));
158
+
159
+ for (const sub of activeSubs) {
160
+ if (sub.type !== "security_alert" && sub.type !== "github_release") continue;
161
+
162
+ const guild = await client.guilds.fetch(sub.guildId).catch(() => null);
163
+ if (!guild) continue;
164
+
165
+ const channel =
166
+ guild.channels.cache.get(sub.channelId) ||
167
+ (await guild.channels.fetch(sub.channelId).catch(() => null));
168
+
169
+ if (channel && "send" in channel) {
170
+ const embed = createBaseEmbed(`${NF.warning} Dependency Alert: ${alert.repoFullName}`)
171
+ .setColor(0xf5a97f)
172
+ .setDescription(
173
+ `**${alert.outdatedCount}** of ${alert.totalCount} packages are outdated.\n\n` +
174
+ alert.outdatedPackages.map((p) => `β€’ \`${p}\``).join("\n") +
175
+ `\n\nRun \`npm outdated\` or \`bun outdated\` for full details.`,
176
+ )
177
+ .setFooter({ text: "GITBOT Dependency Watcher" });
178
+ await (channel as SendableChannel).send({ embeds: [embed] });
179
+ }
180
+ }
181
+ }
182
+ } catch (err: unknown) {
183
+ logger.error({ err }, "Failed to deliver dependency alert");
184
+ }
185
+ });
186
+
151
187
scheduler.start(30000);
152
188
}
153
189
β–Όpackages/ai/src/copilot.ts
+87-27
8
8
GitHubRepo,
9
9
} from "@devpulse/github";
10
10
import { logger } from "@devpulse/logger";
11
+import { z } from "zod";
11
12
import { aiProvider } from "./provider.js";
12
13
14
+const AiCodeReviewFindingSchema = z.object({
15
+ category: z.enum(["security", "performance", "bug", "style"]),
16
+ severity: z.enum(["CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO"]),
17
+ title: z.string().min(1),
18
+ file: z.string(),
19
+ line: z.number().optional(),
20
+ description: z.string().min(1),
21
+ suggestion: z.string().optional(),
22
+});
23
+
24
+const AiCodeReviewResultSchema = z.object({
25
+ summary: z.string().min(1),
26
+ riskLevel: z.enum(["LOW", "MEDIUM", "HIGH", "CRITICAL"]),
27
+ score: z.number().min(0).max(100),
28
+ findings: z.array(AiCodeReviewFindingSchema),
29
+ approvedForMerge: z.boolean(),
30
+ diffProposal: z.string().optional(),
31
+});
32
+
33
+const AiSummaryResultSchema = z.object({
34
+ headline: z.string().min(1),
35
+ features: z.array(z.string()),
36
+ fixes: z.array(z.string()),
37
+ perfAndChores: z.array(z.string()),
38
+ topContributors: z.array(z.string()),
39
+});
40
+
41
+const AiBugfixResultSchema = z.object({
42
+ rootCause: z.string().min(1),
43
+ targetFile: z.string(),
44
+ targetLine: z.number().optional(),
45
+ proposedPatch: z.string().optional().default(""),
46
+ explanation: z.string().min(1),
47
+ confidence: z.enum(["HIGH", "MEDIUM", "LOW"]),
48
+ testSuggestions: z.array(z.string()),
49
+});
50
+
51
+const AiExplanationResultSchema = z.object({
52
+ summary: z.string().min(1),
53
+ architectureRole: z.string().min(1),
54
+ keyComponents: z.array(z.object({ name: z.string(), purpose: z.string() })),
55
+ complexity: z.enum(["Low", "Moderate", "High"]),
56
+ dependencies: z.array(z.string()),
57
+ securityConsiderations: z.array(z.string()),
58
+});
59
+
13
60
export interface AiCodeReviewFinding {
14
61
category: "security" | "performance" | "bug" | "style";
15
62
severity: "CRITICAL" | "HIGH" | "MEDIUM" | "LOW" | "INFO";
23
70
export interface AiCodeReviewResult {
24
71
summary: string;
25
72
riskLevel: "LOW" | "MEDIUM" | "HIGH" | "CRITICAL";
26
- score: number; // 0-100
73
+ score: number;
27
74
findings: AiCodeReviewFinding[];
28
75
approvedForMerge: boolean;
29
76
diffProposal?: string;
130
177
131
178
const jsonMatch = raw.match(/\{[\s\S]*\}/);
132
179
if (jsonMatch) {
133
- const parsed = JSON.parse(jsonMatch[0]) as AiCodeReviewResult;
134
- parsed.poweredBy = aiProvider.name;
135
- return parsed;
180
+ const rawParsed = JSON.parse(jsonMatch[0]);
181
+ const result = AiCodeReviewResultSchema.safeParse(rawParsed);
182
+ if (result.success) {
183
+ return { ...result.data, poweredBy: aiProvider.name };
184
+ }
185
+ logger.warn({ issues: result.error.issues }, "AI review response failed validation");
136
186
}
137
187
} catch (err: unknown) {
138
188
logger.warn(
468
518
469
519
const jsonMatch = raw.match(/\{[\s\S]*\}/);
470
520
if (jsonMatch) {
471
- const parsed = JSON.parse(jsonMatch[0]);
472
- return {
473
- timeframe,
474
- headline: parsed.headline || `High velocity progress in ${repo.name} this ${timeframe}`,
475
- features: (parsed.features || []).slice(0, 4),
476
- fixes: (parsed.fixes || []).slice(0, 4),
477
- perfAndChores: (parsed.perfAndChores || []).slice(0, 4),
478
- topContributors: (parsed.topContributors || Array.from(authors).slice(0, 4)).map(
479
- (a: string) => (a.startsWith("@") ? a : `@${a}`),
480
- ),
481
- stats: {
482
- commitsCount: commits.length,
483
- prsMergedCount: prs.filter((p) => p.state === "closed").length,
484
- activeAuthorsCount: authors.size,
485
- },
486
- poweredBy: aiProvider.name,
487
- };
521
+ const rawParsed = JSON.parse(jsonMatch[0]);
522
+ const result = AiSummaryResultSchema.safeParse(rawParsed);
523
+ if (result.success) {
524
+ return {
525
+ timeframe,
526
+ headline: result.data.headline,
527
+ features: result.data.features.slice(0, 4),
528
+ fixes: result.data.fixes.slice(0, 4),
529
+ perfAndChores: result.data.perfAndChores.slice(0, 4),
530
+ topContributors: result.data.topContributors.map(
531
+ (a: string) => (a.startsWith("@") ? a : `@${a}`),
532
+ ),
533
+ stats: {
534
+ commitsCount: commits.length,
535
+ prsMergedCount: prs.filter((p) => p.state === "closed").length,
536
+ activeAuthorsCount: authors.size,
537
+ },
538
+ poweredBy: aiProvider.name,
539
+ };
540
+ }
541
+ logger.warn({ issues: result.error.issues }, "AI summary response failed validation");
488
542
}
489
543
} catch (err: unknown) {
490
544
logger.warn(
581
635
582
636
const jsonMatch = raw.match(/\{[\s\S]*\}/);
583
637
if (jsonMatch) {
584
- const parsed = JSON.parse(jsonMatch[0]) as AiBugfixResult;
585
- parsed.poweredBy = aiProvider.name;
586
- return parsed;
638
+ const rawParsed = JSON.parse(jsonMatch[0]);
639
+ const result = AiBugfixResultSchema.safeParse(rawParsed);
640
+ if (result.success) {
641
+ return { ...result.data, poweredBy: aiProvider.name };
642
+ }
643
+ logger.warn({ issues: result.error.issues }, "AI bugfix response failed validation");
587
644
}
588
645
} catch (err: unknown) {
589
646
logger.warn({ err }, "LLM bugfix generator failed, using heuristic stack trace analyzer");
675
732
676
733
const jsonMatch = raw.match(/\{[\s\S]*\}/);
677
734
if (jsonMatch) {
678
- const parsed = JSON.parse(jsonMatch[0]) as AiExplanationResult;
679
- parsed.poweredBy = aiProvider.name;
680
- return parsed;
735
+ const rawParsed = JSON.parse(jsonMatch[0]);
736
+ const result = AiExplanationResultSchema.safeParse(rawParsed);
737
+ if (result.success) {
738
+ return { ...result.data, poweredBy: aiProvider.name };
739
+ }
740
+ logger.warn({ issues: result.error.issues }, "AI explanation response failed validation");
681
741
}
682
742
} catch (err: unknown) {
683
743
logger.warn({ err }, "LLM code explainer failed, using deterministic AST parser");
684
744
β–Όpackages/ai/src/provider.ts
+136-66
1
1
import { config } from "@devpulse/config";
2
2
import { AppError } from "@devpulse/core";
3
+import { logger } from "@devpulse/logger";
3
4
4
5
export interface AIProvider {
5
6
readonly name: string;
6
7
isEnabled(): boolean;
7
8
generateResponse(
8
9
prompt: string,
9
- options?: { system?: string; maxTokens?: number },
10
+ options?: { system?: string; maxTokens?: number; temperature?: number },
10
11
): Promise<string>;
11
12
}
12
13
31
32
private apiKey: string;
32
33
private baseUrl: string;
33
34
private model: string;
35
+ private maxRetries = 3;
36
+ private retryDelayMs = 1000;
34
37
35
38
constructor(
36
39
name: string,
50
53
51
54
async generateResponse(
52
55
prompt: string,
53
- options: { system?: string; maxTokens?: number } = {},
56
+ options: { system?: string; maxTokens?: number; temperature?: number } = {},
54
57
): Promise<string> {
55
58
if (!this.apiKey) {
56
59
throw new AppError("AI API key is missing.", "AI_NOT_CONFIGURED", 400);
66
69
{ role: "user", content: prompt },
67
70
];
68
71
69
- const response = await fetch(`${this.baseUrl}/chat/completions`, {
70
- method: "POST",
71
- headers: {
72
- "Content-Type": "application/json",
73
- Authorization: `Bearer ${this.apiKey}`,
74
- },
75
- body: JSON.stringify({
76
- model: this.model,
77
- messages,
78
- max_tokens: options.maxTokens || 1024,
79
- }),
80
- });
81
-
82
- if (!response.ok) {
83
- const errText = await response.text();
84
- throw new AppError(`AI provider error: ${errText}`, "AI_REQUEST_FAILED", 502);
72
+ let lastError: Error | null = null;
73
+
74
+ for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
75
+ try {
76
+ const response = await fetch(`${this.baseUrl}/chat/completions`, {
77
+ method: "POST",
78
+ headers: {
79
+ "Content-Type": "application/json",
80
+ Authorization: `Bearer ${this.apiKey}`,
81
+ },
82
+ body: JSON.stringify({
83
+ model: this.model,
84
+ messages,
85
+ max_tokens: options.maxTokens || 2000,
86
+ temperature: options.temperature ?? 0.3,
87
+ }),
88
+ });
89
+
90
+ if (!response.ok) {
91
+ const errText = await response.text();
92
+ throw new AppError(`AI provider error: ${errText}`, "AI_REQUEST_FAILED", 502);
93
+ }
94
+
95
+ const data = (await response.json()) as {
96
+ choices?: { message?: { content?: string } }[];
97
+ };
98
+ const content = data.choices?.[0]?.message?.content;
99
+ if (!content) {
100
+ throw new AppError("AI returned empty response", "AI_EMPTY_RESPONSE", 502);
101
+ }
102
+ return content;
103
+ } catch (err: unknown) {
104
+ lastError = err instanceof Error ? err : new Error(String(err));
105
+ if (attempt < this.maxRetries) {
106
+ logger.warn(
107
+ { attempt, provider: this.name, error: lastError.message },
108
+ "AI request failed, retrying",
109
+ );
110
+ await new Promise((r) => setTimeout(r, this.retryDelayMs * attempt));
111
+ }
112
+ }
85
113
}
86
114
87
- const data = (await response.json()) as any;
88
- return data.choices?.[0]?.message?.content || "No response generated.";
115
+ throw lastError || new AppError("AI request failed after retries", "AI_RETRY_EXHAUSTED", 502);
89
116
}
90
117
}
91
118
99
126
private apiKey: string | undefined;
100
127
private baseUrl: string;
101
128
private model: string;
129
+ private maxRetries = 2;
130
+ private retryDelayMs = 500;
102
131
103
132
constructor(apiKey?: string, baseUrl = "https://opencode.ai/zen/v1", model = "spark-1.3") {
104
133
this.name = `OpenCode Zen (${model})`;
113
142
114
143
async generateResponse(
115
144
prompt: string,
116
- options: { system?: string; maxTokens?: number } = {},
145
+ options: { system?: string; maxTokens?: number; temperature?: number } = {},
117
146
): Promise<string> {
118
147
const messages = [
119
148
{
133
162
headers.Authorization = `Bearer ${this.apiKey}`;
134
163
}
135
164
136
- const response = await fetch(`${this.baseUrl}/chat/completions`, {
137
- method: "POST",
138
- headers,
139
- body: JSON.stringify({
140
- model: this.model,
141
- messages,
142
- max_tokens: options.maxTokens || 1024,
143
- }),
144
- });
145
-
146
- if (!response.ok) {
147
- const errText = await response.text();
148
- throw new AppError(
149
- `OpenCode Zen API error (${response.status}): ${errText}`,
150
- "AI_REQUEST_FAILED",
151
- 502,
152
- );
165
+ let lastError: Error | null = null;
166
+
167
+ for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
168
+ try {
169
+ const response = await fetch(`${this.baseUrl}/chat/completions`, {
170
+ method: "POST",
171
+ headers,
172
+ body: JSON.stringify({
173
+ model: this.model,
174
+ messages,
175
+ max_tokens: options.maxTokens || 2000,
176
+ temperature: options.temperature ?? 0.3,
177
+ }),
178
+ });
179
+
180
+ if (!response.ok) {
181
+ const errText = await response.text();
182
+ throw new AppError(
183
+ `OpenCode Zen API error (${response.status}): ${errText}`,
184
+ "AI_REQUEST_FAILED",
185
+ 502,
186
+ );
187
+ }
188
+
189
+ const data = (await response.json()) as {
190
+ choices?: { message?: { content?: string } }[];
191
+ };
192
+ const content = data.choices?.[0]?.message?.content;
193
+ if (!content) {
194
+ throw new AppError("AI returned empty response", "AI_EMPTY_RESPONSE", 502);
195
+ }
196
+ return content;
197
+ } catch (err: unknown) {
198
+ lastError = err instanceof Error ? err : new Error(String(err));
199
+ if (attempt < this.maxRetries) {
200
+ await new Promise((r) => setTimeout(r, this.retryDelayMs * attempt));
201
+ }
202
+ }
153
203
}
154
204
155
- const data = (await response.json()) as { choices?: { message?: { content?: string } }[] };
156
- return data.choices?.[0]?.message?.content || "No response generated.";
205
+ throw lastError || new AppError("AI request failed after retries", "AI_RETRY_EXHAUSTED", 502);
157
206
}
158
207
}
159
208
167
216
readonly name: string;
168
217
private apiKey: string;
169
218
private model: string;
219
+ private maxRetries = 2;
220
+ private retryDelayMs = 500;
170
221
171
222
constructor(apiKey: string, model = "longcat-2.0:free") {
172
223
this.name = `Command Code (${model})`;
180
231
181
232
async generateResponse(
182
233
prompt: string,
183
- options: { system?: string; maxTokens?: number } = {},
234
+ options: { system?: string; maxTokens?: number; temperature?: number } = {},
184
235
): Promise<string> {
185
236
if (!this.apiKey) {
186
237
throw new AppError(
205
256
Authorization: `Bearer ${this.apiKey}`,
206
257
};
207
258
208
- const response = await fetch("https://api.commandcode.ai/provider/v1/chat/completions", {
209
- method: "POST",
210
- headers,
211
- body: JSON.stringify({
212
- model: this.model,
213
- messages,
214
- max_tokens: options.maxTokens || 1024,
215
- }),
216
- });
217
-
218
- if (!response.ok) {
219
- const errText = await response.text();
220
- throw new AppError(
221
- `Command Code API error (${response.status}): ${errText}`,
222
- "AI_REQUEST_FAILED",
223
- 502,
224
- );
259
+ let lastError: Error | null = null;
260
+
261
+ for (let attempt = 1; attempt <= this.maxRetries; attempt++) {
262
+ try {
263
+ const response = await fetch(
264
+ "https://api.commandcode.ai/provider/v1/chat/completions",
265
+ {
266
+ method: "POST",
267
+ headers,
268
+ body: JSON.stringify({
269
+ model: this.model,
270
+ messages,
271
+ max_tokens: options.maxTokens || 2000,
272
+ temperature: options.temperature ?? 0.3,
273
+ }),
274
+ },
275
+ );
276
+
277
+ if (!response.ok) {
278
+ const errText = await response.text();
279
+ throw new AppError(
280
+ `Command Code API error (${response.status}): ${errText}`,
281
+ "AI_REQUEST_FAILED",
282
+ 502,
283
+ );
284
+ }
285
+
286
+ const data = (await response.json()) as {
287
+ choices?: { message?: { content?: string } }[];
288
+ };
289
+ const content = data.choices?.[0]?.message?.content;
290
+ if (!content) {
291
+ throw new AppError("AI returned empty response", "AI_EMPTY_RESPONSE", 502);
292
+ }
293
+ return content;
294
+ } catch (err: unknown) {
295
+ lastError = err instanceof Error ? err : new Error(String(err));
296
+ if (attempt < this.maxRetries) {
297
+ await new Promise((r) => setTimeout(r, this.retryDelayMs * attempt));
298
+ }
299
+ }
225
300
}
226
301
227
- const data = (await response.json()) as { choices?: { message?: { content?: string } }[] };
228
- return data.choices?.[0]?.message?.content || "No response generated.";
302
+ throw lastError || new AppError("AI request failed after retries", "AI_RETRY_EXHAUSTED", 502);
229
303
}
230
304
}
231
305
248
322
249
323
async generateResponse(
250
324
prompt: string,
251
- options: { system?: string; maxTokens?: number } = {},
325
+ options: { system?: string; maxTokens?: number; temperature?: number } = {},
252
326
): Promise<string> {
253
327
const errors: string[] = [];
254
328
280
354
return new NoopAiProvider();
281
355
}
282
356
283
- // Command Code β€” standalone provider (longcat-2.0:free)
284
357
if (providerType === "commandcode") {
285
358
if (!apiKey) return new NoopAiProvider();
286
359
return new CommandCodeProvider(apiKey, model || "longcat-2.0:free");
287
360
}
288
361
289
- // OpenCode Zen β€” standalone provider (spark-1.3)
290
362
if (providerType === "opencode") {
291
363
return new OpenCodeZenProvider(
292
364
apiKey,
295
367
);
296
368
}
297
369
298
- // spark = OpenCode Zen (spark-1.3) with Command Code (longcat-2.0:free) fallback
299
370
if (providerType === "spark") {
300
371
return new FallbackChainProvider([
301
372
new OpenCodeZenProvider(apiKey, baseUrl || "https://opencode.ai/zen/v1", "spark-1.3"),
303
374
]);
304
375
}
305
376
306
- // All other providers require an API key
307
377
if (!apiKey) {
308
378
return new NoopAiProvider();
309
379
}
338
408
return new NoopAiProvider();
339
409
}
340
410
341
-export const aiProvider = getAiProvider();
411
+export const aiProvider = getAiProvider();
342
412
β–Όpackages/cache/src/index.ts
+30-2
32
32
}
33
33
34
34
export class MemoryCacheProvider implements CacheProvider {
35
- private store = new Map<string, { value: unknown; expiresAt?: number }>();
35
+ private store = new Map<string, { value: unknown; expiresAt?: number; lastAccess: number }>();
36
36
private rateLimits = new Map<string, { count: number; resetAt: number }>();
37
37
private locks = new Set<string>();
38
+ private readonly MAX_ENTRIES = 2000;
39
+ private readonly CLEANUP_INTERVAL = 60_000;
40
+ private lastCleanup = Date.now();
38
41
39
42
async get<T>(key: string): Promise<T | null> {
40
43
const item = this.store.get(key);
43
46
this.store.delete(key);
44
47
return null;
45
48
}
49
+ item.lastAccess = Date.now();
46
50
return item.value as T;
47
51
}
48
52
49
53
async set<T>(key: string, value: T, ttlSeconds?: number): Promise<void> {
50
54
const expiresAt = ttlSeconds ? Date.now() + ttlSeconds * 1000 : undefined;
51
- this.store.set(key, { value, expiresAt });
55
+ this.store.set(key, { value, expiresAt, lastAccess: Date.now() });
56
+ this.maybeEvict();
57
+ this.maybeCleanup();
52
58
}
53
59
54
60
async del(key: string): Promise<void> {
55
61
this.store.delete(key);
56
62
}
57
63
64
+ private maybeEvict(): void {
65
+ if (this.store.size <= this.MAX_ENTRIES) return;
66
+ const entries = Array.from(this.store.entries());
67
+ entries.sort((a, b) => a[1].lastAccess - b[1].lastAccess);
68
+ const toRemove = entries.slice(0, Math.ceil(this.store.size * 0.2));
69
+ for (const [key] of toRemove) {
70
+ this.store.delete(key);
71
+ }
72
+ logger.debug({ evicted: toRemove.length, remaining: this.store.size }, "Cache LRU eviction");
73
+ }
74
+
75
+ private maybeCleanup(): void {
76
+ const now = Date.now();
77
+ if (now - this.lastCleanup < this.CLEANUP_INTERVAL) return;
78
+ this.lastCleanup = now;
79
+ for (const [key, item] of this.store) {
80
+ if (item.expiresAt && now > item.expiresAt) {
81
+ this.store.delete(key);
82
+ }
83
+ }
84
+ }
85
+
58
86
async acquireLock(key: string, ttlSeconds: number): Promise<boolean> {
59
87
const lockKey = `lock:${key}`;
60
88
if (this.locks.has(lockKey)) {
61
89
β–Όpackages/github/src/client.ts
+33-0
25
25
GitHubSecurityAdvisory,
26
26
GitHubUser,
27
27
GitHubWorkflow,
28
+ GitHubWorkflowJob,
28
29
GitHubWorkflowRun,
29
30
InvestigationTimeline,
30
31
InvestigationTimelineEvent,
991
992
return true;
992
993
}
993
994
995
+ async getWorkflowRunJobs(repoInput: string, runId: number): Promise<GitHubWorkflowJob[]> {
996
+ const { owner, repo } = this.parseRepoInput(repoInput);
997
+ const raw = await this.request<{ jobs?: unknown[] }>(
998
+ `/repos/${owner}/${repo}/actions/runs/${runId}/jobs`,
999
+ );
1000
+ return (raw.jobs || []).map((j) => {
1001
+ const job = j as {
1002
+ id: number;
1003
+ name: string;
1004
+ status: string;
1005
+ conclusion: string | null;
1006
+ started_at: string;
1007
+ completed_at: string | null;
1008
+ steps?: { name: string; status: string; conclusion: string | null; number: number }[];
1009
+ };
1010
+ return {
1011
+ id: job.id,
1012
+ name: job.name,
1013
+ status: job.status,
1014
+ conclusion: job.conclusion,
1015
+ startedAt: job.started_at,
1016
+ completedAt: job.completed_at,
1017
+ steps: (job.steps || []).map((s) => ({
1018
+ name: s.name,
1019
+ status: s.status,
1020
+ conclusion: s.conclusion,
1021
+ number: s.number,
1022
+ })),
1023
+ };
1024
+ });
1025
+ }
1026
+
994
1027
// 8. SECURITY
995
1028
async getSecurityAdvisories(repoInput: string): Promise<GitHubSecurityAdvisory[]> {
996
1029
const { owner, repo } = this.parseRepoInput(repoInput);
997
1030
β–Όpackages/github/src/types.ts
+15-0
286
286
}[];
287
287
}
288
288
289
+export interface GitHubWorkflowJob {
290
+ id: number;
291
+ name: string;
292
+ status: string;
293
+ conclusion: string | null;
294
+ startedAt: string;
295
+ completedAt: string | null;
296
+ steps: {
297
+ name: string;
298
+ status: string;
299
+ conclusion: string | null;
300
+ number: number;
301
+ }[];
302
+}
303
+
289
304
export interface GitHubSecurityAdvisory {
290
305
ghsaId: string;
291
306
cveId: string | null;
292
307
β–Όpackages/monitoring/src/index.ts
+106-0
241
241
}
242
242
243
243
export const monitoringService = new MonitoringService();
244
+
245
+class MetricsCollector {
246
+ private commandCounts = new Map<string, { count: number; totalMs: number; errors: number }>();
247
+ private cacheHits = 0;
248
+ private cacheMisses = 0;
249
+ private aiRequests = 0;
250
+ private aiErrors = 0;
251
+ private githubApiCalls = 0;
252
+ private githubApiErrors = 0;
253
+ private startedAt = Date.now();
254
+
255
+ recordCommand(name: string, durationMs: number, success: boolean): void {
256
+ const existing = this.commandCounts.get(name) || { count: 0, totalMs: 0, errors: 0 };
257
+ existing.count++;
258
+ existing.totalMs += durationMs;
259
+ if (!success) existing.errors++;
260
+ this.commandCounts.set(name, existing);
261
+ }
262
+
263
+ recordCacheHit(): void { this.cacheHits++; }
264
+ recordCacheMiss(): void { this.cacheMisses++; }
265
+ recordAiRequest(): void { this.aiRequests++; }
266
+ recordAiError(): void { this.aiErrors++; }
267
+ recordGithubApiCall(): void { this.githubApiCalls++; }
268
+ recordGithubApiError(): void { this.githubApiErrors++; }
269
+
270
+ getSnapshot(): Record<string, unknown> {
271
+ const commands: Record<string, unknown> = {};
272
+ for (const [name, data] of this.commandCounts) {
273
+ commands[name] = {
274
+ total: data.count,
275
+ avgMs: Math.round(data.totalMs / data.count),
276
+ errors: data.errors,
277
+ errorRate: data.count > 0 ? `${((data.errors / data.count) * 100).toFixed(1)}%` : "0%",
278
+ };
279
+ }
280
+
281
+ const uptimeMs = Date.now() - this.startedAt;
282
+ return {
283
+ uptimeSeconds: Math.floor(uptimeMs / 1000),
284
+ uptimeHuman: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m`,
285
+ commands,
286
+ commandSummary: {
287
+ totalCommands: Array.from(this.commandCounts.values()).reduce((s, c) => s + c.count, 0),
288
+ totalErrors: Array.from(this.commandCounts.values()).reduce((s, c) => s + c.errors, 0),
289
+ avgLatencyMs: Math.round(
290
+ Array.from(this.commandCounts.values()).reduce((s, c) => s + c.totalMs, 0) /
291
+ Math.max(Array.from(this.commandCounts.values()).reduce((s, c) => s + c.count, 0), 1),
292
+ ),
293
+ },
294
+ cache: {
295
+ hits: this.cacheHits,
296
+ misses: this.cacheMisses,
297
+ hitRate: this.cacheHits + this.cacheMisses > 0
298
+ ? `${((this.cacheHits / (this.cacheHits + this.cacheMisses)) * 100).toFixed(1)}%`
299
+ : "N/A",
300
+ },
301
+ ai: {
302
+ requests: this.aiRequests,
303
+ errors: this.aiErrors,
304
+ errorRate: this.aiRequests > 0
305
+ ? `${((this.aiErrors / this.aiRequests) * 100).toFixed(1)}%`
306
+ : "0%",
307
+ },
308
+ github: {
309
+ apiCalls: this.githubApiCalls,
310
+ apiErrors: this.githubApiErrors,
311
+ },
312
+ };
313
+ }
314
+
315
+ getPrometheusMetrics(): string {
316
+ const lines: string[] = [];
317
+ lines.push("# HELP gitbot_commands_total Total number of commands executed");
318
+ lines.push("# TYPE gitbot_commands_total counter");
319
+ for (const [name, data] of this.commandCounts) {
320
+ lines.push(`gitbot_commands_total{command="${name}"} ${data.count}`);
321
+ }
322
+
323
+ lines.push("# HELP gitbot_command_duration_ms Average command duration");
324
+ lines.push("# TYPE gitbot_command_duration_ms gauge");
325
+ for (const [name, data] of this.commandCounts) {
326
+ lines.push(`gitbot_command_duration_ms{command="${name}"} ${Math.round(data.totalMs / data.count)}`);
327
+ }
328
+
329
+ lines.push("# HELP gitbot_command_errors_total Total command errors");
330
+ lines.push("# TYPE gitbot_command_errors_total counter");
331
+ for (const [name, data] of this.commandCounts) {
332
+ lines.push(`gitbot_command_errors_total{command="${name}"} ${data.errors}`);
333
+ }
334
+
335
+ lines.push(`# HELP gitbot_cache_hits_total Cache hits`);
336
+ lines.push(`# TYPE gitbot_cache_hits_total counter`);
337
+ lines.push(`gitbot_cache_hits_total ${this.cacheHits}`);
338
+ lines.push(`# HELP gitbot_cache_misses_total Cache misses`);
339
+ lines.push(`# TYPE gitbot_cache_misses_total counter`);
340
+ lines.push(`gitbot_cache_misses_total ${this.cacheMisses}`);
341
+ lines.push(`# HELP gitbot_uptime_seconds Uptime in seconds`);
342
+ lines.push(`# TYPE gitbot_uptime_seconds gauge`);
343
+ lines.push(`gitbot_uptime_seconds ${Math.floor((Date.now() - this.startedAt) / 1000)}`);
344
+
345
+ return lines.join("\n");
346
+ }
347
+}
348
+
349
+export const metricsCollector = new MetricsCollector();
244
350
β–Όpackages/scheduler/src/index.ts
+57-0
8
8
import { and, desc, eq, lte } from "drizzle-orm";
9
9
import type { GitHubRepoEvent } from "@devpulse/github";
10
10
11
+export interface DependencyAlert {
12
+ repoFullName: string;
13
+ totalCount: number;
14
+ outdatedCount: number;
15
+ outdatedPackages: string[];
16
+}
17
+
11
18
export interface DueReminder {
12
19
id: string;
13
20
guildId: string;
282
289
return;
283
290
}
284
291
}
292
+
293
+ async checkDependencyAlerts(repos: string[]): Promise<DependencyAlert[]> {
294
+ const alerts: DependencyAlert[] = [];
295
+ for (const repo of repos) {
296
+ try {
297
+ const deps = await githubClient.getDependencies(repo);
298
+ if (deps.outdatedCount > 0) {
299
+ const outdatedNames = deps.dependencies
300
+ .filter((d) => d.isOutdated)
301
+ .slice(0, 5)
302
+ .map((d) => `${d.name}@${d.version}`);
303
+ alerts.push({
304
+ repoFullName: repo,
305
+ totalCount: deps.totalCount,
306
+ outdatedCount: deps.outdatedCount,
307
+ outdatedPackages: outdatedNames,
308
+ });
309
+ }
310
+ } catch (err: unknown) {
311
+ logger.warn({ err, repo }, "Failed to check dependencies");
312
+ }
313
+ }
314
+ return alerts;
315
+ }
285
316
}
286
317
287
318
export const watchService = new WatchService();
292
323
private onReminderDue?: (reminder: DueReminder) => Promise<void>;
293
324
private onMonitorAlert?: (outcome: MonitorCheckOutcome) => Promise<void>;
294
325
private onWatchEvent?: (event: WatchNotificationEvent) => Promise<void>;
326
+ private onDependencyAlert?: (alerts: DependencyAlert[]) => Promise<void>;
295
327
296
328
setReminderHandler(handler: (reminder: DueReminder) => Promise<void>) {
297
329
this.onReminderDue = handler;
305
337
this.onWatchEvent = handler;
306
338
}
307
339
340
+ setDependencyAlertHandler(handler: (alerts: DependencyAlert[]) => Promise<void>) {
341
+ this.onDependencyAlert = handler;
342
+ }
343
+
308
344
start(intervalMs = 30000) {
309
345
if (this.isRunning) return;
310
346
this.isRunning = true;
384
420
}
385
421
}
386
422
}
423
+
424
+ // 4. Check dependency alerts (hourly, only if handler set)
425
+ if (this.onDependencyAlert) {
426
+ const depLock = await cache.acquireLock("scheduler:deps:tick", 3600);
427
+ if (depLock) {
428
+ try {
429
+ const subscriptions = await watchService.getActiveSubscriptions();
430
+ const uniqueRepos = [...new Set(subscriptions.map((s) => s.target))];
431
+ if (uniqueRepos.length > 0) {
432
+ const alerts = await watchService.checkDependencyAlerts(uniqueRepos.slice(0, 10));
433
+ if (alerts.length > 0) {
434
+ await this.onDependencyAlert(alerts);
435
+ }
436
+ }
437
+ } catch (err: unknown) {
438
+ logger.error({ err }, "Error checking dependency alerts");
439
+ } finally {
440
+ await cache.releaseLock("scheduler:deps:tick");
441
+ }
442
+ }
443
+ }
387
444
}
388
445
}
389
446
390
447