#!/usr/bin/env node
/**
* GitHub Release Poller
*
* Polls GitHub repositories for new releases and sends notifications to Discord.
* Used for repos where we don't have admin access (can't use webhooks).
*/
import { Octokit } from "@octokit/rest";
import fs from "fs";
import path from "path";
// Configuration
const DISCORD_WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL;
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
const STATE_FILE = path.join(process.env.HOME || "/tmp", ".openclaw/workspace/github-mcp/poller-state.json");
// Repositories to monitor
const MONITORED_REPOS = [
{ owner: "openclaw", repo: "openclaw" },
{ owner: "homeassistant-ai", repo: "ha-mcp" },
{ owner: "modelcontextprotocol", repo: "servers" },
];
interface PollerState {
lastChecked: string;
seenReleases: { [key: string]: string[] }; // repo -> array of release IDs
}
interface Release {
id: number;
tag_name: string;
name: string;
html_url: string;
body: string;
author: {
login: string;
};
published_at: string;
}
/**
* Load state from disk
*/
function loadState(): PollerState {
try {
if (fs.existsSync(STATE_FILE)) {
const data = fs.readFileSync(STATE_FILE, "utf8");
return JSON.parse(data);
}
} catch (error) {
console.error("Error loading state:", error);
}
return {
lastChecked: new Date().toISOString(),
seenReleases: {},
};
}
/**
* Save state to disk
*/
function saveState(state: PollerState): void {
try {
const dir = path.dirname(STATE_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
} catch (error) {
console.error("Error saving state:", error);
}
}
/**
* Send notification to Discord
*/
async function sendToDiscord(message: string): Promise<boolean> {
if (!DISCORD_WEBHOOK_URL) {
console.error("DISCORD_WEBHOOK_URL not configured");
return false;
}
try {
const response = await fetch(DISCORD_WEBHOOK_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
content: message,
username: "GitHub",
avatar_url: "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png",
}),
});
if (!response.ok) {
const errorText = await response.text();
console.error(
`Discord webhook failed: ${response.status} ${response.statusText}`
);
console.error(`Response: ${errorText}`);
return false;
}
console.log("✓ Message sent to Discord");
return true;
} catch (error) {
console.error("Error sending to Discord:", error);
return false;
}
}
/**
* Format release as Discord message
*/
function formatRelease(owner: string, repo: string, release: Release): string {
const repoFullName = `${owner}/${repo}`;
let message = `🚀 **New release in ${repoFullName}**\n`;
message += `**${release.name || release.tag_name}**\n`;
message += `By @${release.author.login}\n`;
message += `${release.html_url}`;
// Add release notes preview if available
if (release.body && release.body.trim()) {
const preview = release.body.split("\n").slice(0, 3).join("\n");
const truncated = preview.length > 200 ? preview.substring(0, 197) + "..." : preview;
if (truncated) {
message += `\n\n${truncated}`;
}
}
return message;
}
/**
* Check a repository for new releases
*/
async function checkRepo(
octokit: Octokit,
owner: string,
repo: string,
state: PollerState
): Promise<void> {
const repoKey = `${owner}/${repo}`;
console.log(`Checking ${repoKey}...`);
try {
const { data: releases } = await octokit.repos.listReleases({
owner,
repo,
per_page: 5, // Check last 5 releases
});
if (!state.seenReleases[repoKey]) {
state.seenReleases[repoKey] = [];
}
const seenIds = state.seenReleases[repoKey];
const newReleases: Release[] = [];
for (const release of releases) {
const releaseId = release.id.toString();
// Skip if we've seen this release before
if (seenIds.includes(releaseId)) {
continue;
}
// Skip pre-releases (beta, RC, etc.) - only notify on stable releases
if (release.prerelease) {
console.log(`Skipping pre-release: ${release.name || release.tag_name}`);
seenIds.push(releaseId); // Track it so we don't check again
continue;
}
newReleases.push(release as Release);
seenIds.push(releaseId);
}
// Keep only last 20 release IDs per repo to prevent unbounded growth
if (seenIds.length > 20) {
state.seenReleases[repoKey] = seenIds.slice(-20);
}
// Send notifications for new releases (newest first)
for (const release of newReleases.reverse()) {
console.log(`New release: ${release.name || release.tag_name}`);
const message = formatRelease(owner, repo, release);
await sendToDiscord(message);
// Small delay between messages to avoid rate limits
await new Promise(resolve => setTimeout(resolve, 500));
}
if (newReleases.length > 0) {
console.log(`✓ Found ${newReleases.length} new release(s) in ${repoKey}`);
} else {
console.log(`✓ No new releases in ${repoKey}`);
}
} catch (error: any) {
console.error(`Error checking ${repoKey}:`, error.message);
}
}
/**
* Main polling function
*/
async function poll(): Promise<void> {
console.log(`\n=== GitHub Release Poller - ${new Date().toISOString()} ===\n`);
if (!GITHUB_TOKEN) {
console.error("GITHUB_TOKEN not configured");
process.exit(1);
}
if (!DISCORD_WEBHOOK_URL) {
console.error("DISCORD_WEBHOOK_URL not configured");
process.exit(1);
}
const octokit = new Octokit({
auth: GITHUB_TOKEN,
});
const state = loadState();
for (const { owner, repo } of MONITORED_REPOS) {
await checkRepo(octokit, owner, repo, state);
}
state.lastChecked = new Date().toISOString();
saveState(state);
console.log("\n✓ Polling complete\n");
}
// Run if executed directly
if (require.main === module) {
poll().catch((error) => {
console.error("Fatal error:", error);
process.exit(1);
});
}