# GitHub release notes bot

PaperChase bot template, version 1.0. MIT license. A GitHub Actions workflow and a dependency-free Node.js 20 script. Push a version tag, get a draft release with grouped, readable notes.

## What it does

1. Runs when you push a tag that starts with `v` (for example `v1.4.0`).
2. Finds the previous tag and every pull request merged since then.
3. Groups them by label: Features, Fixes, Docs. Chores and anything labeled `skip-notes` are hidden.
4. Optionally rewrites developer PR titles into one plain-language line each, using any OpenAI-compatible chat API.
5. Creates a **draft** release. Nothing is public until you press Publish.

## Files

```
.github/workflows/release-notes.yml
scripts/release-notes.mjs
```

## Contents

1. Workflow file
2. Script: scripts/release-notes.mjs
3. Labels and configuration
4. Optional plain-language rewrite
5. Instructions for your AI coding agent
6. Troubleshooting

---

## 1. Workflow file

Save as `.github/workflows/release-notes.yml`:

```yaml
name: release-notes
on:
  push:
    tags: ['v*']
permissions:
  contents: write
  pull-requests: read
jobs:
  notes:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: node scripts/release-notes.mjs
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          MODEL_API_KEY: ${{ secrets.MODEL_API_KEY }}
          MODEL_API_URL: ${{ vars.MODEL_API_URL }}
          MODEL_NAME: ${{ vars.MODEL_NAME }}
```

`fetch-depth: 0` matters: the script needs full tag history to find the previous version.

## 2. Script: scripts/release-notes.mjs

```javascript
import { execSync } from 'node:child_process';

const GROUPS = [
  { title: 'Features', labels: ['feature', 'enhancement'] },
  { title: 'Fixes', labels: ['fix', 'bug'] },
  { title: 'Docs', labels: ['docs', 'documentation'] },
];
const HIDDEN = ['chore', 'skip-notes', 'dependencies'];
const OTHER_TITLE = 'Other changes';

const repo = process.env.GITHUB_REPOSITORY;
const tag = process.env.GITHUB_REF_NAME;
const token = process.env.GITHUB_TOKEN;
const api = (path, opts = {}) => fetch('https://api.github.com' + path, {
  ...opts,
  headers: { authorization: 'Bearer ' + token, accept: 'application/vnd.github+json', 'x-github-api-version': '2022-11-28', ...(opts.headers || {}) },
}).then(async r => { if (!r.ok) throw new Error(path + ' ' + r.status + ' ' + await r.text()); return r.json(); });

const git = cmd => execSync('git ' + cmd, { encoding: 'utf8' }).trim();

function previousTag() {
  const tags = git('tag --list "v*" --sort=-creatordate').split('\n').filter(Boolean);
  const i = tags.indexOf(tag);
  return i >= 0 ? tags[i + 1] : tags[0];
}

async function mergedPulls(since) {
  const q = encodeURIComponent(`repo:${repo} is:pr is:merged merged:>${since}`);
  const out = [];
  for (let page = 1; page <= 10; page++) {
    const res = await api(`/search/issues?q=${q}&per_page=100&page=${page}`);
    out.push(...res.items);
    if (res.items.length < 100) break;
  }
  return out;
}

async function rewrite(lines) {
  const { MODEL_API_KEY: key, MODEL_API_URL: url, MODEL_NAME: model } = process.env;
  if (!key || !url || !model || !lines.length) return lines;
  const prompt = 'Rewrite each pull request title as one line a user of the product would understand, under 15 words. ' +
    'Keep the same order and count, one per line, no numbering. Do not add features that are not in the title.\n\n' + lines.join('\n');
  try {
    const res = await fetch(url, {
      method: 'POST',
      headers: { 'content-type': 'application/json', authorization: 'Bearer ' + key },
      body: JSON.stringify({ model, messages: [{ role: 'user', content: prompt }], max_tokens: 800 }),
    });
    const data = await res.json();
    const out = data.choices[0].message.content.split('\n').map(s => s.trim()).filter(Boolean);
    return out.length === lines.length ? out : lines;
  } catch (e) {
    console.log('rewrite skipped:', e.message);
    return lines;
  }
}

const prev = previousTag();
const since = prev ? git(`log -1 --format=%cI ${prev}`) : '1970-01-01T00:00:00Z';
const pulls = (await mergedPulls(since)).filter(p => !p.labels.some(l => HIDDEN.includes(l.name)));

const buckets = new Map([...GROUPS.map(g => [g.title, []]), [OTHER_TITLE, []]]);
for (const p of pulls) {
  const g = GROUPS.find(g => p.labels.some(l => g.labels.includes(l.name)));
  buckets.get(g ? g.title : OTHER_TITLE).push(p);
}

let body = prev ? `Changes since ${prev}.\n` : '';
for (const [title, list] of buckets) {
  if (!list.length) continue;
  const lines = await rewrite(list.map(p => p.title));
  body += `\n## ${title}\n` + list.map((p, i) => `- ${lines[i]} (#${p.number})`).join('\n') + '\n';
}
if (!pulls.length) body += '\nNo user-facing pull requests in this version.\n';

const release = await api(`/repos/${repo}/releases`, {
  method: 'POST',
  body: JSON.stringify({ tag_name: tag, name: tag, body, draft: true }),
});
console.log('Draft release created:', release.html_url);
```

## 3. Labels and configuration

- Create labels named `feature`, `fix`, `docs`, `chore` and `skip-notes` in your repo, or edit `GROUPS` and `HIDDEN` to match the labels you already use.
- Pull requests with no matching label land under "Other changes".
- The search API caps results at 1,000. If a version has more merged PRs than that, tag more often.

## 4. Optional plain-language rewrite

The rewrite works with any API that accepts the OpenAI-style chat completions format.

1. Add `MODEL_API_KEY` under Settings, Secrets and variables, Actions, Secrets.
2. Add `MODEL_API_URL` (the full chat completions endpoint of your provider) and `MODEL_NAME` (a current small model) under Variables.
3. If any of the three is missing, or the model returns the wrong number of lines, the script uses the original PR titles. A release is never blocked by the model.

## 5. Instructions for your AI coding agent

```
You are editing scripts/release-notes.mjs (Node 20, no dependencies) and .github/workflows/release-notes.yml.
Rules:
- Releases must stay draft: true.
- No new npm dependencies; use built-in fetch and node: modules.
- Never print GITHUB_TOKEN or MODEL_API_KEY.
- If the model call fails, fall back to original titles.
Change I want: [describe the change].
```

## 6. Troubleshooting

- **403 on creating the release.** The workflow needs `contents: write`. Check the permissions block and the repository's Actions settings for workflow permissions.
- **Every PR since the beginning appears.** The previous tag was not found. Make sure `fetch-depth: 0` is set and older tags start with `v`.
- **A PR is missing.** It was merged before the previous tag's commit date, or it has a hidden label.
- **GitHub already has a "Generate release notes" button.** True. Use it if it is enough. This template adds hidden chores, custom groups, the optional rewrite and an automatic draft on every tag.

License: MIT. Copyright PaperChaseWebb Inc. Permission is granted to use, copy, modify and distribute this template, with or without changes, provided this notice is kept. Provided as is, without warranty of any kind.
Questions: mgmt@paperchasewebb.com
