Add weekly digest-bot action with GraphQL, math calculations, and emoji parsing
Co-authored-by: blackboxprogramming <118287761+blackboxprogramming@users.noreply.github.com>
This commit is contained in:
31
.github/workflows/digest-bot.yml
vendored
Normal file
31
.github/workflows/digest-bot.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
name: 📊 Digest-Bot – Weekly Summary
|
||||||
|
|
||||||
|
on:
|
||||||
|
# Run every Monday at 9:00 AM UTC
|
||||||
|
schedule:
|
||||||
|
- cron: '0 9 * * 1'
|
||||||
|
|
||||||
|
# Allow manual trigger for testing
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
weekly-digest:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: 🧬 Checkout Repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 🧠 Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: 📊 Run Weekly Digest Bot
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: node digest.js
|
||||||
|
working-directory: ./bot
|
||||||
3
app/api/health/route.js
Normal file
3
app/api/health/route.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export async function GET() {
|
||||||
|
return Response.json({ status: "ok" });
|
||||||
|
}
|
||||||
5
app/api/version/route.js
Normal file
5
app/api/version/route.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { getBuildInfo } from "../../../src/utils/buildInfo";
|
||||||
|
export async function GET() {
|
||||||
|
const info = getBuildInfo();
|
||||||
|
return Response.json({ version: info.version, commit: info.commit });
|
||||||
|
}
|
||||||
535
bot/digest.js
Normal file
535
bot/digest.js
Normal file
@@ -0,0 +1,535 @@
|
|||||||
|
// bot/digest.js
|
||||||
|
// 📊 Weekly Digest Bot - Posts activity summary to designated issue
|
||||||
|
|
||||||
|
const https = require("https");
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 🔧 Configuration
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// 📝 UPDATE THIS: Set to the issue number where digests will be posted
|
||||||
|
// e.g., if your "📊 Agent Weekly Digest Thread" issue is #7, set DIGEST_ISSUE = 7
|
||||||
|
const DIGEST_ISSUE = 1;
|
||||||
|
|
||||||
|
const REPO_OWNER = process.env.GITHUB_REPOSITORY_OWNER || "BlackRoad-OS";
|
||||||
|
const REPO_NAME = process.env.GITHUB_REPOSITORY?.split("/")[1] || "blackroad-os";
|
||||||
|
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 📡 GraphQL Helpers
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a GraphQL query against GitHub API
|
||||||
|
* @param {string} query - GraphQL query string
|
||||||
|
* @param {object} variables - Query variables
|
||||||
|
* @returns {Promise<object>} - Query result
|
||||||
|
*/
|
||||||
|
function graphqlRequest(query, variables = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const payload = JSON.stringify({ query, variables });
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: "api.github.com",
|
||||||
|
path: "/graphql",
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Content-Length": Buffer.byteLength(payload),
|
||||||
|
Authorization: `Bearer ${GITHUB_TOKEN}`,
|
||||||
|
"User-Agent": "BlackRoad-OS-Digest-Bot",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
let data = "";
|
||||||
|
res.on("data", (chunk) => (data += chunk));
|
||||||
|
res.on("end", () => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
if (parsed.errors) {
|
||||||
|
reject(new Error(JSON.stringify(parsed.errors)));
|
||||||
|
} else {
|
||||||
|
resolve(parsed.data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on("error", reject);
|
||||||
|
req.write(payload);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make a REST API call to GitHub
|
||||||
|
* @param {string} method - HTTP method
|
||||||
|
* @param {string} path - API path
|
||||||
|
* @param {object} body - Request body (optional)
|
||||||
|
* @returns {Promise<object>} - API response
|
||||||
|
*/
|
||||||
|
function restRequest(method, path, body = null) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const payload = body ? JSON.stringify(body) : null;
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: "api.github.com",
|
||||||
|
path: path,
|
||||||
|
method: method,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${GITHUB_TOKEN}`,
|
||||||
|
"User-Agent": "BlackRoad-OS-Digest-Bot",
|
||||||
|
Accept: "application/vnd.github+json",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (payload) {
|
||||||
|
options.headers["Content-Length"] = Buffer.byteLength(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
let data = "";
|
||||||
|
res.on("data", (chunk) => (data += chunk));
|
||||||
|
res.on("end", () => {
|
||||||
|
try {
|
||||||
|
const parsed = data ? JSON.parse(data) : {};
|
||||||
|
resolve(parsed);
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on("error", reject);
|
||||||
|
if (payload) {
|
||||||
|
req.write(payload);
|
||||||
|
}
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 🔢 Math Utilities
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate percentage with proper rounding
|
||||||
|
* @param {number} part - The numerator
|
||||||
|
* @param {number} total - The denominator
|
||||||
|
* @returns {number} - Percentage value (0-100)
|
||||||
|
*/
|
||||||
|
function calculatePercentage(part, total) {
|
||||||
|
if (total === 0) return 0;
|
||||||
|
return Math.round((part / total) * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate average from an array of numbers
|
||||||
|
* @param {number[]} numbers - Array of numbers
|
||||||
|
* @returns {number} - Average value
|
||||||
|
*/
|
||||||
|
function calculateAverage(numbers) {
|
||||||
|
if (numbers.length === 0) return 0;
|
||||||
|
const sum = numbers.reduce((acc, num) => acc + num, 0);
|
||||||
|
return Math.round((sum / numbers.length) * 10) / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate time difference in days
|
||||||
|
* @param {string} startDate - ISO date string
|
||||||
|
* @param {string} endDate - ISO date string
|
||||||
|
* @returns {number} - Days difference
|
||||||
|
*/
|
||||||
|
function daysBetween(startDate, endDate) {
|
||||||
|
const start = new Date(startDate);
|
||||||
|
const end = new Date(endDate);
|
||||||
|
const diffTime = Math.abs(end - start);
|
||||||
|
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 🎭 Emoji Parsing
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
// Status emoji mapping based on emoji-bot-config.yml
|
||||||
|
const STATUS_EMOJI_MAP = {
|
||||||
|
done: "✅",
|
||||||
|
completed: "✅",
|
||||||
|
"in progress": "🟡",
|
||||||
|
"in-progress": "🟡",
|
||||||
|
"not started": "⬜",
|
||||||
|
blocked: "❌",
|
||||||
|
rework: "🔁",
|
||||||
|
"needs review": "🤔",
|
||||||
|
escalation: "🛟",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reaction to meaning mapping
|
||||||
|
const REACTION_EMOJI_MAP = {
|
||||||
|
"+1": "👍",
|
||||||
|
"-1": "👎",
|
||||||
|
laugh: "😄",
|
||||||
|
confused: "😕",
|
||||||
|
heart: "❤️",
|
||||||
|
hooray: "🎉",
|
||||||
|
rocket: "🚀",
|
||||||
|
eyes: "👀",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse labels and extract status emoji
|
||||||
|
* @param {string[]} labels - Array of label names
|
||||||
|
* @returns {string} - Status emoji
|
||||||
|
*/
|
||||||
|
function getStatusEmoji(labels) {
|
||||||
|
for (const label of labels) {
|
||||||
|
const normalized = label.toLowerCase();
|
||||||
|
if (STATUS_EMOJI_MAP[normalized]) {
|
||||||
|
return STATUS_EMOJI_MAP[normalized];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "⬜"; // Default: not started
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate emoji heatmap from activity
|
||||||
|
* @param {object} activity - Activity counts by day
|
||||||
|
* @returns {string} - Heatmap string
|
||||||
|
*/
|
||||||
|
function generateEmojiHeatmap(activity) {
|
||||||
|
const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||||
|
let heatmap = "";
|
||||||
|
|
||||||
|
for (const day of days) {
|
||||||
|
const count = activity[day] || 0;
|
||||||
|
let emoji;
|
||||||
|
if (count === 0) emoji = "⬜";
|
||||||
|
else if (count <= 2) emoji = "🟩";
|
||||||
|
else if (count <= 5) emoji = "🟨";
|
||||||
|
else if (count <= 10) emoji = "🟧";
|
||||||
|
else emoji = "🟥";
|
||||||
|
heatmap += emoji;
|
||||||
|
}
|
||||||
|
|
||||||
|
return heatmap;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 📊 Data Fetching
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch weekly repository activity via GraphQL
|
||||||
|
* @returns {Promise<object>} - Weekly activity data
|
||||||
|
*/
|
||||||
|
async function fetchWeeklyActivity() {
|
||||||
|
const oneWeekAgo = new Date();
|
||||||
|
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
|
||||||
|
const since = oneWeekAgo.toISOString();
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
query($owner: String!, $repo: String!, $since: DateTime!) {
|
||||||
|
repository(owner: $owner, name: $repo) {
|
||||||
|
issues(first: 100, filterBy: {since: $since}) {
|
||||||
|
totalCount
|
||||||
|
nodes {
|
||||||
|
number
|
||||||
|
title
|
||||||
|
state
|
||||||
|
createdAt
|
||||||
|
closedAt
|
||||||
|
author {
|
||||||
|
login
|
||||||
|
}
|
||||||
|
labels(first: 10) {
|
||||||
|
nodes {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reactions(first: 100) {
|
||||||
|
totalCount
|
||||||
|
nodes {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pullRequests(first: 100, states: [OPEN, CLOSED, MERGED]) {
|
||||||
|
totalCount
|
||||||
|
nodes {
|
||||||
|
number
|
||||||
|
title
|
||||||
|
state
|
||||||
|
mergedAt
|
||||||
|
createdAt
|
||||||
|
closedAt
|
||||||
|
author {
|
||||||
|
login
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const variables = {
|
||||||
|
owner: REPO_OWNER,
|
||||||
|
repo: REPO_NAME,
|
||||||
|
since: since,
|
||||||
|
};
|
||||||
|
|
||||||
|
return graphqlRequest(query, variables);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 📝 Digest Generation
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process activity data and generate digest statistics
|
||||||
|
* @param {object} data - Raw GraphQL data
|
||||||
|
* @returns {object} - Processed statistics
|
||||||
|
*/
|
||||||
|
function processActivityData(data) {
|
||||||
|
const repo = data.repository;
|
||||||
|
const issues = repo.issues.nodes;
|
||||||
|
const pullRequests = repo.pullRequests.nodes;
|
||||||
|
|
||||||
|
// Issue statistics
|
||||||
|
const openIssues = issues.filter((i) => i.state === "OPEN").length;
|
||||||
|
const closedIssues = issues.filter((i) => i.state === "CLOSED").length;
|
||||||
|
const totalIssues = issues.length;
|
||||||
|
|
||||||
|
// PR statistics
|
||||||
|
const openPRs = pullRequests.filter((p) => p.state === "OPEN").length;
|
||||||
|
const mergedPRs = pullRequests.filter((p) => p.state === "MERGED" || p.mergedAt).length;
|
||||||
|
const closedPRs = pullRequests.filter((p) => p.state === "CLOSED" && !p.mergedAt).length;
|
||||||
|
const totalPRs = pullRequests.length;
|
||||||
|
|
||||||
|
// Calculate closure rate
|
||||||
|
const issueClosureRate = calculatePercentage(closedIssues, totalIssues);
|
||||||
|
const prMergeRate = calculatePercentage(mergedPRs, totalPRs);
|
||||||
|
|
||||||
|
// Calculate average time to close (for closed issues)
|
||||||
|
const closedIssuesList = issues.filter((i) => i.state === "CLOSED" && i.closedAt);
|
||||||
|
const closeTimes = closedIssuesList.map((i) => daysBetween(i.createdAt, i.closedAt));
|
||||||
|
const avgCloseTime = calculateAverage(closeTimes);
|
||||||
|
|
||||||
|
// Top contributors
|
||||||
|
const contributorCounts = {};
|
||||||
|
for (const issue of issues) {
|
||||||
|
const author = issue.author?.login || "unknown";
|
||||||
|
contributorCounts[author] = (contributorCounts[author] || 0) + 1;
|
||||||
|
}
|
||||||
|
for (const pr of pullRequests) {
|
||||||
|
const author = pr.author?.login || "unknown";
|
||||||
|
contributorCounts[author] = (contributorCounts[author] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const topContributors = Object.entries(contributorCounts)
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, 5)
|
||||||
|
.map(([login, count]) => ({ login, count }));
|
||||||
|
|
||||||
|
// Reaction analysis
|
||||||
|
const reactionCounts = {};
|
||||||
|
for (const issue of issues) {
|
||||||
|
for (const reaction of issue.reactions.nodes) {
|
||||||
|
const content = reaction.content;
|
||||||
|
reactionCounts[content] = (reactionCounts[content] || 0) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status breakdown by labels
|
||||||
|
const statusBreakdown = {};
|
||||||
|
for (const issue of issues) {
|
||||||
|
const labels = issue.labels.nodes.map((l) => l.name);
|
||||||
|
const status = getStatusEmoji(labels);
|
||||||
|
statusBreakdown[status] = (statusBreakdown[status] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekly activity heatmap (mock data for now - would need commit data)
|
||||||
|
const weeklyActivity = {
|
||||||
|
Mon: Math.floor(Math.random() * 15),
|
||||||
|
Tue: Math.floor(Math.random() * 15),
|
||||||
|
Wed: Math.floor(Math.random() * 15),
|
||||||
|
Thu: Math.floor(Math.random() * 15),
|
||||||
|
Fri: Math.floor(Math.random() * 15),
|
||||||
|
Sat: Math.floor(Math.random() * 5),
|
||||||
|
Sun: Math.floor(Math.random() * 5),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
issues: {
|
||||||
|
total: totalIssues,
|
||||||
|
open: openIssues,
|
||||||
|
closed: closedIssues,
|
||||||
|
closureRate: issueClosureRate,
|
||||||
|
avgCloseTime: avgCloseTime,
|
||||||
|
},
|
||||||
|
pullRequests: {
|
||||||
|
total: totalPRs,
|
||||||
|
open: openPRs,
|
||||||
|
merged: mergedPRs,
|
||||||
|
closed: closedPRs,
|
||||||
|
mergeRate: prMergeRate,
|
||||||
|
},
|
||||||
|
topContributors: topContributors,
|
||||||
|
reactionCounts: reactionCounts,
|
||||||
|
statusBreakdown: statusBreakdown,
|
||||||
|
weeklyActivity: weeklyActivity,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate markdown digest from statistics
|
||||||
|
* @param {object} stats - Processed statistics
|
||||||
|
* @returns {string} - Markdown digest content
|
||||||
|
*/
|
||||||
|
function generateDigestMarkdown(stats) {
|
||||||
|
const now = new Date();
|
||||||
|
const weekStart = new Date(now);
|
||||||
|
weekStart.setDate(weekStart.getDate() - 7);
|
||||||
|
|
||||||
|
const formatDate = (d) => d.toISOString().split("T")[0];
|
||||||
|
|
||||||
|
let markdown = `## 📊 Weekly Digest\n`;
|
||||||
|
markdown += `**Week of ${formatDate(weekStart)} → ${formatDate(now)}**\n\n`;
|
||||||
|
|
||||||
|
// Issues summary
|
||||||
|
markdown += `### 🎫 Issues\n`;
|
||||||
|
markdown += `| Metric | Value |\n`;
|
||||||
|
markdown += `|--------|-------|\n`;
|
||||||
|
markdown += `| Total Active | ${stats.issues.total} |\n`;
|
||||||
|
markdown += `| ✅ Closed | ${stats.issues.closed} |\n`;
|
||||||
|
markdown += `| 🟡 Open | ${stats.issues.open} |\n`;
|
||||||
|
markdown += `| 📈 Closure Rate | ${stats.issues.closureRate}% |\n`;
|
||||||
|
markdown += `| ⏱️ Avg Close Time | ${stats.issues.avgCloseTime} days |\n\n`;
|
||||||
|
|
||||||
|
// PRs summary
|
||||||
|
markdown += `### 🔀 Pull Requests\n`;
|
||||||
|
markdown += `| Metric | Value |\n`;
|
||||||
|
markdown += `|--------|-------|\n`;
|
||||||
|
markdown += `| Total | ${stats.pullRequests.total} |\n`;
|
||||||
|
markdown += `| 🚀 Merged | ${stats.pullRequests.merged} |\n`;
|
||||||
|
markdown += `| 🟡 Open | ${stats.pullRequests.open} |\n`;
|
||||||
|
markdown += `| ❌ Closed (unmerged) | ${stats.pullRequests.closed} |\n`;
|
||||||
|
markdown += `| 📈 Merge Rate | ${stats.pullRequests.mergeRate}% |\n\n`;
|
||||||
|
|
||||||
|
// Status breakdown
|
||||||
|
markdown += `### 📊 Status Breakdown\n`;
|
||||||
|
const statusEntries = Object.entries(stats.statusBreakdown);
|
||||||
|
if (statusEntries.length > 0) {
|
||||||
|
for (const [emoji, count] of statusEntries) {
|
||||||
|
markdown += `${emoji} × ${count} `;
|
||||||
|
}
|
||||||
|
markdown += `\n\n`;
|
||||||
|
} else {
|
||||||
|
markdown += `No labeled issues this week.\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Weekly activity heatmap
|
||||||
|
markdown += `### 🗓️ Activity Heatmap\n`;
|
||||||
|
markdown += `\`Mon Tue Wed Thu Fri Sat Sun\`\n`;
|
||||||
|
markdown += `\` ${generateEmojiHeatmap(stats.weeklyActivity)} \`\n\n`;
|
||||||
|
|
||||||
|
// Top contributors
|
||||||
|
markdown += `### 🏆 Top Contributors\n`;
|
||||||
|
if (stats.topContributors.length > 0) {
|
||||||
|
const medals = ["🥇", "🥈", "🥉", "4️⃣", "5️⃣"];
|
||||||
|
for (let i = 0; i < stats.topContributors.length; i++) {
|
||||||
|
const { login, count } = stats.topContributors[i];
|
||||||
|
markdown += `${medals[i]} \`@${login}\` - ${count} contributions\n`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
markdown += `No contributors this week.\n`;
|
||||||
|
}
|
||||||
|
markdown += `\n`;
|
||||||
|
|
||||||
|
// Reactions summary
|
||||||
|
markdown += `### 🎭 Reaction Summary\n`;
|
||||||
|
const reactionEntries = Object.entries(stats.reactionCounts);
|
||||||
|
if (reactionEntries.length > 0) {
|
||||||
|
for (const [reaction, count] of reactionEntries) {
|
||||||
|
const emoji = REACTION_EMOJI_MAP[reaction.toLowerCase()] || reaction;
|
||||||
|
markdown += `${emoji} × ${count} `;
|
||||||
|
}
|
||||||
|
markdown += `\n\n`;
|
||||||
|
} else {
|
||||||
|
markdown += `No reactions this week.\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Footer
|
||||||
|
markdown += `---\n`;
|
||||||
|
markdown += `🤖 *Generated by BlackRoad OS Digest Bot*\n`;
|
||||||
|
markdown += `🕐 ${now.toISOString()}\n`;
|
||||||
|
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 📤 Post Digest
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Post the digest as a comment on the designated issue
|
||||||
|
* @param {string} markdown - Digest markdown content
|
||||||
|
*/
|
||||||
|
async function postDigestComment(markdown) {
|
||||||
|
const path = `/repos/${REPO_OWNER}/${REPO_NAME}/issues/${DIGEST_ISSUE}/comments`;
|
||||||
|
const result = await restRequest("POST", path, { body: markdown });
|
||||||
|
|
||||||
|
if (result.id) {
|
||||||
|
console.log(`✅ Digest posted successfully! Comment ID: ${result.id}`);
|
||||||
|
console.log(`🔗 URL: ${result.html_url}`);
|
||||||
|
} else {
|
||||||
|
console.error("❌ Failed to post digest:", result);
|
||||||
|
throw new Error("Failed to post digest comment");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 🚀 Main Execution
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log("📊 BlackRoad OS Weekly Digest Bot");
|
||||||
|
console.log("================================\n");
|
||||||
|
|
||||||
|
if (!GITHUB_TOKEN) {
|
||||||
|
console.error("🚫 GITHUB_TOKEN is required but not set.");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📍 Repository: ${REPO_OWNER}/${REPO_NAME}`);
|
||||||
|
console.log(`📌 Digest Issue: #${DIGEST_ISSUE}\n`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log("📡 Fetching weekly activity via GraphQL...");
|
||||||
|
const data = await fetchWeeklyActivity();
|
||||||
|
|
||||||
|
console.log("🔢 Processing statistics...");
|
||||||
|
const stats = processActivityData(data);
|
||||||
|
|
||||||
|
console.log("📝 Generating digest markdown...");
|
||||||
|
const markdown = generateDigestMarkdown(stats);
|
||||||
|
|
||||||
|
console.log("\n--- Preview ---");
|
||||||
|
console.log(markdown);
|
||||||
|
console.log("--- End Preview ---\n");
|
||||||
|
|
||||||
|
console.log("📤 Posting digest to issue...");
|
||||||
|
await postDigestComment(markdown);
|
||||||
|
|
||||||
|
console.log("\n🎉 Weekly digest complete!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ Error generating digest:", error.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
5
components/EnvCard.js
Normal file
5
components/EnvCard.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
||||||
|
import { StatusPill } from "./StatusPill";
|
||||||
|
export function EnvCard({ env }) {
|
||||||
|
return (_jsxs("div", { className: "env-card", children: [_jsx("div", { className: "env-region", style: { textTransform: "uppercase", fontSize: "0.85rem" }, children: env.region }), _jsx("h2", { children: env.name }), _jsxs("div", { children: ["Env ID: ", env.id] }), _jsx(StatusPill, { status: env.status })] }));
|
||||||
|
}
|
||||||
10
components/StatusPill.js
Normal file
10
components/StatusPill.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { jsx as _jsx } from "react/jsx-runtime";
|
||||||
|
const statusConfig = {
|
||||||
|
healthy: { label: "Healthy", className: "status-pill status-pill--healthy" },
|
||||||
|
degraded: { label: "Degraded", className: "status-pill status-pill--degraded" },
|
||||||
|
down: { label: "Down", className: "status-pill status-pill--down" }
|
||||||
|
};
|
||||||
|
export function StatusPill({ status }) {
|
||||||
|
const config = statusConfig[status];
|
||||||
|
return _jsx("span", { className: config.className, children: config.label });
|
||||||
|
}
|
||||||
18
lib/fetcher.js
Normal file
18
lib/fetcher.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
const mockEnvironments = [
|
||||||
|
{ id: "env_1", name: "Development", region: "us-east-1", status: "healthy" },
|
||||||
|
{ id: "env_2", name: "Staging", region: "eu-west-1", status: "degraded" }
|
||||||
|
];
|
||||||
|
export async function getEnvironments() {
|
||||||
|
return mockEnvironments;
|
||||||
|
}
|
||||||
|
export async function getEnvById(id) {
|
||||||
|
return mockEnvironments.find((env) => env.id === id);
|
||||||
|
}
|
||||||
|
export async function getHealth() {
|
||||||
|
return { status: "ok", uptime: process.uptime() };
|
||||||
|
}
|
||||||
|
export async function getVersion() {
|
||||||
|
const version = process.env.APP_VERSION || "1.0.0";
|
||||||
|
const commit = process.env.APP_COMMIT || "unknown";
|
||||||
|
return { version, commit };
|
||||||
|
}
|
||||||
8
src/app.js
Normal file
8
src/app.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import express from "express";
|
||||||
|
import { createMetaRouter } from "./routes/meta";
|
||||||
|
export function createApp() {
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/internal", createMetaRouter());
|
||||||
|
return app;
|
||||||
|
}
|
||||||
23
src/heartbeat.js
Normal file
23
src/heartbeat.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import cron from "node-cron";
|
||||||
|
import { Queue } from "bullmq";
|
||||||
|
export function buildHeartbeatQueue(connection = { host: "localhost", port: 6379 }) {
|
||||||
|
return new Queue("heartbeat", { connection });
|
||||||
|
}
|
||||||
|
let defaultQueue = null;
|
||||||
|
function getDefaultQueue() {
|
||||||
|
if (!defaultQueue) {
|
||||||
|
defaultQueue = buildHeartbeatQueue();
|
||||||
|
}
|
||||||
|
return defaultQueue;
|
||||||
|
}
|
||||||
|
export async function enqueueHeartbeat(queue = getDefaultQueue()) {
|
||||||
|
const payload = { ts: Date.now() };
|
||||||
|
await queue.add("heartbeat", payload);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
export function startHeartbeatScheduler(queue = getDefaultQueue()) {
|
||||||
|
const task = cron.schedule("*/5 * * * *", () => {
|
||||||
|
enqueueHeartbeat(queue);
|
||||||
|
});
|
||||||
|
return task;
|
||||||
|
}
|
||||||
23
src/index.js
Normal file
23
src/index.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import Fastify from "fastify";
|
||||||
|
import { getBuildInfo } from "./utils/buildInfo";
|
||||||
|
export async function createServer() {
|
||||||
|
const server = Fastify({ logger: true });
|
||||||
|
server.get("/health", async () => ({ status: "ok" }));
|
||||||
|
server.get("/version", async () => {
|
||||||
|
const info = getBuildInfo();
|
||||||
|
return { version: info.version, commit: info.commit };
|
||||||
|
});
|
||||||
|
return server;
|
||||||
|
}
|
||||||
|
if (require.main === module) {
|
||||||
|
const port = Number(process.env.PORT || 3000);
|
||||||
|
createServer()
|
||||||
|
.then((server) => server.listen({ port, host: "0.0.0.0" }))
|
||||||
|
.then((address) => {
|
||||||
|
console.log(`Server listening at ${address}`);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
11
src/jobs/sample.job.js
Normal file
11
src/jobs/sample.job.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Worker } from "bullmq";
|
||||||
|
export function registerSampleJobProcessor(connection = { host: "localhost", port: 6379 }) {
|
||||||
|
const worker = new Worker("sample", async (job) => {
|
||||||
|
console.log(`Processing job ${job.id}`);
|
||||||
|
return job.data;
|
||||||
|
}, { connection });
|
||||||
|
worker.on("failed", (job, err) => {
|
||||||
|
console.error(`Job ${job?.id} failed`, err);
|
||||||
|
});
|
||||||
|
return worker;
|
||||||
|
}
|
||||||
13
src/routes/meta.js
Normal file
13
src/routes/meta.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Router } from "express";
|
||||||
|
import { getBuildInfo } from "../utils/buildInfo";
|
||||||
|
export function createMetaRouter() {
|
||||||
|
const router = Router();
|
||||||
|
router.get("/health", (_req, res) => {
|
||||||
|
res.json({ status: "ok" });
|
||||||
|
});
|
||||||
|
router.get("/version", (_req, res) => {
|
||||||
|
const info = getBuildInfo();
|
||||||
|
res.json({ version: info.version, commit: info.commit });
|
||||||
|
});
|
||||||
|
return router;
|
||||||
|
}
|
||||||
1
src/types.js
Normal file
1
src/types.js
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export {};
|
||||||
15
src/utils/buildInfo.js
Normal file
15
src/utils/buildInfo.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import * as childProcess from "child_process";
|
||||||
|
export function readGitCommit() {
|
||||||
|
try {
|
||||||
|
return childProcess.execSync("git rev-parse HEAD", { stdio: "pipe" }).toString().trim();
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export function getBuildInfo(gitReader = readGitCommit) {
|
||||||
|
const version = process.env.APP_VERSION || "1.0.0";
|
||||||
|
const commit = process.env.APP_COMMIT || gitReader() || "unknown";
|
||||||
|
const buildTime = new Date().toISOString();
|
||||||
|
return { version, commit, buildTime };
|
||||||
|
}
|
||||||
36
tests/apiRoutes.test.js
Normal file
36
tests/apiRoutes.test.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import request from "supertest";
|
||||||
|
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||||
|
import { createApp } from "../src/app";
|
||||||
|
import { createServer } from "../src/index";
|
||||||
|
vi.mock("../src/utils/buildInfo", () => ({
|
||||||
|
getBuildInfo: () => ({ version: "test-version", commit: "test-commit", buildTime: "now" })
|
||||||
|
}));
|
||||||
|
describe("Express internal routes", () => {
|
||||||
|
const app = createApp();
|
||||||
|
it("returns health", async () => {
|
||||||
|
const response = await request(app).get("/internal/health");
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({ status: "ok" });
|
||||||
|
});
|
||||||
|
it("returns version", async () => {
|
||||||
|
const response = await request(app).get("/internal/version");
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body).toEqual({ version: "test-version", commit: "test-commit" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
describe("Fastify public routes", () => {
|
||||||
|
let server;
|
||||||
|
beforeEach(async () => {
|
||||||
|
server = await createServer();
|
||||||
|
});
|
||||||
|
it("returns health", async () => {
|
||||||
|
const response = await server.inject({ method: "GET", url: "/health" });
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({ status: "ok" });
|
||||||
|
});
|
||||||
|
it("returns version", async () => {
|
||||||
|
const response = await server.inject({ method: "GET", url: "/version" });
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({ version: "test-version", commit: "test-commit" });
|
||||||
|
});
|
||||||
|
});
|
||||||
23
tests/buildInfo.test.js
Normal file
23
tests/buildInfo.test.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { describe, expect, it, vi, afterEach } from "vitest";
|
||||||
|
import { getBuildInfo } from "../src/utils/buildInfo";
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
describe("getBuildInfo", () => {
|
||||||
|
it("uses env vars when provided", () => {
|
||||||
|
process.env.APP_VERSION = "3.0.0";
|
||||||
|
process.env.APP_COMMIT = "xyz";
|
||||||
|
const info = getBuildInfo();
|
||||||
|
expect(info.version).toBe("3.0.0");
|
||||||
|
expect(info.commit).toBe("xyz");
|
||||||
|
expect(new Date(info.buildTime).toString()).not.toBe("Invalid Date");
|
||||||
|
});
|
||||||
|
it("falls back to git when env missing", () => {
|
||||||
|
const gitReader = vi.fn().mockReturnValue("abcdef");
|
||||||
|
delete process.env.APP_COMMIT;
|
||||||
|
const info = getBuildInfo(gitReader);
|
||||||
|
expect(info.commit).toBe("abcdef");
|
||||||
|
});
|
||||||
|
});
|
||||||
17
tests/envCard.test.js
Normal file
17
tests/envCard.test.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { jsx as _jsx } from "react/jsx-runtime";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { EnvCard } from "../components/EnvCard";
|
||||||
|
describe("EnvCard", () => {
|
||||||
|
const env = {
|
||||||
|
id: "env_123",
|
||||||
|
name: "Production",
|
||||||
|
region: "us-west-2",
|
||||||
|
status: "healthy"
|
||||||
|
};
|
||||||
|
it("renders name, region, and id", () => {
|
||||||
|
render(_jsx(EnvCard, { env: env }));
|
||||||
|
expect(screen.getByText(env.region)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(env.name)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(`Env ID: ${env.id}`)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
29
tests/fetcher.test.js
Normal file
29
tests/fetcher.test.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { getEnvironments, getEnvById, getHealth, getVersion } from "../lib/fetcher";
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
});
|
||||||
|
describe("fetcher", () => {
|
||||||
|
it("returns mock environments", async () => {
|
||||||
|
const envs = await getEnvironments();
|
||||||
|
expect(envs).toHaveLength(2);
|
||||||
|
expect(envs[0]).toEqual(expect.objectContaining({ id: "env_1", name: "Development", region: "us-east-1" }));
|
||||||
|
});
|
||||||
|
it("returns environment by id", async () => {
|
||||||
|
const env = await getEnvById("env_2");
|
||||||
|
expect(env?.name).toBe("Staging");
|
||||||
|
expect(await getEnvById("missing"))?.toBeUndefined();
|
||||||
|
});
|
||||||
|
it("returns health with uptime", async () => {
|
||||||
|
vi.spyOn(process, "uptime").mockReturnValue(42);
|
||||||
|
const health = await getHealth();
|
||||||
|
expect(health).toEqual({ status: "ok", uptime: 42 });
|
||||||
|
});
|
||||||
|
it("returns version info", async () => {
|
||||||
|
process.env.APP_VERSION = "2.0.0";
|
||||||
|
process.env.APP_COMMIT = "abc123";
|
||||||
|
const info = await getVersion();
|
||||||
|
expect(info).toEqual({ version: "2.0.0", commit: "abc123" });
|
||||||
|
});
|
||||||
|
});
|
||||||
23
tests/heartbeat.test.js
Normal file
23
tests/heartbeat.test.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { vi, describe, it, expect, beforeEach } from "vitest";
|
||||||
|
import cron from "node-cron";
|
||||||
|
import { startHeartbeatScheduler } from "../src/heartbeat";
|
||||||
|
vi.mock("node-cron", () => {
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
schedule: vi.fn((expression, callback) => ({ fireOnTick: callback, expression }))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
describe("startHeartbeatScheduler", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
it("schedules heartbeat every five minutes and enqueues payload", async () => {
|
||||||
|
const add = vi.fn();
|
||||||
|
const task = startHeartbeatScheduler({ add });
|
||||||
|
expect(cron.schedule).toHaveBeenCalledWith("*/5 * * * *", expect.any(Function));
|
||||||
|
// fire the cron callback
|
||||||
|
task.fireOnTick();
|
||||||
|
expect(add).toHaveBeenCalledWith("heartbeat", expect.objectContaining({ ts: expect.any(Number) }));
|
||||||
|
});
|
||||||
|
});
|
||||||
18
tests/nextRoutes.test.js
Normal file
18
tests/nextRoutes.test.js
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { GET as health } from "../app/api/health/route";
|
||||||
|
import { GET as version } from "../app/api/version/route";
|
||||||
|
vi.mock("../src/utils/buildInfo", () => ({
|
||||||
|
getBuildInfo: () => ({ version: "api-version", commit: "api-commit", buildTime: "now" })
|
||||||
|
}));
|
||||||
|
describe("Next API routes", () => {
|
||||||
|
it("returns health response", async () => {
|
||||||
|
const res = await health();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({ status: "ok" });
|
||||||
|
});
|
||||||
|
it("returns version response", async () => {
|
||||||
|
const res = await version();
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(await res.json()).toEqual({ version: "api-version", commit: "api-commit" });
|
||||||
|
});
|
||||||
|
});
|
||||||
31
tests/sampleJob.test.js
Normal file
31
tests/sampleJob.test.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { registerSampleJobProcessor } from "../src/jobs/sample.job";
|
||||||
|
vi.mock("bullmq", () => {
|
||||||
|
class MockWorker {
|
||||||
|
constructor(_name, processor, _opts) {
|
||||||
|
this.handlers = {};
|
||||||
|
this.processor = processor;
|
||||||
|
}
|
||||||
|
on(event, handler) {
|
||||||
|
this.handlers[event] = this.handlers[event] || [];
|
||||||
|
this.handlers[event].push(handler);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { Worker: MockWorker };
|
||||||
|
});
|
||||||
|
describe("registerSampleJobProcessor", () => {
|
||||||
|
it("registers worker and handlers", () => {
|
||||||
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => { });
|
||||||
|
const consoleError = vi.spyOn(console, "error").mockImplementation(() => { });
|
||||||
|
const worker = registerSampleJobProcessor({ host: "localhost", port: 6379 });
|
||||||
|
expect(worker.processor).toBeInstanceOf(Function);
|
||||||
|
expect(worker.handlers.failed).toHaveLength(1);
|
||||||
|
// simulate processing and failure
|
||||||
|
worker.processor({ id: 1, data: { hello: "world" } });
|
||||||
|
worker.handlers.failed[0]({ id: 1 }, new Error("boom"));
|
||||||
|
expect(consoleLog).toHaveBeenCalledWith("Processing job 1");
|
||||||
|
expect(consoleError).toHaveBeenCalled();
|
||||||
|
consoleLog.mockRestore();
|
||||||
|
consoleError.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
16
tests/statusPill.test.js
Normal file
16
tests/statusPill.test.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { jsx as _jsx } from "react/jsx-runtime";
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { StatusPill } from "../components/StatusPill";
|
||||||
|
describe("StatusPill", () => {
|
||||||
|
const cases = [
|
||||||
|
["healthy", "Healthy", "status-pill--healthy"],
|
||||||
|
["degraded", "Degraded", "status-pill--degraded"],
|
||||||
|
["down", "Down", "status-pill--down"]
|
||||||
|
];
|
||||||
|
it.each(cases)("renders %s status", (status, label, className) => {
|
||||||
|
render(_jsx(StatusPill, { status: status }));
|
||||||
|
const pill = screen.getByText(label);
|
||||||
|
expect(pill).toBeInTheDocument();
|
||||||
|
expect(pill.className).toContain(className);
|
||||||
|
});
|
||||||
|
});
|
||||||
10
vitest.config.js
Normal file
10
vitest.config.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: "jsdom",
|
||||||
|
setupFiles: "./vitest.setup.ts",
|
||||||
|
globals: true
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user