diff --git a/.gitignore b/.gitignore index a264d91822..3fc8aa2c41 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,4 @@ CHANGELOG.ignore.md # nix related .direnv .envrc +scripts/releases/ diff --git a/scripts/release_gen.sh b/scripts/release_gen.sh new file mode 100755 index 0000000000..cf2287155c --- /dev/null +++ b/scripts/release_gen.sh @@ -0,0 +1,321 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Simple stderr logger +header() { echo "==> $*" >&2; } + +# Generate summarized release notes using Codex CLI based on PR dump. +# Can also generate just the dump via --dump-only. + +usage() { + cat <<'USAGE' +Usage: scripts/release_gen.sh [--dump-only] [-q|--quiet] [owner/repo] [version] + +Examples: + scripts/release_gen.sh openai/codex v0.23.0 v0.24.0 + scripts/release_gen.sh v0.23.0 v0.24.0 # auto-detect repo from git remote + scripts/release_gen.sh v0.23.0 v0.24.0 0.24.0 # auto-detect with explicit version + scripts/release_gen.sh --dump-only v0.23.0 v0.24.0 # only generate releases/release_dump_.txt + scripts/release_gen.sh -q v0.23.0 v0.24.0 # quiet Codex call with progress dots + +Notes: + - Requires: gh and jq for dump generation; codex CLI for note generation. + - If release_dump_.txt does not exist, it will be created automatically. + - Then runs codex to generate .txt based on the dump (unless --dump-only). + - If you omit tags, the script lists the last 20 releases for the repo. +USAGE +} + +# Parse flags (currently: --dump-only, --quiet) +DUMP_ONLY=0 +QUIET=0 +ARGS=() +for arg in "$@"; do + case "$arg" in + --dump-only) + DUMP_ONLY=1 + ;; + -q|--quiet) + QUIET=1 + ;; + -h|--help) + usage + exit 0 + ;; + *) + ARGS+=("$arg") + ;; + esac +done +# Reset positional args safely under set -u, even if ARGS is empty +if ((${#ARGS[@]})); then + set -- "${ARGS[@]}" +else + set -- +fi + +if [[ ${1:-} == "-h" || ${1:-} == "--help" ]]; then + usage + exit 1 +fi + +# Resolve repo: allow optional first arg; otherwise detect from git remote +detect_repo() { + local remote + remote=$(git remote get-url origin 2>/dev/null || git remote get-url upstream 2>/dev/null || true) + if [[ -z "$remote" ]]; then + echo ""; return 1 + fi + # Normalize and extract owner/repo from SSH or HTTPS/HTTP URL + local path="$remote" + # Strip protocols and user@ + path="${path#git@}" + path="${path#ssh://}" + path="${path#https://}" + path="${path#http://}" + path="${path#*@}" + # If contains github.com:, take after ':'; else after 'github.com/' if present + if [[ "$path" == *":"* ]]; then + path="${path#*:}" + fi + if [[ "$path" == *github.com/* ]]; then + path="${path#*github.com/}" + fi + # Trim leading slashes + path="${path#/}" + # Drop trailing .git + path="${path%.git}" + # Ensure only owner/repo + echo "$path" | awk -F/ '{print $1"/"$2}' +} + +if [[ ${1:-} == */* ]]; then + REPO="$1"; shift +else + REPO="$(detect_repo || true)" + if [[ -z "$REPO" ]]; then + echo "Error: failed to auto-detect repository from git remote. Provide [owner/repo] explicitly." >&2 + exit 1 + fi +fi + +# Show a recent releases list if tags are missing +show_recent_releases_and_exit() { + local repo="$1" + echo "" >&2 + echo "Please pass a source/target release." >&2 + echo "" >&2 + echo "e.g.: ./scripts/release_gen.sh rust-v0.23.0 rust-v0.24.0" >&2 + echo "" >&2 + header "Recent releases for $repo:" + echo "" >&2 + local list + list=$(gh release list --repo "$repo" --limit 20 2>/dev/null || true) + if [[ -z "$list" ]]; then + echo "Error: unable to fetch releases for $repo" >&2 + exit 1 + fi + # Print only the tag (first column) as bullets to stderr + printf '%s\n' "$list" | awk '{print "- " $1}' >&2 + exit 1 +} + +if [[ $# -lt 2 ]]; then + show_recent_releases_and_exit "$REPO" +fi + +FROM_TAG="$1" +TO_TAG="$2" +VER="${3:-$TO_TAG}" +VER="${VER#v}" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +RELEASES_DIR="$SCRIPT_DIR/releases" +DUMP_FILE="$RELEASES_DIR/release_dump_$VER.txt" +GEN_FILE="$RELEASES_DIR/$VER.txt" + +# Ensure releases directory exists (under scripts/) +mkdir -p "$RELEASES_DIR" + +abspath() { + local p="$1" + if command -v realpath >/dev/null 2>&1; then + realpath "$p" + else + python3 -c 'import os,sys;print(os.path.abspath(sys.argv[1]))' "$p" 2>/dev/null || echo "$(pwd)/$p" + fi +} + +# ========== Dump generation logic (ported from release_dump_util.sh) ========== +header() { echo "==> $*" >&2; } + +# Get an ISO 8601 datetime for a tag. Prefer release publish date; fallback to tag/commit date. +get_tag_datetime_iso() { + local repo="$1" tag="$2" + # Try release by tag + local ts + ts=$(gh release view "$tag" --repo "$repo" --json publishedAt --jq '.publishedAt' 2>/dev/null || true) + if [[ -n "$ts" && "$ts" != "null" ]]; then + echo "$ts"; return 0 + fi + # Fallback: tag ref -> (annotated tag ->) commit -> date + local ref obj_type obj_url commit_sha commit + ref=$(gh api "repos/$repo/git/ref/tags/$tag") + obj_type=$(jq -r '.object.type' <<<"$ref") + obj_url=$(jq -r '.object.url' <<<"$ref") + if [[ "$obj_type" == "tag" ]]; then + local tag_obj + tag_obj=$(gh api "$obj_url") + commit_sha=$(jq -r '.object.sha' <<<"$tag_obj") + else + commit_sha=$(jq -r '.object.sha' <<<"$ref") + fi + commit=$(gh api "repos/$repo/commits/$commit_sha") + jq -r '.commit.committer.date' <<<"$commit" +} + +collect_prs_within_range() { + local repo="$1" from_iso="$2" to_iso="$3" + gh pr list --repo "$repo" --state merged --limit 1000 \ + --json number,title,mergedAt,author,body | \ + jq -c --arg from "$from_iso" --arg to "$to_iso" \ + '[ .[] + | select(.mergedAt != null and .mergedAt >= $from and .mergedAt <= $to) + | { + number: .number, + title: .title, + merged_at: .mergedAt, + author: (.author.login // "-"), + body: (.body // "") + } + ] | sort_by(.merged_at) | .[]' +} + +format_related_issues() { + # shellcheck disable=SC2016 + sed 's/\r//g' | \ + grep -Eio '(close|closed|closes|fix|fixed|fixes|resolve|resolved|resolves)[[:space:]:]+([[:alnum:]_.-]+\/[[:alnum:]_.-]+)?#[0-9]+' || true | \ + grep -Eo '#[0-9]+' | tr -d '#' | sort -n -u | sed 's/^/#/' | paste -sd ', ' - +} + +generate_dump() { + local repo="$1" from_tag="$2" to_tag="$3" out_file="$4" + command -v gh >/dev/null 2>&1 || { echo "Error: gh (GitHub CLI) is required" >&2; exit 1; } + command -v jq >/dev/null 2>&1 || { echo "Error: jq is required" >&2; exit 1; } + + header "Resolving tag dates ($from_tag -> $to_tag)" + local from_iso to_iso + from_iso=$(get_tag_datetime_iso "$repo" "$from_tag") + to_iso=$(get_tag_datetime_iso "$repo" "$to_tag") + if [[ -z "$from_iso" || -z "$to_iso" ]]; then + echo "Error: failed to resolve tag dates. from=$from_tag ($from_iso) to=$to_tag ($to_iso)" >&2 + exit 1 + fi + + header "Collecting merged PRs via gh pr list" + local tmpdir sorted + tmpdir=$(mktemp -d) + sorted="$tmpdir/prs.sorted.ndjson" + collect_prs_within_range "$repo" "$from_iso" "$to_iso" > "$sorted" + + local count + count=$(wc -l < "$sorted" | tr -d ' ') + + header "Writing $out_file (Total PRs: $count)" + { + echo "Repository: $repo" + echo "Range: $from_tag ($from_iso) -> $to_tag ($to_iso)" + echo "Generated: $(date -u +%Y-%m-%dT%H:%M:%SZ)" + echo "Total PRs: $count" + echo "" + } > "$out_file" + + if [[ "$count" -eq 0 ]]; then + return 0 + fi + + while IFS= read -r line; do + local title number merged_at author body issues + title=$(jq -r '.title' <<<"$line") + number=$(jq -r '.number' <<<"$line") + merged_at=$(jq -r '.merged_at' <<<"$line") + author=$(jq -r '.author' <<<"$line") + body=$(jq -r '.body' <<<"$line") + + issues=$(printf '%s' "$body" | format_related_issues || true) + [[ -z "$issues" ]] && issues="-" + + { + echo "PR #$number: $title" + echo "Merged: $merged_at | Author: $author" + echo "Related issues: $issues" + echo "" + # Skip verbose descriptions for Dependabot PRs + if [[ "$author" != "app/dependabot" && "$author" != "dependabot[bot]" && ! "$author" =~ [Dd]ependabot ]]; then + echo "Description:" + # Limit descriptions to 2000 characters; add ellipses if truncated + local max=2000 + if (( ${#body} > max )); then + printf '%s\n' "${body:0:max}..." + else + printf '%s\n' "$body" + fi + echo "" + fi + echo "-----" + echo "" + } >> "$out_file" + done < "$sorted" + + header "Done -> $out_file" +} + +# ========== Orchestrate dump + optional codex generation ========== + +# Create dump if missing +if [[ ! -f "$DUMP_FILE" ]]; then + header "Dump not found: $DUMP_FILE. Generating..." + generate_dump "$REPO" "$FROM_TAG" "$TO_TAG" "$DUMP_FILE" +else + header "Using existing dump: $DUMP_FILE" +fi + +if (( DUMP_ONLY )); then + # Dump-only mode: no stdout output + exit 0 +fi + +# Now run codex to generate notes +command -v codex >/dev/null 2>&1 || { echo "Error: codex CLI is required for generation. Use --dump-only to skip." >&2; exit 1; } + +DUMP_PATH="$(abspath "$DUMP_FILE")" +PROMPT="`cat ${DUMP_PATH}`\n\n---\n\nPlease generate a summarized release note based on the list of PRs above. Then, write a file called $GEN_FILE with your suggested release notes. It should follow this structure (+ the style/tone/brevity in this example):\n\n\"- Highlights:\n - New commands and controls: support /mcp in TUI (#2430) and a slash command /approvals to control approvals (#2474).\n - Reasoning controls: change reasoning effort and model at runtime (#2435) /model; add “minimal” effort for GPT‑5 models (#2326).\n - Auth improvements: show login options when not signed in with ChatGPT (#2440) and auto‑refresh ChatGPT auth token (#2484).\n - UI/UX polish: Ghostty Ctrl‑b/Ctrl‑f fallback (#2427), Ctrl+H as backspace (#2412), cursor position tweak after tab completion (#2442), color/accessibility updates (#2401, #2421).\n - Distribution/infra: zip archived binaries added to releases (#2438) and DotSlash entry for Windows x86_64 (#2361); upgraded to Rust 1.89 (#2465, #2467).\n- Full list of merged PRs:\n\n - #2352 tui: skip identical consecutive entries in local composer history\n - #2355 fix: introduce codex-protocol crate\n...\"\n\nMake sure you limit the highlights to, at most, 5 bullet points." + +header "Calling codex to generate $GEN_FILE" +if (( QUIET )); then + # Quiet mode: run Codex silently and show progress dots + ( + set +x 2>/dev/null || true + codex exec --yolo "$PROMPT" >/dev/null 2>&1 + ) & + CODEX_PID=$! + while :; do + kill -0 "$CODEX_PID" 2>/dev/null || break + printf "." >&2 + sleep 1 + done + wait "$CODEX_PID" || true + CODEX_STATUS=$? + echo "" >&2 +else + # Normal mode: stream Codex output to stderr as before + codex exec --yolo "$PROMPT" 1>&2 +fi + +if [[ -f "$GEN_FILE" ]]; then + # On success, output only the generated release notes to stdout + cat "$GEN_FILE" +else + echo "Warning: $GEN_FILE not created. Check codex output." >&2 + exit 1 +fi