Skip to content

Commit 43e4266

Browse files
Create discussion-assistant.yml (#2130)
* Create discussion-assistant.yml * Update discussion-assistant.yml * Update .github/workflows/discussion-assistant.yml Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 070be82 commit 43e4266

1 file changed

Lines changed: 340 additions & 0 deletions

File tree

Lines changed: 340 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,340 @@
1+
name: Discussion Assistant
2+
3+
on:
4+
discussion:
5+
types: [created]
6+
7+
permissions:
8+
discussions: write
9+
contents: read
10+
11+
jobs:
12+
find-similar-discussions:
13+
runs-on: ubuntu-latest
14+
15+
steps:
16+
- name: Find and comment on similar discussions
17+
uses: actions/github-script@v8
18+
env:
19+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
20+
with:
21+
github-token: ${{ secrets.GITHUB_TOKEN }}
22+
script: |
23+
// Configuration
24+
const MAX_RESULTS = 5;
25+
const MIN_SIMILARITY_TO_COMMENT = 0.65;
26+
const MIN_KEYWORD_SIMILARITY = 0.20;
27+
const IGNORED_CATEGORIES = ['Announcement', 'Announcements', 'Polls', 'Poll']; // Categories to ignore
28+
29+
// Get the new discussion details
30+
const discussionNumber = context.payload.discussion.number;
31+
const discussionTitle = context.payload.discussion.title;
32+
const discussionBody = context.payload.discussion.body || '';
33+
const discussionUrl = context.payload.discussion.html_url;
34+
const discussionCategory = context.payload.discussion.category?.name || '';
35+
36+
console.log(`Processing new discussion #${discussionNumber}: "${discussionTitle}"`);
37+
console.log(`Category: ${discussionCategory}`);
38+
39+
// Check if discussion is in an ignored category
40+
if (IGNORED_CATEGORIES.some(cat => discussionCategory.toLowerCase().includes(cat.toLowerCase()))) {
41+
console.log(`Skipping discussion in category "${discussionCategory}" - category is in ignore list`);
42+
return;
43+
}
44+
45+
// Function to fetch all discussions using GraphQL
46+
async function fetchAllDiscussions() {
47+
const query = `
48+
query($owner: String!, $repo: String!, $cursor: String) {
49+
repository(owner: $owner, name: $repo) {
50+
discussions(first: 100, after: $cursor, orderBy: {field: CREATED_AT, direction: DESC}) {
51+
pageInfo {
52+
hasNextPage
53+
endCursor
54+
}
55+
nodes {
56+
number
57+
title
58+
body
59+
url
60+
createdAt
61+
category {
62+
name
63+
}
64+
answer {
65+
id
66+
}
67+
}
68+
}
69+
}
70+
}
71+
`;
72+
73+
let allDiscussions = [];
74+
let hasNextPage = true;
75+
let cursor = null;
76+
77+
while (hasNextPage) {
78+
const result = await github.graphql(query, {
79+
owner: context.repo.owner,
80+
repo: context.repo.repo,
81+
cursor: cursor
82+
});
83+
84+
const discussions = result.repository.discussions.nodes;
85+
// Filter out the current discussion and ignored categories
86+
const filtered = discussions.filter(d => {
87+
if (d.number === discussionNumber) return false;
88+
const category = d.category?.name || '';
89+
if (IGNORED_CATEGORIES.some(cat => category.toLowerCase().includes(cat.toLowerCase()))) {
90+
return false;
91+
}
92+
return true;
93+
});
94+
allDiscussions = allDiscussions.concat(filtered);
95+
96+
hasNextPage = result.repository.discussions.pageInfo.hasNextPage;
97+
cursor = result.repository.discussions.pageInfo.endCursor;
98+
99+
// Limit to prevent excessive API calls (adjust as needed)
100+
if (allDiscussions.length >= 500) break;
101+
}
102+
103+
console.log(`Fetched ${allDiscussions.length} existing discussions (excluding ignored categories)`);
104+
return allDiscussions;
105+
}
106+
107+
// Function to calculate similarity using OpenAI embeddings
108+
async function calculateSimilarityOpenAI(newText, existingDiscussions) {
109+
const apiKey = process.env.OPENAI_API_KEY;
110+
if (!apiKey) {
111+
console.log('OPENAI_API_KEY not found, skipping AI analysis');
112+
return [];
113+
}
114+
115+
try {
116+
// Get embedding for new discussion
117+
const newEmbedding = await getEmbedding(newText, apiKey);
118+
119+
// Prepare texts for batch embedding with indices
120+
const existingTexts = existingDiscussions.map(d => `${d.title}\n${d.body || ''}`);
121+
122+
// Process in batches of 100
123+
const BATCH_SIZE = 100;
124+
const discussionEmbeddings = []; // Array of {discussion, embedding} pairs
125+
126+
for (let i = 0; i < existingTexts.length; i += BATCH_SIZE) {
127+
const batchStart = i;
128+
const batchEnd = Math.min(i + BATCH_SIZE, existingTexts.length);
129+
const batch = existingTexts.slice(batchStart, batchEnd);
130+
131+
try {
132+
const batchEmbeddings = await getEmbedding(batch, apiKey);
133+
if (!Array.isArray(batchEmbeddings) || batchEmbeddings.length !== batch.length) {
134+
throw new Error(`Expected ${batch.length} embeddings, got ${batchEmbeddings?.length || 0}`);
135+
}
136+
// Map embeddings back to their discussions
137+
for (let j = 0; j < batchEmbeddings.length; j++) {
138+
discussionEmbeddings.push({
139+
discussion: existingDiscussions[batchStart + j],
140+
embedding: batchEmbeddings[j]
141+
});
142+
}
143+
// Small delay between batches to avoid rate limits
144+
if (i + BATCH_SIZE < existingTexts.length) {
145+
await new Promise(resolve => setTimeout(resolve, 500));
146+
}
147+
} catch (batchError) {
148+
console.error(`Error processing batch ${Math.floor(i / BATCH_SIZE) + 1} (discussions ${batchStart}-${batchEnd - 1}):`, batchError);
149+
// Skip this batch and continue with next one
150+
}
151+
}
152+
153+
if (discussionEmbeddings.length === 0) {
154+
console.log('Failed to retrieve embeddings for all discussion batches. Check API key, quota, and network connectivity.');
155+
throw new Error('Failed to retrieve embeddings for all discussion batches');
156+
}
157+
158+
if (discussionEmbeddings.length < existingDiscussions.length) {
159+
console.log(`Warning: Only got ${discussionEmbeddings.length} embeddings for ${existingDiscussions.length} discussions. Some batches may have failed.`);
160+
}
161+
162+
// Calculate similarity scores
163+
const similarities = [];
164+
165+
for (const item of discussionEmbeddings) {
166+
const similarity = cosineSimilarity(newEmbedding, item.embedding);
167+
168+
if (similarity >= MIN_SIMILARITY_TO_COMMENT) {
169+
similarities.push({
170+
discussion: item.discussion,
171+
similarity
172+
});
173+
}
174+
}
175+
176+
// Sort by similarity and return top results
177+
similarities.sort((a, b) => b.similarity - a.similarity);
178+
return similarities.slice(0, MAX_RESULTS);
179+
180+
} catch (error) {
181+
console.error('Error calculating similarity:', error);
182+
return [];
183+
}
184+
}
185+
186+
// Function to get embedding from OpenAI
187+
async function getEmbedding(text, apiKey) {
188+
const response = await fetch('https://api.openai.com/v1/embeddings', {
189+
method: 'POST',
190+
headers: {
191+
'Content-Type': 'application/json',
192+
'Authorization': `Bearer ${apiKey}`
193+
},
194+
body: JSON.stringify({
195+
model: 'text-embedding-3-small',
196+
input: Array.isArray(text)
197+
? text.map(t => t.substring(0, 8000))
198+
: text.substring(0, 8000)
199+
}),
200+
signal: AbortSignal.timeout(30000) // 30s timeout
201+
});
202+
203+
if (!response.ok) {
204+
const errorBody = await response.text();
205+
throw new Error(`OpenAI API error: ${response.status} ${response.statusText} - ${errorBody}`);
206+
}
207+
208+
const data = await response.json();
209+
// Return single embedding or array of embeddings
210+
return Array.isArray(text)
211+
? data.data.sort((a, b) => a.index - b.index).map(item => item.embedding)
212+
: data.data[0].embedding;
213+
}
214+
215+
// Function to calculate cosine similarity
216+
function cosineSimilarity(vecA, vecB) {
217+
let dotProduct = 0;
218+
let normA = 0;
219+
let normB = 0;
220+
221+
for (let i = 0; i < vecA.length; i++) {
222+
dotProduct += vecA[i] * vecB[i];
223+
normA += vecA[i] * vecA[i];
224+
normB += vecB[i] * vecB[i];
225+
}
226+
227+
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
228+
return denominator === 0 ? 0 : dotProduct / denominator;
229+
}
230+
231+
// Function to use simple keyword matching as fallback
232+
function calculateSimilarityKeywords(newText, existingDiscussions) {
233+
const newKeywords = extractKeywords(newText);
234+
const similarities = [];
235+
236+
for (const discussion of existingDiscussions) {
237+
const existingText = `${discussion.title}\n${discussion.body || ''}`;
238+
const existingKeywords = extractKeywords(existingText);
239+
240+
const similarity = keywordSimilarity(newKeywords, existingKeywords);
241+
242+
if (similarity >= MIN_KEYWORD_SIMILARITY) {
243+
similarities.push({
244+
discussion,
245+
similarity
246+
});
247+
}
248+
}
249+
250+
similarities.sort((a, b) => b.similarity - a.similarity);
251+
return similarities.slice(0, MAX_RESULTS);
252+
}
253+
254+
// Extract keywords (simple implementation)
255+
function extractKeywords(text) {
256+
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']);
257+
258+
const words = text.toLowerCase()
259+
.replace(/[^\w\s]/g, ' ')
260+
.split(/\s+/)
261+
.filter(word => word.length > 3 && !stopWords.has(word));
262+
263+
return words;
264+
}
265+
266+
// Calculate keyword similarity using Jaccard index
267+
function keywordSimilarity(keywords1, keywords2) {
268+
const set1 = new Set(keywords1);
269+
const set2 = new Set(keywords2);
270+
271+
const intersection = new Set([...set1].filter(x => set2.has(x)));
272+
const union = new Set([...set1, ...set2]);
273+
274+
return intersection.size / union.size;
275+
}
276+
277+
// Main execution
278+
try {
279+
// Fetch all existing discussions
280+
const existingDiscussions = await fetchAllDiscussions();
281+
282+
if (existingDiscussions.length === 0) {
283+
console.log('No existing discussions found');
284+
return;
285+
}
286+
287+
// Prepare text for comparison
288+
const newText = `${discussionTitle}\n${discussionBody}`;
289+
290+
// Try AI-based similarity first, fallback to keyword matching
291+
let similarDiscussions;
292+
if (process.env.OPENAI_API_KEY) {
293+
console.log('Using OpenAI for similarity analysis');
294+
similarDiscussions = await calculateSimilarityOpenAI(newText, existingDiscussions);
295+
}
296+
if (!similarDiscussions || similarDiscussions.length === 0) {
297+
console.log('Using keyword matching for similarity analysis');
298+
similarDiscussions = calculateSimilarityKeywords(newText, existingDiscussions);
299+
}
300+
301+
// Post comment if similar discussions found
302+
if (similarDiscussions.length > 0) {
303+
console.log(`Found ${similarDiscussions.length} similar discussions`);
304+
305+
for (const item of similarDiscussions) {
306+
const { discussion, similarity } = item;
307+
const percentage = Math.round(similarity * 100);
308+
const answeredTag = discussion.answer ? ' ✅ (Answered)' : '';
309+
310+
const safeTitle = discussion.title.replace(/\[/g, '\\[').replace(/\]/g, '\\]');
311+
commentBody += `- [${safeTitle}](${discussion.url}) (${percentage}% similar)${answeredTag}\n`;
312+
}
313+
314+
// Post the comment using GraphQL
315+
const discussionId = context.payload.discussion.node_id;
316+
317+
const mutation = `
318+
mutation($discussionId: ID!, $body: String!) {
319+
addDiscussionComment(input: {discussionId: $discussionId, body: $body}) {
320+
comment {
321+
id
322+
}
323+
}
324+
}
325+
`;
326+
327+
await github.graphql(mutation, {
328+
discussionId: discussionId,
329+
body: commentBody
330+
});
331+
332+
console.log('Comment posted successfully');
333+
} else {
334+
console.log('No similar discussions found above threshold');
335+
}
336+
337+
} catch (error) {
338+
console.error('Error in discussion assistant:', error);
339+
core.setFailed(error.message);
340+
}

0 commit comments

Comments
 (0)