#!/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'.*?', '', 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()