Discussion Assistant #41
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | |
| } |