Skip to content

Commit 33eb0a6

Browse files
authored
Adding scripts and workflow for release notes generation (#81)
1 parent af9df77 commit 33eb0a6

File tree

4 files changed

+320
-15
lines changed

4 files changed

+320
-15
lines changed
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
name: AI Release Notes
2+
description: Generate AI release notes using git and openai, outputs 'RELEASE_NOTES' and 'OPENAI_PROMPT'
3+
4+
inputs:
5+
OPENAI_API_KEY:
6+
required: true
7+
type: string
8+
GHA_PAT:
9+
required: true
10+
type: string
11+
model_name:
12+
required: false
13+
type: string
14+
default: gpt-4o-mini
15+
repo_path:
16+
required: false
17+
type: string
18+
custom_prompt:
19+
required: false
20+
default: ''
21+
type: string
22+
git_ref:
23+
required: false
24+
type: string
25+
default: ''
26+
head_ref:
27+
required: false
28+
type: string
29+
default: main
30+
base_ref:
31+
required: false
32+
type: string
33+
default: main
34+
35+
outputs:
36+
RELEASE_NOTES:
37+
description: "AI generated release notes"
38+
value: ${{ steps.ai_release_notes.outputs.RELEASE_NOTES }}
39+
OPENAI_PROMPT:
40+
description: "Prompt used to generate release notes"
41+
value: ${{ steps.ai_prompt.outputs.OPENAI_PROMPT }}
42+
43+
env:
44+
GITHUB_REF: ${{ inputs.git_ref == '' && github.event.pull_request.head.ref || inputs.git_ref }}
45+
BASE_REF: ${{ inputs.base_ref == '' && github.base_ref || inputs.base_ref }}
46+
HEAD_REF: ${{ inputs.head_ref == '' && github.event.pull_request.head.sha || inputs.head_ref }}
47+
48+
runs:
49+
using: "composite"
50+
steps:
51+
- uses: actions/checkout@v4
52+
with:
53+
repository: ${{ inputs.repo_path }}
54+
token: ${{ inputs.GHA_PAT }}
55+
ref: ${{ env.GITHUB_REF }}
56+
fetch-depth: 0
57+
58+
- name: Set Workspace
59+
shell: bash
60+
run: |
61+
pip install tiktoken
62+
pip install pytz
63+
64+
# Github outputs: 'OPENAI_PROMPT'
65+
- name: Add Git Info to base prompt
66+
id: ai_prompt
67+
shell: bash
68+
env:
69+
BASE_REF: ${{ env.BASE_REF }}
70+
HEAD_SHA: ${{ env.HEAD_SHA }}
71+
PR_TITLE: ${{ github.event.pull_request.title }}
72+
PR_BODY: ${{ github.event.pull_request.body }}
73+
MODEL_NAME: ${{ inputs.model_name }}
74+
CUSTOM_PROMPT: ${{ inputs.custom_prompt }} # Default: ''
75+
run: python .github/scripts/release-notes-prompt.py
76+
77+
# Github outputs: 'RELEASE_NOTES'
78+
- name: Generate AI release notes
79+
id: ai_release_notes
80+
shell: bash
81+
env:
82+
OPENAI_API_KEY: ${{ inputs.OPENAI_API_KEY }}
83+
CUSTOM_PROMPT: ${{ steps.ai_prompt.outputs.OPENAI_PROMPT }}
84+
MODEL_NAME: ${{ inputs.model_name }}
85+
run: python .github/scripts/ai-release-notes.py
86+
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""
2+
AI-powered release notes generator that creates concise and informative release notes from git changes.
3+
4+
This script uses OpenAI's API to analyze git changes (summary, diff, and commit log) and generate
5+
well-formatted release notes in markdown. It focuses on important changes and their impact,
6+
particularly highlighting new types and schemas while avoiding repetitive information.
7+
8+
Environment Variables Required:
9+
OPENAI_API_KEY: OpenAI API key for authentication
10+
CHANGE_SUMMARY: Summary of changes made (optional if CUSTOM_PROMPT provided)
11+
CHANGE_DIFF: Git diff of changes (optional if CUSTOM_PROMPT provided)
12+
CHANGE_LOG: Git commit log (optional if CUSTOM_PROMPT provided)
13+
GITHUB_OUTPUT: Path to GitHub output file
14+
CUSTOM_PROMPT: Custom prompt to override default (optional)
15+
"""
16+
17+
import os
18+
import requests # type: ignore
19+
import json
20+
import tiktoken # type: ignore
21+
22+
OPENAI_API_KEY = os.environ["OPENAI_API_KEY"]
23+
CHANGE_SUMMARY = os.environ.get('CHANGE_SUMMARY', '')
24+
CHANGE_DIFF = os.environ.get('CHANGE_DIFF', '')
25+
CHANGE_LOG = os.environ.get('CHANGE_LOG', '')
26+
GITHUB_OUTPUT = os.getenv("GITHUB_OUTPUT")
27+
OPEN_AI_BASE_URL = "https://api.openai.com/v1"
28+
OPEN_API_HEADERS = {"Authorization": f"Bearer {OPENAI_API_KEY}", "Content-Type": "application/json"}
29+
CUSTOM_PROMPT = os.environ.get('CUSTOM_PROMPT', '')
30+
MODEL_NAME = os.environ.get('MODEL_NAME', 'gpt-3.5-turbo-16k')
31+
32+
def num_tokens_from_string(string: str, model_name: str) -> int:
33+
"""
34+
Calculate the number of tokens in a text string for a specific model.
35+
36+
Args:
37+
string: The input text to count tokens for
38+
model_name: Name of the OpenAI model to use for token counting
39+
40+
Returns:
41+
int: Number of tokens in the input string
42+
"""
43+
encoding = tiktoken.encoding_for_model(model_name)
44+
num_tokens = len(encoding.encode(string))
45+
return num_tokens
46+
47+
def truncate_to_token_limit(text, max_tokens, model_name):
48+
"""
49+
Truncate text to fit within a maximum token limit for a specific model.
50+
51+
Args:
52+
text: The input text to truncate
53+
max_tokens: Maximum number of tokens allowed
54+
model_name: Name of the OpenAI model to use for tokenization
55+
56+
Returns:
57+
str: Truncated text that fits within the token limit
58+
"""
59+
encoding = tiktoken.encoding_for_model(model_name)
60+
encoded = encoding.encode(text)
61+
truncated = encoded[:max_tokens]
62+
return encoding.decode(truncated)
63+
64+
def generate_release_notes(model_name):
65+
"""
66+
Generate release notes using OpenAI's API based on git changes.
67+
68+
Uses the GPT-3.5-turbo model to analyze change summary, commit log, and code diff
69+
to generate concise and informative release notes in markdown format. The notes
70+
focus on important changes and their impact, with sections for new types/schemas
71+
and other updates.
72+
73+
Returns:
74+
str: Generated release notes in markdown format
75+
76+
Raises:
77+
requests.exceptions.RequestException: If the OpenAI API request fails
78+
"""
79+
max_tokens = 14000 # Reserve some tokens for the response
80+
81+
# Truncate inputs if necessary to fit within token limits
82+
change_summary = '' if CUSTOM_PROMPT else truncate_to_token_limit(CHANGE_SUMMARY, 1000, model_name)
83+
change_log = '' if CUSTOM_PROMPT else truncate_to_token_limit(CHANGE_LOG, 2000, model_name)
84+
change_diff = '' if CUSTOM_PROMPT else truncate_to_token_limit(CHANGE_DIFF, max_tokens - num_tokens_from_string(change_summary, model_name) - num_tokens_from_string(change_log, model_name) - 1000, model_name)
85+
86+
url = f"{OPEN_AI_BASE_URL}/chat/completions"
87+
88+
# Construct prompt for OpenAI API
89+
openai_prompt = CUSTOM_PROMPT if CUSTOM_PROMPT else f"""Based on the following summary of changes, commit log and code diff, please generate concise and informative release notes:
90+
Summary of changes:
91+
{change_summary}
92+
Commit log:
93+
{change_log}
94+
Code Diff:
95+
{json.dumps(change_diff)}
96+
"""
97+
98+
data = {
99+
"model": model_name,
100+
"messages": [{"role": "user", "content": openai_prompt}],
101+
"temperature": 0.7,
102+
"max_tokens": 1000,
103+
}
104+
105+
print("----------------------------------------------------------------------------------------------------------")
106+
print("POST request to OpenAI")
107+
print("----------------------------------------------------------------------------------------------------------")
108+
ai_response = requests.post(url, headers=OPEN_API_HEADERS, json=data)
109+
print(f"Status Code: {str(ai_response.status_code)}")
110+
print(f"Response: {ai_response.text}")
111+
ai_response.raise_for_status()
112+
113+
return ai_response.json()["choices"][0]["message"]["content"]
114+
115+
release_notes = generate_release_notes(MODEL_NAME)
116+
print("----------------------------------------------------------------------------------------------------------")
117+
print("OpenAI generated release notes")
118+
print("----------------------------------------------------------------------------------------------------------")
119+
print(release_notes)
120+
121+
# Write the release notes to GITHUB_OUTPUT
122+
with open(GITHUB_OUTPUT, "a") as outputs_file:
123+
outputs_file.write(f"RELEASE_NOTES<<EOF\n{release_notes}\nEOF")
Lines changed: 110 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,125 @@
1-
"""
2-
This script generates a base prompt for OpenAI to create release notes.
3-
"""
4-
5-
#!/usr/bin/env python3
6-
71
import os
2+
import subprocess
3+
import json
4+
import re
5+
import tiktoken # type: ignore
86
from datetime import datetime;
97
from pytz import timezone
108

119
GITHUB_OUTPUT = os.getenv("GITHUB_OUTPUT")
10+
BASE_REF = os.getenv("BASE_REF", "main")
11+
HEAD_SHA = os.environ["HEAD_SHA"]
12+
PR_TITLE = os.environ["PR_TITLE"]
13+
PR_BODY = os.environ["PR_BODY"]
14+
EXISTING_NOTES = os.environ.get("EXISTING_NOTES", "null")
15+
MODEL_NAME = os.environ.get('MODEL_NAME', 'gpt-3.5-turbo-16k')
16+
CUSTOM_PROMPT = os.environ.get('CUSTOM_PROMPT', '')
17+
18+
def extract_description_section(pr_body):
19+
# Find content between ## Description and the next ## or end of text
20+
description_match = re.search(r'## Description\s*\n(.*?)(?=\n##|$)', pr_body, re.DOTALL)
21+
if description_match:
22+
content = description_match.group(1).strip()
23+
# Remove the comment line if it exists
24+
comment_pattern = r'\[comment\]:.+?\n'
25+
content = re.sub(comment_pattern, '', content)
26+
return content.strip()
27+
return ""
28+
29+
def extract_ellipsis_important(pr_body):
30+
# Find content between <!-- ELLIPSIS_HIDDEN --> and <!-- ELLIPSIS_HIDDEN --> that contains [!IMPORTANT]
31+
ellipsis_match = re.search(r'<!--\s*ELLIPSIS_HIDDEN\s*-->(.*?)<!--\s*ELLIPSIS_HIDDEN\s*-->', pr_body, re.DOTALL)
32+
if ellipsis_match:
33+
content = ellipsis_match.group(1).strip()
34+
important_match = re.search(r'\[!IMPORTANT\](.*?)(?=\[!|$)', content, re.DOTALL)
35+
if important_match:
36+
important_text = important_match.group(1).strip()
37+
important_text = re.sub(r'^-+\s*', '', important_text)
38+
return important_text.strip()
39+
return ""
40+
41+
def extract_coderabbit_summary(pr_body):
42+
# Find content between ## Summary by CodeRabbit and the next ## or end of text
43+
summary_match = re.search(r'## Summary by CodeRabbit\s*\n(.*?)(?=\n##|$)', pr_body, re.DOTALL)
44+
return summary_match.group(1).strip() if summary_match else ""
45+
46+
def num_tokens_from_string(string: str, model_name: str) -> int:
47+
"""
48+
Calculate the number of tokens in a text string for a specific model.
49+
50+
Args:
51+
string: The input text to count tokens for
52+
model_name: Name of the OpenAI model to use for token counting
53+
54+
Returns:
55+
int: Number of tokens in the input string
56+
"""
57+
encoding = tiktoken.encoding_for_model(model_name)
58+
num_tokens = len(encoding.encode(string))
59+
return num_tokens
60+
61+
def truncate_to_token_limit(text, max_tokens, model_name):
62+
"""
63+
Truncate text to fit within a maximum token limit for a specific model.
64+
65+
Args:
66+
text: The input text to truncate
67+
max_tokens: Maximum number of tokens allowed
68+
model_name: Name of the OpenAI model to use for tokenization
69+
70+
Returns:
71+
str: Truncated text that fits within the token limit
72+
"""
73+
encoding = tiktoken.encoding_for_model(model_name)
74+
encoded = encoding.encode(text)
75+
truncated = encoded[:max_tokens]
76+
return encoding.decode(truncated)
1277

78+
# Extract sections and combine into PR_OVERVIEW
79+
description = extract_description_section(PR_BODY)
80+
important = extract_ellipsis_important(PR_BODY)
81+
summary = extract_coderabbit_summary(PR_BODY)
82+
83+
PR_OVERVIEW = "\n\n".join(filter(None, [description, important, summary]))
84+
85+
# Get git information
86+
base_sha = subprocess.getoutput(f"git rev-parse origin/{BASE_REF}") if BASE_REF == 'main' else BASE_REF
87+
diff_overview = subprocess.getoutput(f"git diff {base_sha}..{HEAD_SHA} --name-status | awk '{{print $2}}' | sort | uniq -c | awk '{{print $2 \": \" $1 \" files changed\"}}'")
88+
git_log = subprocess.getoutput(f"git log {base_sha}..{HEAD_SHA} --pretty=format:'%h - %s (%an)' --reverse | head -n 50")
89+
git_diff = subprocess.getoutput(f"git diff {base_sha}..{HEAD_SHA} --minimal --abbrev --ignore-cr-at-eol --ignore-space-at-eol --ignore-space-change --ignore-all-space --ignore-blank-lines --unified=0 --diff-filter=ACDMRT")
90+
91+
max_tokens = 14000 # Reserve some tokens for the response
92+
changes_summary = truncate_to_token_limit(diff_overview, 1000, MODEL_NAME)
93+
git_logs = truncate_to_token_limit(git_log, 2000, MODEL_NAME)
94+
changes_diff = truncate_to_token_limit(git_diff, max_tokens - num_tokens_from_string(changes_summary, MODEL_NAME) - num_tokens_from_string(git_logs, MODEL_NAME) - 1000, MODEL_NAME)
95+
96+
# Get today's existing changelog if any
97+
existing_changelog = EXISTING_NOTES if EXISTING_NOTES != "null" else None
98+
existing_changelog_text = f"\nAdditional context:\n{existing_changelog}" if existing_changelog else ""
1399
TODAY = datetime.now(timezone('US/Eastern')).isoformat(sep=' ', timespec='seconds')
14100

15-
BASE_PROMPT = f"""Based on the following 'PR Information', please generate concise and informative release notes to be read by developers.
101+
BASE_PROMPT = CUSTOM_PROMPT if CUSTOM_PROMPT else f"""Based on the following 'PR Information', please generate concise and informative release notes to be read by developers.
16102
Format the release notes with markdown, and always use this structure: a descriptive and very short title (no more than 8 words) with heading level 2, a paragraph with a summary of changes (no header), and if applicable, sections for '🚀 New Features & Improvements', '🐛 Bugs Fixed' and '🔧 Other Updates', with heading level 3, skip respectively the sections if not applicable.
17103
Finally include the following markdown comment with the PR merged date: <!-- PR_DATE: {TODAY} -->.
18104
Avoid being repetitive and focus on the most important changes and their impact, discard any mention of version bumps/updates, changeset files, environment variables or syntax updates.
19105
PR Information:"""
20106

107+
OPENAI_PROMPT = f"""{BASE_PROMPT}
108+
Git log summary:
109+
{changes_summary}
110+
Commit Messages:
111+
{git_logs}
112+
PR Title:
113+
{PR_TITLE}
114+
PR Overview:
115+
{PR_OVERVIEW}{existing_changelog_text}
116+
Code Diff:
117+
{json.dumps(changes_diff)}"""
118+
119+
print("OpenAI Prompt")
120+
print("----------------------------------------------------------------")
121+
print(OPENAI_PROMPT)
122+
21123
# Write the prompt to GITHUB_OUTPUT
22124
with open(GITHUB_OUTPUT, "a") as outputs_file:
23-
outputs_file.write(f"BASE_PROMPT<<EOF\n{BASE_PROMPT}\nEOF")
125+
outputs_file.write(f"OPENAI_PROMPT<<EOF\n{OPENAI_PROMPT}\nEOF")

.github/workflows/changeset-ai-releases.yml

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,6 @@ jobs:
9696
echo "version=$VERSION"
9797
echo "prev_version=$PREV_VERSION"
9898
99-
# Generate base prompt for OpenAI, GITHUB_OUTPUT: 'BASE_PROMPT'
100-
- name: Release Notes Prompt
101-
id: ai_prompt
102-
run: python .github/scripts/release-notes-prompt.py
103-
10499
# Get previous version refs, GITHUB_OUTPUT: 'BASE_REF' and 'HEAD_REF'
105100
- name: Get Previous Version Refs
106101
id: version_refs
@@ -109,7 +104,7 @@ jobs:
109104
# Generate release notes using OpenAI if not already edited, GITHUB_OUTPUT: 'RELEASE_NOTES' and 'OPENAI_PROMPT'
110105
- name: AI Release Notes
111106
if: ${{ !contains(github.event.pull_request.labels.*.name, 'openai-edited') }}
112-
uses: RooVetGit/Roo-GHA/.github/actions/ai-release-notes@main
107+
uses: ./.github/actions/ai-release-notes
113108
id: ai_release_notes
114109
with:
115110
GHA_PAT: ${{ secrets.CROSS_REPO_ACCESS_TOKEN }}
@@ -118,7 +113,6 @@ jobs:
118113
repo_path: ${{ env.REPO_PATH }}
119114
base_ref: ${{ steps.version_refs.outputs.base_ref }}
120115
head_ref: ${{ steps.version_refs.outputs.head_ref }}
121-
custom_prompt: ${{ steps.ai_prompt.outputs.BASE_PROMPT }}
122116

123117
# Update CHANGELOG.md with AI-generated notes
124118
- name: Update Changeset Changelog

0 commit comments

Comments
 (0)