Skip to content

Discussion Assistant #36

Discussion Assistant

Discussion Assistant #36

name: Discussion Assistant
on:
discussion:
types: [created]
permissions:
discussions: write
contents: read
jobs:
find-similar-discussions:
runs-on: ubuntu-latest
steps:
- name: Find and comment on similar discussions
uses: actions/github-script@v8
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
// Configuration
const MAX_RESULTS = 5;
const MIN_SIMILARITY_TO_COMMENT = 0.65;
const MIN_KEYWORD_SIMILARITY = 0.20;
const IGNORED_CATEGORIES = ['Announcement', 'Announcements', 'Polls', 'Poll']; // Categories to ignore
// Get the new discussion details
const discussionNumber = context.payload.discussion.number;
const discussionTitle = context.payload.discussion.title;
const discussionBody = context.payload.discussion.body || '';
const discussionUrl = context.payload.discussion.html_url;
const discussionCategory = context.payload.discussion.category?.name || '';
console.log(`Processing new discussion #${discussionNumber}: "${discussionTitle}"`);
console.log(`Category: ${discussionCategory}`);
// Check if discussion is in an ignored category
if (IGNORED_CATEGORIES.some(cat => discussionCategory.toLowerCase().includes(cat.toLowerCase()))) {
console.log(`Skipping discussion in category "${discussionCategory}" - category is in ignore list`);
return;
}
// Function to fetch all discussions using GraphQL
async function fetchAllDiscussions() {
const query = `
query($owner: String!, $repo: String!, $cursor: String) {
repository(owner: $owner, name: $repo) {
discussions(first: 100, after: $cursor, orderBy: {field: CREATED_AT, direction: DESC}) {
pageInfo {
hasNextPage
endCursor
}
nodes {
number
title
body
url
createdAt
category {
name
}
answer {
id
}
}
}
}
}
`;
let allDiscussions = [];
let hasNextPage = true;
let cursor = null;
while (hasNextPage) {
const result = await github.graphql(query, {
owner: context.repo.owner,
repo: context.repo.repo,
cursor: cursor
});
const discussions = result.repository.discussions.nodes;
// Filter out the current discussion and ignored categories
const filtered = discussions.filter(d => {
if (d.number === discussionNumber) return false;
const category = d.category?.name || '';
if (IGNORED_CATEGORIES.some(cat => category.toLowerCase().includes(cat.toLowerCase()))) {
return false;
}
return true;
});
allDiscussions = allDiscussions.concat(filtered);
hasNextPage = result.repository.discussions.pageInfo.hasNextPage;
cursor = result.repository.discussions.pageInfo.endCursor;
// Limit to prevent excessive API calls (adjust as needed)
if (allDiscussions.length >= 1000) break;
}
console.log(`Fetched ${allDiscussions.length} existing discussions (excluding ignored categories)`);
return allDiscussions;
}
// Function to calculate similarity using OpenAI embeddings
async function calculateSimilarityOpenAI(newText, existingDiscussions) {
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) {
console.log('OPENAI_API_KEY not found, skipping AI analysis');
return [];
}
try {
// Get embedding for new discussion
const newEmbedding = await getEmbedding(newText, apiKey);
// Prepare texts for batch embedding with indices
const existingTexts = existingDiscussions.map(d => `${d.title}\n${d.body || ''}`);
// Process in batches of 50
const BATCH_SIZE = 50;
const MAX_RETRIES = 3;
const discussionEmbeddings = []; // Array of {discussion, embedding} pairs
for (let i = 0; i < existingTexts.length; i += BATCH_SIZE) {
const batchStart = i;
const batchEnd = Math.min(i + BATCH_SIZE, existingTexts.length);
const batch = existingTexts.slice(batchStart, batchEnd);
let success = false;
for (let attempt = 1; attempt <= MAX_RETRIES && !success; attempt++) {
try {
const batchEmbeddings = await getEmbedding(batch, apiKey);
if (!Array.isArray(batchEmbeddings) || batchEmbeddings.length !== batch.length) {
throw new Error(`Expected ${batch.length} embeddings, got ${batchEmbeddings?.length || 0}`);
}
// Map embeddings back to their discussions
for (let j = 0; j < batchEmbeddings.length; j++) {
discussionEmbeddings.push({
discussion: existingDiscussions[batchStart + j],
embedding: batchEmbeddings[j]
});
}
success = true;
} catch (batchError) {
console.error(`Error processing batch ${Math.floor(i / BATCH_SIZE) + 1} (discussions ${batchStart}-${batchEnd - 1}), attempt ${attempt}/${MAX_RETRIES}:`, batchError);
if (attempt < MAX_RETRIES) {
const backoff = Math.pow(2, attempt) * 1000;
console.log(`Retrying in ${backoff / 1000}s...`);
await new Promise(resolve => setTimeout(resolve, backoff));
}
}
}
// Delay between batches to avoid rate limits
if (i + BATCH_SIZE < existingTexts.length) {
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
if (discussionEmbeddings.length === 0) {
console.log('Failed to retrieve embeddings for all discussion batches. Check API key, quota, and network connectivity.');
throw new Error('Failed to retrieve embeddings for all discussion batches');
}
if (discussionEmbeddings.length < existingDiscussions.length) {
console.log(`Warning: Only got ${discussionEmbeddings.length} embeddings for ${existingDiscussions.length} discussions. Some batches may have failed.`);
}
// Calculate similarity scores
const similarities = [];
for (const item of discussionEmbeddings) {
const similarity = cosineSimilarity(newEmbedding, item.embedding);
if (similarity >= MIN_SIMILARITY_TO_COMMENT) {
similarities.push({
discussion: item.discussion,
similarity
});
}
}
// Sort by similarity and return top results
similarities.sort((a, b) => b.similarity - a.similarity);
return similarities.slice(0, MAX_RESULTS);
} catch (error) {
console.error('Error calculating similarity:', error);
return [];
}
}
// Function to get embedding from OpenAI
async function getEmbedding(text, apiKey) {
const response = await fetch('https://api.openai.com/v1/embeddings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
body: JSON.stringify({
model: 'text-embedding-3-small',
input: Array.isArray(text)
? text.map(t => t.substring(0, 8000))
: text.substring(0, 8000)
}),
signal: AbortSignal.timeout(60000) // 60s timeout
});
if (!response.ok) {
const errorBody = await response.text();
throw new Error(`OpenAI API error: ${response.status} ${response.statusText} - ${errorBody}`);
}
const data = await response.json();
// Return single embedding or array of embeddings
return Array.isArray(text)
? data.data.sort((a, b) => a.index - b.index).map(item => item.embedding)
: data.data[0].embedding;
}
// Function to calculate cosine similarity
function cosineSimilarity(vecA, vecB) {
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < vecA.length; i++) {
dotProduct += vecA[i] * vecB[i];
normA += vecA[i] * vecA[i];
normB += vecB[i] * vecB[i];
}
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
return denominator === 0 ? 0 : dotProduct / denominator;
}
// Function to use simple keyword matching as fallback
function calculateSimilarityKeywords(newText, existingDiscussions) {
const newKeywords = extractKeywords(newText);
const similarities = [];
for (const discussion of existingDiscussions) {
const existingText = `${discussion.title}\n${discussion.body || ''}`;
const existingKeywords = extractKeywords(existingText);
const similarity = keywordSimilarity(newKeywords, existingKeywords);
if (similarity >= MIN_KEYWORD_SIMILARITY) {
similarities.push({
discussion,
similarity
});
}
}
similarities.sort((a, b) => b.similarity - a.similarity);
return similarities.slice(0, MAX_RESULTS);
}
// Extract keywords (simple implementation)
function extractKeywords(text) {
const stopWords = new Set(['the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'from', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'can', 'this', 'that', 'these', 'those', 'i', 'you', 'he', 'she', 'it', 'we', 'they', 'what', 'which', 'who', 'when', 'where', 'why', 'how']);
const words = text.toLowerCase()
.replace(/[^\w\s]/g, ' ')
.split(/\s+/)
.filter(word => word.length > 3 && !stopWords.has(word));
return words;
}
// Calculate keyword similarity using Jaccard index
function keywordSimilarity(keywords1, keywords2) {
const set1 = new Set(keywords1);
const set2 = new Set(keywords2);
const intersection = new Set([...set1].filter(x => set2.has(x)));
const union = new Set([...set1, ...set2]);
return intersection.size / union.size;
}
// Main execution
try {
// Fetch all existing discussions
const existingDiscussions = await fetchAllDiscussions();
if (existingDiscussions.length === 0) {
console.log('No existing discussions found');
return;
}
// Prepare text for comparison
const newText = `${discussionTitle}\n${discussionBody}`;
// Try AI-based similarity first, fallback to keyword matching
let similarDiscussions;
if (process.env.OPENAI_API_KEY) {
console.log('Using OpenAI for similarity analysis');
similarDiscussions = await calculateSimilarityOpenAI(newText, existingDiscussions);
}
if (!similarDiscussions || similarDiscussions.length === 0) {
console.log('Using keyword matching for similarity analysis');
similarDiscussions = calculateSimilarityKeywords(newText, existingDiscussions);
}
// Post comment if similar discussions found
if (similarDiscussions.length > 0) {
console.log(`Found ${similarDiscussions.length} similar discussions`);
let commentBody = '🤖 **Discussion Assistant**: I found some similar discussions that might be helpful:\n\n';
for (const item of similarDiscussions) {
const { discussion, similarity } = item;
const percentage = Math.round(similarity * 100);
const answeredTag = discussion.answer ? ' ✅ (Answered)' : '';
const safeTitle = discussion.title.replace(/\[/g, '\\[').replace(/\]/g, '\\]');
commentBody += `- [${safeTitle}](${discussion.url}) (${percentage}% similar)${answeredTag}\n`;
}
// Post the comment using GraphQL
const discussionId = context.payload.discussion.node_id;
const mutation = `
mutation($discussionId: ID!, $body: String!) {
addDiscussionComment(input: {discussionId: $discussionId, body: $body}) {
comment {
id
}
}
}
`;
await github.graphql(mutation, {
discussionId: discussionId,
body: commentBody
});
console.log('Comment posted successfully');
} else {
console.log('No similar discussions found above threshold');
}
} catch (error) {
console.error('Error in discussion assistant:', error);
core.setFailed(error.message);
}