diff --git a/.github/scripts/draft-release-notes/classify.py b/.github/scripts/draft-release-notes/classify.py new file mode 100644 index 000000000..377ed0a12 --- /dev/null +++ b/.github/scripts/draft-release-notes/classify.py @@ -0,0 +1,451 @@ +#!/usr/bin/env python3 +"""Classify each PR in build/changelog-bundle/prs/ into a CHANGELOG section. + +For every PR bundle produced by .github/scripts/draft-release-notes/fetch.py, +this script writes a per-PR decision artifact. The artifact forces a one-PR- +at-a-time diff-based decision before any CHANGELOG text is written, which +is the design intent of the draft-release-notes skill. + +Outputs per PR (under build/changelog-bundle/prs//): + - prompt.md — LLM prompt with the diff embedded + - decision.json — structured classification (schema below) + - decision.md — human-readable rendering + - cli-response.jsonl / .txt — raw copilot stdout (forensic; always written + on non-preclassify runs regardless of outcome) + +decision.json schema: + { + "pr": , + "decision": "include" | "omit", + "section": "breaking" | "deprecations" | "new-javaagent" + | "new-library" | "enhancements" | "bug-fixes" | null, + "surface": , + "user_visible_effect": , + "bullet": | null, + "evidence": <2-4 line verbatim quote from the diff>, + "source": "preclassify" | "llm" + } + +Invokes `copilot` (must be on PATH) per PR. Response is expected on stdout +as a JSON object matching the schema above (markdown code fences tolerated). +Model is overridable via $CLASSIFY_MODEL (default: gpt-5.4-mini). + +Run with --jobs N for parallelism (default 4). +Idempotent: skips PRs whose decision.json already exists unless --force. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass +from pathlib import Path + +BUNDLE_ROOT = Path("build/changelog-bundle/prs") +RULES_PATH = Path(__file__).resolve().parent / "rules.md" +# Initial diff cap. The build_prompt() function further trims the diff if the +# full prompt would exceed MAX_PROMPT_CHARS. +MAX_DIFF_CHARS = 20_000 +# Hard cap on total prompt length. Windows CreateProcess rejects command +# lines longer than 32767 wide chars, and copilot has no stdin/@file prompt +# input, so the entire prompt must fit in a single argv token. We leave +# headroom for the copilot.exe path, flags, and argv quoting overhead. +MAX_PROMPT_CHARS = 24_000 + + +VALID_SECTIONS = { + "breaking", + "deprecations", + "new-javaagent", + "new-library", + "enhancements", + "bug-fixes", + None, +} + +PROMPT_TEMPLATE = """You are classifying a single PR from the \ +opentelemetry-java-contrib repository for inclusion in CHANGELOG.md. + +Apply the classification rules below. Respond with a single JSON object \ +matching the schema described in those rules and nothing else (no prose, \ +no code fences). + +---BEGIN RULES--- +{rules} +---END RULES--- + +PR number: {pr} +Title (for link bookkeeping only, not evidence): {title} + +Changed files: +{files_summary} + +---BEGIN DIFF--- +{diff} +---END DIFF--- +""" + + +def load_rules() -> str: + try: + return RULES_PATH.read_text(encoding="utf-8") + except FileNotFoundError: + sys.exit(f"rules file not found: {RULES_PATH}") + + +@dataclass +class PrBundle: + pr: int + dir: Path + meta: dict + diff: str + + +def iter_bundles() -> list[PrBundle]: + if not BUNDLE_ROOT.is_dir(): + sys.exit(f"{BUNDLE_ROOT} not found; run .github/scripts/draft-release-notes/fetch.py first") + out = [] + for d in sorted(BUNDLE_ROOT.iterdir(), key=lambda p: int(p.name) if p.name.isdigit() else 0): + if not d.is_dir() or not d.name.isdigit(): + continue + meta_path = d / "meta.json" + diff_path = d / "patch.diff" + if not meta_path.exists() or not diff_path.exists(): + continue + meta = json.loads(meta_path.read_text(encoding="utf-8")) + diff = diff_path.read_text(encoding="utf-8", errors="replace") + out.append(PrBundle(pr=int(d.name), dir=d, meta=meta, diff=diff)) + return out + + +# --- preclassifier --------------------------------------------------------- + + +def preclassify(bundle: PrBundle) -> dict | None: + """Return a decision dict if we can decide without the LLM, else None.""" + labels = bundle.meta.get("labels") or [] + if "module cleanup" in labels: + return { + "decision": "omit", + "section": None, + "surface": "module cleanup", + "user_visible_effect": "none", + "bullet": None, + "evidence": "PR labeled 'module cleanup'", + "source": "preclassify", + } + if not bundle.meta.get("touches_src_main"): + files = [f["path"] for f in bundle.meta.get("files", [])] + return { + "decision": "omit", + "section": None, + "surface": "test/build/docs only", + "user_visible_effect": "none", + "bullet": None, + "evidence": "no changed paths are user-facing /src/main/: " + + ", ".join(files[:5]) + + ("..." if len(files) > 5 else ""), + "source": "preclassify", + } + return None + + +# --- prompt + invocation --------------------------------------------------- + +def build_prompt(bundle: PrBundle, rules: str) -> str: + diff = bundle.diff + truncated = False + if len(diff) > MAX_DIFF_CHARS: + diff = diff[:MAX_DIFF_CHARS] + "\n...[diff truncated for length]...\n" + truncated = True + files = bundle.meta.get("files", []) + files_summary = "\n".join( + f" - {f['path']} (+{f.get('additions', 0)}/-{f.get('deletions', 0)})" + for f in files[:50] + ) + if len(files) > 50: + files_summary += f"\n ... and {len(files) - 50} more" + if truncated: + files_summary += "\n (diff truncated; changed files list above is authoritative)" + prompt = PROMPT_TEMPLATE.format( + rules=rules, + pr=bundle.pr, + title=bundle.meta.get("title", ""), + files_summary=files_summary, + diff=diff, + ) + # Enforce hard total cap. Oversized prompts come almost entirely from + # pathological diffs (e.g. generated files, large snapshots); trim the + # diff further and rebuild. + if len(prompt) > MAX_PROMPT_CHARS: + overshoot = len(prompt) - MAX_PROMPT_CHARS + new_diff_len = max(1000, len(diff) - overshoot - 200) + diff = diff[:new_diff_len] + "\n...[diff truncated for length]...\n" + prompt = PROMPT_TEMPLATE.format( + rules=rules, + pr=bundle.pr, + title=bundle.meta.get("title", ""), + files_summary=files_summary, + diff=diff, + ) + return prompt + + +def invoke_cli(prompt_text: str, timeout: int) -> tuple[int, str, str]: + """Run `copilot -p` with the prompt as a single argv token. + + --output-format json emits JSONL whose final `result` event carries + premiumRequests, which we record in decision.json. + --allow-all-tools is required in non-interactive mode. + Model is overridable via $CLASSIFY_MODEL. + """ + model = os.environ.get("CLASSIFY_MODEL", "gpt-5.4-mini") + argv = [ + "copilot", + "-p", prompt_text, + "--output-format", "json", + "--allow-all-tools", + "--model", model, + ] + proc = subprocess.run( + argv, + capture_output=True, + text=True, + encoding="utf-8", + timeout=timeout, + ) + return proc.returncode, proc.stdout, proc.stderr + + +def parse_copilot_jsonl(s: str) -> tuple[str, dict]: + """Extract concatenated assistant message text and usage from copilot JSONL. + + Returns (response_text, usage) where usage is: + {"premium_requests": } + """ + parts: list[str] = [] + premium_requests: int | None = None + for line in s.splitlines(): + line = line.strip() + if not line or not line.startswith("{"): + continue + try: + evt = json.loads(line) + except json.JSONDecodeError: + continue + et = evt.get("type") + data = evt.get("data") or {} + if et == "assistant.message": + content = data.get("content") + if isinstance(content, str): + parts.append(content) + elif et == "result": + usage = evt.get("usage") or {} + if isinstance(usage.get("premiumRequests"), int): + premium_requests = usage["premiumRequests"] + return "\n".join(parts), {"premium_requests": premium_requests} + + +def parse_response(s: str) -> dict: + s = s.strip() + s = re.sub(r"^```(?:json)?\s*", "", s, flags=re.I) + s = re.sub(r"\s*```$", "", s) + # The model sometimes emits scratchpad objects (e.g. {"intent": "..."}) + # before the real decision object. Walk all top-level JSON objects in + # the string and return the last one that has a "decision" key, falling + # back to the last object if none match. + decoder = json.JSONDecoder() + objects: list[dict] = [] + i = 0 + n = len(s) + while i < n: + # Skip to the next object start. + j = s.find("{", i) + if j == -1: + break + try: + obj, end = decoder.raw_decode(s, j) + except json.JSONDecodeError: + i = j + 1 + continue + if isinstance(obj, dict): + objects.append(obj) + i = end + if not objects: + # Force the original error path for callers that expect JSONDecodeError. + return json.loads(s) + for obj in reversed(objects): + if "decision" in obj: + return obj + return objects[-1] + + +def validate(decision: dict) -> list[str]: + errors = [] + if decision.get("decision") not in ("include", "omit"): + errors.append("decision must be include or omit") + if decision.get("decision") == "include": + if decision.get("section") not in VALID_SECTIONS - {None}: + errors.append("section required for include") + if not decision.get("bullet"): + errors.append("bullet required for include") + else: + if decision.get("section") not in (None, "", "null"): + errors.append("section must be null for omit") + if not decision.get("evidence"): + errors.append("evidence required") + return errors + + +def render_markdown(pr: int, decision: dict) -> str: + lines = [ + f"# PR #{pr}", + "", + f"- decision: **{decision.get('decision')}**", + f"- section: {decision.get('section')}", + f"- source: {decision.get('source', 'llm')}", + f"- surface: {decision.get('surface')}", + f"- user-visible effect: {decision.get('user_visible_effect')}", + "", + "## bullet", + "", + decision.get("bullet") or "_(none)_", + "", + "## evidence", + "", + "```", + (decision.get("evidence") or "").strip(), + "```", + "", + ] + return "\n".join(lines) + + +# --- main ------------------------------------------------------------------ + + +def process_one(bundle: PrBundle, args) -> tuple[str, str | None, dict | None]: + """Classify one PR. Returns (status, error_or_None, decision_or_None).""" + decision_path = bundle.dir / "decision.json" + md_path = bundle.dir / "decision.md" + prompt_path = bundle.dir / "prompt.md" + + if decision_path.exists() and not args.force: + return "skip", None, None + + # Preclassify first. Deterministic: path-pattern and metadata rules only. + pre = preclassify(bundle) + if pre is not None: + pre["pr"] = bundle.pr + decision_path.write_text(json.dumps(pre, indent=2), encoding="utf-8") + md_path.write_text(render_markdown(bundle.pr, pre), encoding="utf-8") + return f"pre:{pre['decision']}", None, pre + + if args.preclassify_only: + return "needs-llm", None, None + + # Write prompt so it is inspectable alongside the decision. + prompt = build_prompt(bundle, args.rules) + prompt_path.write_text(prompt, encoding="utf-8") + + try: + rc, out, err = invoke_cli(prompt, args.timeout) + except subprocess.TimeoutExpired: + return "error", f"timeout after {args.timeout}s", None + if rc != 0: + return "error", f"cli rc={rc}: {err.strip()[:500]}", None + # Always persist the raw CLI stdout for forensic inspection, regardless + # of format or success/failure. File extension reflects the content + # format the copilot CLI returned. + is_jsonl = out.lstrip().startswith('{"type":') + raw_path = bundle.dir / ("cli-response.jsonl" if is_jsonl else "cli-response.txt") + raw_path.write_text(out, encoding="utf-8") + usage: dict | None = None + response_text = out + if is_jsonl: + response_text, usage = parse_copilot_jsonl(out) + try: + decision = parse_response(response_text) + except (json.JSONDecodeError, ValueError) as e: + return "error", f"parse failure ({e}); raw saved to {raw_path}", None + errs = validate(decision) + if errs: + return "error", "validation: " + "; ".join(errs) + f"; raw saved to {raw_path}", None + decision["pr"] = bundle.pr + decision.setdefault("source", "llm") + if usage is not None: + decision["usage"] = usage + decision_path.write_text(json.dumps(decision, indent=2), encoding="utf-8") + md_path.write_text(render_markdown(bundle.pr, decision), encoding="utf-8") + return f"llm:{decision['decision']}", None, decision + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--jobs", type=int, default=4, help="parallel CLI invocations (default 4)") + ap.add_argument("--timeout", type=int, default=900, help="per-PR CLI timeout seconds") + ap.add_argument("--force", action="store_true", help="re-classify PRs with existing decision.json") + ap.add_argument("--only", type=int, nargs="*", help="restrict to these PR numbers") + ap.add_argument( + "--preclassify-only", + action="store_true", + help="Run deterministic preclassifier only; skip LLM calls. " + "PRs that need LLM classification are reported but left without a decision.json.", + ) + args = ap.parse_args() + args.rules = "" if args.preclassify_only else load_rules() + + bundles = iter_bundles() + if args.only: + wanted = set(args.only) + bundles = [b for b in bundles if b.pr in wanted] + if not bundles: + print("No PR bundles to process.") + return 0 + + counts: dict[str, int] = {} + errors: list[str] = [] + premium_requests = 0 + prs_with_usage = 0 + total = len(bundles) + + with ThreadPoolExecutor(max_workers=max(1, args.jobs)) as ex: + futures = {ex.submit(process_one, b, args): b for b in bundles} + for done, fut in enumerate(as_completed(futures), start=1): + bundle = futures[fut] + status, err, decision = fut.result() + counts[status] = counts.get(status, 0) + 1 + if err: + errors.append(f"#{bundle.pr}: {err}") + print(f"[{done}/{total}] #{bundle.pr}: ERROR {err}", file=sys.stderr) + continue + print(f"[{done}/{total}] #{bundle.pr}: {status}") + usage = (decision or {}).get("usage") + if isinstance(usage, dict): + prs_with_usage += 1 + v = usage.get("premium_requests") + if isinstance(v, int): + premium_requests += v + + print() + print("Summary:") + for k, v in sorted(counts.items()): + print(f" {k}: {v}") + if prs_with_usage: + print() + print("LLM usage (from copilot --output-format json):") + print(f" PRs with usage data: {prs_with_usage}") + print(f" premium requests: {premium_requests}") + if errors: + print(f"\n{len(errors)} errors; rerun with --force on those PRs after fixing.") + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/.github/scripts/draft-release-notes/draft-release-notes.py b/.github/scripts/draft-release-notes/draft-release-notes.py new file mode 100644 index 000000000..153b08044 --- /dev/null +++ b/.github/scripts/draft-release-notes/draft-release-notes.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Draft the Unreleased section of CHANGELOG.md end-to-end. + +Runs three steps in order, aborting on the first failure: + + 1. fetch.py + - generates build/changelog-bundle/prs//{patch.diff,meta.json,...} + - incremental: reuses per-PR dirs whose meta.json commit hash matches; + (re)fetches only new or changed PRs. Stale PR/ref dirs are pruned. + - --refetch re-downloads every PR + 2. classify.py + - deterministic preclassify (renovate, docs/test/build-only, version + bumps) followed by per-PR LLM classification for everything else + - requires `copilot` on PATH; model overridable via $CLASSIFY_MODEL + 3. merge.py --splice --report + - rewrites ## Unreleased in CHANGELOG.md + +After it finishes, edit CHANGELOG.md directly to adjust wording, grouping, +or section. Classification rules live in rules.md alongside this script. +""" + +from __future__ import annotations + +import argparse +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[3] +HERE = Path(__file__).resolve().parent + +DRAFT = HERE / "fetch.py" +CLASSIFY = HERE / "classify.py" +MERGE = HERE / "merge.py" + + +def run(cmd: list[str], *, dry_run: bool) -> int: + printable = " ".join(repr(c) if " " in c else c for c in cmd) + print(f"$ {printable}", flush=True) + if dry_run: + return 0 + return subprocess.run(cmd, cwd=REPO_ROOT).returncode + + +def main() -> int: + ap = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + ap.add_argument( + "--refetch", + action="store_true", + help="re-download every PR even if its bundle already exists", + ) + ap.add_argument( + "--force-classify", + action="store_true", + help="reclassify PRs that already have a decision.json", + ) + ap.add_argument( + "--dry-run", + action="store_true", + help="print commands without running them", + ) + args = ap.parse_args() + + python = sys.executable or "python3" + + fetch_cmd = [python, str(DRAFT)] + if args.refetch: + fetch_cmd.append("--refetch") + + classify_cmd = [python, str(CLASSIFY)] + if args.force_classify: + classify_cmd.append("--force") + + # fetch.py: incremental by default; --refetch re-downloads all. + # classify.py: deterministic preclassify + per-PR LLM in one pass. + # merge.py --splice: rewrite ## Unreleased in CHANGELOG.md. + steps = [ + ("fetch.py", fetch_cmd), + ("classify.py", classify_cmd), + ("merge.py", [python, str(MERGE), "--splice", "--report"]), + ] + for name, cmd in steps: + rc = run(cmd, dry_run=args.dry_run) + if rc != 0: + print(f"{name} failed; aborting", file=sys.stderr) + return rc + + if not args.dry_run: + print( + "\nDone. Review:" + "\n git diff CHANGELOG.md" + "\n" + "\nEdit CHANGELOG.md directly to adjust wording, grouping, or section.", + file=sys.stderr, + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/.github/scripts/draft-release-notes/fetch.py b/.github/scripts/draft-release-notes/fetch.py new file mode 100644 index 000000000..7af6bbebc --- /dev/null +++ b/.github/scripts/draft-release-notes/fetch.py @@ -0,0 +1,893 @@ +#!/usr/bin/env python3 +"""Generate raw changelog draft output and a local review bundle. + +This script is the source of truth for release-note draft generation. It emits +the raw markdown scaffold on stdout and prepares a local bundle of per-PR +patches and metadata under build/changelog-bundle/. +""" + +from __future__ import annotations + +import argparse +import json +import re +import shutil +import subprocess +import sys +import time +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable + + +REPO = "open-telemetry/opentelemetry-java-contrib" +REPO_ROOT = Path(__file__).resolve().parents[3] +DEFAULT_BUNDLE_DIR = REPO_ROOT / "build" / "changelog-bundle" +AUTHOR_FILTER = r"^(?!renovate\[bot\] )" +SRC_MAIN_JAVA_PATHSPEC = "*/src/main/**/*.java" +PR_SUFFIX_RE = re.compile(r"\s*\(#(\d+)\)$") +VERSION_RE = re.compile(r'val stableVersion = "(\d+\.\d+\.\d+)') +ISSUE_REF_RE = re.compile(r"(?:issues|pull)/(\d+)|(? str: + if self.pr_number is not None: + return str(self.pr_number) + return f"commit-{self.commit_hash[:12]}" + + @property + def bundle_group(self) -> str: + return "prs" if self.pr_number is not None else "commits" + + @property + def label(self) -> str: + """Human-readable identifier used in logs and markdown headings.""" + if self.pr_number is not None: + return f"#{self.pr_number}" + return f"commit {self.commit_hash[:12]}" + + @property + def review_priority(self) -> str: + if self.deprecated_added or self.deprecated_removed: + return "high" + if self.touches_src_main: + return "normal" + return "low" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("range", nargs="?", default=None) + parser.add_argument( + "--skip-bundle", + action="store_true", + help="Skip generating the local changelog bundle.", + ) + parser.add_argument( + "--refetch", + action="store_true", + help="Re-download every PR even if its bundle already exists. " + "By default, fetch is incremental: PRs whose commit hash matches " + "the existing meta.json are reused.", + ) + + return parser.parse_args() + + +def run_command(args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]: + return subprocess.run( + args, + cwd=REPO_ROOT, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + check=check, + ) + + +def load_json(args: list[str]) -> Any: + result = run_command(args) + return json.loads(result.stdout) + + +def load_json_with_retry(args: list[str]) -> Any: + """Run a `gh` command with retries to absorb transient API failures.""" + last_error: subprocess.CalledProcessError | None = None + for attempt in range(1, GH_FETCH_RETRIES + 1): + try: + return load_json(args) + except subprocess.CalledProcessError as e: + last_error = e + if attempt == GH_FETCH_RETRIES: + break + warn( + f"gh command failed (attempt {attempt}/{GH_FETCH_RETRIES}): " + f"{' '.join(args)}\nstderr: {e.stderr}" + ) + time.sleep(GH_FETCH_RETRY_DELAY * attempt) + assert last_error is not None + raise last_error + + +def warn(message: str) -> None: + print(message, file=sys.stderr) + + +def configure_standard_streams() -> None: + sys.stdout.reconfigure(encoding="utf-8", errors="replace") + sys.stderr.reconfigure(encoding="utf-8", errors="replace") + + +def get_version() -> str: + text = (REPO_ROOT / "version.gradle.kts").read_text(encoding="utf-8") + match = VERSION_RE.search(text) + if match is None: + raise RuntimeError("Could not determine stableVersion from version.gradle.kts") + return match.group(1) + + +def compute_default_range(version: str) -> str: + match = re.fullmatch(r"(\d+)\.(\d+)\.0", version) + if match is None: + raise RuntimeError(f"unexpected version: {version}") + + major = int(match.group(1)) + minor = int(match.group(2)) + changelog = (REPO_ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + + if minor == 0: + prior_major = major - 1 + prior_minor_match = re.search( + rf"^## Version {prior_major}\.(\d+)\..*$", + changelog, + re.MULTILINE, + ) + if prior_minor_match is None: + return "HEAD" + prior_minor = int(prior_minor_match.group(1)) + return f"v{prior_major}.{prior_minor}.0..HEAD" + + return f"v{major}.{minor - 1}.0..HEAD" + + +def get_commit_hashes(range_spec: str) -> list[str]: + result = run_command( + [ + "git", + "log", + "--reverse", + "--perl-regexp", + f"--author={AUTHOR_FILTER}", + "--pretty=format:%H", + range_spec, + ] + ) + hashes = [line.strip() for line in result.stdout.splitlines() if line.strip()] + return hashes + + +def get_commit_subject(commit_hash: str) -> str: + result = run_command(["git", "log", "--format=%s", "-n", "1", commit_hash]) + return result.stdout.strip() + + +def get_commit_files(commit_hash: str) -> list[str]: + result = run_command(["git", "diff-tree", "--no-commit-id", "--name-only", "-r", commit_hash]) + return [line.strip() for line in result.stdout.splitlines() if line.strip()] + + +def is_module_cleanup_commit(subject: str) -> bool: + return subject.startswith("Cleanup for ") + + +# A file is "user-facing runtime" iff it sits under `/src/main/` and is not +# inside a testing, smoke-test, or docs module. Everything else (tests, docs, +# build scripts, CI config, gradle wrappers, top-level properties, etc.) has +# no `/src/main/` in its path by construction and is therefore non-runtime +# without needing an explicit allow-list. +_NON_USER_FACING_PREFIXES = ( + "smoke-tests/", + "smoke-tests-otel-starter/", + "instrumentation-docs/", +) +_NON_USER_FACING_SUBSTRINGS = ("/testing/", "-testing/") + + +def touches_user_facing_src_main(files: list[str]) -> bool: + """Return True if any file is a user-facing /src/main/ runtime source.""" + return any( + "/src/main/" in f + and not f.startswith(_NON_USER_FACING_PREFIXES) + and not any(s in f for s in _NON_USER_FACING_SUBSTRINGS) + for f in files + ) + + +def extract_pr_number(subject: str) -> int | None: + match = PR_SUFFIX_RE.search(subject) + if match is None: + return None + return int(match.group(1)) + + +def trim_pr_suffix(subject: str) -> str: + return PR_SUFFIX_RE.sub("", subject).rstrip() + + +def format_subject_as_entry(subject: str) -> str: + pr_number = extract_pr_number(subject) + summary = trim_pr_suffix(subject) + if pr_number is None: + return summary + return ( + f"{summary}\n" + f" ([#{pr_number}](https://github.com/{REPO}/pull/{pr_number}))" + ) + + +def count_deprecated_deltas(commit_hash: str) -> tuple[int, int]: + result = run_command( + ["git", "diff-tree", "-p", commit_hash, "--", SRC_MAIN_JAVA_PATHSPEC], + check=False, + ) + added_count = 0 + removed_count = 0 + for line in result.stdout.splitlines(): + if "@Deprecated" not in line: + continue + if line.startswith("+") and not line.startswith("++"): + added_count += 1 + elif line.startswith("-") and not line.startswith("--"): + removed_count += 1 + return added_count, removed_count + + +def build_candidates(range_spec: str) -> list[Candidate]: + candidates: list[Candidate] = [] + hashes = get_commit_hashes(range_spec) + warn(f"Inspecting {len(hashes)} commit(s) in {range_spec}...") + for commit_hash in hashes: + subject = get_commit_subject(commit_hash) + if is_module_cleanup_commit(subject): + continue + + files = get_commit_files(commit_hash) + added_count, removed_count = count_deprecated_deltas(commit_hash) + candidates.append( + Candidate( + commit_hash=commit_hash, + subject=subject, + pr_number=extract_pr_number(subject), + files=files, + touches_src_main=touches_user_facing_src_main(files), + deprecated_added=added_count > removed_count, + deprecated_removed=removed_count > added_count, + ) + ) + return candidates + + +def get_since_date(range_spec: str) -> str: + if range_spec == "HEAD": + # Use the unfiltered oldest commit so we don't miss PRs merged before + # the oldest non-renovate commit in a first-release scenario. + result = run_command(["git", "rev-list", "--max-parents=0", "HEAD"]) + oldest_commit = result.stdout.strip().splitlines()[0] + else: + match = re.fullmatch(r"(.+)\.\.(.+)", range_spec) + if match is None: + raise RuntimeError(f"Invalid range format: {range_spec}") + oldest_commit = run_command(["git", "rev-parse", match.group(1)]).stdout.strip() + + result = run_command(["git", "show", "-s", "--format=%ci", oldest_commit]) + return result.stdout.strip().split(" ", 1)[0] + + +def fetch_labeled_prs(range_spec: str, label: str) -> list[dict[str, Any]]: + since_date = get_since_date(range_spec) + data = load_json( + [ + "gh", + "pr", + "list", + "--repo", + REPO, + "--label", + label, + "--state", + "merged", + "--search", + f"merged:>={since_date}", + "--json", + "number,title", + ] + ) + return [item for item in data if isinstance(item, dict)] + + +def render_labeled_prs(title: str, items: list[dict[str, Any]]) -> list[str]: + if not items: + return [] + lines = [title, ""] + for item in items: + pr_number = item.get("number") + pr_title = str(item.get("title", "")).strip() + if not isinstance(pr_number, int) or not pr_title: + continue + lines.append(f"- {pr_title}") + lines.append(f" ([#{pr_number}](https://github.com/{REPO}/pull/{pr_number}))") + lines.append("") + return lines + + +def get_patch_from_git(commit_hash: str) -> str: + result = run_command(["git", "show", "--format=medium", "--unified=10", commit_hash], check=False) + if result.returncode != 0: + return "" + return result.stdout or "" + + +def write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def write_json(path: Path, data: Any) -> None: + write_text(path, json.dumps(data, indent=2, sort_keys=True) + "\n") + + +def get_author_name(author: Any) -> str: + if isinstance(author, dict): + for key in ("login", "name"): + value = author.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + if isinstance(author, str) and author.strip(): + return author.strip() + return "unknown" + + +def render_text_block(title: str, text: str, empty_message: str) -> str: + body = text.strip() + if not body: + body = empty_message + return f"# {title}\n\n{body}\n" + + +def render_discussion_markdown(title: str, items: Any, item_type: str) -> str: + lines = [f"# {title}", ""] + if not isinstance(items, list) or not items: + lines.append(f"No {item_type} available.") + lines.append("") + return "\n".join(lines) + + for index, item in enumerate(items, start=1): + if not isinstance(item, dict): + continue + lines.append(f"## {index}. {get_author_name(item.get('author'))}") + url = item.get("url") + created_at = item.get("createdAt") + state = item.get("state") + if isinstance(url, str) and url.strip(): + lines.append(f"URL: {url.strip()}") + if isinstance(created_at, str) and created_at.strip(): + lines.append(f"Created: {created_at.strip()}") + if isinstance(state, str) and state.strip(): + lines.append(f"State: {state.strip()}") + lines.append("") + body = str(item.get("body") or "").strip() + if body: + lines.append(body) + lines.append("") + else: + lines.append("(no body)") + lines.append("") + return "\n".join(lines) + + +def extract_reference_numbers(texts: list[str], excluded_numbers: set[int]) -> list[int]: + references: set[int] = set() + for text in texts: + for match in ISSUE_REF_RE.finditer(text): + number = match.group(1) or match.group(2) + if number is None: + continue + parsed = int(number) + if parsed in excluded_numbers: + continue + references.add(parsed) + return sorted(references) + + +def discussion_texts(pr_data: dict[str, Any]) -> list[str]: + """Return body + all comment/review bodies as strings for ref extraction.""" + texts = [str(pr_data.get("body") or "")] + for key in ("comments", "reviews"): + for item in pr_data.get(key) or []: + if isinstance(item, dict): + texts.append(str(item.get("body") or "")) + return texts + + +def fetch_parallel( + fn: Callable[[int], dict[str, Any]], numbers: list[int] +) -> dict[int, dict[str, Any]]: + with ThreadPoolExecutor(max_workers=GH_FETCH_WORKERS) as ex: + return dict(zip(numbers, ex.map(fn, numbers))) + + +def fetch_pr_data(pr_number: int) -> dict[str, Any]: + return load_json_with_retry( + [ + "gh", + "pr", + "view", + str(pr_number), + "--repo", + REPO, + "--json", + "number,title,mergeCommit,author,labels,files,body,comments,reviews,state,url", + ] + ) + + +def fetch_ref_data(ref_number: int) -> dict[str, Any]: + return load_json_with_retry( + [ + "gh", + "issue", + "view", + str(ref_number), + "--repo", + REPO, + "--json", + "number,title,author,body,comments,labels,state,url", + ] + ) + + +def _load_existing_meta(path: Path) -> dict[str, Any] | None: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + + +def _is_candidate_fresh(candidate_dir: Path, candidate: Candidate) -> bool: + """Return True if the on-disk bundle for this candidate is usable as-is.""" + meta_path = candidate_dir / "meta.json" + patch_path = candidate_dir / "patch.diff" + if not meta_path.exists() or not patch_path.exists(): + return False + existing = _load_existing_meta(meta_path) + if not isinstance(existing, dict): + return False + return existing.get("commit_hash") == candidate.commit_hash + + +def prepare_bundle( + bundle_dir: Path, + version: str, + range_spec: str, + candidates: list[Candidate], + breaking_prs: list[dict[str, Any]], + deprecation_prs: list[dict[str, Any]], + refetch: bool = False, +) -> None: + (bundle_dir / "prs").mkdir(parents=True, exist_ok=True) + (bundle_dir / "commits").mkdir(parents=True, exist_ok=True) + (bundle_dir / "refs").mkdir(parents=True, exist_ok=True) + + # Split candidates into reusable (meta.json + patch.diff already match + # current commit hash) and needing a fresh fetch. Reusable entries avoid + # burning gh API calls on PRs that haven't changed since last run. + reusable: set[int] = set() # indices into `candidates` + to_fetch: list[Candidate] = [] + for i, c in enumerate(candidates): + candidate_dir = bundle_dir / c.bundle_group / c.bundle_name + if not refetch and _is_candidate_fresh(candidate_dir, c): + reusable.add(i) + else: + to_fetch.append(c) + + warn( + f"Candidates: {len(candidates)} total; " + f"reusing {len(reusable)}, (re)fetching {len(to_fetch)}" + ) + + # Pre-fetch PR metadata in parallel. gh spawns a subprocess per call, so + # a release with 100+ PRs is dominated by wall time if run serially. + pr_numbers_to_fetch = [c.pr_number for c in to_fetch if c.pr_number is not None] + if pr_numbers_to_fetch: + warn(f"Fetching metadata for {len(pr_numbers_to_fetch)} PR(s)...") + pr_data_map = fetch_parallel(fetch_pr_data, pr_numbers_to_fetch) + + # Collect referenced issue/PR numbers from newly-fetched PRs' discussion + # text. Reused PRs already have their linked-issues.json on disk. + pr_refs: dict[int, list[int]] = { + c.pr_number: extract_reference_numbers( + discussion_texts(pr_data_map[c.pr_number]), {c.pr_number} + ) + for c in to_fetch + if c.pr_number is not None + } + # Fetch only refs we don't already have on disk (unless refetch). + refs_needed = sorted({n for refs in pr_refs.values() for n in refs}) + refs_to_fetch = [ + n for n in refs_needed + if refetch or not (bundle_dir / "refs" / str(n) / "meta.json").exists() + ] + if refs_to_fetch: + warn(f"Fetching metadata for {len(refs_to_fetch)} referenced issue(s)/PR(s)...") + ref_data_map = fetch_parallel(fetch_ref_data, refs_to_fetch) + + manifest_candidates: list[dict[str, Any]] = [] + index_lines = [ + "# Changelog Bundle", + "", + f"Version: `{version}`", + f"Range: `{range_spec}`", + "", + "This bundle is intended to support diff-first changelog review.", + "", + ] + + # Track ref numbers that should remain on disk across this run. + live_refs: set[int] = set() + + total = len(candidates) + for index, candidate in enumerate(candidates, start=1): + candidate_dir = bundle_dir / candidate.bundle_group / candidate.bundle_name + + if (index - 1) in reusable: + warn(f"[{index}/{total}] {candidate.label} (reused)") + entry = _reuse_candidate(candidate_dir, live_refs) + else: + warn(f"[{index}/{total}] {candidate.label}") + entry = _fetch_candidate( + candidate, candidate_dir, bundle_dir, + pr_data_map, pr_refs, ref_data_map, live_refs, + ) + + manifest_candidates.append(entry) + _append_index_entry(index_lines, candidate, entry) + + _prune_stale_dirs(bundle_dir, candidates, live_refs) + + manifest = { + "bundle_dir": str(bundle_dir.relative_to(REPO_ROOT)).replace("\\", "/"), + "candidates": manifest_candidates, + "deprecation_prs": deprecation_prs, + "generated_by": ".github/scripts/draft-release-notes/fetch.py", + "range": range_spec, + "repo": REPO, + "version": version, + "breaking_change_prs": breaking_prs, + } + write_json(bundle_dir / "manifest.json", manifest) + write_text(bundle_dir / "index.md", "\n".join(index_lines) + "\n") + + +def _reuse_candidate(candidate_dir: Path, live_refs: set[int]) -> dict[str, Any]: + """Reuse path: load the existing meta.json and propagate its refs. + + Note: labels/url are not refreshed for reused PRs; that's acceptable + since the commit hasn't changed. + """ + existing_meta = _load_existing_meta(candidate_dir / "meta.json") or {} + # Propagate any refs this PR previously referenced so they are not pruned. + for ref in existing_meta.get("issue_refs") or []: + if isinstance(ref, int): + live_refs.add(ref) + return existing_meta + + +def _fetch_candidate( + candidate: Candidate, + candidate_dir: Path, + bundle_dir: Path, + pr_data_map: dict[int, dict[str, Any]], + pr_refs: dict[int, list[int]], + ref_data_map: dict[int, dict[str, Any]], + live_refs: set[int], +) -> dict[str, Any]: + """Fetch path: write patch, body, comments, reviews, linked refs, meta.""" + candidate_dir.mkdir(parents=True, exist_ok=True) + + write_text(candidate_dir / "patch.diff", get_patch_from_git(candidate.commit_hash)) + + pr_data: dict[str, Any] = ( + pr_data_map[candidate.pr_number] if candidate.pr_number is not None else {} + ) + labels = [ + str(lbl.get("name")) + for lbl in pr_data.get("labels") or [] + if isinstance(lbl, dict) and isinstance(lbl.get("name"), str) + ] + raw_url = pr_data.get("url") + url = raw_url.strip() if isinstance(raw_url, str) and raw_url.strip() else None + + body_title = ( + f"PR body for {candidate.label}" + if candidate.pr_number is not None + else f"Commit body for {candidate.commit_hash[:12]}" + ) + write_text( + candidate_dir / "body.md", + render_text_block(body_title, str(pr_data.get("body") or ""), "No PR body available."), + ) + write_text( + candidate_dir / "comments.md", + render_discussion_markdown( + f"Comments for {candidate.label}", pr_data.get("comments") or [], "comments" + ), + ) + write_text( + candidate_dir / "reviews.md", + render_discussion_markdown( + f"Reviews for {candidate.label}", pr_data.get("reviews") or [], "reviews" + ), + ) + + reference_numbers = pr_refs[candidate.pr_number] if candidate.pr_number is not None else [] + linked_refs = _write_linked_refs(reference_numbers, bundle_dir, ref_data_map, live_refs) + write_json(candidate_dir / "linked-issues.json", linked_refs) + + gh_files = list(pr_data.get("files") or []) + manifest_entry = { + "author": get_author_name(pr_data.get("author")) if pr_data else None, + "bundle_dir": f"{candidate.bundle_group}/{candidate.bundle_name}", + "commit_hash": candidate.commit_hash, + "deprecated_added": candidate.deprecated_added, + "deprecated_removed": candidate.deprecated_removed, + "files": gh_files or candidate.files, + "issue_refs": [item["number"] for item in linked_refs], + "labels": labels, + "pr": candidate.pr_number, + "review_priority": candidate.review_priority, + "subject": candidate.subject, + "title": trim_pr_suffix(candidate.subject), + "touches_src_main": candidate.touches_src_main, + "url": url, + } + write_json(candidate_dir / "meta.json", manifest_entry) + + # If the fetch changed the commit hash (e.g. PR was backported/rebased), + # any prior decision.json is stale — drop it so classify.py reruns. + for aux in ( + "decision.json", + "decision.md", + "prompt.md", + "cli-response.jsonl", + "cli-response.txt", + ): + aux_path = candidate_dir / aux + if aux_path.exists(): + aux_path.unlink() + + return manifest_entry + + +def _write_linked_refs( + reference_numbers: list[int], + bundle_dir: Path, + ref_data_map: dict[int, dict[str, Any]], + live_refs: set[int], +) -> list[dict[str, Any]]: + linked_refs: list[dict[str, Any]] = [] + for ref_number in reference_numbers: + live_refs.add(ref_number) + ref_dir = bundle_dir / "refs" / str(ref_number) + ref_dir.mkdir(parents=True, exist_ok=True) + + ref_data = ref_data_map.get(ref_number) + if ref_data is None: + # Reusing an existing ref bundle; pull title from disk. + existing_ref_meta = _load_existing_meta(ref_dir / "meta.json") or {} + title = str(existing_ref_meta.get("title") or f"Reference #{ref_number}") + else: + ref_meta: dict[str, Any] = { + "number": ref_number, + "bundle_dir": f"refs/{ref_number}", + **ref_data, + } + title = str(ref_data.get("title") or f"Reference #{ref_number}") + write_json(ref_dir / "meta.json", ref_meta) + write_text( + ref_dir / "body.md", + render_text_block( + title, + str(ref_meta.get("body") or ""), + "No reference body available.", + ), + ) + write_text( + ref_dir / "comments.md", + render_discussion_markdown( + f"Comments for reference #{ref_number}", + ref_data.get("comments"), + "comments", + ), + ) + + linked_refs.append({ + "number": ref_number, + "bundle_dir": f"refs/{ref_number}", + "title": title, + }) + return linked_refs + + +def _prune_stale_dirs( + bundle_dir: Path, candidates: list[Candidate], live_refs: set[int] +) -> None: + # Prune PR/commit dirs no longer in the current candidate set. + current_dirs: dict[str, set[str]] = {"prs": set(), "commits": set()} + for c in candidates: + current_dirs[c.bundle_group].add(c.bundle_name) + for group, live in current_dirs.items(): + group_root = bundle_dir / group + if not group_root.is_dir(): + continue + for entry in group_root.iterdir(): + if entry.is_dir() and entry.name not in live: + warn(f"Pruning stale {group}/{entry.name}") + shutil.rmtree(entry) + + # Prune refs no longer referenced by any current PR. + refs_root = bundle_dir / "refs" + if refs_root.is_dir(): + for entry in refs_root.iterdir(): + if not entry.is_dir() or not entry.name.isdigit(): + continue + if int(entry.name) not in live_refs: + warn(f"Pruning stale refs/{entry.name}") + shutil.rmtree(entry) + + +def _append_index_entry( + index_lines: list[str], candidate: Candidate, manifest_entry: dict[str, Any] +) -> None: + title = manifest_entry.get("title") or trim_pr_suffix(candidate.subject) + heading = ( + f"PR #{candidate.pr_number}" + if candidate.pr_number is not None + else f"Commit {candidate.commit_hash[:12]}" + ) + bundle_ref = f"{candidate.bundle_group}/{candidate.bundle_name}" + index_lines.extend([ + f"## {heading}: {title}", + "", + f"- Bundle dir: `{manifest_entry.get('bundle_dir', bundle_ref)}`", + f"- review_priority: `{candidate.review_priority}`", + f"- touches_src_main: `{candidate.touches_src_main}`", + f"- deprecated_added: `{candidate.deprecated_added}`", + f"- deprecated_removed: `{candidate.deprecated_removed}`", + f"- patch: [{bundle_ref}/patch.diff]({bundle_ref}/patch.diff)", + f"- meta: [{bundle_ref}/meta.json]({bundle_ref}/meta.json)", + f"- body: [{bundle_ref}/body.md]({bundle_ref}/body.md)", + "", + ]) + + +def render_draft_output( + breaking_prs: list[dict[str, Any]], + deprecation_prs: list[dict[str, Any]], + candidates: list[Candidate], +) -> str: + lines = ["# Changelog", "", "## Unreleased", ""] + lines.extend(render_labeled_prs("### ⚠️ Breaking changes to non-stable APIs", breaking_prs)) + lines.extend(render_labeled_prs("### 🚫 Deprecations", deprecation_prs)) + + lines.extend( + [ + "### 🌟 New javaagent instrumentation", + "", + "### 🌟 New library instrumentation", + "", + "### 📈 Enhancements", + "", + "### 🛠️ Bug fixes", + "", + "### 🧰 Tooling", + "", + ] + ) + + breaking_candidates = [candidate for candidate in candidates if candidate.deprecated_removed] + if breaking_candidates: + lines.extend(["#### Possible breaking changes (diff removes @Deprecated)", ""]) + for candidate in breaking_candidates: + lines.append(f"- {format_subject_as_entry(candidate.subject)}") + lines.append("") + + deprecation_candidates = [candidate for candidate in candidates if candidate.deprecated_added] + if deprecation_candidates: + lines.extend(["#### Possible deprecations (diff adds @Deprecated)", ""]) + for candidate in deprecation_candidates: + lines.append(f"- {format_subject_as_entry(candidate.subject)}") + lines.append("") + + src_main_candidates = [ + candidate + for candidate in candidates + if candidate.touches_src_main and not candidate.deprecated_added and not candidate.deprecated_removed + ] + if src_main_candidates: + lines.extend(["#### Changes with src/main updates", ""]) + for candidate in src_main_candidates: + lines.append(f"- {format_subject_as_entry(candidate.subject)}") + lines.append("") + + no_src_main_candidates = [ + candidate + for candidate in candidates + if not candidate.touches_src_main and not candidate.deprecated_added and not candidate.deprecated_removed + ] + if no_src_main_candidates: + lines.extend(["#### Changes without src/main updates", ""]) + for candidate in no_src_main_candidates: + lines.append(f"- {format_subject_as_entry(candidate.subject)}") + lines.append("") + + return "\n".join(lines) + "\n" + + +def do_draft( + range_spec: str | None, + bundle_dir: Path, + skip_bundle: bool, + refetch: bool = False, +) -> int: + version = get_version() + actual_range = range_spec or compute_default_range(version) + candidates = build_candidates(actual_range) + breaking_prs = fetch_labeled_prs(actual_range, "breaking change") + deprecation_prs = fetch_labeled_prs(actual_range, "deprecation") + + if not skip_bundle: + prepare_bundle( + bundle_dir, + version, + actual_range, + candidates, + breaking_prs, + deprecation_prs, + refetch=refetch, + ) + relative_bundle = bundle_dir.relative_to(REPO_ROOT).as_posix() + warn(f"Prepared changelog bundle at {relative_bundle}") + + sys.stdout.write(render_draft_output(breaking_prs, deprecation_prs, candidates)) + return 0 + + +def main() -> int: + configure_standard_streams() + args = parse_args() + return do_draft( + args.range, + DEFAULT_BUNDLE_DIR, + args.skip_bundle, + refetch=args.refetch, + ) + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/.github/scripts/draft-release-notes/merge.py b/.github/scripts/draft-release-notes/merge.py new file mode 100644 index 000000000..25b574ce4 --- /dev/null +++ b/.github/scripts/draft-release-notes/merge.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Merge per-PR decision.json files into a CHANGELOG Unreleased section. + +Reads build/changelog-bundle/prs//decision.json for every PR that has +one, groups kept entries by section, sorts each section by ascending PR +number, and prints the Unreleased markdown block to stdout. + +The output contains only the `## Unreleased` heading and section bullets; +the SDK-version preamble is inserted at release time by +.github/scripts/update-changelog-for-release.sh. + +Any entry in state other than `include`/`omit`, or `include` without a +section and bullet, is reported on stderr and excluded. + +By default writes to stdout. Use --splice to rewrite CHANGELOG.md in +place, replacing the entire `## Unreleased` block. Any hand-written +content in that block is discarded; review the resulting diff to recover +anything worth keeping. +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +import textwrap +from pathlib import Path + +BUNDLE_ROOT = Path("build/changelog-bundle/prs") +CHANGELOG = Path("CHANGELOG.md") + +SECTION_ORDER = [ + ("breaking", "### ⚠️ Breaking changes to non-stable APIs"), + ("deprecations", "### 🚫 Deprecations"), + ("new-javaagent", "### 🌟 New javaagent instrumentation"), + ("new-library", "### 🌟 New library instrumentation"), + ("enhancements", "### 📈 Enhancements"), + ("bug-fixes", "### 🛠️ Bug fixes"), +] + +PR_URL = "https://github.com/open-telemetry/opentelemetry-java-contrib/pull/{pr}" + + +def load_decisions() -> list[dict]: + out = [] + if not BUNDLE_ROOT.is_dir(): + sys.exit(f"{BUNDLE_ROOT} not found") + for d in sorted(BUNDLE_ROOT.iterdir(), key=lambda p: int(p.name) if p.name.isdigit() else 0): + if not d.is_dir() or not d.name.isdigit(): + continue + p = d / "decision.json" + if not p.exists(): + continue + try: + obj = json.loads(p.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as e: + print(f"#{d.name}: decision.json unreadable: {e}", file=sys.stderr) + continue + obj.setdefault("pr", int(d.name)) + out.append(obj) + return out + + +def format_bullet(bullet: str, pr: int) -> str: + bullet = bullet.rstrip() + # Wrap the bullet text to match repo style (see .editorconfig + # max_line_length = 100). First line starts with "- " (2-char prefix); + # continuation lines indent 2 spaces so they align with the bullet text. + # textwrap preserves inline code spans and punctuation verbatim. + # + # Replace spaces inside `...` code spans with U+00A0 (non-breaking space) + # so textwrap does not split the span across lines. Python textwrap treats + # only ASCII whitespace as break opportunities, so NBSP survives the fill + # and is swapped back to a regular space in the output. + NBSP = "\u00a0" + protected = re.sub( + r"`[^`\n]+`", + lambda m: m.group(0).replace(" ", NBSP), + bullet, + ) + wrapped = textwrap.fill( + protected, + width=100, + initial_indent="- ", + subsequent_indent=" ", + break_long_words=False, + break_on_hyphens=False, + ) + wrapped = wrapped.replace(NBSP, " ") + return f"{wrapped}\n ([#{pr}]({PR_URL.format(pr=pr)}))" + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--missing-ok", action="store_true", + help="do not warn about PRs lacking decision.json") + ap.add_argument("--report", action="store_true", + help="also print a section-count summary on stderr") + ap.add_argument("--splice", action="store_true", + help="rewrite CHANGELOG.md in place (otherwise write to stdout)") + args = ap.parse_args() + + decisions = load_decisions() + + if not args.missing_ok: + # Warn about PR bundles with no decision artifact. + bundles = {int(d.name) for d in BUNDLE_ROOT.iterdir() if d.is_dir() and d.name.isdigit()} + decided = {d["pr"] for d in decisions} + missing = sorted(bundles - decided) + if missing: + print( + f"WARNING: {len(missing)} PR bundles have no decision.json: " + + ", ".join(f"#{n}" for n in missing[:20]) + + (" ..." if len(missing) > 20 else ""), + file=sys.stderr, + ) + + grouped: dict[str, list[dict]] = {key: [] for key, _ in SECTION_ORDER} + errors = 0 + for d in decisions: + pr = d.get("pr") + decision = d.get("decision") + section = d.get("section") + if decision == "omit": + continue + reason: str | None = None + if decision != "include": + reason = f"unknown decision {decision!r}" + elif section not in grouped: + reason = f"unknown section {section!r}" + elif not d.get("bullet"): + reason = "empty bullet" + if reason is not None: + print(f"#{pr}: skipping, {reason}", file=sys.stderr) + errors += 1 + continue + grouped[section].append(d) + + out_lines = [ + "## Unreleased", + "", + ] + + for key, header in SECTION_ORDER: + items = sorted(grouped[key], key=lambda d: d["pr"]) + if not items: + continue + out_lines.append(header) + out_lines.append("") + for d in items: + out_lines.append(format_bullet(d["bullet"], d["pr"])) + out_lines.append("") + + block = "\n".join(out_lines) + if not block.endswith("\n"): + block += "\n" + + if args.splice: + if not CHANGELOG.exists(): + sys.exit(f"{CHANGELOG} not found") + text = CHANGELOG.read_text(encoding="utf-8") + # Match `## Unreleased` through the next `## ` heading, or end of file + # if Unreleased is the final heading. + m = re.search(r"^## Unreleased\n.*?(?=^## |\Z)", text, re.S | re.M) + if not m: + sys.exit("## Unreleased section not found in CHANGELOG.md") + new_text = text[: m.start()] + block + "\n" + text[m.end():] + CHANGELOG.write_text(new_text, encoding="utf-8") + bullet_count = sum(len(v) for v in grouped.values()) + print(f"Rewrote {CHANGELOG} ({bullet_count} PR-linked bullets)", file=sys.stderr) + else: + sys.stdout.write(block) + + if args.report: + print("Section counts:", file=sys.stderr) + for key, header in SECTION_ORDER: + print(f" {key}: {len(grouped[key])}", file=sys.stderr) + + return 1 if errors else 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/.github/scripts/draft-release-notes/rules.md b/.github/scripts/draft-release-notes/rules.md new file mode 100644 index 000000000..c88ce5f24 --- /dev/null +++ b/.github/scripts/draft-release-notes/rules.md @@ -0,0 +1,170 @@ +# Classification rules + +Single source of truth for the draft-release-notes skill. Read by +`classify.py` (embedded verbatim into the per-PR LLM prompt) and by +humans during the finalize step. + +## Schema + +Respond with a single JSON object matching exactly this schema and +nothing else (no prose). A surrounding `json` code fence is tolerated +by the parser but discouraged — prefer a bare JSON object: + +```text +{ + "decision": "include" | "omit", + "section": "breaking" | "deprecations" | "new-javaagent" | "new-library" | "enhancements" | "bug-fixes" | null, + "surface": "", + "user_visible_effect": "", + "bullet": "", + "evidence": "<2-4 line verbatim quote from the diff that justifies the decision>" +} +``` + +## Core rule + +Classify every PR from its diff only. PR titles, manifest `subject`, +draft-script bullet text, scratch-bucket headings, file lists, and +`--stat` summaries are indexing metadata, not evidence. If the diff and +the metadata disagree, the diff wins. + +## Breaking changes to non-stable APIs + +Removes or changes the signature of a non-private method, class, or +interface in a non-stable (`-alpha`) module or in `javaagent-extension-api` +/ `*/internal/**`. Includes: + +* removal of a non-`@Deprecated` method, +* removal of a `default` method from an internal interface, +* signature change even when the method never carried `@Deprecated`. + +Treat non-private `Experimental*` helpers in published `:library` +artifacts as incubating public API even when their package name +contains `.internal`; removals or binary-incompatible reshaping belong +under Breaking. + +Emitted-attribute, attribute-value, or span-name changes are Breaking +**only** when they ship unconditionally. If the change is gated behind +`otel.instrumentation.common.v3-preview`, +`otel.semconv-stability.opt-in=…`, or an `experimental` property, the +entry belongs under Enhancements. + +Deprecate-then-remove across two PRs in one cycle produces two bullets — +one under Deprecations, one under Breaking. + +## Deprecations + +Adds `@Deprecated` to a user-facing API, or renames a config property / +YAML key while keeping the old one. Name both the old and new user-facing +flat property; include the YAML key when relevant. + +Configuration property renames always go here, never in Enhancements. +Stability policy: + +* Stable property/API: may be deprecated in any minor; removable only in + a major. +* Experimental property (name contains `experimental` or YAML key ends + with `/development`): may be deprecated in one release and removed in + the next. + +If an unlinked summary bullet at the top of Deprecations already covers +the rename, do not add a duplicate PR-linked bullet. + +## New javaagent / library instrumentation + +Only for a brand-new module under `instrumentation//javaagent/**` +or `instrumentation//library/**` — new `build.gradle.kts`, new +sources, and a new `settings.gradle.kts` entry. Renames or extractions +do not qualify. + +## Enhancements + +New attributes, new config flags, new stable-semconv support, observable +behavior gated on a flag (`v3-preview`, `SemconvStability`, experimental +property), or measurable hot-path performance improvements. For semconv +opt-ins, cite the flag value (for example +`otel.semconv-stability.opt-in=messaging`) — the known values in this +repo are `database`, `messaging`, `http`, `jvm`, `rpc`. Gated changes go +here, never under Breaking. + +## Bug fixes + +Wrong attributes, missing spans, NPE/leak/deadlock fixes, latest-dep +compatibility, instrumentation-activation fixes (muzzle `versionRange`, +SPI resource names, type matchers), startup ordering, context +propagation, and class-loading fixes. Restoring silently broken +behavior is a bug fix, not an enhancement — diffs that remove an +over-restrictive condition, add a fallback branch, or invert an `&&` +usually belong here. Describe the user-visible symptom. + +## metadata.yaml is documentation, not evidence + +`metadata.yaml` files are static documentation; they don't change +runtime behavior. Treat any change to `metadata.yaml` as describing +existing functionality. Don't emit an Enhancements bullet for a config +property whose only diff evidence is a metadata.yaml entry. + +## When to omit + +Omit only when the PR's `src/main` runtime changes are entirely limited +to one or more of: + +* pure refactor, style, or naming cleanup of non-API surfaces, +* test-only changes, cross-testing, moving tests out of default packages, +* CI/build-tooling with no runtime effect, +* renames of internal (not extension-API) fields, packages, or helpers, +* new package-private, `internal`-package, or test-only methods, +* `metadata.yaml` documentation (see section above). + +Do not use the internal-helper omit rule for non-private `Experimental*` +classes in published artifacts; classify their +removal or binary-incompatible reshaping under Breaking. + +Trivial omits (renovate bumps, all-test/docs/build paths, post-release +version bumps) are handled by `classify.py --preclassify-only`. +Everything else must be decided from the diff on a per-PR basis. + +Omit reasons that lean on appearance words — "probably internal", +"mostly plumbing", "looks like refactor", "reads as tooling", "diff is +dominated by X" — are not acceptable while `src/main` runtime code is +touched. Re-read the diff and write a concrete user-visible effect, or +keep the PR. + +## Bias toward keeping when the diff touches + +* Emitted telemetry: new attributes, gated-behavior changes, schema URL + changes, new `SemconvStability.emitStable…` branches. +* Startup, context propagation, class loading, or lifecycle behavior + that can disable telemetry, leak memory, deadlock, or otherwise break + normal operation (removal of an early `GlobalOpenTelemetry.get()` + call; closing bridged callbacks on GC; fixing an agent deadlock). +* Agent transformation correctness: `@Advice` inline vs indy, advice + scope, helper-class exposure to the application class loader. +* Any public or extension-facing API, builder method, config key, or + semconv surface, even when the diff also includes plumbing. + +## Bullet style + +* One sentence per bullet. +* Name concrete user-facing surfaces: flag names, property names, class + names, attribute names. Use backticks for config keys, property names, + attributes, and class/method names. +* For `v3-preview`-gated changes, cite the user-facing property name + `otel.instrumentation.common.v3-preview`, not the internal + `v3_preview` key. +* Do not describe implementation details ("refactored", "moved", + "simplified") unless that is the user-visible change. +* Do not credit authors. + +The merger renders bullets with the PR link on the second line, indented +two spaces: + +```text +- Short user-facing description + ([#NNNN](https://github.com/open-telemetry/opentelemetry-java-contrib/pull/NNNN)) +``` + +Grouping multiple PRs into one logical bullet is done by hand after +merging — edit `CHANGELOG.md` directly to combine trailing PR links, or +set identical `bullet` text on each `decision.json` and collapse by hand +after running the merger. diff --git a/.github/workflows/draft-release-notes.yml b/.github/workflows/draft-release-notes.yml new file mode 100644 index 000000000..38c2cbb07 --- /dev/null +++ b/.github/workflows/draft-release-notes.yml @@ -0,0 +1,40 @@ +name: Draft Release Notes + +on: + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + draft-notes: + runs-on: ubuntu-latest + environment: protected + steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Set up Python 3.11 + uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0 + with: + python-version: '3.11' + + - name: Install GitHub CLI + run: | + sudo apt-get update + sudo apt-get install -y gh + + - name: Install Copilot CLI + run: npm install -g @githubnext/github-copilot-cli + + - name: Authenticate Copilot CLI + run: github-copilot-cli auth "${{ secrets.COPILOT_GITHUB_TOKEN }}" + + - name: Draft release notes + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: python3 .github/scripts/draft-release-notes/draft-release-notes.py