9fbbdd0
commit 9fbbdd0
11 files changed
+589 -99
Showing 11 changed files+589-99
apps/api/src/server.tsapps/bot/src/commands/actions.tsapps/bot/src/events/interactionCreate.tsapps/bot/src/events/ready.tspackages/ai/src/copilot.tspackages/ai/src/provider.tspackages/cache/src/index.tspackages/github/src/client.tspackages/github/src/types.tspackages/monitoring/src/index.tspackages/scheduler/src/index.ts
βΌapps/api/src/server.ts
+7-0
| @@ -3,6 +3,7 @@ import { config } from "@devpulse/config"; | ||
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,9 +115,15 @@ export function buildServer(options?: ServerOptions): FastifyInstance { | ||
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,7 +9,7 @@ export const actionsCommand: Command = { | ||
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,6 +27,17 @@ export const actionsCommand: Command = { | ||
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,7 +79,7 @@ export const actionsCommand: Command = { | ||
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,6 +89,69 @@ export const actionsCommand: Command = { | ||
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,4 +181,4 @@ export const actionsCommand: Command = { | ||
107 | 181 | }); |
108 | 182 | } |
109 | 183 | }, |
110 | -}; | |
184 | +}; | |
111 | 185 | |
βΌapps/bot/src/events/interactionCreate.ts
+4-0
| @@ -3,6 +3,7 @@ import { config } from "@devpulse/config"; | ||
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,9 +83,12 @@ async function handleSlashCommand(interaction: ChatInputCommandInteraction): Pro | ||
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,7 +2,7 @@ import { db, notifications } from "@devpulse/database"; | ||
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,5 +148,41 @@ export async function handleReady(client: Client<true>): Promise<void> { | ||
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 +8,55 @@ import type { | ||
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,7 +70,7 @@ export interface AiCodeReviewFinding { | ||
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,9 +177,12 @@ Respond strictly with valid JSON conforming to: | ||
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,23 +518,27 @@ Respond strictly in valid JSON conforming to: | ||
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,9 +635,12 @@ Respond strictly in valid JSON conforming to: | ||
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,9 +732,12 @@ Respond strictly in valid JSON conforming to: | ||
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,12 +1,13 @@ | ||
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,6 +32,8 @@ export class GenericOpenAiCompatibleProvider implements AIProvider { | ||
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,7 +53,7 @@ export class GenericOpenAiCompatibleProvider implements AIProvider { | ||
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,26 +69,50 @@ export class GenericOpenAiCompatibleProvider implements AIProvider { | ||
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,6 +126,8 @@ export class OpenCodeZenProvider implements AIProvider { | ||
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,7 +142,7 @@ export class OpenCodeZenProvider implements AIProvider { | ||
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,27 +162,47 @@ export class OpenCodeZenProvider implements AIProvider { | ||
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,6 +216,8 @@ export class CommandCodeProvider implements AIProvider { | ||
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,7 +231,7 @@ export class CommandCodeProvider implements AIProvider { | ||
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,27 +256,50 @@ export class CommandCodeProvider implements AIProvider { | ||
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,7 +322,7 @@ export class FallbackChainProvider implements AIProvider { | ||
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,13 +354,11 @@ export function getAiProvider(): AIProvider { | ||
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,7 +367,6 @@ export function getAiProvider(): AIProvider { | ||
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,7 +374,6 @@ export function getAiProvider(): AIProvider { | ||
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,4 +408,4 @@ export function getAiProvider(): AIProvider { | ||
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,9 +32,12 @@ export interface CacheProvider { | ||
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,18 +46,43 @@ export class MemoryCacheProvider implements CacheProvider { | ||
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,6 +25,7 @@ import type { | ||
25 | 25 | GitHubSecurityAdvisory, |
26 | 26 | GitHubUser, |
27 | 27 | GitHubWorkflow, |
28 | + GitHubWorkflowJob, | |
28 | 29 | GitHubWorkflowRun, |
29 | 30 | InvestigationTimeline, |
30 | 31 | InvestigationTimelineEvent, |
| @@ -991,6 +992,38 @@ export class GitHubClient { | ||
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,6 +286,21 @@ export interface GitHubWorkflowRun { | ||
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,3 +241,109 @@ export class MonitoringService { | ||
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,6 +8,13 @@ import cronParser from "cron-parser"; | ||
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,6 +289,30 @@ export class WatchService { | ||
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,6 +323,7 @@ export class SchedulerRunner { | ||
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,6 +337,10 @@ export class SchedulerRunner { | ||
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,6 +420,27 @@ export class SchedulerRunner { | ||
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 | |