Release Notes MCP Server
- release-notes-server
- src
- release-notes-server
import axios from 'axios';
import { isAfter, isBefore, parseISO } from 'date-fns';
import { ParsedCommit, CommitType } from './types.js';
import { promises as fs } from 'fs';
import { join } from 'path';
const GITHUB_TOKEN = process.env.GITHUB_TOKEN;
if (!GITHUB_TOKEN) {
throw new Error('GITHUB_TOKEN environment variable is required');
}
const githubApi = axios.create({
baseURL: 'https://api.github.com',
headers: {
Authorization: `token ${GITHUB_TOKEN}`,
Accept: 'application/vnd.github.v3+json',
},
});
interface GitHubCommit {
sha: string;
commit: {
message: string;
author: {
name: string;
date: string;
};
};
html_url: string;
}
interface PullRequest {
number: number;
title: string;
body: string | null;
labels: Array<{ name: string }>;
}
function parseConventionalCommit(message: string): {
type: CommitType;
scope?: string;
breaking: boolean;
message: string;
description?: string;
} {
// Match conventional commit format
const regex = /^(feat|fix|docs|perf|refactor|test|build|chore)(?:\(([^)]+)\))?(!)?:\s*(.+)(?:\n\n([\s\S]*))?$/i;
const match = message.match(regex);
if (match) {
const [, type, scope, breaking, msg, desc] = match;
return {
type: mapCommitType(type),
scope: scope,
breaking: !!breaking || message.includes('BREAKING CHANGE:'),
message: msg.trim(),
description: desc?.trim(),
};
}
// Check for breaking change in body
const hasBreakingChange = message.includes('BREAKING CHANGE:');
// Try to infer type from message content
const lowerMessage = message.toLowerCase();
let inferredType: CommitType = 'other';
if (hasBreakingChange) inferredType = 'breaking';
else if (lowerMessage.includes('fix') || lowerMessage.includes('bug')) inferredType = 'fix';
else if (lowerMessage.includes('feat')) inferredType = 'feature';
else if (lowerMessage.includes('doc')) inferredType = 'docs';
else if (lowerMessage.includes('perf')) inferredType = 'perf';
else if (lowerMessage.includes('test')) inferredType = 'test';
else if (lowerMessage.includes('build') || lowerMessage.includes('deps')) inferredType = 'build';
else if (lowerMessage.includes('refactor')) inferredType = 'refactor';
// Split message into title and description
const [title, ...descLines] = message.split('\n');
const description = descLines.join('\n').trim();
return {
type: inferredType,
breaking: hasBreakingChange,
message: title.trim(),
description: description || undefined,
};
}
function mapCommitType(type: string): CommitType {
const typeMap: Record<string, CommitType> = {
feat: 'feature',
fix: 'fix',
docs: 'docs',
perf: 'perf',
refactor: 'refactor',
test: 'test',
build: 'build',
chore: 'other',
};
return typeMap[type.toLowerCase()] || 'other';
}
export async function fetchCommits(
owner: string,
repo: string,
fromDate?: string,
toDate?: string,
fromCommit?: string,
toCommit?: string
): Promise<ParsedCommit[]> {
const commits: ParsedCommit[] = [];
let page = 1;
const perPage = 100;
let reachedFromCommit = !fromCommit;
while (true) {
console.log(`Fetching page ${page} of commits...`);
// If we have a fromCommit, first fetch it to get its date
if (fromCommit && !reachedFromCommit) {
try {
const fromCommitResponse = await githubApi.get<GitHubCommit>(`/repos/${owner}/${repo}/commits/${fromCommit}`);
fromDate = fromCommitResponse.data.commit.author.date;
reachedFromCommit = true;
console.log(`Using date from fromCommit: ${fromDate}`);
} catch (error) {
console.error(`Failed to get date for fromCommit ${fromCommit}, falling back to SHA-based filtering. This is less efficient but will still work.`);
}
}
const response = await githubApi.get<GitHubCommit[]>(`/repos/${owner}/${repo}/commits`, {
params: {
page,
per_page: perPage,
...(toCommit && { sha: toCommit }),
...(fromDate && { since: fromDate }), // Use GitHub's since parameter if we have a date
},
});
if (response.data.length === 0) break;
console.log(`Got ${response.data.length} commits on page ${page}`);
for (const commit of response.data) {
console.log(`Processing commit ${commit.sha.substring(0, 7)}: ${commit.commit.message.split('\n')[0]}`);
// If we couldn't get the date for fromCommit, fall back to SHA-based filtering
if (fromCommit && !fromDate && !reachedFromCommit) {
if (commit.sha === fromCommit) {
console.log(`Using SHA-based filtering: Found fromCommit ${fromCommit.substring(0, 7)}, including it and all commits after`);
reachedFromCommit = true;
} else {
continue;
}
}
const date = commit.commit.author.date;
// Only need to check toDate since fromDate is handled by API
if (toDate && isAfter(parseISO(date), parseISO(toDate))) continue;
// Parse commit message
const parsed = parseConventionalCommit(commit.commit.message);
// Try to extract PR number
const prMatch = commit.commit.message.match(/\(#(\d+)\)|\n\n#(\d+)/);
const prNumber = prMatch ? parseInt(prMatch[1] || prMatch[2]) : undefined;
commits.push({
sha: commit.sha,
type: parsed.type,
scope: parsed.scope,
message: parsed.message,
description: parsed.description,
breaking: parsed.breaking,
author: commit.commit.author.name,
date,
prNumber,
commitUrl: commit.html_url,
});
}
page++;
}
console.log(`Total commits collected: ${commits.length}`);
console.log('First (newest) commit:', commits[0]?.message);
console.log('Last (oldest) commit:', commits[commits.length - 1]?.message);
// Keep commits in reverse chronological order (newest first) for release notes
return commits;
}
export async function fetchPullRequest(
owner: string,
repo: string,
prNumber: number
): Promise<PullRequest | null> {
try {
const response = await githubApi.get<PullRequest>(
`/repos/${owner}/${repo}/pulls/${prNumber}`
);
return response.data;
} catch (error) {
if (axios.isAxiosError(error) && error.response?.status === 404) {
return null;
}
throw error;
}
}
export async function getVersionFromPackageJson(owner: string, repo: string): Promise<string | undefined> {
try {
const packageJsonPath = join(process.cwd(), owner, repo, 'package.json');
const content = await fs.readFile(packageJsonPath, 'utf-8');
const packageJson = JSON.parse(content);
return packageJson.version;
} catch {
return undefined;
}
}
export async function enrichCommitsWithPRData(
owner: string,
repo: string,
commits: ParsedCommit[]
): Promise<ParsedCommit[]> {
const enrichedCommits = [...commits];
for (const commit of enrichedCommits) {
if (!commit.prNumber) continue;
const pr = await fetchPullRequest(owner, repo, commit.prNumber);
if (!pr) continue;
// Update commit type based on PR labels if not already determined
if (commit.type === 'other') {
const labels = pr.labels.map(l => l.name.toLowerCase());
if (labels.some(l => l.includes('breaking'))) commit.breaking = true;
if (labels.some(l => l.includes('feature'))) commit.type = 'feature';
else if (labels.some(l => l.includes('bug') || l.includes('fix'))) commit.type = 'fix';
else if (labels.some(l => l.includes('doc'))) commit.type = 'docs';
else if (labels.some(l => l.includes('perf'))) commit.type = 'perf';
else if (labels.some(l => l.includes('refactor'))) commit.type = 'refactor';
else if (labels.some(l => l.includes('test'))) commit.type = 'test';
else if (labels.some(l => l.includes('build'))) commit.type = 'build';
}
// Use PR title if commit message is not descriptive enough
if (commit.message.length < pr.title.length) {
commit.message = pr.title;
}
// Add PR description if commit has no description
if (!commit.description && pr.body) {
commit.description = pr.body;
}
}
return enrichedCommits;
}