husteno-o

[email protected]
2026-09-10 18:00:31 +0600 committed NaN years ago
967bdc7
commit 967bdc7
9 files changed
+647 -3
▼apps/api/src/server.ts
+78-0
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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