Merge commit '457ca9d503748b157c9390fb3d377eeb8eebe5cb'

This commit is contained in:
Alexa Amundson
2025-11-25 13:45:16 -06:00
2 changed files with 593 additions and 0 deletions

37
.github/workflows/digest-bot.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
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:
inputs:
digest_issue:
description: 'Issue number to post digest to (overrides default)'
required: false
type: number
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 }}
DIGEST_ISSUE: ${{ inputs.digest_issue || '' }}
run: node digest.js
working-directory: ./bot

View File

@@ -134,3 +134,559 @@ if (require.main === module) {
console.log("\n📋 Recommendations:"); console.log("\n📋 Recommendations:");
result.actionRecommendations.forEach((rec) => console.log(` ${rec}`)); result.actionRecommendations.forEach((rec) => console.log(` ${rec}`));
} }
// 📊 Weekly Digest Bot - Posts activity summary to designated issue
const https = require("https");
// ============================================================
// 🔧 Configuration
// ============================================================
// 📝 Set via DIGEST_ISSUE env var or update the default value below
// e.g., if your "📊 Agent Weekly Digest Thread" issue is #7, set DIGEST_ISSUE=7
const DIGEST_ISSUE = parseInt(process.env.DIGEST_ISSUE, 10) || 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));
}
/**
* Get the date from one week ago
* @returns {Date} - Date object representing one week ago
*/
function getOneWeekAgo() {
const date = new Date();
date.setDate(date.getDate() - 7);
return date;
}
// ============================================================
// 🎭 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 since = getOneWeekAgo().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;
// Filter PRs to only include those from the past week
// Note: GitHub GraphQL API doesn't support filterBy.since for pullRequests like it does for issues,
// so we filter in JavaScript instead
const oneWeekAgo = getOneWeekAgo();
const pullRequests = repo.pullRequests.nodes.filter((pr) => {
const createdAt = new Date(pr.createdAt);
return createdAt >= oneWeekAgo;
});
// 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.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 (filter out null authors)
const contributorCounts = {};
for (const issue of issues) {
const author = issue.author?.login;
if (author) {
contributorCounts[author] = (contributorCounts[author] || 0) + 1;
}
}
for (const pr of pullRequests) {
const author = pr.author?.login;
if (author) {
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 based on actual issue/PR creation dates
const weeklyActivity = { Mon: 0, Tue: 0, Wed: 0, Thu: 0, Fri: 0, Sat: 0, Sun: 0 };
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
for (const issue of issues) {
const day = dayNames[new Date(issue.createdAt).getDay()];
weeklyActivity[day]++;
}
for (const pr of pullRequests) {
const day = dayNames[new Date(pr.createdAt).getDay()];
weeklyActivity[day]++;
}
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();