diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..1cfaf99 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,23 @@ +name: Tests + +on: + push: + branches: + - master + pull_request: + +jobs: + python-tests: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Run tests + run: python3 -m unittest discover -s tests -v diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1b7359c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,7 @@ +# Agent Guidelines + +- Keep changes small and focused. +- Follow the existing code style. +- Prefer simple, clear solutions. +- Run relevant checks when possible. +- Do not modify unrelated files. diff --git a/README.md b/README.md index a532e57..00b98ec 100644 --- a/README.md +++ b/README.md @@ -1,206 +1,106 @@ # Changelog Action -Github Action to generate a `CHANGELOG.md` on new tags. -This action is split into four steps: -1. [Prepare](#prepare-action) - Collects tag data, PRs, commits, and repository statistics. -2. [Generate](#generate-action) - Uses Claude Code AI to generate a human-readable changelog. -3. [Notify](#notify-action) - Reformats the changelog with Claude Code AI and sends it via Telegram. -4. [Annotate](#annotate-action) - Annotates the new tag on tracking platforms (e.g. Mixpanel). +GitHub Action to generate a `CHANGELOG.md` on new tags, with optional Telegram notifications and Mixpanel annotations. -## Prepare Action +## Breaking changes in 2.x -### Inputs +- The action now lives at the repository root and is used as `Waveful/ChangelogAction@2`. +- The old split actions (`/generate`, `/notify`, `/annotate`) have been removed from the default setup. +- Changelog generation is always the default behavior. +- Telegram notification only runs when `telegram-bot-token`, `telegram-chat-id`, and `changelog-url` are all provided. +- Mixpanel annotation only runs when `mixpanel-project-id` and `mixpanel-token` are provided. + +## Inputs | Name | Description | Value Type | Required | Default Value | |------|-------------|------------|----------|---------------| -| `github-token` | GitHub Token for Auth | string | Yes | - | - -### Outputs +| `target-branch` | Branch where `CHANGELOG.md` will be committed | string | No | `master` | +| `github-token` | GitHub token for checkout, PR queries, and push | string | Yes | - | +| `github-username` | Git author name for the changelog commit | string | No | `Dolful` | +| `github-email` | Git author email for the changelog commit | string | No | `github-bot@waveful.app` | +| `openai-base-url` | Base URL for an OpenAI-compatible provider | string | No | `https://api.openai.com/v1` | +| `openai-api-key` | API key for the configured provider | string | Yes | - | +| `openai-model` | Model name exposed by the configured provider | string | Yes | - | +| `openai-temperature` | Sampling temperature for changelog generation | string | No | `0.2` | +| `openai-auth-header` | Authentication header name for advanced providers | string | No | `Authorization` | +| `openai-auth-prefix` | Prefix prepended to the API key in the auth header | string | No | `Bearer ` | +| `previous-tag` | Optional previous tag override for manual runs | string | No | empty | +| `current-tag` | Optional current tag override for manual runs | string | No | empty | +| `telegram-bot-token` | Telegram bot token for optional notifications | string | No | empty | +| `telegram-chat-id` | Telegram chat ID for optional notifications | string | No | empty | +| `changelog-url` | Public changelog URL used in optional Telegram notifications | string | No | empty | +| `mixpanel-tag-group` | Mixpanel annotation group for optional release annotations | string | No | `github-action` | +| `mixpanel-project-id` | Mixpanel project ID for optional release annotations | string | No | empty | +| `mixpanel-token` | Mixpanel token (Basic Auth) for optional release annotations | string | No | empty | +| `mixpanel-region-domain` | Mixpanel region domain for optional release annotations | string | No | `mixpanel` | + +## Outputs | Name | Description | |------|-------------| -| `previous-tag` | Previous tag | -| `current-tag` | Current tag | +| `previous-tag` | Previous tag used for the release range | +| `current-tag` | Current tag used for the release range | | `pr-count` | Number of PRs merged | | `commit-count` | Number of commits | | `author-count` | Number of unique authors | | `files-changed` | Number of files changed | | `additions` | Number of lines added | | `deletions` | Number of lines deleted | -| `prs` | PR details with commits and AI summaries (base64 encoded) | -| `commits` | All commits including those not in PRs (base64 encoded, excludes automated changelog commits) | - -Note: the `prepare` action automatically excludes commits generated by the `generate` action (`docs: update changelog for ...`) so they are not fed back into the next AI changelog generation. - -## Generate Action - -### Inputs - -| Name | Description | Value Type | Required | Default Value | -|------|-------------|------------|----------|---------------| -| `target-branch` | Target branch | string | No | `master` | -| `github-token` | GitHub Token for Auth | string | Yes | - | -| `github-username` | GitHub Username | string | No | `Dolful` | -| `github-email` | GitHub Email | string | No | `github-bot@waveful.app` | -| `claude-code-oauth-token` | Claude Code OAuth Token | string | Yes | - | -| `previous-tag` | Previous tag | string | Yes | - | -| `current-tag` | Current tag | string | Yes | - | -| `prs` | PR details (base64 encoded, from prepare step) | string | Yes | - | -| `commits` | All commits (base64 encoded, from prepare step) | string | Yes | - | - -### Outputs - -| Name | Description | -|------|-------------| -| `changelog` | Changelog (base64 encoded) | - -## Notify Action - -### Inputs - -| Name | Description | Value Type | Required | Default Value | -|------|-------------|------------|----------|---------------| -| `changelog` | Changelog (base64 encoded, from generate step) | string | Yes | - | -| `claude-code-oauth-token` | Claude Code OAuth Token | string | Yes | - | -| `telegram-bot-token` | Telegram Bot Token | string | Yes | - | -| `telegram-chat-id` | Telegram Chat ID for notification | string | Yes | - | -| `changelog-url` | URL to the changelog file | string | Yes | - | - -### Outputs - -This action does not produce any outputs. It sends a notification to Telegram with the changelog summary. - -## Annotate Action - -### Inputs - -| Name | Description | Value Type | Required | Default Value | -|------|-------------|------------|----------|---------------| -| `tag` | Tag to annotate | string | Yes | - | -| `mixpanel-tag-group` | Mixpanel annotation group | string | No | `github-action` | -| `mixpanel-project-id` | Mixpanel Project ID | string | No | - | -| `mixpanel-token` | Mixpanel Token (Basic Auth) | string | No | - | -| `mixpanel-region-domain` | Mixpanel Region Domain | string | No | `mixpanel` | - -### Outputs - -This action does not produce any outputs. It creates an annotation on the configured tracking platform(s). - ## Usage -###### prepare.yml + ```yaml -name: Prepare +name: Changelog on: push: tags: - '*' -permissions: - contents: read - pull-requests: read - actions: write - -jobs: - prepare-data: - runs-on: ubuntu-latest - name: Prepare PRs, Commits and Statistics - steps: - - - name: Checkout - uses: actions/checkout@v5 - with: - fetch-depth: 0 # important! fetches full history & tags - - - name: Prepare Data - id: prepare - uses: Waveful/ChangelogAction/prepare@1.2.2 - with: - github-token: ${{ secrets.GITHUB_TOKEN }} - - - name: Print Statistics - id: statistics - run: | - echo "Authors: ${{ steps.prepare.outputs.author-count }}" - echo "PRs: ${{ steps.prepare.outputs.pr-count }}" - echo "Commits: ${{ steps.prepare.outputs.commit-count }}" - echo "File changed: ${{ steps.prepare.outputs.files-changed }}" - echo "Additions / Deletions: ${{ steps.prepare.outputs.additions }} / ${{ steps.prepare.outputs.deletions }}" - - - name: Trigger Changelog - id: trigger-changelog - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh workflow run changelog.yml \ - -f from-ref="${{ steps.prepare.outputs.previous-tag }}" \ - -f to-ref="${{ steps.prepare.outputs.current-tag }}" \ - -f commits="${{ steps.prepare.outputs.commits }}" \ - -f prs="${{ steps.prepare.outputs.prs }}" -``` - -###### changelog.yml -```yaml -name: Changelog - -on: - workflow_dispatch: - inputs: - from-ref: - required: true - type: string - to-ref: - required: true - type: string - commits: - required: true - type: string - prs: - required: true - type: string - permissions: contents: write - id-token: write + pull-requests: read jobs: changelog: runs-on: ubuntu-latest - name: Generate changelog - steps: - - - name: Checkout - uses: actions/checkout@v5 - - name: Changelog + steps: + - name: Run changelog action id: changelog - uses: Waveful/ChangelogAction/generate@1.2.2 + uses: Waveful/ChangelogAction@2 with: - target-branch: master - claude-code-oauth-token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }} - previous-tag: ${{ inputs.from-ref }} - current-tag: ${{ inputs.to-ref }} - commits: ${{ inputs.commits }} - prs: ${{ inputs.prs }} + target-branch: master + openai-base-url: ${{ secrets.OPENAI_BASE_URL }} + openai-api-key: ${{ secrets.OPENAI_API_KEY }} + openai-model: ${{ vars.OPENAI_MODEL }} + + telegram-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }} + telegram-chat-id: ${{ secrets.TELEGRAM_CHAT_ID }} + changelog-url: 'https://github.com/your-org/your-repo/blob/master/CHANGELOG.md' - - name: Annotate - uses: Waveful/ChangelogAction/annotate@1.2.2 - with: - tag: ${{ inputs.to-ref }} mixpanel-tag-group: github-action mixpanel-project-id: ${{ secrets.MIXPANEL_PROJECT_ID }} mixpanel-token: ${{ secrets.MIXPANEL_TOKEN }} - - name: Notify - uses: Waveful/ChangelogAction/notify@1.2.2 - with: - changelog: ${{ steps.changelog.outputs.changelog }} - claude-code-oauth-token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} - telegram-bot-token: ${{ secrets.TELEGRAM_BOT_TOKEN }} - telegram-chat-id: ${{ secrets.TELEGRAM_CHAT_ID }} - changelog-url: 'https://github.com/your-org/your-repo/blob/master/CHANGELOG.md' + - name: Print statistics + run: | + echo "Authors: ${{ steps.changelog.outputs.author-count }}" + echo "PRs: ${{ steps.changelog.outputs.pr-count }}" + echo "Commits: ${{ steps.changelog.outputs.commit-count }}" + echo "Files changed: ${{ steps.changelog.outputs.files-changed }}" + echo "Additions / Deletions: ${{ steps.changelog.outputs.additions }} / ${{ steps.changelog.outputs.deletions }}" ``` +If you use a provider that needs a custom auth header, set `openai-auth-header` and `openai-auth-prefix` accordingly. For example, Azure-style setups can use `openai-auth-header: api-key` and an empty `openai-auth-prefix`. + +## Behavior + +- The action always generates the changelog entry and updates `CHANGELOG.md`. +- Telegram notification is skipped unless all Telegram inputs are provided. +- Mixpanel annotation is skipped unless both Mixpanel credentials are provided. +- Commits generated by the action (`docs: update changelog for ...`) are excluded from future changelog prompts. + ## Credits + _ChangelogAction_ is made with ♥ by [Waveful](https://github.com/Waveful) and it's released under the [Apache License 2.0](./LICENSE). diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..1027af1 --- /dev/null +++ b/action.yml @@ -0,0 +1,450 @@ +name: 'Changelog' +description: 'Generate a changelog and optionally notify Telegram or annotate Mixpanel' + +inputs: + target-branch: + description: 'Branch where CHANGELOG.md will be committed' + default: master + github-token: + description: 'GitHub token for checkout, PR queries, and push' + required: true + github-username: + description: 'Git author name for the changelog commit' + default: Dolful + github-email: + description: 'Git author email for the changelog commit' + default: github-bot@waveful.app + openai-base-url: + description: 'OpenAI-compatible API base URL' + default: https://api.openai.com/v1 + openai-api-key: + description: 'OpenAI-compatible API key' + required: true + openai-model: + description: 'Model name exposed by the configured provider' + required: true + openai-temperature: + description: 'Sampling temperature for the changelog request' + default: '0.2' + openai-auth-header: + description: 'HTTP header used for provider authentication' + default: Authorization + openai-auth-prefix: + description: 'Prefix added before the API key in the auth header' + default: 'Bearer ' + previous-tag: + description: 'Optional previous tag override' + required: false + default: '' + current-tag: + description: 'Optional current tag override' + required: false + default: '' + telegram-bot-token: + description: 'Telegram bot token for optional notifications' + required: false + default: '' + telegram-chat-id: + description: 'Telegram chat ID for optional notifications' + required: false + default: '' + changelog-url: + description: 'Public changelog URL used in optional Telegram notifications' + required: false + default: '' + mixpanel-tag-group: + description: 'Mixpanel annotation group for optional release annotations' + required: false + default: github-action + mixpanel-project-id: + description: 'Mixpanel project ID for optional release annotations' + required: false + default: '' + mixpanel-token: + description: 'Mixpanel token (Basic Auth) for optional release annotations' + required: false + default: '' + mixpanel-region-domain: + description: 'Mixpanel region domain for optional release annotations' + required: false + default: mixpanel + +outputs: + previous-tag: + description: 'Previous tag used for the release range' + value: ${{ steps.tag_range.outputs.previous-tag }} + current-tag: + description: 'Current tag used for the release range' + value: ${{ steps.tag_range.outputs.current-tag }} + pr-count: + description: 'Number of pull requests included in the release range' + value: ${{ steps.release_data.outputs.pr_count }} + commit-count: + description: 'Number of commits included in the release range' + value: ${{ steps.release_data.outputs.commit_count }} + author-count: + description: 'Number of unique authors included in the release range' + value: ${{ steps.release_data.outputs.author_count }} + files-changed: + description: 'Number of changed files in the release range' + value: ${{ steps.release_data.outputs.files_changed }} + additions: + description: 'Number of added lines in the release range' + value: ${{ steps.release_data.outputs.additions }} + deletions: + description: 'Number of deleted lines in the release range' + value: ${{ steps.release_data.outputs.deletions }} + +runs: + using: 'composite' + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + token: ${{ inputs.github-token }} + + - name: Resolve tag range + id: tag_range + shell: bash + run: | + set -euo pipefail + + git fetch --force --tags origin + + CURRENT_TAG="${{ inputs.current-tag }}" + if [ -z "$CURRENT_TAG" ]; then + if [[ "${GITHUB_REF:-}" == refs/tags/* ]]; then + CURRENT_TAG="${GITHUB_REF#refs/tags/}" + else + echo "current-tag input is required when the workflow is not triggered by a tag push" + exit 1 + fi + fi + + if ! git rev-parse "$CURRENT_TAG^{tag}" >/dev/null 2>&1 && ! git rev-parse "$CURRENT_TAG^{commit}" >/dev/null 2>&1; then + echo "Tag '$CURRENT_TAG' was not found in the repository history" + exit 1 + fi + + PREVIOUS_TAG="${{ inputs.previous-tag }}" + if [ -z "$PREVIOUS_TAG" ]; then + PREVIOUS_TAG=$(git tag --sort=creatordate | awk -v current="$CURRENT_TAG" '$0 == current { print previous; exit } { previous = $0 }') + fi + + if [ -z "$PREVIOUS_TAG" ]; then + echo "Unable to determine a previous tag for '$CURRENT_TAG'" + exit 1 + fi + + echo "previous-tag=$PREVIOUS_TAG" >> "$GITHUB_OUTPUT" + echo "current-tag=$CURRENT_TAG" >> "$GITHUB_OUTPUT" + + - name: Collect release data + id: release_data + shell: bash + env: + GH_TOKEN: ${{ inputs.github-token }} + run: | + set -euo pipefail + + WORK_DIR="${RUNNER_TEMP}/changelog-action" + mkdir -p "$WORK_DIR" + + FROM_REF="${{ steps.tag_range.outputs.previous-tag }}" + TO_REF="${{ steps.tag_range.outputs.current-tag }}" + CHANGELOG_BOT_COMMIT_PATTERN='^docs: update changelog for ' + + MERGE_PR_NUMBERS=$(git log "$FROM_REF..$TO_REF" --grep="^Merge pull request #" --pretty=format:"%s" | grep -oE '#[0-9]+' | tr -d '#' | sort -u || true) + SQUASH_PR_NUMBERS=$(git log "$FROM_REF..$TO_REF" --pretty=format:"%s" | grep -oE '\(#[0-9]+\)' | grep -oE '[0-9]+' | sort -u || true) + ALL_PR_NUMBERS=$(printf '%s\n%s\n' "$MERGE_PR_NUMBERS" "$SQUASH_PR_NUMBERS" | sed '/^$/d' | sort -u) + + { + echo "# Pull Request Details" + echo + echo "Range: $FROM_REF to $TO_REF" + echo + } > "$WORK_DIR/pr_details_full.md" + + PR_COUNT=0 + if [ -n "$ALL_PR_NUMBERS" ]; then + for pr_num in $ALL_PR_NUMBERS; do + PR_DATA=$(gh pr view "$pr_num" --repo "${GITHUB_REPOSITORY}" --json number,title,author,body,labels,mergedAt,commits,files 2>/dev/null || echo "{}") + + if [ "$PR_DATA" = "{}" ] || [ "$PR_DATA" = "null" ]; then + continue + fi + + PR_TITLE=$(echo "$PR_DATA" | jq -r '.title // "Unknown"') + PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login // "unknown"') + PR_BODY=$(echo "$PR_DATA" | jq -r '.body // ""') + PR_LABELS=$(echo "$PR_DATA" | jq -r '(.labels // []) | map(.name) | join(", ")') + PR_MERGED=$(echo "$PR_DATA" | jq -r '.mergedAt // ""') + FILE_COUNT=$(echo "$PR_DATA" | jq -r '.files | length') + + { + echo "## PR #$pr_num - $PR_TITLE" + echo "Author: @$PR_AUTHOR" + if [ -n "$PR_LABELS" ]; then + echo "Labels: $PR_LABELS" + fi + if [ -n "$PR_MERGED" ]; then + echo "Merged: $PR_MERGED" + fi + echo + if [ -n "$PR_BODY" ]; then + echo "Body:" + printf '%s\n' "$PR_BODY" | head -n 40 | sed 's/^/> /' + echo + fi + echo "Commits:" + echo "$PR_DATA" | jq -r '.commits[:20] | .[] | "- \(.messageHeadline) by \(.authors[0].name // \"unknown\")"' 2>/dev/null || echo "- Unable to fetch commits" + echo + echo "Files Changed: $FILE_COUNT" + echo "$PR_DATA" | jq -r '.files[:15] | .[] | "- \(.path)"' 2>/dev/null || echo "- Unable to fetch file list" + if [ "$FILE_COUNT" -gt 15 ]; then + echo "- ... and $((FILE_COUNT - 15)) more files" + fi + echo + echo "---" + echo + } >> "$WORK_DIR/pr_details_full.md" + + PR_COUNT=$((PR_COUNT + 1)) + done + else + echo "No merged pull requests found in this range." >> "$WORK_DIR/pr_details_full.md" + fi + + { + echo "# All Commits in Range" + echo + echo "Range: $FROM_REF to $TO_REF" + echo + git log "$FROM_REF..$TO_REF" --invert-grep --grep="$CHANGELOG_BOT_COMMIT_PATTERN" --pretty=format:'- %h - %s (%an) - %ar' --no-merges + } > "$WORK_DIR/commits_simple.md" + + DIFF_STAT=$(git diff --shortstat "$FROM_REF..$TO_REF" || true) + COMMIT_COUNT=$(git rev-list --count "$FROM_REF..$TO_REF" --no-merges --invert-grep --grep="$CHANGELOG_BOT_COMMIT_PATTERN") + AUTHOR_COUNT=$(git log "$FROM_REF..$TO_REF" --invert-grep --grep="$CHANGELOG_BOT_COMMIT_PATTERN" --pretty=format:'%an' --no-merges | sort -u | wc -l | tr -d ' ') + FILES_CHANGED=$(git diff --name-only "$FROM_REF..$TO_REF" | sed '/^$/d' | wc -l | tr -d ' ') + ADDITIONS=$(printf '%s' "$DIFF_STAT" | grep -oE '[0-9]+ insertions?' | grep -oE '[0-9]+' || true) + DELETIONS=$(printf '%s' "$DIFF_STAT" | grep -oE '[0-9]+ deletions?' | grep -oE '[0-9]+' || true) + + if [ -z "$ADDITIONS" ]; then + ADDITIONS=0 + fi + + if [ -z "$DELETIONS" ]; then + DELETIONS=0 + fi + + echo "pr_count=$PR_COUNT" >> "$GITHUB_OUTPUT" + echo "commit_count=$COMMIT_COUNT" >> "$GITHUB_OUTPUT" + echo "author_count=$AUTHOR_COUNT" >> "$GITHUB_OUTPUT" + echo "files_changed=$FILES_CHANGED" >> "$GITHUB_OUTPUT" + echo "additions=$ADDITIONS" >> "$GITHUB_OUTPUT" + echo "deletions=$DELETIONS" >> "$GITHUB_OUTPUT" + + - name: Build LLM prompts + shell: bash + run: | + set -euo pipefail + + WORK_DIR="${RUNNER_TEMP}/changelog-action" + mkdir -p "$WORK_DIR" + + cat > "$WORK_DIR/system_prompt.txt" <<'EOF' + You write release changelogs for end users. + Return markdown only. + Start with a level-2 heading using the current tag. + Use the bullet symbol "•" for every list item. + Keep the language simple, concise, and understandable for non-technical readers. + Group related changes together when it improves readability. + Ignore automation-only changes unless they materially affect users. + Do not add any introduction, analysis notes, or closing text. + EOF + + { + echo "Repository: ${GITHUB_REPOSITORY}" + echo "Tag range: ${{ steps.tag_range.outputs.previous-tag }} -> ${{ steps.tag_range.outputs.current-tag }}" + echo + echo "Write the changelog directly in this format:" + echo "## ${{ steps.tag_range.outputs.current-tag }}" + echo "• First bullet" + echo "• Second bullet" + echo + echo "Use both sources below so the changelog covers PRs and direct commits." + echo + echo "Pull Request Details:" + cat "$WORK_DIR/pr_details_full.md" + echo + echo "All Commits:" + cat "$WORK_DIR/commits_simple.md" + } > "$WORK_DIR/user_prompt.txt" + + - name: Generate changelog + id: changelog + shell: bash + env: + OPENAI_BASE_URL: ${{ inputs.openai-base-url }} + OPENAI_API_KEY: ${{ inputs.openai-api-key }} + OPENAI_MODEL: ${{ inputs.openai-model }} + OPENAI_TEMPERATURE: ${{ inputs.openai-temperature }} + OPENAI_AUTH_HEADER: ${{ inputs.openai-auth-header }} + OPENAI_AUTH_PREFIX: ${{ inputs.openai-auth-prefix }} + run: | + set -euo pipefail + + WORK_DIR="${RUNNER_TEMP}/changelog-action" + CHANGELOG_FILE="$WORK_DIR/changelog_entry.md" + + export CHANGELOG_FILE + + python3 "${{ github.action_path }}/scripts/openai_chat.py" \ + --system-prompt-file "$WORK_DIR/system_prompt.txt" \ + --user-prompt-file "$WORK_DIR/user_prompt.txt" \ + --output-file "$CHANGELOG_FILE" + + python3 - <<'PY' + import os + from pathlib import Path + + path = Path(os.environ["CHANGELOG_FILE"]) + content = path.read_text(encoding="utf-8").strip() + if not content: + raise SystemExit("The configured LLM returned an empty changelog.") + path.write_text(content + "\n", encoding="utf-8") + + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as github_output: + github_output.write(f"entry-file={path.resolve()}\n") + PY + + - name: Update CHANGELOG.md + shell: bash + run: | + set -euo pipefail + + git fetch origin "${{ inputs.target-branch }}" + git checkout "${{ inputs.target-branch }}" + git pull --ff-only origin "${{ inputs.target-branch }}" + + python3 - <<'PY' + import os + from pathlib import Path + + changelog_path = Path("CHANGELOG.md") + entry = Path(os.environ["CHANGELOG_FILE"]).read_text(encoding="utf-8").strip() + + if changelog_path.exists(): + current = changelog_path.read_text(encoding="utf-8") + else: + current = "# Changelog\n" + + if current.startswith("# Changelog"): + header, _, remainder = current.partition("\n") + remainder = remainder.lstrip("\n") + updated = header + "\n\n" + entry + "\n" + if remainder: + updated += "\n" + remainder.rstrip() + "\n" + else: + updated = entry + "\n" + if current.strip(): + updated += "\n" + current.strip() + "\n" + + changelog_path.write_text(updated, encoding="utf-8") + PY + env: + CHANGELOG_FILE: ${{ steps.changelog.outputs.entry-file }} + + - name: Commit and push changelog + shell: bash + run: | + set -euo pipefail + + git add CHANGELOG.md + + if git diff --cached --quiet; then + echo "No changelog changes to commit" + exit 0 + fi + + RELEASE_HASH=$(git rev-parse --short=7 "${{ steps.tag_range.outputs.current-tag }}^{commit}") + git -c user.name="${{ inputs.github-username }}" -c user.email="${{ inputs.github-email }}" commit -m "docs: update changelog for ${{ steps.tag_range.outputs.current-tag }} (${RELEASE_HASH})" + git push origin "${{ inputs.target-branch }}" + + - name: Render Telegram message + if: inputs.telegram-bot-token != '' && inputs.telegram-chat-id != '' && inputs.changelog-url != '' + shell: bash + run: | + set -euo pipefail + + WORK_DIR="${RUNNER_TEMP}/changelog-notify" + mkdir -p "$WORK_DIR" + cp "${{ steps.changelog.outputs.entry-file }}" "$WORK_DIR/changelog.txt" + python3 "${{ github.action_path }}/scripts/changelog_to_telegram_html.py" --input "$WORK_DIR/changelog.txt" --output "$WORK_DIR/telegram_message.html" + + - name: Send changelog to Telegram + if: inputs.telegram-bot-token != '' && inputs.telegram-chat-id != '' && inputs.changelog-url != '' + shell: bash + run: | + set -euo pipefail + + WORK_DIR="${RUNNER_TEMP}/changelog-notify" + TELEGRAM_MESSAGE=$(cat "$WORK_DIR/telegram_message.html") + + FULL_TEXT="🔄 ${TELEGRAM_MESSAGE} + + More details available on CHANGELOG." + + JSON_PAYLOAD=$(jq -n \ + --arg chat_id "${{ inputs.telegram-chat-id }}" \ + --arg text "$FULL_TEXT" \ + '{chat_id: $chat_id, text: $text, parse_mode: "HTML"}') + + curl --fail --silent --show-error -X POST "https://api.telegram.org/bot${{ inputs.telegram-bot-token }}/sendMessage" \ + -H "Content-Type: application/json" \ + -d "$JSON_PAYLOAD" + + - name: Create Mixpanel annotation tag + if: inputs.mixpanel-project-id != '' && inputs.mixpanel-token != '' + id: create_mixpanel_tag + shell: bash + run: | + set -euo pipefail + + RESPONSE=$(curl --silent --request POST \ + --url "https://${{ inputs.mixpanel-region-domain }}.com/api/app/projects/${{ inputs.mixpanel-project-id }}/annotations/tags" \ + --header "accept: application/json" \ + --header "authorization: Basic ${{ inputs.mixpanel-token }}" \ + --header "content-type: application/json" \ + --data '{"name":"${{ inputs.mixpanel-tag-group }}"}') + + STATUS=$(echo "$RESPONSE" | jq -r '.status') + if [ "$STATUS" != "ok" ]; then + echo "Failed to create Mixpanel tag. Status: $STATUS" + exit 1 + fi + + TAG_ID=$(echo "$RESPONSE" | jq -r '.results.id') + if [ -z "$TAG_ID" ] || [ "$TAG_ID" = "null" ]; then + echo "Failed to extract Mixpanel tag ID" + exit 1 + fi + + echo "tag-id=$TAG_ID" >> "$GITHUB_OUTPUT" + + - name: Create Mixpanel annotation + if: inputs.mixpanel-project-id != '' && inputs.mixpanel-token != '' && steps.create_mixpanel_tag.outputs.tag-id != '' + shell: bash + run: | + set -euo pipefail + + ANNOTATION_DATE=$(date -u +"%Y-%m-%d %H:%M:%S") + + curl --fail --silent --show-error --request POST \ + --url "https://${{ inputs.mixpanel-region-domain }}.com/api/app/projects/${{ inputs.mixpanel-project-id }}/annotations" \ + --header "accept: application/json" \ + --header "authorization: Basic ${{ inputs.mixpanel-token }}" \ + --header "content-type: application/json" \ + --data "{\"tags\": [${{ steps.create_mixpanel_tag.outputs.tag-id }}], \"date\": \"$ANNOTATION_DATE\", \"description\": \"${{ steps.tag_range.outputs.current-tag }}\"}" diff --git a/annotate/action.yml b/annotate/action.yml deleted file mode 100644 index 26710c8..0000000 --- a/annotate/action.yml +++ /dev/null @@ -1,73 +0,0 @@ -name: 'Annotate' -description: 'Annotate a new tag (e.g. on tracking platform)' - -inputs: - tag: - description: 'Version to annotate' - required: true - mixpanel-tag-group: - description: 'Mixpanel Annotation Group' - required: false - default: 'github-action' - mixpanel-project-id: - description: 'Mixpanel Project ID' - required: false - mixpanel-token: - description: 'Mixpanel Token (Basic Auth)' - required: false - mixpanel-region-domain: - description: 'Mixpanel Region Domain' - required: false - default: 'mixpanel' - -runs: - using: "composite" - steps: - - - name: Create Mixpanel Annotation Tag - if: inputs.mixpanel-project-id != '' && inputs.mixpanel-token != '' - id: create-tag - shell: bash - # https://developer.mixpanel.com/reference/create-annotation-tag-1 - run: | - RESPONSE=$(curl --silent --request POST \ - --url "https://${{ inputs.mixpanel-region-domain }}.com/api/app/projects/${{ inputs.mixpanel-project-id }}/annotations/tags" \ - --header "accept: application/json" \ - --header "authorization: Basic ${{ inputs.mixpanel-token }}" \ - --header "content-type: application/json" \ - --data '{"name":"${{ inputs.mixpanel-tag-group }}"}') - - echo "Tag response: $RESPONSE" - - STATUS=$(echo "$RESPONSE" | jq -r '.status') - if [ "$STATUS" != "ok" ]; then - echo "Failed to create tag. Status: $STATUS" - exit 1 - fi - - TAG_ID=$(echo "$RESPONSE" | jq -r '.results.id') - if [ -z "$TAG_ID" ] || [ "$TAG_ID" = "null" ]; then - echo "Failed to extract tag ID from response" - exit 1 - fi - - echo "Created tag with ID: $TAG_ID" - echo "tag-id=$TAG_ID" >> $GITHUB_OUTPUT - - - name: Create Mixpanel Annotation - if: inputs.mixpanel-project-id != '' && inputs.mixpanel-token != '' && steps.create-tag.outputs.tag-id != '' - shell: bash - # https://developer.mixpanel.com/reference/create-annotation - run: | - ANNOTATION_DATE=$(date -u +"%Y-%m-%d %H:%M:%S") - - curl --fail --silent --request POST \ - --url "https://${{ inputs.mixpanel-region-domain }}.com/api/app/projects/${{ inputs.mixpanel-project-id }}/annotations" \ - --header "accept: application/json" \ - --header "authorization: Basic ${{ inputs.mixpanel-token }}" \ - --header "content-type: application/json" \ - --data "{\"tags\": [${{ steps.create-tag.outputs.tag-id }}], \"date\": \"$ANNOTATION_DATE\", \"description\": \"${{ inputs.tag }}\"}" - - curlExitCode=$? - echo "Annotation created on Mixpanel for tag: ${{ inputs.tag }}" - exit $curlExitCode diff --git a/generate/action.yml b/generate/action.yml deleted file mode 100644 index ea7b77e..0000000 --- a/generate/action.yml +++ /dev/null @@ -1,212 +0,0 @@ -name: 'Changelog' -description: 'Generate a changelog for a given tag' - -inputs: - target-branch: - description: 'Target Branch' - default: master - github-token: - description: 'GitHub Token for authentication' - required: true - github-username: - description: 'GitHub Username' - default: Dolful - github-email: - description: 'GitHub Email' - default: github-bot@waveful.app - claude-code-oauth-token: - description: 'Claude Code OAuth Token' - required: true - previous-tag: - description: 'Previous Tag' - required: true - current-tag: - description: 'Current Tag' - required: true - prs: - description: 'PR Details with Commits and AI Summaries' - required: true - commits: - description: 'All Commits (including those not in PRs)' - required: true - -outputs: - changelog: - description: "Changelog (base64 encoded)" - value: ${{ steps.changelog.outputs.changelog }} - -runs: - using: "composite" - steps: - - - name: Decode PRs and Commits - shell: bash - run: | - echo "${{ inputs.prs }}" | base64 -d > prs.txt - echo "${{ inputs.commits }}" | base64 -d > commits.txt - - - name: Print PRs and Commits - shell: bash - run: | - echo "PRs: $(cat prs.txt 2>/dev/null || echo "No PR details available")" - echo "Commits: $(cat commits.txt 2>/dev/null || echo "No commit details available")" - - - name: Generate changelog with Claude - id: claude - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ inputs.claude-code-oauth-token }} - allowed_bots: '*' - prompt: | - Write ONLY the changelog using Markdown formatting. - Start directly with tag in Header 2 (##) followed by a list of changes in bullet points. See example below: - - Example: - - ## ${{ inputs.current-tag }} - • Use bullet points with • symbol for changes (UI, features, fixes). - • Second bullet point. - • Third and last bullet point of the example. - - - IMPORTANT: - - DO NOT add introductions or explanations before the changelog - - DO NOT say "Analyzing commits..." or similar phrases - - Start directly with "## ${{ inputs.current-tag }}" - - Use Markdown formatting: **text** for bold, • for bullet points - - Use • symbol (not - or *) for all bullet points - - Add blank lines between sections for better readability - - Ignore workflow/automation commits, focus only on core changes - - Write in simple English - - Keep bullet points concise and understandable also for non-technical users - - Do NOT escape Markdown characters (** ** for bold, * * for italic, etc.) - leave them as is - - Pull Request Details with Commits and AI Summaries: - $(cat prs.txt 2>/dev/null || echo "No PR details available") - - All Commits (including those not in PRs): - $(cat commits.txt 2>/dev/null || echo "No commit details available") - - IMPORTANT: You have two sources of information: - 1. PR details with body descriptions - these explain what was changed in PRs - 2. Complete commit list - includes ALL changes, even those not in PRs - - The commit list helps you catch: - - Direct commits to the branch - - Hotfixes not done through PRs - - Small tweaks and adjustments - - Configuration changes - - Use BOTH sources to create a complete changelog. The PR descriptions explain the major features, - while the commit list ensures nothing is missed. - Focus on core changes and what shouldn't be missed. - Group related changes together for better readability. - - Write ONLY the direct changelog using Markdown formatting, nothing else. - - - - name: Extract Claude result - id: extract - uses: actions/github-script@v8 - with: - result-encoding: string - script: | - const fs = require('fs'); - const path = '${{ steps.claude.outputs.execution_file }}'; - - try { - if (fs.existsSync(path)) { - console.log('Found Claude execution output file'); - const jsonString = fs.readFileSync(path, 'utf8'); - const data = JSON.parse(jsonString); - - // Debug: Show the structure of the JSON - console.log('JSON is an array with', data.length, 'items'); - - // Find the object with type "result" and subtype "success" - const resultObj = data.find(item => - item.type === 'result' && - item.subtype === 'success' && - item.result && - typeof item.result === 'string' - ); - - if (resultObj) { - console.log('Found result object with content length:', resultObj.result.length); - return resultObj.result; - } else { - console.log('No result object found. Available types:'); - data.forEach((item, index) => { - console.log(` ${index}: type="${item.type}", subtype="${item.subtype}"`); - }); - return ''; - } - } else { - console.log('Claude execution output file not found'); - return ''; - } - } catch (err) { - console.error('Error reading Claude output:', err.message); - return ''; - } - - - - name: Update CHANGELOG.md - id: changelog - shell: bash - run: | - - # Get Claude output from previous step and save to file safely - cat > changelog_entry.txt << 'EOF' - ${{ steps.extract.outputs.result }} - EOF - - # Check if we got valid content - if [ ! -s changelog_entry.txt ] || grep -q "automated changelog generation failed" changelog_entry.txt; then - echo "No Claude output available, using fallback" - cat > changelog_entry.txt << EOF - Automated changelog generation failed - EOF - else - echo "Using Claude output successfully" - fi - - # Clean up trailing whitespace but keep intentional blank lines - sed -i 's/[[:space:]]\+$//' changelog_entry.txt - - # Save PRs to outputs - echo "changelog=$(base64 -i changelog_entry.txt | tr -d '\n')" >> $GITHUB_OUTPUT - - # Debug: Show final content - echo "=== DEBUG: Final changelog content ===" - cat changelog_entry.txt - echo "=====================================" - - # Create CHANGELOG.md if it doesn't exist. - if [ ! -f CHANGELOG.md ]; then - echo "# Changelog" > CHANGELOG.md - echo "" >> CHANGELOG.md - fi - - # Prepend new entry after the header. - if grep -q "^# Changelog" CHANGELOG.md; then - sed -i '1 r changelog_entry.txt' CHANGELOG.md - sed -i '2d' CHANGELOG.md - else - cat changelog_entry.txt CHANGELOG.md > temp.md - mv temp.md CHANGELOG.md - fi - - - name: Commit and push changelog - shell: bash - run: | - git config user.name "${{ inputs.github-username }}" - git config user.email "${{ inputs.github-email }}" - git remote set-url origin https://x-access-token:${{ inputs.github-token }}@github.com/${GITHUB_REPOSITORY}.git - git fetch origin ${{ inputs.target-branch }} >/dev/null - git checkout ${{ inputs.target-branch }} - git pull origin ${{ inputs.target-branch }} - git add CHANGELOG.md - COMMIT_HASH=$(git rev-parse --short=7 HEAD) - git commit -m "docs: update changelog for ${{ inputs.current-tag }} (${COMMIT_HASH})" - git push origin ${{ inputs.target-branch }} diff --git a/notify/action.yml b/notify/action.yml deleted file mode 100644 index 87cc7d4..0000000 --- a/notify/action.yml +++ /dev/null @@ -1,125 +0,0 @@ -name: 'Notify' -description: 'Notify about a new tag' - -inputs: - changelog: - description: 'Changelog (base64 encoded)' - required: true - claude-code-oauth-token: - description: 'Claude Code OAuth Token' - required: true - telegram-bot-token: - description: 'Telegram Bot Token' - required: true - telegram-chat-id: - description: 'Telegram Chat ID for notification' - required: true - changelog-url: - description: 'URL to the changelog file' - required: true - -runs: - using: "composite" - steps: - - - name: Decode Changelog - shell: bash - run: | - echo "${{ inputs.changelog }}" | base64 -d > changelog.txt - - - name: Print Changelog - shell: bash - run: | - echo "Changelog: $(cat changelog.txt 2>/dev/null || echo "No changelog available")" - - - name: Summarize Changelog with Claude - id: claude - uses: anthropics/claude-code-action@v1 - with: - claude_code_oauth_token: ${{ inputs.claude-code-oauth-token }} - allowed_bots: '*' - prompt: | - Rewrite the changelog using simple HTML format. - It will be used as Telegram message. - - IMPORTANT: - - Avoid any character that may break the telegram message in HTML format. - - DO NOT add introductions or explanations. - - DO NOT say "Analyzing commits..." or similar phrases. - - Start directly with the tag name. - - Then write a list of bullet points about the changes. - - Write in simple English. - - Keep bullet points concise and understandable also for non-technical users. - - Changelog: - $(cat changelog.txt 2>/dev/null || echo "No changelog available") - - - name: Extract Claude result - id: extract - uses: actions/github-script@v8 - with: - result-encoding: string - script: | - const fs = require('fs'); - const path = '${{ steps.claude.outputs.execution_file }}'; - - try { - if (fs.existsSync(path)) { - console.log('Found Claude execution output file'); - const jsonString = fs.readFileSync(path, 'utf8'); - const data = JSON.parse(jsonString); - - // Debug: Show the structure of the JSON - console.log('JSON is an array with', data.length, 'items'); - - // Find the object with type "result" and subtype "success" - const resultObj = data.find(item => - item.type === 'result' && - item.subtype === 'success' && - item.result && - typeof item.result === 'string' - ); - - if (resultObj) { - console.log('Found result object with content length:', resultObj.result.length); - return resultObj.result; - } else { - console.log('No result object found. Available types:'); - data.forEach((item, index) => { - console.log(` ${index}: type="${item.type}", subtype="${item.subtype}"`); - }); - return ''; - } - } else { - console.log('Claude execution output file not found'); - return ''; - } - } catch (err) { - console.error('Error reading Claude output:', err.message); - return ''; - } - - - name: Send changelog to Telegram - if: success() - shell: bash - run: | - TELEGRAM_MESSAGE=$(cat <<'ENDOFMESSAGE' - ${{ steps.extract.outputs.result }} - ENDOFMESSAGE - ) - - FULL_TEXT="🔄 ${TELEGRAM_MESSAGE} - - More details available on CHANGELOG." - - JSON_PAYLOAD=$(jq -n \ - --arg chat_id "${{ inputs.telegram-chat-id }}" \ - --arg text "$FULL_TEXT" \ - '{chat_id: $chat_id, text: $text, parse_mode: "HTML"}') - - curl -s -X POST "https://api.telegram.org/bot${{ inputs.telegram-bot-token }}/sendMessage" \ - -H "Content-Type: application/json" \ - -d "$JSON_PAYLOAD" - sendExitCode=$? - echo "Changelog sent to Telegram" - exit $sendExitCode diff --git a/prepare/action.yml b/prepare/action.yml deleted file mode 100644 index 41883fe..0000000 --- a/prepare/action.yml +++ /dev/null @@ -1,177 +0,0 @@ -name: 'Retrieve Tag Data' -description: 'Retrieve some useful data for a given tag' - -inputs: - github-token: - description: 'GitHub Token for authentication' - required: true - -outputs: - previous-tag: - description: "Previous tag" - value: ${{ steps.tag_info.outputs.previous }} - current-tag: - description: "Current tag" - value: ${{ steps.tag_info.outputs.current }} - pr-count: - description: "PRs" - value: ${{ steps.prs.outputs.pr_count }} - commit-count: - description: "Commits" - value: ${{ steps.prs.outputs.commit_count }} - author-count: - description: "Authors" - value: ${{ steps.prs.outputs.author_count }} - files-changed: - description: "Files changed" - value: ${{ steps.prs.outputs.files_changed }} - additions: - description: "Additions" - value: ${{ steps.prs.outputs.additions }} - deletions: - description: "Deletions" - value: ${{ steps.prs.outputs.deletions }} - prs: - description: "PR Details with Commits and AI Summaries" - value: ${{ steps.prs.outputs.prs }} - commits: - description: "All Commits (including those not in PRs)" - value: ${{ steps.prs.outputs.commits }} - -runs: - using: "composite" - steps: - - - name: Get repository - uses: actions/checkout@v5 - with: - fetch-depth: 0 - - - name: Get tags - id: tag_info - shell: bash - run: | - git ls-remote --tags --refs origin | awk '{print $2}' | sed 's#^refs/tags/##' | grep -v -F -x -f <(git tag -l) | xargs -r -I{} git fetch origin "refs/tags/{}:refs/tags/{}" &>/dev/null || true - CURRENT_TAG=${GITHUB_REF#refs/tags/} - PREVIOUS_TAG=$(git tag --sort=creatordate | grep -B 1 "^${CURRENT_TAG}$" | head -n 1) - echo "previous=${PREVIOUS_TAG}" >> $GITHUB_OUTPUT - echo "current=${CURRENT_TAG}" >> $GITHUB_OUTPUT - echo "Previous tag: ${PREVIOUS_TAG}" - echo "Current tag: ${CURRENT_TAG}" - - - name: Collect PRs and Commits - id: prs - shell: bash - env: - GH_TOKEN: ${{ inputs.github-token }} - run: | - FROM_REF="${{ steps.tag_info.outputs.previous }}" - TO_REF="${{ steps.tag_info.outputs.current }}" - CHANGELOG_BOT_COMMIT_PATTERN='^docs: update changelog for ' - - echo "Fetching PRs from $FROM_REF to $TO_REF" - - # Get merge commits to find PR numbers - PR_NUMBERS=$(git log $FROM_REF..$TO_REF --grep="^Merge pull request #" --pretty=format:"%s" | grep -oE '#[0-9]+' | tr -d '#' | sort -u || true) - - # Check for squash-merged PRs (commits with (#PR) in subject) - SQUASH_PR_NUMBERS=$(git log $FROM_REF..$TO_REF --pretty=format:"%s" | grep -oE '\(#[0-9]+\)' | grep -oE '[0-9]+' | sort -u || true) - - # Combine and deduplicate PR numbers - ALL_PR_NUMBERS=$(echo -e "$PR_NUMBERS\n$SQUASH_PR_NUMBERS" | sort -u | grep -v '^$' || true) - - trim() { - local s=$1 - s="${s#"${s%%[![:space:]]*}"}" # remove leading - s="${s%"${s##*[![:space:]]}"}" # remove trailing - printf '%s\n' "$s" - } - - ALL_PR_NUMBERS=$(trim "$ALL_PR_NUMBERS") - - PR_COUNT=0 - if [ -n "$ALL_PR_NUMBERS" ]; then - echo "Found PRs: $ALL_PR_NUMBERS" - - # Create detailed PR information file - echo "# Pull Request Details with Commits" > pr_details_full.md - echo "" >> pr_details_full.md - echo "Range: $FROM_REF to $TO_REF" >> pr_details_full.md - echo "" >> pr_details_full.md - - for pr_num in $ALL_PR_NUMBERS; do - echo "Fetching PR #$pr_num with full details..." - - # Get PR details including body, commits, and files - PR_DATA=$(gh pr view $pr_num --repo ${{ github.repository }} \ - --json number,title,author,body,labels,mergedAt,commits,files \ - 2>/dev/null || echo "{}") - - if [ "$PR_DATA" != "{}" ] && [ "$PR_DATA" != "null" ]; then - PR_TITLE=$(echo "$PR_DATA" | jq -r '.title // "Unknown"') - PR_AUTHOR=$(echo "$PR_DATA" | jq -r '.author.login // "unknown"') - PR_BODY=$(echo "$PR_DATA" | jq -r '.body // ""') - PR_MERGED=$(echo "$PR_DATA" | jq -r '.mergedAt // ""') - - if [ -n "$PR_BODY" ]; then - echo "$PR_BODY" | head -n 100 | sed 's/^/> /' >> pr_details_full.md - fi - - # List commits in this PR - echo "### Commits:" >> pr_details_full.md - echo "$PR_DATA" | jq -r '.commits[] | "- \(.messageHeadline) by \(.authors[0].name // \"unknown\")"' >> pr_details_full.md 2>/dev/null || echo "- Unable to fetch commits" >> pr_details_full.md - echo "" >> pr_details_full.md - - # List changed files (summary) - FILE_COUNT=$(echo "$PR_DATA" | jq '.files | length' 2>/dev/null || echo "0") - echo "### Files Changed: $FILE_COUNT" >> pr_details_full.md - - # Get top changed files - echo "$PR_DATA" | jq -r '.files[:10] | .[] | "- \(.path)"' >> pr_details_full.md 2>/dev/null || echo "- Unable to fetch file list" >> pr_details_full.md - - if [ "$FILE_COUNT" -gt "10" ]; then - echo "- ... and $((FILE_COUNT - 10)) more files" >> pr_details_full.md - fi - - echo "" >> pr_details_full.md - echo "---" >> pr_details_full.md - echo "" >> pr_details_full.md - - PR_COUNT=$((PR_COUNT + 1)) - fi - done - - echo "Total PRs processed: $PR_COUNT" >> pr_details_full.md - else - echo "No merged PRs found in range $FROM_REF..$TO_REF" > pr_details_full.md - fi - - # Create a simple commit list for additional context - echo "# All Commits in Range" > commits_simple.md - echo "" >> commits_simple.md - echo "Range: $FROM_REF to $TO_REF" >> commits_simple.md - echo "" >> commits_simple.md - - # Get all commits (including those not in PRs) - git log $FROM_REF..$TO_REF --invert-grep --grep="$CHANGELOG_BOT_COMMIT_PATTERN" --pretty=format:"%h - %s (%an) - %ar" --no-merges | while read line; do - echo "- $line" >> commits_simple.md - done - - # Get statistics - COMMIT_COUNT=$(git rev-list --count $FROM_REF..$TO_REF --no-merges --invert-grep --grep="$CHANGELOG_BOT_COMMIT_PATTERN") - AUTHOR_COUNT=$(git log $FROM_REF..$TO_REF --invert-grep --grep="$CHANGELOG_BOT_COMMIT_PATTERN" --pretty=format:"%an" --no-merges | sort -u | wc -l) - FILES_CHANGED=$(git diff --name-only $FROM_REF..$TO_REF | wc -l) - ADDITIONS=$(git diff --shortstat $FROM_REF..$TO_REF | grep -oE '[0-9]+ insertions?' | grep -oE '[0-9]+' || echo "0") - DELETIONS=$(git diff --shortstat $FROM_REF..$TO_REF | grep -oE '[0-9]+ deletions?' | grep -oE '[0-9]+' || echo "0") - - echo "pr_count=$PR_COUNT" >> $GITHUB_OUTPUT - echo "commit_count=$COMMIT_COUNT" >> $GITHUB_OUTPUT - echo "author_count=$AUTHOR_COUNT" >> $GITHUB_OUTPUT - echo "files_changed=$FILES_CHANGED" >> $GITHUB_OUTPUT - echo "additions=$ADDITIONS" >> $GITHUB_OUTPUT - echo "deletions=$DELETIONS" >> $GITHUB_OUTPUT - echo "prs=$(base64 -i pr_details_full.md | tr -d '\n')" >> $GITHUB_OUTPUT - echo "commits=$(base64 -i commits_simple.md | tr -d '\n')" >> $GITHUB_OUTPUT - - echo "All outputs OK:" - echo "$(cat $GITHUB_OUTPUT)" diff --git a/scripts/changelog_to_telegram_html.py b/scripts/changelog_to_telegram_html.py new file mode 100644 index 0000000..b11bec4 --- /dev/null +++ b/scripts/changelog_to_telegram_html.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 + +import argparse +import html +import re +from pathlib import Path + + +def format_inline(text): + escaped = html.escape(text, quote=False) + escaped = re.sub(r"\*\*(.+?)\*\*", r"\1", escaped) + escaped = re.sub(r"(?\1", escaped) + escaped = re.sub(r"(?\1", escaped) + return escaped + + +def main(): + parser = argparse.ArgumentParser(description="Convert changelog markdown to Telegram HTML.") + parser.add_argument("--input", required=True) + parser.add_argument("--output", required=True) + args = parser.parse_args() + + rendered_lines = [] + for raw_line in Path(args.input).read_text(encoding="utf-8").splitlines(): + line = raw_line.strip() + + if not line: + rendered_lines.append("") + continue + + if line.startswith("## "): + rendered_lines.append(f"{format_inline(line[3:].strip())}") + continue + + if line.startswith("# "): + rendered_lines.append(f"{format_inline(line[2:].strip())}") + continue + + if line.startswith("\u2022 "): + rendered_lines.append(f"• {format_inline(line[2:].strip())}") + continue + + if re.match(r"^[*-]\s+", line): + cleaned_line = re.sub(r"^[*-]\s+", "", line) + rendered_lines.append(f"• {format_inline(cleaned_line)}") + continue + + rendered_lines.append(format_inline(line)) + + message = "\n".join(rendered_lines).strip() + Path(args.output).write_text(message + "\n", encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/scripts/openai_chat.py b/scripts/openai_chat.py new file mode 100644 index 0000000..63f9726 --- /dev/null +++ b/scripts/openai_chat.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 + +import argparse +import json +import os +import sys +import urllib.error +import urllib.request +from pathlib import Path + + +def read_text(path): + return Path(path).read_text(encoding="utf-8").strip() + + +def build_url(base_url): + normalized = base_url.rstrip("/") + if normalized.endswith("/chat/completions"): + return normalized + return f"{normalized}/chat/completions" + + +def extract_message_text(response_json): + choices = response_json.get("choices") or [] + if choices: + choice = choices[0] or {} + message = choice.get("message") or {} + content = message.get("content") + + if isinstance(content, str): + return content + + if isinstance(content, list): + parts = [] + for item in content: + if not isinstance(item, dict): + continue + text = item.get("text") + if isinstance(text, str): + parts.append(text) + if parts: + return "".join(parts) + + text = choice.get("text") + if isinstance(text, str): + return text + + output = response_json.get("output") or [] + if isinstance(output, list): + parts = [] + for block in output: + if not isinstance(block, dict): + continue + for item in block.get("content") or []: + if not isinstance(item, dict): + continue + text = item.get("text") + if isinstance(text, str): + parts.append(text) + if parts: + return "".join(parts) + + raise ValueError("Unable to extract a text response from the provider payload.") + + +def main(): + parser = argparse.ArgumentParser(description="Call an OpenAI-compatible chat endpoint.") + parser.add_argument("--system-prompt-file", required=True) + parser.add_argument("--user-prompt-file", required=True) + parser.add_argument("--output-file", required=True) + args = parser.parse_args() + + base_url = os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1").strip() + api_key = os.environ.get("OPENAI_API_KEY", "").strip() + model = os.environ.get("OPENAI_MODEL", "").strip() + temperature = os.environ.get("OPENAI_TEMPERATURE", "0.2").strip() + auth_header = os.environ.get("OPENAI_AUTH_HEADER", "Authorization").strip() + auth_prefix = os.environ.get("OPENAI_AUTH_PREFIX", "Bearer ") + + if not api_key: + raise SystemExit("OPENAI_API_KEY is required.") + + if not model: + raise SystemExit("OPENAI_MODEL is required.") + + payload = { + "model": model, + "messages": [ + {"role": "system", "content": read_text(args.system_prompt_file)}, + {"role": "user", "content": read_text(args.user_prompt_file)}, + ], + "temperature": float(temperature), + } + + request = urllib.request.Request( + build_url(base_url), + data=json.dumps(payload).encode("utf-8"), + headers={ + "Content-Type": "application/json", + auth_header: f"{auth_prefix}{api_key}", + }, + method="POST", + ) + + try: + with urllib.request.urlopen(request) as response: + response_json = json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + error_body = exc.read().decode("utf-8", errors="replace") + print(error_body, file=sys.stderr) + raise SystemExit(f"LLM request failed with HTTP {exc.code}.") from exc + except urllib.error.URLError as exc: + raise SystemExit(f"Unable to reach the configured LLM endpoint: {exc.reason}") from exc + + message = extract_message_text(response_json).strip() + if not message: + raise SystemExit("The configured LLM endpoint returned an empty response.") + + Path(args.output_file).write_text(message + "\n", encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/tests/test_changelog_to_telegram_html.py b/tests/test_changelog_to_telegram_html.py new file mode 100644 index 0000000..14ead76 --- /dev/null +++ b/tests/test_changelog_to_telegram_html.py @@ -0,0 +1,70 @@ +import importlib.util +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "scripts" / "changelog_to_telegram_html.py" + + +def load_module(): + spec = importlib.util.spec_from_file_location( + "changelog_to_telegram_html", MODULE_PATH + ) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +telegram_html = load_module() + + +class TelegramHtmlTests(unittest.TestCase): + def test_format_inline_escapes_html_and_formats_markdown(self): + self.assertEqual( + telegram_html.format_inline("Use **bold** and *italic* plus _notes_"), + "Use bold <tag> and italic plus notes", + ) + + def test_main_converts_markdown_to_telegram_safe_html(self): + with tempfile.TemporaryDirectory() as tmpdir: + temp_path = Path(tmpdir) + input_file = temp_path / "input.md" + output_file = temp_path / "output.html" + + input_file.write_text( + "## v1.2.3\n" + "\n" + "\u2022 Added **support**\n" + "- Fixed \n" + "Plain _text_\n", + encoding="utf-8", + ) + + argv = [ + "changelog_to_telegram_html.py", + "--input", + str(input_file), + "--output", + str(output_file), + ] + + with mock.patch.object(sys, "argv", argv): + telegram_html.main() + + self.assertEqual( + output_file.read_text(encoding="utf-8"), + "v1.2.3\n" + "\n" + "• Added support\n" + "• Fixed <bug>\n" + "Plain text\n", + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_openai_chat.py b/tests/test_openai_chat.py new file mode 100644 index 0000000..52b8efa --- /dev/null +++ b/tests/test_openai_chat.py @@ -0,0 +1,190 @@ +import importlib.util +import json +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + + +ROOT = Path(__file__).resolve().parents[1] +MODULE_PATH = ROOT / "scripts" / "openai_chat.py" + + +def load_module(): + spec = importlib.util.spec_from_file_location("openai_chat", MODULE_PATH) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +openai_chat = load_module() + + +class FakeResponse: + def __init__(self, payload): + self.payload = payload + + def read(self): + return json.dumps(self.payload).encode("utf-8") + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + +class OpenAIChatTests(unittest.TestCase): + def test_build_url_appends_chat_completions(self): + self.assertEqual( + openai_chat.build_url("https://example.com/v1"), + "https://example.com/v1/chat/completions", + ) + + def test_build_url_keeps_existing_chat_completions_endpoint(self): + self.assertEqual( + openai_chat.build_url("https://example.com/v1/chat/completions"), + "https://example.com/v1/chat/completions", + ) + + def test_extract_message_text_supports_multiple_response_shapes(self): + self.assertEqual( + openai_chat.extract_message_text( + {"choices": [{"message": {"content": "first response"}}]} + ), + "first response", + ) + + self.assertEqual( + openai_chat.extract_message_text( + { + "choices": [ + { + "message": { + "content": [ + {"type": "output_text", "text": "hello "}, + {"type": "output_text", "text": "world"}, + ] + } + } + ] + } + ), + "hello world", + ) + + self.assertEqual( + openai_chat.extract_message_text( + { + "output": [ + { + "content": [ + {"type": "output_text", "text": "fallback "}, + {"type": "output_text", "text": "format"}, + ] + } + ] + } + ), + "fallback format", + ) + + def test_main_posts_request_and_writes_output(self): + with tempfile.TemporaryDirectory() as tmpdir: + temp_path = Path(tmpdir) + system_prompt = temp_path / "system.txt" + user_prompt = temp_path / "user.txt" + output_file = temp_path / "output.txt" + + system_prompt.write_text("system prompt\n", encoding="utf-8") + user_prompt.write_text("user prompt\n", encoding="utf-8") + + captured = {} + + def fake_urlopen(request): + captured["url"] = request.full_url + captured["headers"] = { + key.lower(): value for key, value in request.header_items() + } + captured["payload"] = json.loads(request.data.decode("utf-8")) + return FakeResponse( + {"choices": [{"message": {"content": "## v1.2.3\n\u2022 Added support"}}]} + ) + + argv = [ + "openai_chat.py", + "--system-prompt-file", + str(system_prompt), + "--user-prompt-file", + str(user_prompt), + "--output-file", + str(output_file), + ] + + env = { + "OPENAI_BASE_URL": "https://provider.example/v1", + "OPENAI_API_KEY": "secret-key", + "OPENAI_MODEL": "test-model", + "OPENAI_TEMPERATURE": "0.7", + "OPENAI_AUTH_HEADER": "api-key", + "OPENAI_AUTH_PREFIX": "", + } + + with mock.patch.dict(os.environ, env, clear=True), mock.patch.object( + sys, "argv", argv + ), mock.patch("urllib.request.urlopen", side_effect=fake_urlopen): + openai_chat.main() + + self.assertEqual( + output_file.read_text(encoding="utf-8"), + "## v1.2.3\n\u2022 Added support\n", + ) + self.assertEqual( + captured["url"], + "https://provider.example/v1/chat/completions", + ) + self.assertEqual(captured["headers"]["api-key"], "secret-key") + self.assertEqual(captured["payload"]["model"], "test-model") + self.assertEqual(captured["payload"]["temperature"], 0.7) + self.assertEqual( + captured["payload"]["messages"], + [ + {"role": "system", "content": "system prompt"}, + {"role": "user", "content": "user prompt"}, + ], + ) + + def test_main_requires_api_key(self): + with tempfile.TemporaryDirectory() as tmpdir: + temp_path = Path(tmpdir) + system_prompt = temp_path / "system.txt" + user_prompt = temp_path / "user.txt" + output_file = temp_path / "output.txt" + + system_prompt.write_text("system prompt", encoding="utf-8") + user_prompt.write_text("user prompt", encoding="utf-8") + + argv = [ + "openai_chat.py", + "--system-prompt-file", + str(system_prompt), + "--user-prompt-file", + str(user_prompt), + "--output-file", + str(output_file), + ] + + with mock.patch.dict(os.environ, {"OPENAI_MODEL": "test-model"}, clear=True), mock.patch.object( + sys, "argv", argv + ): + with self.assertRaises(SystemExit) as error: + openai_chat.main() + + self.assertEqual(str(error.exception), "OPENAI_API_KEY is required.") + + +if __name__ == "__main__": + unittest.main()