228 lines
9.4 KiB
Python
Executable File
228 lines
9.4 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
generate_notes.py - Generate Markdown release notes from git commits using OmniRoute or OpenAI-compatible endpoints.
|
|
Works standalone or as part of a Gitea / GitHub composite action.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
import uuid
|
|
|
|
|
|
def parse_args():
|
|
parser = argparse.ArgumentParser(description="Generate AI release notes from git commits.")
|
|
parser.add_argument("--api-key", default=os.environ.get("INPUT_API_KEY", os.environ.get("OMNI_API_KEY", "")))
|
|
parser.add_argument("--base-url", default=os.environ.get("INPUT_BASE_URL", "https://omni.freakma.com/v1"))
|
|
parser.add_argument("--model", default=os.environ.get("INPUT_MODEL", "auto/fast"))
|
|
parser.add_argument("--version", default=os.environ.get("INPUT_VERSION", ""))
|
|
parser.add_argument("--from-ref", default=os.environ.get("INPUT_FROM_REF", ""))
|
|
parser.add_argument("--to-ref", default=os.environ.get("INPUT_TO_REF", "HEAD"))
|
|
parser.add_argument("--tag-pattern", default=os.environ.get("INPUT_TAG_PATTERN", "v*"))
|
|
parser.add_argument("--max-commits", type=int, default=int(os.environ.get("INPUT_MAX_COMMITS", "50")))
|
|
parser.add_argument("--fallback-notes", default=os.environ.get("INPUT_FALLBACK_NOTES", "Maintenance updates and performance improvements."))
|
|
parser.add_argument("--timeout", type=int, default=int(os.environ.get("INPUT_TIMEOUT", "25")))
|
|
parser.add_argument("--temperature", type=float, default=float(os.environ.get("INPUT_TEMPERATURE", "0.2")))
|
|
parser.add_argument("--system-prompt", default=os.environ.get("INPUT_SYSTEM_PROMPT", ""))
|
|
parser.add_argument("--save-to-file", default=os.environ.get("INPUT_SAVE_TO_FILE", ""))
|
|
return parser.parse_args()
|
|
|
|
|
|
def run_git(args):
|
|
try:
|
|
res = subprocess.run(["git"] + args, capture_output=True, text=True, check=False)
|
|
return res.returncode, res.stdout.strip(), res.stderr.strip()
|
|
except Exception as e:
|
|
return 1, "", str(e)
|
|
|
|
|
|
def find_previous_tag(to_ref, tag_pattern, version=""):
|
|
# 0. If a target version is provided (e.g. 1.8.0), check if tag v$VERSION already exists.
|
|
# If it does, the commits for this release start from the tag BEFORE v$VERSION:
|
|
if version:
|
|
target_tag = version if version.startswith("v") else f"v{version}"
|
|
code_prev, prev_tag, _ = run_git(["describe", "--tags", f"--match={tag_pattern}", "--abbrev=0", f"{target_tag}^"])
|
|
if code_prev == 0 and prev_tag:
|
|
return prev_tag
|
|
|
|
# 1. Check if to_ref itself is tagged with an exact tag matching tag_pattern:
|
|
if tag_pattern:
|
|
code, stdout, _ = run_git(["describe", "--tags", f"--match={tag_pattern}", "--exact-match", to_ref])
|
|
if code == 0 and stdout:
|
|
# to_ref is an existing release tag (e.g. tag was just created on HEAD).
|
|
# The prior tag must come from to_ref's parent commit.
|
|
code_prev, prev_tag, _ = run_git(["describe", "--tags", f"--match={tag_pattern}", "--abbrev=0", f"{to_ref}^"])
|
|
if code_prev == 0 and prev_tag:
|
|
return prev_tag
|
|
|
|
# 2. to_ref is not an exact tag; find the most recent tag matching pattern reaching to_ref:
|
|
if tag_pattern:
|
|
code, stdout, _ = run_git(["describe", "--tags", f"--match={tag_pattern}", "--abbrev=0", to_ref])
|
|
if code == 0 and stdout:
|
|
return stdout
|
|
|
|
# 3. Fallback: try describe without tag pattern:
|
|
code, stdout, _ = run_git(["describe", "--tags", "--exact-match", to_ref])
|
|
if code == 0 and stdout:
|
|
code_prev, prev_tag, _ = run_git(["describe", "--tags", "--abbrev=0", f"{to_ref}^"])
|
|
if code_prev == 0 and prev_tag:
|
|
return prev_tag
|
|
|
|
code, stdout, _ = run_git(["describe", "--tags", "--abbrev=0", to_ref])
|
|
if code == 0 and stdout:
|
|
return stdout
|
|
|
|
return None
|
|
|
|
|
|
def collect_commits(from_ref, to_ref, tag_pattern, max_commits, version=""):
|
|
# Ensure tags are up-to-date
|
|
run_git(["fetch", "--tags", "--prune"])
|
|
|
|
if not from_ref:
|
|
from_ref = find_previous_tag(to_ref, tag_pattern, version)
|
|
|
|
commits = ""
|
|
if from_ref:
|
|
sys.stderr.write(f"[action-release-notes] Comparing commits from {from_ref} to {to_ref}...\n")
|
|
code, stdout, _ = run_git(["log", f"{from_ref}..{to_ref}", "--pretty=format:- %s (%h)"])
|
|
if code == 0 and stdout:
|
|
commits = stdout
|
|
|
|
if not commits:
|
|
sys.stderr.write(f"[action-release-notes] No range commits found or no prior tag; falling back to last {max_commits} commits on {to_ref}...\n")
|
|
code, stdout, _ = run_git(["log", f"-n{max_commits}", "--pretty=format:- %s (%h)", to_ref])
|
|
if code == 0 and stdout:
|
|
commits = stdout
|
|
|
|
if not commits:
|
|
return ""
|
|
|
|
# Filter out noisy commits (version bump, chores, merge pull requests)
|
|
filtered = []
|
|
for line in commits.splitlines():
|
|
line_clean = line.strip()
|
|
if not line_clean:
|
|
continue
|
|
# Drop version bumps
|
|
if re.search(r'chore(\(ci\))?:\s*(bump version|release)', line_clean, re.I):
|
|
continue
|
|
filtered.append(line_clean)
|
|
|
|
return "\n".join(filtered) if filtered else commits
|
|
|
|
|
|
def clean_llm_markdown(content):
|
|
# Remove thinking tags from reasoning models (e.g. Qwen, DeepSeek)
|
|
content = re.sub(r'<think>.*?</think>', '', content, flags=re.DOTALL).strip()
|
|
|
|
# Strip markdown code fences if model wrapped entire answer
|
|
lines = content.splitlines()
|
|
if len(lines) >= 2:
|
|
if lines[0].strip().startswith("```"):
|
|
lines = lines[1:]
|
|
if lines and lines[-1].strip().startswith("```"):
|
|
lines = lines[:-1]
|
|
|
|
return "\n".join(lines).strip()
|
|
|
|
|
|
def query_llm(args, commits_text):
|
|
if not args.api_key:
|
|
sys.stderr.write("[action-release-notes] Warning: No API key provided; using fallback release notes.\n")
|
|
return args.fallback_notes
|
|
|
|
default_system = (
|
|
"You are an automated release notes generator. Given git commit messages, "
|
|
"produce concise, structured Markdown release notes grouped into categories "
|
|
"like '### Features', '### Bug Fixes', and '### Improvements'. "
|
|
"Filter out chores and noise. Output ONLY valid Markdown release notes without "
|
|
"conversational preamble, thinking blocks, or code fences."
|
|
)
|
|
system_prompt = args.system_prompt if args.system_prompt else default_system
|
|
|
|
version_str = f" v{args.version}" if args.version else ""
|
|
user_prompt = f"Generate concise release notes for{version_str} based on the following git commits:\n\n{commits_text}"
|
|
|
|
payload = {
|
|
"model": args.model,
|
|
"messages": [
|
|
{"role": "system", "content": system_prompt},
|
|
{"role": "user", "content": user_prompt}
|
|
],
|
|
"temperature": args.temperature
|
|
}
|
|
|
|
url = f"{args.base_url.rstrip('/')}/chat/completions"
|
|
req = urllib.request.Request(
|
|
url,
|
|
data=json.dumps(payload).encode("utf-8"),
|
|
headers={
|
|
"Authorization": f"Bearer {args.api_key}",
|
|
"Content-Type": "application/json",
|
|
"User-Agent": "gitea-action-release-notes"
|
|
}
|
|
)
|
|
|
|
try:
|
|
sys.stderr.write(f"[action-release-notes] Requesting release notes from {url} using model {args.model}...\n")
|
|
with urllib.request.urlopen(req, timeout=args.timeout) as response:
|
|
resp_bytes = response.read()
|
|
data = json.loads(resp_bytes.decode("utf-8"))
|
|
content = data["choices"][0]["message"]["content"]
|
|
cleaned = clean_llm_markdown(content)
|
|
if cleaned:
|
|
return cleaned
|
|
sys.stderr.write("[action-release-notes] Model returned empty content; using fallback.\n")
|
|
return args.fallback_notes
|
|
except Exception as e:
|
|
sys.stderr.write(f"[action-release-notes] LLM query failed ({e}); using fallback release notes.\n")
|
|
return args.fallback_notes
|
|
|
|
|
|
def main():
|
|
args = parse_args()
|
|
commits = collect_commits(args.from_ref, args.to_ref, args.tag_pattern, args.max_commits, args.version)
|
|
|
|
if not commits:
|
|
sys.stderr.write("[action-release-notes] No commits discovered; using fallback release notes.\n")
|
|
notes = args.fallback_notes
|
|
else:
|
|
notes = query_llm(args, commits)
|
|
|
|
# Save to file if requested
|
|
notes_file_path = ""
|
|
if args.save_to_file:
|
|
try:
|
|
with open(args.save_to_file, "w", encoding="utf-8") as f:
|
|
f.write(notes + "\n")
|
|
notes_file_path = os.path.abspath(args.save_to_file)
|
|
sys.stderr.write(f"[action-release-notes] Notes saved to {notes_file_path}\n")
|
|
except Exception as e:
|
|
sys.stderr.write(f"[action-release-notes] Failed to save notes to file: {e}\n")
|
|
|
|
# Write to GITHUB_OUTPUT if available
|
|
gh_output = os.environ.get("GITHUB_OUTPUT")
|
|
if gh_output and os.path.exists(os.path.dirname(gh_output) or "."):
|
|
delim = f"EOF_{uuid.uuid4().hex}"
|
|
try:
|
|
with open(gh_output, "a", encoding="utf-8") as f:
|
|
f.write(f"notes<<{delim}\n")
|
|
f.write(f"{notes}\n")
|
|
f.write(f"{delim}\n")
|
|
f.write(f"notes_file={notes_file_path}\n")
|
|
except Exception as e:
|
|
sys.stderr.write(f"[action-release-notes] Failed to write to GITHUB_OUTPUT: {e}\n")
|
|
|
|
# Output notes to stdout for build log visibility
|
|
print(notes)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|