• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

VolvoxLLC / volvox-bot / 22975100419

11 Mar 2026 09:22PM UTC coverage: 89.87% (-0.006%) from 89.876%
22975100419

Pull #293

github

web-flow
Merge 7f0c3c9a6 into 99bb01f5a
Pull Request #293: test: fix stale audit stream guild filter assertion

6181 of 7263 branches covered (85.1%)

Branch coverage included in aggregate %.

10543 of 11346 relevant lines covered (92.92%)

229.16 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.33
/src/modules/githubFeed.js
1
/**
2
 * GitHub Activity Feed Module
3
 * Polls GitHub repos and posts activity embeds to a Discord channel.
4
 *
5
 * @see https://github.com/VolvoxLLC/volvox-bot/issues/51
6
 */
7

8
import { execFile } from 'node:child_process';
9
import { promisify } from 'node:util';
10
import { EmbedBuilder } from 'discord.js';
11
import { getPool } from '../db.js';
12
import { info, error as logError, warn as logWarn } from '../logger.js';
13
import { fetchChannelCached } from '../utils/discordCache.js';
14
import { safeSend } from '../utils/safeSend.js';
15
import { getConfig } from './config.js';
16

17
const execFileAsync = promisify(execFile);
34✔
18

19
/**
20
 * Regex for valid GitHub owner/repo name segments.
21
 * Allows alphanumeric characters, dots, hyphens, and underscores.
22
 * Prevents path traversal (e.g. `../../users/admin`) via the `gh` CLI.
23
 *
24
 * @see https://github.com/VolvoxLLC/volvox-bot/issues/160
25
 */
26
export const VALID_GH_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
34✔
27

28
/**
29
 * Return true when both owner and repo are safe to pass to the `gh` CLI.
30
 *
31
 * @param {string} owner
32
 * @param {string} repo
33
 * @returns {boolean}
34
 */
35
export function isValidGhRepo(owner, repo) {
36
  return (
33✔
37
    typeof owner === 'string' &&
161✔
38
    typeof repo === 'string' &&
39
    owner.length > 0 &&
40
    repo.length > 0 &&
41
    VALID_GH_NAME.test(owner) &&
42
    VALID_GH_NAME.test(repo)
43
  );
44
}
45

46
/** @type {ReturnType<typeof setInterval> | null} */
47
let feedInterval = null;
34✔
48

49
/** @type {ReturnType<typeof setTimeout> | null} */
50
let firstPollTimeout = null;
34✔
51

52
/** Re-entrancy guard */
53
let pollInFlight = false;
34✔
54

55
/**
56
 * Fetch recent GitHub events for a repo via the `gh` CLI.
57
 *
58
 * @param {string} owner - GitHub owner (user or org)
59
 * @param {string} repo - Repository name
60
 * @returns {Promise<object[]>} Array of event objects (up to 10)
61
 */
62
export async function fetchRepoEvents(owner, repo) {
63
  if (!isValidGhRepo(owner, repo)) {
10✔
64
    logWarn('GitHub feed: invalid owner/repo format, refusing CLI call', { owner, repo });
4✔
65
    return [];
4✔
66
  }
67
  const { stdout } = await execFileAsync(
6✔
68
    'gh',
69
    ['api', `repos/${owner}/${repo}/events?per_page=10`],
70
    { timeout: 30_000 },
71
  );
72
  const text = stdout.trim();
5✔
73
  if (!text) return [];
5✔
74
  return JSON.parse(text);
4✔
75
}
76

77
/**
78
 * Build a Discord embed for a PullRequestEvent.
79
 *
80
 * @param {object} event - GitHub event object
81
 * @returns {EmbedBuilder|null} Embed or null if action not handled
82
 */
83
export function buildPrEmbed(event) {
84
  const pr = event.payload?.pull_request;
8✔
85
  const action = event.payload?.action;
8✔
86
  if (!pr) return null;
8✔
87

88
  let color;
89
  let actionLabel;
90

91
  if (action === 'opened') {
7✔
92
    color = 0x2ecc71; // green
4✔
93
    actionLabel = 'opened';
4✔
94
  } else if (action === 'closed' && pr.merged) {
3✔
95
    color = 0x9b59b6; // purple
1✔
96
    actionLabel = 'merged';
1✔
97
  } else if (action === 'closed') {
2✔
98
    color = 0xe74c3c; // red
1✔
99
    actionLabel = 'closed';
1✔
100
  } else {
101
    return null;
1✔
102
  }
103

104
  const embed = new EmbedBuilder()
6✔
105
    .setColor(color)
106
    .setTitle(`[PR #${pr.number}] ${pr.title}`)
107
    .setURL(pr.html_url)
108
    .setAuthor({ name: event.actor?.login || 'unknown', iconURL: event.actor?.avatar_url })
8✔
109
    .addFields(
110
      { name: 'Action', value: actionLabel, inline: true },
111
      { name: 'Repo', value: event.repo?.name || 'unknown', inline: true },
10✔
112
    )
113
    .setTimestamp(new Date(event.created_at));
114

115
  if (pr.additions !== undefined && pr.deletions !== undefined) {
8✔
116
    embed.addFields({
1✔
117
      name: 'Changes',
118
      value: `+${pr.additions} / -${pr.deletions}`,
119
      inline: true,
120
    });
121
  }
122

123
  return embed;
6✔
124
}
125

126
/**
127
 * Build a Discord embed for an IssuesEvent.
128
 *
129
 * @param {object} event - GitHub event object
130
 * @returns {EmbedBuilder|null} Embed or null if action not handled
131
 */
132
export function buildIssueEmbed(event) {
133
  const issue = event.payload?.issue;
6✔
134
  const action = event.payload?.action;
6✔
135
  if (!issue) return null;
6✔
136

137
  let color;
138
  let actionLabel;
139

140
  if (action === 'opened') {
5✔
141
    color = 0x3498db; // blue
3✔
142
    actionLabel = 'opened';
3✔
143
  } else if (action === 'closed') {
2✔
144
    color = 0xe74c3c; // red
1✔
145
    actionLabel = 'closed';
1✔
146
  } else {
147
    return null;
1✔
148
  }
149

150
  const embed = new EmbedBuilder()
4✔
151
    .setColor(color)
152
    .setTitle(`[Issue #${issue.number}] ${issue.title}`)
153
    .setURL(issue.html_url)
154
    .setAuthor({ name: event.actor?.login || 'unknown', iconURL: event.actor?.avatar_url })
5✔
155
    .addFields(
156
      { name: 'Action', value: actionLabel, inline: true },
157
      { name: 'Repo', value: event.repo?.name || 'unknown', inline: true },
7✔
158
    )
159
    .setTimestamp(new Date(event.created_at));
160

161
  if (issue.labels?.length) {
6✔
162
    embed.addFields({
1✔
163
      name: 'Labels',
164
      value: issue.labels.map((l) => l.name).join(', '),
1✔
165
      inline: true,
166
    });
167
  }
168

169
  if (issue.assignee) {
4✔
170
    embed.addFields({ name: 'Assignee', value: issue.assignee.login, inline: true });
1✔
171
  }
172

173
  return embed;
4✔
174
}
175

176
/**
177
 * Build a Discord embed for a ReleaseEvent.
178
 *
179
 * @param {object} event - GitHub event object
180
 * @returns {EmbedBuilder|null} Embed or null if not a published release
181
 */
182
export function buildReleaseEmbed(event) {
183
  const release = event.payload?.release;
5✔
184
  if (!release) return null;
5✔
185

186
  const bodyPreview = release.body ? release.body.slice(0, 200) : '';
4✔
187

188
  const embed = new EmbedBuilder()
5✔
189
    .setColor(0xf1c40f) // gold
190
    .setTitle(`🚀 Release: ${release.tag_name}`)
191
    .setURL(release.html_url)
192
    .setAuthor({ name: event.actor?.login || 'unknown', iconURL: event.actor?.avatar_url })
6✔
193
    .addFields({ name: 'Repo', value: event.repo?.name || 'unknown', inline: true })
6✔
194
    .setTimestamp(new Date(event.created_at));
195

196
  if (bodyPreview) {
5✔
197
    embed.addFields({ name: 'Notes', value: bodyPreview });
2✔
198
  }
199

200
  return embed;
4✔
201
}
202

203
/**
204
 * Build a Discord embed for a PushEvent.
205
 *
206
 * @param {object} event - GitHub event object
207
 * @returns {EmbedBuilder|null} Embed or null if no commits
208
 */
209
export function buildPushEmbed(event) {
210
  const payload = event.payload;
8✔
211
  if (!payload) return null;
8✔
212

213
  const commits = payload.commits || [];
7!
214
  if (commits.length === 0) return null;
8✔
215

216
  // Extract branch name from ref (refs/heads/main → main)
217
  const branch = payload.ref ? payload.ref.replace('refs/heads/', '') : 'unknown';
6✔
218

219
  const commitLines = commits
8✔
220
    .slice(0, 3)
221
    .map((c) => `• \`${c.sha?.slice(0, 7) || '???????'}\` ${c.message?.split('\n')[0] || ''}`)
9!
222
    .join('\n');
223

224
  const embed = new EmbedBuilder()
8✔
225
    .setColor(0x95a5a6) // gray
226
    .setTitle(`⬆️ Push to ${branch} (${commits.length} commit${commits.length !== 1 ? 's' : ''})`)
6✔
227
    .setAuthor({ name: event.actor?.login || 'unknown', iconURL: event.actor?.avatar_url })
10✔
228
    .addFields(
229
      { name: 'Repo', value: event.repo?.name || 'unknown', inline: true },
10✔
230
      { name: 'Branch', value: branch, inline: true },
231
      { name: 'Commits', value: commitLines || '—' },
8!
232
    )
233
    .setTimestamp(new Date(event.created_at));
234

235
  return embed;
8✔
236
}
237

238
/**
239
 * Build an embed for a GitHub event based on its type.
240
 *
241
 * @param {object} event - GitHub event object
242
 * @param {string[]} enabledEvents - List of enabled event type keys ('pr','issue','release','push')
243
 * @returns {EmbedBuilder|null} Embed or null if type not handled / not enabled
244
 */
245
export function buildEmbed(event, enabledEvents) {
246
  switch (event.type) {
7✔
247
    case 'PullRequestEvent':
248
      if (!enabledEvents.includes('pr')) return null;
2✔
249
      return buildPrEmbed(event);
1✔
250
    case 'IssuesEvent':
251
      if (!enabledEvents.includes('issue')) return null;
1!
252
      return buildIssueEmbed(event);
1✔
253
    case 'ReleaseEvent':
254
      if (!enabledEvents.includes('release')) return null;
1!
255
      return buildReleaseEmbed(event);
1✔
256
    case 'PushEvent':
257
      if (!enabledEvents.includes('push')) return null;
2!
258
      return buildPushEmbed(event);
2✔
259
    default:
260
      return null;
1✔
261
  }
262
}
263

264
/**
265
 * Poll a single guild's GitHub feed.
266
 *
267
 * @param {import('discord.js').Client} client - Discord client
268
 * @param {string} guildId - Guild ID
269
 * @param {object} feedConfig - Feed configuration section
270
 */
271
async function pollGuildFeed(client, guildId, feedConfig) {
272
  const pool = getPool();
2✔
273
  const channelId = feedConfig.channelId;
2✔
274
  const repos = feedConfig.repos || [];
2!
275
  const enabledEvents = feedConfig.events || ['pr', 'issue', 'release', 'push'];
2!
276

277
  if (!channelId) {
2!
278
    logWarn('GitHub feed: no channelId configured', { guildId });
×
279
    return;
×
280
  }
281

282
  const channel = await fetchChannelCached(client, channelId);
2✔
283
  if (!channel) {
2!
284
    logWarn('GitHub feed: channel not found', { guildId, channelId });
×
285
    return;
×
286
  }
287

288
  for (const repoFullName of repos) {
2✔
289
    const [owner, repo] = repoFullName.split('/');
2✔
290
    if (!isValidGhRepo(owner, repo)) {
2!
291
      logWarn('GitHub feed: invalid owner/repo format, skipping', { guildId, repo: repoFullName });
×
292
      continue;
×
293
    }
294

295
    try {
2✔
296
      // Get last seen event ID from DB
297
      const { rows } = await pool.query(
2✔
298
        'SELECT last_event_id FROM github_feed_state WHERE guild_id = $1 AND repo = $2',
299
        [guildId, repoFullName],
300
      );
301
      const lastEventId = rows[0]?.last_event_id || null;
2!
302

303
      // Fetch events
304
      const events = await fetchRepoEvents(owner, repo);
2✔
305

306
      // Filter to events newer than last seen (events are newest-first)
307
      const newEvents = lastEventId
2!
308
        ? events.filter((e) => BigInt(e.id) > BigInt(lastEventId))
2✔
309
        : events.slice(0, 1); // first run: only latest to avoid spam
310

311
      if (newEvents.length === 0) {
2✔
312
        // Update poll time even if no new events
313
        await pool.query(
1✔
314
          `INSERT INTO github_feed_state (guild_id, repo, last_event_id, last_poll_at)
315
           VALUES ($1, $2, $3, NOW())
316
           ON CONFLICT (guild_id, repo) DO UPDATE
317
             SET last_poll_at = NOW()`,
318
          [guildId, repoFullName, lastEventId || (events[0]?.id ?? null)],
1!
319
        );
320
        continue;
1✔
321
      }
322

323
      // Process events oldest-first so they appear in chronological order
324
      const orderedEvents = [...newEvents].reverse();
1✔
325
      let newestId = lastEventId;
1✔
326

327
      for (const event of orderedEvents) {
1✔
328
        const embed = buildEmbed(event, enabledEvents);
1✔
329
        if (embed) {
1!
330
          await safeSend(channel, { embeds: [embed] });
1✔
331
          info('GitHub feed: event posted', {
1✔
332
            guildId,
333
            repo: repoFullName,
334
            type: event.type,
335
            eventId: event.id,
336
          });
337
        }
338
        // Track newest ID regardless of whether we posted (skip unsupported types)
339
        if (!newestId || BigInt(event.id) > BigInt(newestId)) {
1!
340
          newestId = event.id;
1✔
341
        }
342
      }
343

344
      // Upsert state with new last_event_id
345
      await pool.query(
1✔
346
        `INSERT INTO github_feed_state (guild_id, repo, last_event_id, last_poll_at)
347
         VALUES ($1, $2, $3, NOW())
348
         ON CONFLICT (guild_id, repo) DO UPDATE
349
           SET last_event_id = $3, last_poll_at = NOW()`,
350
        [guildId, repoFullName, newestId],
351
      );
352
    } catch (err) {
353
      logError('GitHub feed: error polling repo', {
×
354
        guildId,
355
        repo: repoFullName,
356
        error: err.message,
357
      });
358
    }
359
  }
360
}
361

362
/**
363
 * Poll GitHub feeds for all guilds that have it enabled.
364
 *
365
 * @param {import('discord.js').Client} client - Discord client
366
 */
367
async function pollAllFeeds(client) {
368
  if (pollInFlight) return;
3!
369
  pollInFlight = true;
3✔
370

371
  try {
3✔
372
    // Iterate over all guilds the bot is in
373
    for (const [guildId] of client.guilds.cache) {
3✔
374
      const config = getConfig(guildId);
3✔
375
      if (!config?.github?.feed?.enabled) continue;
3✔
376

377
      await pollGuildFeed(client, guildId, config.github.feed).catch((err) => {
2✔
378
        logError('GitHub feed: guild poll failed', { guildId, error: err.message });
×
379
      });
380
    }
381
  } catch (err) {
382
    logError('GitHub feed: poll error', { error: err.message });
×
383
  } finally {
384
    pollInFlight = false;
3✔
385
  }
386
}
387

388
/**
389
 * Start the GitHub feed polling interval.
390
 *
391
 * @param {import('discord.js').Client} client - Discord client
392
 */
393
export function startGithubFeed(client) {
394
  if (feedInterval) return;
17✔
395

396
  const defaultMinutes = 5;
16✔
397

398
  // Fixed 5-minute poll interval.
399
  const intervalMs = defaultMinutes * 60_000;
16✔
400

401
  // Kick off first poll after bot is settled (5s delay)
402
  firstPollTimeout = setTimeout(() => {
16✔
403
    firstPollTimeout = null;
3✔
404
    pollAllFeeds(client).catch((err) => {
3✔
405
      logError('GitHub feed: initial poll failed', { error: err.message });
×
406
    });
407
  }, 5_000);
408

409
  // Note: intervalMs is captured at setInterval creation time and does not change dynamically.
410
  feedInterval = setInterval(() => {
16✔
411
    pollAllFeeds(client).catch((err) => {
×
412
      logError('GitHub feed: poll failed', { error: err.message });
×
413
    });
414
  }, intervalMs);
415

416
  info('GitHub feed started');
16✔
417
}
418

419
/**
420
 * Stop the GitHub feed polling interval.
421
 */
422
export function stopGithubFeed() {
423
  if (firstPollTimeout) {
13✔
424
    clearTimeout(firstPollTimeout);
4✔
425
    firstPollTimeout = null;
4✔
426
  }
427
  if (feedInterval) {
13✔
428
    clearInterval(feedInterval);
7✔
429
    feedInterval = null;
7✔
430
    info('GitHub feed stopped');
7✔
431
  }
432
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc