Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 122 additions & 71 deletions .github/actions/auto-pr-description/generate_pr_description.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const path = require('path');
const MAX_TOKENS_PER_REQUEST = 80000; // Conservative limit for Gemini 2.5 Flash
const CHARS_PER_TOKEN = 4; // Rough estimation
//const MAX_CHARS_PER_CHUNK = MAX_TOKENS_PER_REQUEST * CHARS_PER_TOKEN;
const MAX_CHUNKS = 3; // Limit to prevent excessive API calls
const MAX_CHUNKS = 5; // Limit to prevent excessive API calls

/**
* Estimate token count for text (rough approximation)
Expand Down Expand Up @@ -52,46 +52,69 @@ function chunkDiffByFiles(diffContent) {
const lines = diffContent.split('\n');
let currentChunk = '';
let currentFile = '';
let tokenCount = 0;
let currentChunkTokenCount = 0;
const PROMPT_OVERHEAD = 2000; // Reserve tokens for prompt overhead
const MAX_CHUNK_TOKENS = MAX_TOKENS_PER_REQUEST - PROMPT_OVERHEAD;

for (const line of lines) {
// Check if this is a new file header
//console.error(`Line is estimated at ${estimateTokens(line)} tokens`);
tokenCount += estimateTokens(line);
//console.error(`Total tokens for this chunk is ${tokenCount}`);
if (line.startsWith('diff --git') || line.startsWith('+++') || line.startsWith('---')) {
// If we have content and it's getting large, save current chunk
if (currentChunk && tokenCount > MAX_TOKENS_PER_REQUEST) {
fileChunks.push({
content: currentChunk.trim(),
file: currentFile,
type: 'file-chunk'
});
currentChunk = '';
tokenCount = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineTokens = estimateTokens(line);
const isNewFile = line.startsWith('diff --git');
const isFileHeader = line.startsWith('+++') || line.startsWith('---');

// Check if we need to split current chunk before adding this line
if (currentChunk && (currentChunkTokenCount + lineTokens) > MAX_CHUNK_TOKENS) {
// Current chunk is getting too large, save it
fileChunks.push({
content: currentChunk.trim(),
file: currentFile,
type: 'file-chunk'
});
console.error(`Chunk ${fileChunks.length} saved: ${currentChunkTokenCount} tokens for ${currentFile || 'unknown'}`);
currentChunk = '';
currentChunkTokenCount = 0;
}

// Handle new file boundaries
if (isNewFile) {
// Extract filename from next lines
// Look ahead for +++ line
for (let j = i + 1; j < Math.min(i + 10, lines.length); j++) {
if (lines[j].startsWith('+++')) {
currentFile = lines[j].replace('+++ b/', '').replace('+++ a/', '');
break;
}
}
}

// Add line to current chunk
currentChunk += line + '\n';
currentChunkTokenCount += lineTokens;

// If a single line is too large, split it
if (lineTokens > MAX_CHUNK_TOKENS && currentChunk.length > 100) {
// Remove the line from current chunk
currentChunk = currentChunk.substring(0, currentChunk.length - line.length - 1);
currentChunkTokenCount -= lineTokens;

// Start new chunk
currentChunk = line + '\n';


// Extract filename for reference
if (line.startsWith('+++')) {
currentFile = line.replace('+++ b/', '').replace('+++ a/', '');
}
if(tokenCount > MAX_TOKENS_PER_REQUEST){
const split_chunk = splitStringByTokens(currentChunk, MAX_TOKENS_PER_REQUEST);
currentChunk = split_chunk[split_chunk.length-1];
for(let i = 0; i < split_chunk.length -1;i++){
fileChunks.push({
content: split_chunk[i].trim(),
file: currentFile,
type: 'file-chunk'
});
// Split the large line
const splitChunks = splitStringByTokens(line, MAX_CHUNK_TOKENS);
for (let j = 0; j < splitChunks.length; j++) {
if (j > 0) {
// Save previous chunk if it has content
if (currentChunk.trim()) {
fileChunks.push({
content: currentChunk.trim(),
file: currentFile,
type: 'file-chunk'
});
currentChunk = '';
currentChunkTokenCount = 0;
}
}
currentChunk = splitChunks[j] + '\n';
currentChunkTokenCount = estimateTokens(currentChunk);
}
} else {
currentChunk += line + '\n';
}
}

Expand All @@ -102,6 +125,7 @@ function chunkDiffByFiles(diffContent) {
file: currentFile,
type: 'file-chunk'
});
console.error(`Final chunk ${fileChunks.length} saved: ${currentChunkTokenCount} tokens for ${currentFile || 'unknown'}`);
}

return fileChunks;
Expand Down Expand Up @@ -149,44 +173,67 @@ ${diffContent}`;
}

/**
* Call Gemini API with the given prompt
* Call Gemini API with the given prompt (with retry logic for rate limits)
*/
async function callGeminiAPI(prompt, apiKey) {
async function callGeminiAPI(prompt, apiKey, retryCount = 0) {
const maxRetries = 3;
const baseDelay = 1000; // 1 second base delay

console.error(`Sending prompt with an estimated ${estimateTokens(prompt)} tokens`);
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{
parts: [{
text: prompt
}]
}],
generationConfig: {
temperature: 0.7,
topK: 40,
topP: 0.95,
maxOutputTokens: 8192,
}
})
});

try {
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{
parts: [{
text: prompt
}]
}],
generationConfig: {
temperature: 0.7,
topK: 40,
topP: 0.95,
maxOutputTokens: 8192,
}
})
});

if (!response.ok) {
const errorText = await response.text();
throw new Error(`Gemini API request failed with status ${response.status}: ${errorText}`);
}
// Handle rate limiting (429) with exponential backoff
if (response.status === 429 && retryCount < maxRetries) {
const delay = baseDelay * Math.pow(2, retryCount); // Exponential backoff: 1s, 2s, 4s
console.error(`Rate limit hit, retrying in ${delay}ms (attempt ${retryCount + 1}/${maxRetries})`);
await sleep(delay);
return await callGeminiAPI(prompt, apiKey, retryCount + 1);
}

const json = await response.json();

if (!json.candidates || !json.candidates[0]) {
throw new Error('Invalid response from Gemini API');
}
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Gemini API request failed with status ${response.status}: ${errorText}`);
}

if (!json.candidates[0].content || !json.candidates[0].content.parts || !json.candidates[0].content.parts[0] || !json.candidates[0].content.parts[0].text) {
throw new Error('Invalid response structure from Gemini API - missing content');
}
const json = await response.json();

if (!json.candidates || !json.candidates[0]) {
throw new Error('Invalid response from Gemini API');
}

if (!json.candidates[0].content || !json.candidates[0].content.parts || !json.candidates[0].content.parts[0] || !json.candidates[0].content.parts[0].text) {
throw new Error('Invalid response structure from Gemini API - missing content');
}

return json.candidates[0].content.parts[0].text;
return json.candidates[0].content.parts[0].text;
} catch (error) {
// If it's a network error and we have retries left, retry with exponential backoff
if (retryCount < maxRetries && (error.message.includes('fetch') || error.message.includes('network'))) {
const delay = baseDelay * Math.pow(2, retryCount);
console.error(`Network error, retrying in ${delay}ms (attempt ${retryCount + 1}/${maxRetries}): ${error.message}`);
await sleep(delay);
return await callGeminiAPI(prompt, apiKey, retryCount + 1);
}
throw error;
}
}

/**
Expand All @@ -201,12 +248,13 @@ async function processChunks(chunks, apiKey) {

// Multiple chunks - process each and combine
const chunkResults = [];
const CHUNK_DELAY = 500; // 500ms delay between chunks (reduced from 5s)

for (let i = 0; i < Math.min(chunks.length, MAX_CHUNKS); i++) {
const chunk = chunks[i];
if (i > 0) {
// sleep for 3 seconds
sleep(5 * 1000);
// Small delay between chunks to avoid rate limits (reduced from 5s to 500ms)
await sleep(CHUNK_DELAY);
}
console.error(`Processing chunk ${i + 1}/${Math.min(chunks.length, MAX_CHUNKS)} (${chunk.file || 'unknown file'})`);

Expand All @@ -225,7 +273,10 @@ async function processChunks(chunks, apiKey) {
if (chunkResults.length === 0) {
throw new Error('Failed to process any chunks');
}
sleep(5*1000);

// Small delay before combining (reduced from 5s to 500ms)
await sleep(CHUNK_DELAY);

// Combine results from multiple chunks
const combinedPrompt = `Combine these pull request descriptions into a single, coherent PR description. Use the same format:

Expand Down
64 changes: 39 additions & 25 deletions .github/actions/auto-release-description/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,34 +67,47 @@ runs:
PR_NUMBER: ${{ inputs.pr-number }}
JIRA_TICKET_URL_PREFIX: ${{ inputs.jira-ticket-url-prefix }}
run: |
# Generate description using AI
DESCRIPTION=$(node ${{ github.action_path }}/generate_pr_description.js pr.diff)

# Get existing PR body to check for images
FIRST_LINE=$(gh pr view ${{ inputs.pr-number }} --json body --jq '.body' | head -n 1)

# Preserve images if they exist at the beginning
if echo "$FIRST_LINE" | grep -qE '^(<img[^>]*>[[:space:]]*|!\[[^]]*\]\([^)]*\))$'; then
printf '%s\n\n%s\n' "$FIRST_LINE" "$DESCRIPTION" > pr_body.md
else
printf '%s\n' "$DESCRIPTION" > pr_body.md
# Generate description using AI (with chunking support for large diffs)
# The script writes description to stdout, errors to stderr
if ! node ${{ github.action_path }}/generate_pr_description.js pr.diff > release_description.md 2>generate_errors.log; then
echo "Error: Failed to generate release description" >&2
cat generate_errors.log >&2
exit 1
fi

# Add JIRA ticket link if found
PR_TITLE=$(gh pr view ${{ inputs.pr-number }} --json title --jq '.title')
TICKET_ID=$(echo "$PR_TITLE" | grep -oE '[A-Z]+-[0-9]+' || true)
if [ -z "$TICKET_ID" ]; then
TICKET_ID=$(echo "$GITHUB_HEAD_REF" | grep -oE '[A-Z]+-[0-9]+' || true)
fi
if [ -n "$TICKET_ID" ]; then
TICKET_URL="${{ inputs.jira-ticket-url-prefix }}${TICKET_ID}"
printf '\n## Ticket\n%s\n' "$TICKET_URL" >> pr_body.md
fi
# Get existing PR body to preserve all content
gh pr view ${{ inputs.pr-number }} --json body --jq '.body' > pr_body.md || {
echo "Error: Failed to get PR body" >&2
exit 1
}

# Insert the generated description into the existing PR body
# This preserves all existing content and uses comment tags for auto-generated section
node ${{ github.action_path }}/insert_release_description.js pr_body.md release_description.md > new_body.md || {
echo "Error: Failed to insert release description" >&2
exit 1
}

# Output the description for other steps to use
echo "description<<EOF" >> $GITHUB_OUTPUT
cat pr_body.md >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
# Use a unique delimiter to avoid conflicts with content that might contain "EOF"
# Generate delimiter once and ensure it's used consistently
DELIMITER="GITHUB_OUTPUT_DESCRIPTION_$(date +%s)_$$"
{
echo "description<<${DELIMITER}"
# Ensure file exists and is readable
if [ -f release_description.md ] && [ -s release_description.md ]; then
# Output the file content
cat release_description.md
# Always ensure newline before delimiter (GitHub Actions requirement)
# This handles cases where file doesn't end with newline
printf '\n'
else
echo "Error: release_description.md is missing or empty" >&2
echo "Error: release_description.md is missing or empty"
fi
# Closing delimiter must match exactly - use the same variable
echo "${DELIMITER}"
} >> $GITHUB_OUTPUT

- name: Update PR description
id: update_pr
Expand All @@ -103,6 +116,7 @@ runs:
GH_TOKEN: ${{ inputs.github-token }}
PR_NUMBER: ${{ inputs.pr-number }}
run: |
gh pr edit ${{ inputs.pr-number }} --body-file pr_body.md
# Update PR with merged body (preserves all existing content)
gh pr edit ${{ inputs.pr-number }} --body-file new_body.md
echo "updated=true" >> $GITHUB_OUTPUT
echo "Successfully updated PR #${{ inputs.pr-number }} description"
Loading