Skip to content

Commit 2d6e53a

Browse files
feat: update the release description code (CORE-2803) (#124)
* feat: 🎸 update the release description code ✅ Closes: CORE-2803 * feat: better chunking and pr description preservation * feat: increase the max chunks to 5 * fix: use proper waiting techniques * fix: add fixes to the pr description code * fix: update the action * Fix GitHub Actions output delimiter error in auto-release-description - Ensure content always ends with newline before delimiter - Simplify delimiter handling to prevent 'Matching delimiter not found' errors - Use printf to consistently add newline before closing delimiter
1 parent b0b59b7 commit 2d6e53a

File tree

4 files changed

+516
-144
lines changed

4 files changed

+516
-144
lines changed

.github/actions/auto-pr-description/generate_pr_description.js

Lines changed: 122 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ const path = require('path');
77
const MAX_TOKENS_PER_REQUEST = 80000; // Conservative limit for Gemini 2.5 Flash
88
const CHARS_PER_TOKEN = 4; // Rough estimation
99
//const MAX_CHARS_PER_CHUNK = MAX_TOKENS_PER_REQUEST * CHARS_PER_TOKEN;
10-
const MAX_CHUNKS = 3; // Limit to prevent excessive API calls
10+
const MAX_CHUNKS = 5; // Limit to prevent excessive API calls
1111

1212
/**
1313
* Estimate token count for text (rough approximation)
@@ -52,46 +52,69 @@ function chunkDiffByFiles(diffContent) {
5252
const lines = diffContent.split('\n');
5353
let currentChunk = '';
5454
let currentFile = '';
55-
let tokenCount = 0;
55+
let currentChunkTokenCount = 0;
56+
const PROMPT_OVERHEAD = 2000; // Reserve tokens for prompt overhead
57+
const MAX_CHUNK_TOKENS = MAX_TOKENS_PER_REQUEST - PROMPT_OVERHEAD;
5658

57-
for (const line of lines) {
58-
// Check if this is a new file header
59-
//console.error(`Line is estimated at ${estimateTokens(line)} tokens`);
60-
tokenCount += estimateTokens(line);
61-
//console.error(`Total tokens for this chunk is ${tokenCount}`);
62-
if (line.startsWith('diff --git') || line.startsWith('+++') || line.startsWith('---')) {
63-
// If we have content and it's getting large, save current chunk
64-
if (currentChunk && tokenCount > MAX_TOKENS_PER_REQUEST) {
65-
fileChunks.push({
66-
content: currentChunk.trim(),
67-
file: currentFile,
68-
type: 'file-chunk'
69-
});
70-
currentChunk = '';
71-
tokenCount = 0;
59+
for (let i = 0; i < lines.length; i++) {
60+
const line = lines[i];
61+
const lineTokens = estimateTokens(line);
62+
const isNewFile = line.startsWith('diff --git');
63+
const isFileHeader = line.startsWith('+++') || line.startsWith('---');
64+
65+
// Check if we need to split current chunk before adding this line
66+
if (currentChunk && (currentChunkTokenCount + lineTokens) > MAX_CHUNK_TOKENS) {
67+
// Current chunk is getting too large, save it
68+
fileChunks.push({
69+
content: currentChunk.trim(),
70+
file: currentFile,
71+
type: 'file-chunk'
72+
});
73+
console.error(`Chunk ${fileChunks.length} saved: ${currentChunkTokenCount} tokens for ${currentFile || 'unknown'}`);
74+
currentChunk = '';
75+
currentChunkTokenCount = 0;
76+
}
77+
78+
// Handle new file boundaries
79+
if (isNewFile) {
80+
// Extract filename from next lines
81+
// Look ahead for +++ line
82+
for (let j = i + 1; j < Math.min(i + 10, lines.length); j++) {
83+
if (lines[j].startsWith('+++')) {
84+
currentFile = lines[j].replace('+++ b/', '').replace('+++ a/', '');
85+
break;
86+
}
7287
}
88+
}
89+
90+
// Add line to current chunk
91+
currentChunk += line + '\n';
92+
currentChunkTokenCount += lineTokens;
93+
94+
// If a single line is too large, split it
95+
if (lineTokens > MAX_CHUNK_TOKENS && currentChunk.length > 100) {
96+
// Remove the line from current chunk
97+
currentChunk = currentChunk.substring(0, currentChunk.length - line.length - 1);
98+
currentChunkTokenCount -= lineTokens;
7399

74-
// Start new chunk
75-
currentChunk = line + '\n';
76-
77-
78-
// Extract filename for reference
79-
if (line.startsWith('+++')) {
80-
currentFile = line.replace('+++ b/', '').replace('+++ a/', '');
81-
}
82-
if(tokenCount > MAX_TOKENS_PER_REQUEST){
83-
const split_chunk = splitStringByTokens(currentChunk, MAX_TOKENS_PER_REQUEST);
84-
currentChunk = split_chunk[split_chunk.length-1];
85-
for(let i = 0; i < split_chunk.length -1;i++){
86-
fileChunks.push({
87-
content: split_chunk[i].trim(),
88-
file: currentFile,
89-
type: 'file-chunk'
90-
});
100+
// Split the large line
101+
const splitChunks = splitStringByTokens(line, MAX_CHUNK_TOKENS);
102+
for (let j = 0; j < splitChunks.length; j++) {
103+
if (j > 0) {
104+
// Save previous chunk if it has content
105+
if (currentChunk.trim()) {
106+
fileChunks.push({
107+
content: currentChunk.trim(),
108+
file: currentFile,
109+
type: 'file-chunk'
110+
});
111+
currentChunk = '';
112+
currentChunkTokenCount = 0;
113+
}
91114
}
115+
currentChunk = splitChunks[j] + '\n';
116+
currentChunkTokenCount = estimateTokens(currentChunk);
92117
}
93-
} else {
94-
currentChunk += line + '\n';
95118
}
96119
}
97120

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

107131
return fileChunks;
@@ -149,44 +173,67 @@ ${diffContent}`;
149173
}
150174

151175
/**
152-
* Call Gemini API with the given prompt
176+
* Call Gemini API with the given prompt (with retry logic for rate limits)
153177
*/
154-
async function callGeminiAPI(prompt, apiKey) {
178+
async function callGeminiAPI(prompt, apiKey, retryCount = 0) {
179+
const maxRetries = 3;
180+
const baseDelay = 1000; // 1 second base delay
181+
155182
console.error(`Sending prompt with an estimated ${estimateTokens(prompt)} tokens`);
156-
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`, {
157-
method: 'POST',
158-
headers: { 'Content-Type': 'application/json' },
159-
body: JSON.stringify({
160-
contents: [{
161-
parts: [{
162-
text: prompt
163-
}]
164-
}],
165-
generationConfig: {
166-
temperature: 0.7,
167-
topK: 40,
168-
topP: 0.95,
169-
maxOutputTokens: 8192,
170-
}
171-
})
172-
});
183+
184+
try {
185+
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${apiKey}`, {
186+
method: 'POST',
187+
headers: { 'Content-Type': 'application/json' },
188+
body: JSON.stringify({
189+
contents: [{
190+
parts: [{
191+
text: prompt
192+
}]
193+
}],
194+
generationConfig: {
195+
temperature: 0.7,
196+
topK: 40,
197+
topP: 0.95,
198+
maxOutputTokens: 8192,
199+
}
200+
})
201+
});
173202

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

179-
const json = await response.json();
180-
181-
if (!json.candidates || !json.candidates[0]) {
182-
throw new Error('Invalid response from Gemini API');
183-
}
211+
if (!response.ok) {
212+
const errorText = await response.text();
213+
throw new Error(`Gemini API request failed with status ${response.status}: ${errorText}`);
214+
}
184215

185-
if (!json.candidates[0].content || !json.candidates[0].content.parts || !json.candidates[0].content.parts[0] || !json.candidates[0].content.parts[0].text) {
186-
throw new Error('Invalid response structure from Gemini API - missing content');
187-
}
216+
const json = await response.json();
217+
218+
if (!json.candidates || !json.candidates[0]) {
219+
throw new Error('Invalid response from Gemini API');
220+
}
221+
222+
if (!json.candidates[0].content || !json.candidates[0].content.parts || !json.candidates[0].content.parts[0] || !json.candidates[0].content.parts[0].text) {
223+
throw new Error('Invalid response structure from Gemini API - missing content');
224+
}
188225

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

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

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

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

@@ -225,7 +273,10 @@ async function processChunks(chunks, apiKey) {
225273
if (chunkResults.length === 0) {
226274
throw new Error('Failed to process any chunks');
227275
}
228-
sleep(5*1000);
276+
277+
// Small delay before combining (reduced from 5s to 500ms)
278+
await sleep(CHUNK_DELAY);
279+
229280
// Combine results from multiple chunks
230281
const combinedPrompt = `Combine these pull request descriptions into a single, coherent PR description. Use the same format:
231282

.github/actions/auto-release-description/action.yml

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -67,34 +67,47 @@ runs:
6767
PR_NUMBER: ${{ inputs.pr-number }}
6868
JIRA_TICKET_URL_PREFIX: ${{ inputs.jira-ticket-url-prefix }}
6969
run: |
70-
# Generate description using AI
71-
DESCRIPTION=$(node ${{ github.action_path }}/generate_pr_description.js pr.diff)
72-
73-
# Get existing PR body to check for images
74-
FIRST_LINE=$(gh pr view ${{ inputs.pr-number }} --json body --jq '.body' | head -n 1)
75-
76-
# Preserve images if they exist at the beginning
77-
if echo "$FIRST_LINE" | grep -qE '^(<img[^>]*>[[:space:]]*|!\[[^]]*\]\([^)]*\))$'; then
78-
printf '%s\n\n%s\n' "$FIRST_LINE" "$DESCRIPTION" > pr_body.md
79-
else
80-
printf '%s\n' "$DESCRIPTION" > pr_body.md
70+
# Generate description using AI (with chunking support for large diffs)
71+
# The script writes description to stdout, errors to stderr
72+
if ! node ${{ github.action_path }}/generate_pr_description.js pr.diff > release_description.md 2>generate_errors.log; then
73+
echo "Error: Failed to generate release description" >&2
74+
cat generate_errors.log >&2
75+
exit 1
8176
fi
8277
83-
# Add JIRA ticket link if found
84-
PR_TITLE=$(gh pr view ${{ inputs.pr-number }} --json title --jq '.title')
85-
TICKET_ID=$(echo "$PR_TITLE" | grep -oE '[A-Z]+-[0-9]+' || true)
86-
if [ -z "$TICKET_ID" ]; then
87-
TICKET_ID=$(echo "$GITHUB_HEAD_REF" | grep -oE '[A-Z]+-[0-9]+' || true)
88-
fi
89-
if [ -n "$TICKET_ID" ]; then
90-
TICKET_URL="${{ inputs.jira-ticket-url-prefix }}${TICKET_ID}"
91-
printf '\n## Ticket\n%s\n' "$TICKET_URL" >> pr_body.md
92-
fi
78+
# Get existing PR body to preserve all content
79+
gh pr view ${{ inputs.pr-number }} --json body --jq '.body' > pr_body.md || {
80+
echo "Error: Failed to get PR body" >&2
81+
exit 1
82+
}
83+
84+
# Insert the generated description into the existing PR body
85+
# This preserves all existing content and uses comment tags for auto-generated section
86+
node ${{ github.action_path }}/insert_release_description.js pr_body.md release_description.md > new_body.md || {
87+
echo "Error: Failed to insert release description" >&2
88+
exit 1
89+
}
9390
9491
# Output the description for other steps to use
95-
echo "description<<EOF" >> $GITHUB_OUTPUT
96-
cat pr_body.md >> $GITHUB_OUTPUT
97-
echo "EOF" >> $GITHUB_OUTPUT
92+
# Use a unique delimiter to avoid conflicts with content that might contain "EOF"
93+
# Generate delimiter once and ensure it's used consistently
94+
DELIMITER="GITHUB_OUTPUT_DESCRIPTION_$(date +%s)_$$"
95+
{
96+
echo "description<<${DELIMITER}"
97+
# Ensure file exists and is readable
98+
if [ -f release_description.md ] && [ -s release_description.md ]; then
99+
# Output the file content
100+
cat release_description.md
101+
# Always ensure newline before delimiter (GitHub Actions requirement)
102+
# This handles cases where file doesn't end with newline
103+
printf '\n'
104+
else
105+
echo "Error: release_description.md is missing or empty" >&2
106+
echo "Error: release_description.md is missing or empty"
107+
fi
108+
# Closing delimiter must match exactly - use the same variable
109+
echo "${DELIMITER}"
110+
} >> $GITHUB_OUTPUT
98111
99112
- name: Update PR description
100113
id: update_pr
@@ -103,6 +116,7 @@ runs:
103116
GH_TOKEN: ${{ inputs.github-token }}
104117
PR_NUMBER: ${{ inputs.pr-number }}
105118
run: |
106-
gh pr edit ${{ inputs.pr-number }} --body-file pr_body.md
119+
# Update PR with merged body (preserves all existing content)
120+
gh pr edit ${{ inputs.pr-number }} --body-file new_body.md
107121
echo "updated=true" >> $GITHUB_OUTPUT
108122
echo "Successfully updated PR #${{ inputs.pr-number }} description"

0 commit comments

Comments
 (0)