967bdc7
commit 967bdc7
9 files changed
+647 -3
Showing 9 changed files+647-3
▼apps/api/src/server.ts
+78-0
| @@ -5,6 +5,7 @@ import { db, users } from "@devpulse/database"; | ||
5 | 5 | import { logger } from "@devpulse/logger"; |
6 | 6 | import { metricsCollector } from "@devpulse/monitoring"; |
7 | 7 | import { encryptSecret } from "@devpulse/security"; |
8 | +import { createHmac, timingSafeEqual } from "crypto"; | |
8 | 9 | import { eq, sql } from "drizzle-orm"; |
9 | 10 | import Fastify, { type FastifyBaseLogger, type FastifyInstance } from "fastify"; |
10 | 11 | |
| @@ -124,6 +125,83 @@ export function buildServer(options?: ServerOptions): FastifyInstance { | ||
124 | 125 | return metricsCollector.getPrometheusMetrics(); |
125 | 126 | }); |
126 | 127 | |
128 | + // -------------------------------------------------------------------------- | |
129 | + // GitHub Webhook Endpoint | |
130 | + // -------------------------------------------------------------------------- | |
131 | + server.post("/webhooks/github", async (req, reply) => { | |
132 | + const signature = req.headers["x-hub-signature-256"] as string | undefined; | |
133 | + const event = req.headers["x-github-event"] as string | undefined; | |
134 | + const deliveryId = req.headers["x-github-delivery"] as string | undefined; | |
135 | + | |
136 | + if (!event) { | |
137 | + return reply.status(400).send({ error: "Missing X-GitHub-Event header" }); | |
138 | + } | |
139 | + | |
140 | + // Verify webhook signature if secret is configured | |
141 | + if (config.GITHUB_WEBHOOK_SECRET && signature) { | |
142 | + const payload = JSON.stringify(req.body); | |
143 | + const expectedSig = `sha256=${createHmac("sha256", config.GITHUB_WEBHOOK_SECRET).update(payload).digest("hex")}`; | |
144 | + const sigBytes = Buffer.from(signature); | |
145 | + const expectedBytes = Buffer.from(expectedSig); | |
146 | + | |
147 | + if (sigBytes.length !== expectedBytes.length || !timingSafeEqual(sigBytes, expectedBytes)) { | |
148 | + logger.warn({ deliveryId, event }, "Webhook signature verification failed"); | |
149 | + return reply.status(401).send({ error: "Invalid signature" }); | |
150 | + } | |
151 | + } | |
152 | + | |
153 | + const payload = req.body as Record<string, unknown>; | |
154 | + const repo = (payload.repository as { full_name?: string })?.full_name || "unknown"; | |
155 | + | |
156 | + logger.info({ event, repo, deliveryId }, "GitHub webhook received"); | |
157 | + | |
158 | + // Store event for deduplication | |
159 | + const eventId = `webhook:${deliveryId || Date.now()}:${event}`; | |
160 | + const existing = await cache.get(eventId); | |
161 | + if (existing) { | |
162 | + return reply.status(200).send({ status: "duplicate" }); | |
163 | + } | |
164 | + await cache.set(eventId, "1", 3600); | |
165 | + | |
166 | + // Process event type | |
167 | + switch (event) { | |
168 | + case "release": { | |
169 | + const action = payload.action as string; | |
170 | + const release = payload.release as { tag_name?: string; html_url?: string } | undefined; | |
171 | + if (action === "published" && release) { | |
172 | + logger.info({ repo, tag: release.tag_name }, "New release published"); | |
173 | + } | |
174 | + break; | |
175 | + } | |
176 | + case "pull_request": { | |
177 | + const action = payload.action as string; | |
178 | + const pr = payload.pull_request as { number?: number; title?: string } | undefined; | |
179 | + if (action === "opened" && pr) { | |
180 | + logger.info({ repo, pr: pr.number, title: pr.title }, "New PR opened"); | |
181 | + } | |
182 | + break; | |
183 | + } | |
184 | + case "issues": { | |
185 | + const action = payload.action as string; | |
186 | + const issue = payload.issue as { number?: number; title?: string } | undefined; | |
187 | + if (action === "opened" && issue) { | |
188 | + logger.info({ repo, issue: issue.number, title: issue.title }, "New issue opened"); | |
189 | + } | |
190 | + break; | |
191 | + } | |
192 | + case "push": { | |
193 | + const commits = payload.commits as Array<{ message?: string }> | undefined; | |
194 | + const branch = (payload.ref as string)?.replace("refs/heads/", ""); | |
195 | + logger.info({ repo, branch, commitCount: commits?.length || 0 }, "Push received"); | |
196 | + break; | |
197 | + } | |
198 | + default: | |
199 | + logger.debug({ event, repo }, "Unhandled webhook event type"); | |
200 | + } | |
201 | + | |
202 | + return reply.status(200).send({ status: "ok", event, repo }); | |
203 | + }); | |
204 | + | |
127 | 205 | // -------------------------------------------------------------------------- |
128 | 206 | // GitHub OAuth Endpoints |
129 | 207 | // -------------------------------------------------------------------------- |
130 | 208 | |
▼apps/bot/src/commands/index.ts
+4-0
| @@ -8,6 +8,7 @@ import { connectCommand } from "./connect.js"; | ||
8 | 8 | import { helpCommand } from "./help.js"; |
9 | 9 | import { homeCommand } from "./home.js"; |
10 | 10 | import { investigateCommand } from "./investigate.js"; |
11 | +import { issueCommand } from "./issue.js"; | |
11 | 12 | import { prCommand } from "./pr.js"; |
12 | 13 | import { releaseCommand } from "./release.js"; |
13 | 14 | import { repoCommand } from "./repo.js"; |
| @@ -15,6 +16,7 @@ import { roleCommand } from "./role.js"; | ||
15 | 16 | import { searchCommand } from "./search.js"; |
16 | 17 | import { securityCommand } from "./security.js"; |
17 | 18 | import { settingsCommand } from "./settings.js"; |
19 | +import { standupCommand } from "./standup.js"; | |
18 | 20 | import { teamCommand } from "./team.js"; |
19 | 21 | import { toolsCommand } from "./tools.js"; |
20 | 22 | import { trendingCommand } from "./trending.js"; |
| @@ -44,6 +46,8 @@ export const commands: Command[] = [ | ||
44 | 46 | toolsCommand, |
45 | 47 | settingsCommand, |
46 | 48 | roleCommand, |
49 | + issueCommand, | |
50 | + standupCommand, | |
47 | 51 | helpCommand, |
48 | 52 | ]; |
49 | 53 | |
50 | 54 | |
▼apps/bot/src/commands/issue.ts
+217-0
| @@ -0,0 +1,217 @@ | ||
1 | +import { githubClient } from "@devpulse/github"; | |
2 | +import { SlashCommandBuilder, PermissionFlagsBits } from "discord.js"; | |
3 | +import { BrandColors } from "../ui/colors.js"; | |
4 | +import { createBaseEmbed, createErrorEmbed } from "../ui/embeds.js"; | |
5 | +import { NF } from "../ui/icons.js"; | |
6 | +import type { Command } from "./types.js"; | |
7 | + | |
8 | +export const issueCommand: Command = { | |
9 | + data: new SlashCommandBuilder() | |
10 | + .setName("issue") | |
11 | + .setDescription("GitHub Issue Management: create, close, reopen, and list issues from Discord") | |
12 | + .setDefaultMemberPermissions(PermissionFlagsBits.ManageGuild) | |
13 | + .addSubcommand((sub) => | |
14 | + sub | |
15 | + .setName("create") | |
16 | + .setDescription("Create a new issue in a repository") | |
17 | + .addStringOption((opt) => | |
18 | + opt.setName("repo").setDescription("Repository format 'owner/repo'").setRequired(true), | |
19 | + ) | |
20 | + .addStringOption((opt) => | |
21 | + opt.setName("title").setDescription("Issue title").setRequired(true), | |
22 | + ) | |
23 | + .addStringOption((opt) => | |
24 | + opt.setName("body").setDescription("Issue description / body").setRequired(false), | |
25 | + ) | |
26 | + .addStringOption((opt) => | |
27 | + opt | |
28 | + .setName("labels") | |
29 | + .setDescription("Comma-separated labels (e.g. 'bug,urgent')") | |
30 | + .setRequired(false), | |
31 | + ) | |
32 | + .addStringOption((opt) => | |
33 | + opt.setName("assignee").setDescription("GitHub username to assign").setRequired(false), | |
34 | + ), | |
35 | + ) | |
36 | + .addSubcommand((sub) => | |
37 | + sub | |
38 | + .setName("close") | |
39 | + .setDescription("Close an issue") | |
40 | + .addStringOption((opt) => | |
41 | + opt.setName("repo").setDescription("Repository format 'owner/repo'").setRequired(true), | |
42 | + ) | |
43 | + .addIntegerOption((opt) => | |
44 | + opt.setName("number").setDescription("Issue number to close").setRequired(true), | |
45 | + ) | |
46 | + .addStringOption((opt) => | |
47 | + opt.setName("comment").setDescription("Close comment").setRequired(false), | |
48 | + ), | |
49 | + ) | |
50 | + .addSubcommand((sub) => | |
51 | + sub | |
52 | + .setName("reopen") | |
53 | + .setDescription("Reopen a closed issue") | |
54 | + .addStringOption((opt) => | |
55 | + opt.setName("repo").setDescription("Repository format 'owner/repo'").setRequired(true), | |
56 | + ) | |
57 | + .addIntegerOption((opt) => | |
58 | + opt.setName("number").setDescription("Issue number to reopen").setRequired(true), | |
59 | + ), | |
60 | + ) | |
61 | + .addSubcommand((sub) => | |
62 | + sub | |
63 | + .setName("list") | |
64 | + .setDescription("List issues in a repository") | |
65 | + .addStringOption((opt) => | |
66 | + opt.setName("repo").setDescription("Repository format 'owner/repo'").setRequired(true), | |
67 | + ) | |
68 | + .addStringOption((opt) => | |
69 | + opt | |
70 | + .setName("state") | |
71 | + .setDescription("Filter by state") | |
72 | + .addChoices( | |
73 | + { name: "Open", value: "open" }, | |
74 | + { name: "Closed", value: "closed" }, | |
75 | + { name: "All", value: "all" }, | |
76 | + ) | |
77 | + .setRequired(false), | |
78 | + ), | |
79 | + ) | |
80 | + .addSubcommand((sub) => | |
81 | + sub | |
82 | + .setName("view") | |
83 | + .setDescription("View issue details") | |
84 | + .addStringOption((opt) => | |
85 | + opt.setName("repo").setDescription("Repository format 'owner/repo'").setRequired(true), | |
86 | + ) | |
87 | + .addIntegerOption((opt) => | |
88 | + opt.setName("number").setDescription("Issue number to view").setRequired(true), | |
89 | + ), | |
90 | + ), | |
91 | + | |
92 | + async execute(interaction) { | |
93 | + await interaction.deferReply(); | |
94 | + const subcommand = interaction.options.getSubcommand(); | |
95 | + const repoInput = interaction.options.getString("repo", true); | |
96 | + | |
97 | + try { | |
98 | + if (subcommand === "create") { | |
99 | + const title = interaction.options.getString("title", true); | |
100 | + const body = interaction.options.getString("body") || ""; | |
101 | + const labelsStr = interaction.options.getString("labels") || ""; | |
102 | + const assignee = interaction.options.getString("assignee") || undefined; | |
103 | + | |
104 | + const labels = labelsStr | |
105 | + ? labelsStr.split(",").map((l) => l.trim()).filter(Boolean) | |
106 | + : []; | |
107 | + | |
108 | + const result = await githubClient.createIssue(repoInput, title, body, labels, assignee); | |
109 | + | |
110 | + const embed = createBaseEmbed(`${NF.issue} Issue Created`) | |
111 | + .setColor(BrandColors.success) | |
112 | + .setDescription( | |
113 | + `**#${result.number}** [${title}](${result.htmlUrl})\n\n` + | |
114 | + `• **Repo:** \`${repoInput}\`\n` + | |
115 | + (labels.length > 0 ? `• **Labels:** ${labels.map((l) => `\`${l}\``).join(", ")}\n` : "") + | |
116 | + (assignee ? `• **Assigned:** @${assignee}\n` : "") + | |
117 | + (body ? `\n> ${body.slice(0, 200)}${body.length > 200 ? "..." : ""}` : ""), | |
118 | + ) | |
119 | + .setURL(result.htmlUrl); | |
120 | + | |
121 | + await interaction.editReply({ embeds: [embed] }); | |
122 | + return; | |
123 | + } | |
124 | + | |
125 | + if (subcommand === "close") { | |
126 | + const number = interaction.options.getInteger("number", true); | |
127 | + const comment = interaction.options.getString("comment") || "Closed via GITBOT"; | |
128 | + | |
129 | + const result = await githubClient.closeIssue(repoInput, number, comment); | |
130 | + | |
131 | + const embed = createBaseEmbed(`${NF.check} Issue Closed`) | |
132 | + .setColor(BrandColors.warning) | |
133 | + .setDescription( | |
134 | + `**#${number}** has been closed in \`${repoInput}\`.\n\n` + | |
135 | + `• **Comment:** ${comment}\n` + | |
136 | + `• **By:** @${interaction.user.username}`, | |
137 | + ) | |
138 | + .setURL(result.htmlUrl); | |
139 | + | |
140 | + await interaction.editReply({ embeds: [embed] }); | |
141 | + return; | |
142 | + } | |
143 | + | |
144 | + if (subcommand === "reopen") { | |
145 | + const number = interaction.options.getInteger("number", true); | |
146 | + | |
147 | + const result = await githubClient.reopenIssue(repoInput, number); | |
148 | + | |
149 | + const embed = createBaseEmbed(`${NF.check} Issue Reopened`) | |
150 | + .setColor(BrandColors.success) | |
151 | + .setDescription( | |
152 | + `**#${number}** has been reopened in \`${repoInput}\`.\n\n` + | |
153 | + `• **By:** @${interaction.user.username}`, | |
154 | + ) | |
155 | + .setURL(result.htmlUrl); | |
156 | + | |
157 | + await interaction.editReply({ embeds: [embed] }); | |
158 | + return; | |
159 | + } | |
160 | + | |
161 | + if (subcommand === "list") { | |
162 | + const state = (interaction.options.getString("state") || "open") as "open" | "closed" | "all"; | |
163 | + const issues = await githubClient.getIssues(repoInput, state, 15); | |
164 | + | |
165 | + if (issues.length === 0) { | |
166 | + await interaction.editReply({ | |
167 | + embeds: [ | |
168 | + createBaseEmbed(`${NF.issue} Issues: ${repoInput}`).setDescription( | |
169 | + `No ${state} issues found in \`${repoInput}\`.`, | |
170 | + ), | |
171 | + ], | |
172 | + }); | |
173 | + return; | |
174 | + } | |
175 | + | |
176 | + const lines = issues.map((i) => { | |
177 | + const icon = i.state === "open" ? "🟢" : "🔴"; | |
178 | + return `• ${icon} **#${i.number}** [${i.title.slice(0, 60)}](${i.htmlUrl}) (${i.commentsCount} comments)`; | |
179 | + }); | |
180 | + | |
181 | + const embed = createBaseEmbed(`${NF.issue} ${state.charAt(0).toUpperCase() + state.slice(1)} Issues: ${repoInput}`) | |
182 | + .setDescription(lines.join("\n")) | |
183 | + .setFooter({ text: `Showing ${issues.length} issues` }); | |
184 | + | |
185 | + await interaction.editReply({ embeds: [embed] }); | |
186 | + return; | |
187 | + } | |
188 | + | |
189 | + if (subcommand === "view") { | |
190 | + const number = interaction.options.getInteger("number", true); | |
191 | + const issue = await githubClient.getSingleIssue(repoInput, number); | |
192 | + | |
193 | + const labels = issue.labels?.join(", ") || "None"; | |
194 | + | |
195 | + const embed = createBaseEmbed(`${NF.issue} #${number}: ${issue.title}`) | |
196 | + .setColor(issue.state === "open" ? BrandColors.success : BrandColors.danger) | |
197 | + .setDescription( | |
198 | + (issue.body || "No description provided.").slice(0, 2000), | |
199 | + ) | |
200 | + .addFields( | |
201 | + { name: "State", value: `\`${issue.state}\``, inline: true }, | |
202 | + { name: "Labels", value: labels, inline: true }, | |
203 | + { name: "Created", value: `<t:${Math.floor(new Date(issue.createdAt).getTime() / 1000)}:R>`, inline: true }, | |
204 | + ) | |
205 | + .setURL(issue.htmlUrl) | |
206 | + .setFooter({ text: `GITBOT Issue Viewer • ${repoInput}` }); | |
207 | + | |
208 | + await interaction.editReply({ embeds: [embed] }); | |
209 | + return; | |
210 | + } | |
211 | + } catch (err: unknown) { | |
212 | + await interaction.editReply({ | |
213 | + embeds: [createErrorEmbed(err instanceof Error ? err : new Error(String(err)))], | |
214 | + }); | |
215 | + } | |
216 | + }, | |
217 | +}; | |
0 | 218 | |
▼apps/bot/src/commands/standup.ts
+208-0
| @@ -0,0 +1,208 @@ | ||
1 | +import { db, standups, users } from "@devpulse/database"; | |
2 | +import { SlashCommandBuilder } from "discord.js"; | |
3 | +import { and, eq } from "drizzle-orm"; | |
4 | +import { BrandColors } from "../ui/colors.js"; | |
5 | +import { createBaseEmbed, createErrorEmbed } from "../ui/embeds.js"; | |
6 | +import { NF } from "../ui/icons.js"; | |
7 | +import type { Command } from "./types.js"; | |
8 | + | |
9 | +export const standupCommand: Command = { | |
10 | + data: new SlashCommandBuilder() | |
11 | + .setName("standup") | |
12 | + .setDescription("Daily Standup: log what you did, what you'll do, and any blockers") | |
13 | + .addSubcommand((sub) => | |
14 | + sub | |
15 | + .setName("submit") | |
16 | + .setDescription("Submit your daily standup update") | |
17 | + .addStringOption((opt) => | |
18 | + opt | |
19 | + .setName("yesterday") | |
20 | + .setDescription("What you did yesterday (comma-separated list)") | |
21 | + .setRequired(true), | |
22 | + ) | |
23 | + .addStringOption((opt) => | |
24 | + opt | |
25 | + .setName("today") | |
26 | + .setDescription("What you plan to do today") | |
27 | + .setRequired(true), | |
28 | + ) | |
29 | + .addStringOption((opt) => | |
30 | + opt | |
31 | + .setName("blockers") | |
32 | + .setDescription("Any blockers or issues (optional)") | |
33 | + .setRequired(false), | |
34 | + ), | |
35 | + ) | |
36 | + .addSubcommand((sub) => | |
37 | + sub | |
38 | + .setName("view") | |
39 | + .setDescription("View today's standup for the team") | |
40 | + .addStringOption((opt) => | |
41 | + opt | |
42 | + .setName("date") | |
43 | + .setDescription("Date to view (YYYY-MM-DD, defaults to today)") | |
44 | + .setRequired(false), | |
45 | + ), | |
46 | + ) | |
47 | + .addSubcommand((sub) => | |
48 | + sub | |
49 | + .setName("history") | |
50 | + .setDescription("View your recent standup history") | |
51 | + .addIntegerOption((opt) => | |
52 | + opt | |
53 | + .setName("days") | |
54 | + .setDescription("Number of days to look back (default 7)") | |
55 | + .setRequired(false), | |
56 | + ), | |
57 | + ), | |
58 | + | |
59 | + async execute(interaction) { | |
60 | + if (!interaction.guildId) { | |
61 | + await interaction.reply({ | |
62 | + content: `${NF.cross} This command can only be used in a Discord server.`, | |
63 | + ephemeral: true, | |
64 | + }); | |
65 | + return; | |
66 | + } | |
67 | + | |
68 | + await interaction.deferReply(); | |
69 | + const subcommand = interaction.options.getSubcommand(); | |
70 | + const guildId = interaction.guildId; | |
71 | + const today = new Date().toISOString().split("T")[0]; | |
72 | + | |
73 | + try { | |
74 | + if (subcommand === "submit") { | |
75 | + const yesterdayStr = interaction.options.getString("yesterday", true); | |
76 | + const todayPlan = interaction.options.getString("today", true); | |
77 | + const blockers = interaction.options.getString("blockers") || null; | |
78 | + | |
79 | + const yesterdayItems = yesterdayStr | |
80 | + .split(",") | |
81 | + .map((s) => s.trim()) | |
82 | + .filter(Boolean); | |
83 | + | |
84 | + // Upsert standup | |
85 | + const existing = await db.query.standups.findFirst({ | |
86 | + where: and( | |
87 | + eq(standups.guildId, guildId), | |
88 | + eq(standups.userId, interaction.user.id), | |
89 | + eq(standups.date, today), | |
90 | + ), | |
91 | + }); | |
92 | + | |
93 | + if (existing) { | |
94 | + await db | |
95 | + .update(standups) | |
96 | + .set({ | |
97 | + yesterdayActivity: yesterdayItems, | |
98 | + todayPlan, | |
99 | + blockers, | |
100 | + }) | |
101 | + .where(eq(standups.id, existing.id)); | |
102 | + } else { | |
103 | + await db.insert(standups).values({ | |
104 | + id: crypto.randomUUID(), | |
105 | + guildId, | |
106 | + userId: interaction.user.id, | |
107 | + date: today, | |
108 | + yesterdayActivity: yesterdayItems, | |
109 | + todayPlan, | |
110 | + blockers, | |
111 | + }); | |
112 | + } | |
113 | + | |
114 | + const embed = createBaseEmbed(`${NF.check} Standup Submitted`) | |
115 | + .setColor(BrandColors.success) | |
116 | + .setDescription( | |
117 | + `**Standup for ${today}** submitted by ${interaction.user.toString()}\n\n` + | |
118 | + `**📋 Yesterday:**\n${yesterdayItems.map((i) => `• ${i}`).join("\n")}\n\n` + | |
119 | + `**🎯 Today:**\n• ${todayPlan}\n\n` + | |
120 | + (blockers ? `**🚧 Blockers:**\n• ${blockers}\n\n` : "") + | |
121 | + `_Use /standup view to see the team's standups._`, | |
122 | + ); | |
123 | + | |
124 | + await interaction.editReply({ embeds: [embed] }); | |
125 | + return; | |
126 | + } | |
127 | + | |
128 | + if (subcommand === "view") { | |
129 | + const date = interaction.options.getString("date") || today; | |
130 | + | |
131 | + const dayStandups = await db.query.standups.findMany({ | |
132 | + where: and(eq(standups.guildId, guildId), eq(standups.date, date)), | |
133 | + }); | |
134 | + | |
135 | + if (dayStandups.length === 0) { | |
136 | + await interaction.editReply({ | |
137 | + embeds: [ | |
138 | + createBaseEmbed(`${NF.clipboard} Standup: ${date}`).setDescription( | |
139 | + `No standups submitted for **${date}**.\n\nUse \`/standup submit\` to log your update.`, | |
140 | + ), | |
141 | + ], | |
142 | + }); | |
143 | + return; | |
144 | + } | |
145 | + | |
146 | + const lines: string[] = []; | |
147 | + for (const s of dayStandups) { | |
148 | + const user = await db.query.users.findFirst({ | |
149 | + where: eq(users.id, s.userId), | |
150 | + }); | |
151 | + const name = user?.username || s.userId; | |
152 | + const yesterday = (s.yesterdayActivity as string[]) || []; | |
153 | + lines.push( | |
154 | + `**@${name}**\n` + | |
155 | + `• Yesterday: ${yesterday.join(", ") || "N/A"}\n` + | |
156 | + `• Today: ${s.todayPlan}\n` + | |
157 | + (s.blockers ? `• 🚧 Blockers: ${s.blockers}` : ""), | |
158 | + ); | |
159 | + } | |
160 | + | |
161 | + const embed = createBaseEmbed(`${NF.clipboard} Team Standup: ${date}`) | |
162 | + .setDescription(lines.join("\n\n")) | |
163 | + .setFooter({ text: `${dayStandups.length} standup(s) submitted` }); | |
164 | + | |
165 | + await interaction.editReply({ embeds: [embed] }); | |
166 | + return; | |
167 | + } | |
168 | + | |
169 | + if (subcommand === "history") { | |
170 | + const days = interaction.options.getInteger("days") || 7; | |
171 | + const userId = interaction.user.id; | |
172 | + | |
173 | + const history = await db.query.standups.findMany({ | |
174 | + where: and(eq(standups.guildId, guildId), eq(standups.userId, userId)), | |
175 | + orderBy: (s, { desc }) => [desc(s.date)], | |
176 | + limit: days, | |
177 | + }); | |
178 | + | |
179 | + if (history.length === 0) { | |
180 | + await interaction.editReply({ | |
181 | + embeds: [ | |
182 | + createBaseEmbed(`${NF.clipboard} Your Standup History`).setDescription( | |
183 | + "No standups found. Use `/standup submit` to get started!", | |
184 | + ), | |
185 | + ], | |
186 | + }); | |
187 | + return; | |
188 | + } | |
189 | + | |
190 | + const lines = history.map((s) => { | |
191 | + const yesterday = (s.yesterdayActivity as string[]) || []; | |
192 | + return `**${s.date}**\n• Yesterday: ${yesterday.slice(0, 3).join(", ") || "N/A"}\n• Today: ${s.todayPlan.slice(0, 80)}${s.todayPlan.length > 80 ? "..." : ""}${s.blockers ? `\n• 🚧 ${s.blockers}` : ""}`; | |
193 | + }); | |
194 | + | |
195 | + const embed = createBaseEmbed(`${NF.clipboard} Your Standup History`) | |
196 | + .setDescription(lines.join("\n\n")) | |
197 | + .setFooter({ text: `Last ${history.length} standups` }); | |
198 | + | |
199 | + await interaction.editReply({ embeds: [embed] }); | |
200 | + return; | |
201 | + } | |
202 | + } catch (err: unknown) { | |
203 | + await interaction.editReply({ | |
204 | + embeds: [createErrorEmbed(err instanceof Error ? err : new Error(String(err)))], | |
205 | + }); | |
206 | + } | |
207 | + }, | |
208 | +}; | |
0 | 209 | |
▼apps/bot/src/events/ready.ts
+24-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 DependencyAlert, type DueReminder, type WatchNotificationEvent, scheduler } from "@devpulse/scheduler"; | |
5 | +import { type DependencyAlert, type DueReminder, type NewsDigestItem, type WatchNotificationEvent, scheduler } from "@devpulse/scheduler"; | |
6 | 6 | import { |
7 | 7 | ActivityType, |
8 | 8 | ApplicationIntegrationType, |
| @@ -184,5 +184,28 @@ export async function handleReady(client: Client<true>): Promise<void> { | ||
184 | 184 | } |
185 | 185 | }); |
186 | 186 | |
187 | + scheduler.setNewsDigestHandler(async (items: NewsDigestItem[]) => { | |
188 | + try { | |
189 | + // Post to all guilds that have notification channels | |
190 | + for (const [, guild] of client.guilds.cache) { | |
191 | + const channel = guild.systemChannel || guild.channels.cache.find((c) => c.isTextBased() && "send" in c); | |
192 | + if (!channel || !("send" in channel)) continue; | |
193 | + | |
194 | + const lines = items.slice(0, 6).map((item, i) => | |
195 | + `**${i + 1}. [${item.title}](${item.url})**\n${item.summary}`, | |
196 | + ); | |
197 | + | |
198 | + const embed = createBaseEmbed(`${NF.flame} Daily Dev News Digest`) | |
199 | + .setColor(0xf5a97f) | |
200 | + .setDescription(lines.join("\n\n")) | |
201 | + .setFooter({ text: "GITBOT News Digest • Powered by Hacker News" }); | |
202 | + | |
203 | + await (channel as SendableChannel).send({ embeds: [embed] }).catch(() => {}); | |
204 | + } | |
205 | + } catch (err: unknown) { | |
206 | + logger.error({ err }, "Failed to deliver news digest"); | |
207 | + } | |
208 | + }); | |
209 | + | |
187 | 210 | scheduler.start(30000); |
188 | 211 | } |
189 | 212 | |
▼packages/ai/src/copilot.ts
+5-2
| @@ -126,11 +126,14 @@ export class AiCopilotService { | ||
126 | 126 | |
127 | 127 | if (isAiOnline) { |
128 | 128 | try { |
129 | + // Paginate: send up to 25 files with shorter patches for large PRs | |
130 | + const maxFiles = Math.min(files.length, 25); | |
131 | + const patchLimit = files.length > 15 ? 800 : 1200; | |
129 | 132 | const patchBundle = files |
130 | - .slice(0, 10) | |
133 | + .slice(0, maxFiles) | |
131 | 134 | .map( |
132 | 135 | (f) => |
133 | - `### File: ${f.filename} (${f.status}, +${f.additions} -${f.deletions})\n\`\`\`diff\n${f.patch ? f.patch.slice(0, 1200) : "Binary or no diff"}\n\`\`\``, | |
136 | + `### File: ${f.filename} (${f.status}, +${f.additions} -${f.deletions})\n\`\`\`diff\n${f.patch ? f.patch.slice(0, patchLimit) : "Binary or no diff"}\n\`\`\``, | |
134 | 137 | ) |
135 | 138 | .join("\n\n"); |
136 | 139 | |
137 | 140 | |
▼packages/config/src/index.ts
+1-0
| @@ -50,6 +50,7 @@ export const ConfigSchema = z.object({ | ||
50 | 50 | GITHUB_CLIENT_ID: z.string().optional(), |
51 | 51 | GITHUB_CLIENT_SECRET: z.string().optional(), |
52 | 52 | GITHUB_REDIRECT_URI: z.string().optional(), |
53 | + GITHUB_WEBHOOK_SECRET: z.string().optional(), | |
53 | 54 | |
54 | 55 | // Optional AI integration |
55 | 56 | // spark = OpenCode Zen (spark-1.3) → Command Code (longcat-2.0:free) fallback chain |
56 | 57 | |
▼packages/github/src/client.ts
+49-0
| @@ -1411,6 +1411,55 @@ export class GitHubClient { | ||
1411 | 1411 | } |
1412 | 1412 | } |
1413 | 1413 | |
1414 | + async createIssue( | |
1415 | + repoInput: string, | |
1416 | + title: string, | |
1417 | + body: string, | |
1418 | + labels: string[] = [], | |
1419 | + assignee?: string, | |
1420 | + ): Promise<{ number: number; htmlUrl: string }> { | |
1421 | + const { owner, repo } = this.parseRepoInput(repoInput); | |
1422 | + const payload: Record<string, unknown> = { title, body }; | |
1423 | + if (labels.length > 0) payload.labels = labels; | |
1424 | + if (assignee) payload.assignees = [assignee]; | |
1425 | + const result = await this.request<{ number: number; html_url: string }>( | |
1426 | + `/repos/${owner}/${repo}/issues`, | |
1427 | + { method: "POST", body: payload }, | |
1428 | + ); | |
1429 | + return { number: result.number, htmlUrl: result.html_url }; | |
1430 | + } | |
1431 | + | |
1432 | + async closeIssue( | |
1433 | + repoInput: string, | |
1434 | + issueNumber: number, | |
1435 | + comment?: string, | |
1436 | + ): Promise<{ htmlUrl: string }> { | |
1437 | + const { owner, repo } = this.parseRepoInput(repoInput); | |
1438 | + await this.request(`/repos/${owner}/${repo}/issues/${issueNumber}`, { | |
1439 | + method: "PATCH", | |
1440 | + body: { state: "closed" }, | |
1441 | + }); | |
1442 | + if (comment) { | |
1443 | + await this.request(`/repos/${owner}/${repo}/issues/${issueNumber}/comments`, { | |
1444 | + method: "POST", | |
1445 | + body: { body: comment }, | |
1446 | + }); | |
1447 | + } | |
1448 | + return { htmlUrl: `https://github.com/${repoInput}/issues/${issueNumber}` }; | |
1449 | + } | |
1450 | + | |
1451 | + async reopenIssue( | |
1452 | + repoInput: string, | |
1453 | + issueNumber: number, | |
1454 | + ): Promise<{ htmlUrl: string }> { | |
1455 | + const { owner, repo } = this.parseRepoInput(repoInput); | |
1456 | + await this.request(`/repos/${owner}/${repo}/issues/${issueNumber}`, { | |
1457 | + method: "PATCH", | |
1458 | + body: { state: "open" }, | |
1459 | + }); | |
1460 | + return { htmlUrl: `https://github.com/${repoInput}/issues/${issueNumber}` }; | |
1461 | + } | |
1462 | + | |
1414 | 1463 | async getUser(username: string): Promise<GitHubUser> { |
1415 | 1464 | const cleanUser = username.replace(/^@/, "").trim(); |
1416 | 1465 | const cacheKey = `gh:user:${cleanUser.toLowerCase()}`; |
1417 | 1466 | |
▼packages/scheduler/src/index.ts
+61-0
| @@ -15,6 +15,13 @@ export interface DependencyAlert { | ||
15 | 15 | outdatedPackages: string[]; |
16 | 16 | } |
17 | 17 | |
18 | +export interface NewsDigestItem { | |
19 | + title: string; | |
20 | + url: string; | |
21 | + source: string; | |
22 | + summary: string; | |
23 | +} | |
24 | + | |
18 | 25 | export interface DueReminder { |
19 | 26 | id: string; |
20 | 27 | guildId: string; |
| @@ -313,6 +320,38 @@ export class WatchService { | ||
313 | 320 | } |
314 | 321 | return alerts; |
315 | 322 | } |
323 | + | |
324 | + async fetchNewsDigest(): Promise<NewsDigestItem[]> { | |
325 | + try { | |
326 | + const cacheKey = "news:digest:latest"; | |
327 | + const cached = await cache.get<NewsDigestItem[]>(cacheKey); | |
328 | + if (cached && cached.length > 0) return cached; | |
329 | + | |
330 | + const response = await fetch( | |
331 | + "https://hn.algolia.com/api/v1/search_by_date?query=programming&tags=story&hitsPerPage=10", | |
332 | + ); | |
333 | + if (!response.ok) return []; | |
334 | + | |
335 | + const data = (await response.json()) as { hits?: Array<{ title?: string; url?: string; points?: number; author?: string; created_at?: string }> }; | |
336 | + const items: NewsDigestItem[] = (data.hits || []) | |
337 | + .filter((h) => h.title && h.url) | |
338 | + .slice(0, 8) | |
339 | + .map((h) => ({ | |
340 | + title: h.title!, | |
341 | + url: h.url!, | |
342 | + source: "Hacker News", | |
343 | + summary: `${h.points || 0} points by @${h.author || "unknown"}`, | |
344 | + })); | |
345 | + | |
346 | + if (items.length > 0) { | |
347 | + await cache.set(cacheKey, items, 900); | |
348 | + } | |
349 | + return items; | |
350 | + } catch (err: unknown) { | |
351 | + logger.warn({ err }, "Failed to fetch news digest"); | |
352 | + return []; | |
353 | + } | |
354 | + } | |
316 | 355 | } |
317 | 356 | |
318 | 357 | export const watchService = new WatchService(); |
| @@ -324,6 +363,7 @@ export class SchedulerRunner { | ||
324 | 363 | private onMonitorAlert?: (outcome: MonitorCheckOutcome) => Promise<void>; |
325 | 364 | private onWatchEvent?: (event: WatchNotificationEvent) => Promise<void>; |
326 | 365 | private onDependencyAlert?: (alerts: DependencyAlert[]) => Promise<void>; |
366 | + private onNewsDigest?: (items: NewsDigestItem[]) => Promise<void>; | |
327 | 367 | |
328 | 368 | setReminderHandler(handler: (reminder: DueReminder) => Promise<void>) { |
329 | 369 | this.onReminderDue = handler; |
| @@ -341,6 +381,10 @@ export class SchedulerRunner { | ||
341 | 381 | this.onDependencyAlert = handler; |
342 | 382 | } |
343 | 383 | |
384 | + setNewsDigestHandler(handler: (items: NewsDigestItem[]) => Promise<void>) { | |
385 | + this.onNewsDigest = handler; | |
386 | + } | |
387 | + | |
344 | 388 | start(intervalMs = 30000) { |
345 | 389 | if (this.isRunning) return; |
346 | 390 | this.isRunning = true; |
| @@ -441,6 +485,23 @@ export class SchedulerRunner { | ||
441 | 485 | } |
442 | 486 | } |
443 | 487 | } |
488 | + | |
489 | + // 5. News digest (daily, only if handler set) | |
490 | + if (this.onNewsDigest) { | |
491 | + const newsLock = await cache.acquireLock("scheduler:news:tick", 86400); | |
492 | + if (newsLock) { | |
493 | + try { | |
494 | + const items = await watchService.fetchNewsDigest(); | |
495 | + if (items.length > 0) { | |
496 | + await this.onNewsDigest(items); | |
497 | + } | |
498 | + } catch (err: unknown) { | |
499 | + logger.error({ err }, "Error fetching news digest"); | |
500 | + } finally { | |
501 | + await cache.releaseLock("scheduler:news:tick"); | |
502 | + } | |
503 | + } | |
504 | + } | |
444 | 505 | } |
445 | 506 | } |
446 | 507 | |
447 | 508 | |