mirror of
https://github.com/immich-app/immich.git
synced 2026-10-12 02:16:40 +00:00
feat: custom release notes (#32172)
This commit is contained in:
@@ -136,13 +136,16 @@ jobs:
|
||||
name: release-apk-signed
|
||||
github-token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- name: Create draft release
|
||||
- name: Setup Mise
|
||||
uses: immich-app/devtools/actions/use-mise@06a9ef925332c91be647d6256642b86b398592c8 # use-mise-action-v3.2.1
|
||||
with:
|
||||
github_token: ${{ steps.generate-token.outputs.token }}
|
||||
|
||||
- name: Generate release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
TAG: ${{ needs.tag.outputs.version }}
|
||||
PRERELEASE: ${{ needs.tag.outputs.rc }}
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
run: |
|
||||
tags=$(git -c versionsort.suffix=-rc tag --list 'v[0-9]*' --sort=-v:refname)
|
||||
if [[ "${PRERELEASE}" != 'true' ]]; then
|
||||
@@ -151,15 +154,24 @@ jobs:
|
||||
previous=$(grep -A1 -xF "${TAG}" <<< "${tags}" | tail -1)
|
||||
echo "generating notes from ${previous}"
|
||||
|
||||
mise //:notes --tag "${TAG}" --previous "${previous}" > "${RUNNER_TEMP}/notes.md"
|
||||
cat "${RUNNER_TEMP}/notes.md"
|
||||
|
||||
- name: Create draft release
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.generate-token.outputs.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
TAG: ${{ needs.tag.outputs.version }}
|
||||
PRERELEASE: ${{ needs.tag.outputs.rc }}
|
||||
BRANCH: ${{ github.ref_name }}
|
||||
run: |
|
||||
gh release create "${TAG}" \
|
||||
--repo "${REPO}" \
|
||||
--target "${BRANCH}" \
|
||||
--draft \
|
||||
--verify-tag \
|
||||
--prerelease="${PRERELEASE}" \
|
||||
--notes-file misc/release/notes.tmpl \
|
||||
--generate-notes \
|
||||
--notes-start-tag "${previous}" \
|
||||
--notes-file "${RUNNER_TEMP}/notes.md" \
|
||||
docker/docker-compose.yml \
|
||||
docker/docker-compose.rootless.yml \
|
||||
docker/example.env \
|
||||
|
||||
@@ -47,6 +47,13 @@ run = [
|
||||
{ task = "//:open-api" },
|
||||
]
|
||||
|
||||
[tasks.notes]
|
||||
run = [
|
||||
"pnpm --filter @immich/scripts install --frozen-lockfile",
|
||||
"pnpm --filter @immich/scripts build",
|
||||
"pnpm --filter @immich/scripts cli notes {{arg(name='args', var=true)}}",
|
||||
]
|
||||
|
||||
[tasks.plugins]
|
||||
run = [
|
||||
"pnpm --filter @immich/sdk --filter @immich/plugin-sdk --filter @immich/plugin-core install --frozen-lockfile",
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"commander": "^15.0.0",
|
||||
"semver": "^7.8.1"
|
||||
"semver": "^7.8.1",
|
||||
"yaml": "^2.9.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.13.6",
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Command, Option } from 'commander';
|
||||
import {
|
||||
GitClient,
|
||||
GitHubClient,
|
||||
type NotesOptions,
|
||||
NotesService,
|
||||
} from './commands/notes';
|
||||
import { handleRelease } from './commands/release';
|
||||
import {
|
||||
RELEASE_TYPES,
|
||||
@@ -43,5 +49,29 @@ export const cli = (argv: string[]) => {
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
.command('notes')
|
||||
.description('generate release notes for a tag')
|
||||
.requiredOption('-t, --tag <tag>', 'the tag being released')
|
||||
.requiredOption('-p, --previous <tag>', 'the previously released tag')
|
||||
.addOption(
|
||||
new Option('--token <token>', 'a GitHub token')
|
||||
.env('GH_TOKEN')
|
||||
.default(process.env.GITHUB_TOKEN, 'GITHUB_TOKEN')
|
||||
.makeOptionMandatory(),
|
||||
)
|
||||
.action(async ({ token, ...options }: NotesOptions & { token: string }) => {
|
||||
try {
|
||||
const service = new NotesService(
|
||||
new GitClient(),
|
||||
new GitHubClient(token),
|
||||
);
|
||||
process.stdout.write(await service.handleNotes(options));
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
return program.parse(argv);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import type { Public } from 'src/types.js';
|
||||
import { beforeEach, describe, expect, it, type Mocked, vi } from 'vitest';
|
||||
import {
|
||||
type Contributor,
|
||||
type GitClient,
|
||||
type GitHubClient,
|
||||
NotesService,
|
||||
type PullRequest,
|
||||
} from './notes';
|
||||
|
||||
type Fixture = Partial<PullRequest> & {
|
||||
number: number;
|
||||
original?: number;
|
||||
published?: boolean;
|
||||
commit?: boolean;
|
||||
known?: boolean;
|
||||
};
|
||||
|
||||
const alice = { login: 'alice', bot: false };
|
||||
const bob = { login: 'bob', bot: false };
|
||||
const bot = { login: 'immich-push-o-matic', bot: true };
|
||||
|
||||
const link = (number: number) =>
|
||||
`https://github.com/immich-app/immich/pull/${number}`;
|
||||
const fix = (number: number, author = alice) =>
|
||||
`* fix: change ${number} by @${author.login}${author.bot ? '[bot]' : ''} in ${link(number)}`;
|
||||
|
||||
const changelog = (...lines: string[]) =>
|
||||
[
|
||||
"## What's Changed",
|
||||
...lines,
|
||||
'',
|
||||
'**Full Changelog**: https://github.com/immich-app/immich/compare/v3.2.4...v3.3.0',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const body = (notes: string) => notes.slice(notes.indexOf("## What's Changed"));
|
||||
|
||||
describe(NotesService.name, () => {
|
||||
let git: Mocked<Public<GitClient>>;
|
||||
let github: Mocked<Public<GitHubClient>>;
|
||||
let sut: NotesService;
|
||||
|
||||
const setup = (fixtures: Fixture[], firstTimers: Contributor[] = []) => {
|
||||
const pulls = new Map<number, PullRequest>();
|
||||
const log = ['> chore: version v3.3.0'];
|
||||
|
||||
for (const {
|
||||
original,
|
||||
published,
|
||||
commit = true,
|
||||
known = true,
|
||||
...overrides
|
||||
} of fixtures) {
|
||||
const pull: PullRequest = {
|
||||
title: `fix: change ${original ?? overrides.number}`,
|
||||
body: original ? `Backport of #${original} to \`release/v3.2\`.` : null,
|
||||
mergedAt: `2026-01-${String(overrides.number).padStart(2, '0')}T00:00:00Z`,
|
||||
author: original ? bot : alice,
|
||||
labels: original
|
||||
? ['changelog:bugfix', 'backport']
|
||||
: ['changelog:bugfix'],
|
||||
...overrides,
|
||||
};
|
||||
|
||||
if (known) {
|
||||
pulls.set(pull.number, pull);
|
||||
}
|
||||
|
||||
if (commit) {
|
||||
log.push(`${published ? '<' : '>'} ${pull.title} (#${pull.number})`);
|
||||
}
|
||||
}
|
||||
|
||||
log.push('< chore: version v3.2.4');
|
||||
|
||||
git.log.mockReturnValue(log.join('\n') + '\n');
|
||||
github.getPullRequests.mockImplementation(async (numbers) => {
|
||||
const found = new Map<number, PullRequest>();
|
||||
for (const number of numbers) {
|
||||
const pull = pulls.get(number);
|
||||
if (pull) {
|
||||
found.set(number, pull);
|
||||
}
|
||||
}
|
||||
return found;
|
||||
});
|
||||
github.getFirstTimers.mockResolvedValue(firstTimers);
|
||||
};
|
||||
|
||||
const run = () => sut.handleNotes({ tag: 'v3.3.0', previous: 'v3.2.4' });
|
||||
|
||||
beforeEach(() => {
|
||||
git = { log: vi.fn() };
|
||||
github = { getPullRequests: vi.fn(), getFirstTimers: vi.fn() };
|
||||
sut = new NotesService(git, github);
|
||||
});
|
||||
|
||||
it('should compare the previous tag with the new tag', async () => {
|
||||
setup([{ number: 1 }]);
|
||||
|
||||
await run();
|
||||
|
||||
expect(git.log).toHaveBeenCalledWith('v3.2.4', 'v3.3.0');
|
||||
});
|
||||
|
||||
it('should start with the template and end with the full changelog', async () => {
|
||||
setup([{ number: 1 }]);
|
||||
|
||||
const notes = await run();
|
||||
|
||||
expect(notes.startsWith('## Highlights')).toBe(true);
|
||||
expect(body(notes)).toMatchInlineSnapshot(`
|
||||
"## What's Changed
|
||||
### 🐛 Bug fixes
|
||||
* fix: change 1 by @alice in https://github.com/immich-app/immich/pull/1
|
||||
|
||||
**Full Changelog**: https://github.com/immich-app/immich/compare/v3.2.4...v3.3.0
|
||||
"
|
||||
`);
|
||||
});
|
||||
|
||||
it('should attribute a backport to the original pull request', async () => {
|
||||
setup([
|
||||
{ number: 2, original: 1 },
|
||||
{ number: 1, commit: false },
|
||||
]);
|
||||
|
||||
const notes = await run();
|
||||
|
||||
expect(github.getPullRequests).toHaveBeenNthCalledWith(1, [2]);
|
||||
expect(github.getPullRequests).toHaveBeenNthCalledWith(2, [1]);
|
||||
expect(body(notes)).toBe(changelog('### 🐛 Bug fixes', fix(1)));
|
||||
});
|
||||
|
||||
it('should fall back to the backport when the body lacks a reference', async () => {
|
||||
setup([
|
||||
{ number: 2, original: 1, body: '* BACKPORT-CONFLICT' },
|
||||
{ number: 1, commit: false },
|
||||
]);
|
||||
|
||||
const notes = await run();
|
||||
|
||||
expect(body(notes)).toBe(
|
||||
changelog(
|
||||
'### 🐛 Bug fixes',
|
||||
`* fix: change 1 by @immich-push-o-matic[bot] in ${link(2)}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should drop main changes already published through a backport on the previous line', async () => {
|
||||
setup([
|
||||
{ number: 3 },
|
||||
{ number: 1 },
|
||||
{ number: 2, original: 1, published: true },
|
||||
]);
|
||||
|
||||
const notes = await run();
|
||||
|
||||
expect(body(notes)).toBe(changelog('### 🐛 Bug fixes', fix(3)));
|
||||
});
|
||||
|
||||
it('should drop changes backported to both lines when the previous line shipped first', async () => {
|
||||
setup([
|
||||
{ number: 3, original: 1 },
|
||||
{ number: 4 },
|
||||
{ number: 2, original: 1, published: true },
|
||||
{ number: 1, commit: false },
|
||||
]);
|
||||
|
||||
const notes = await run();
|
||||
|
||||
expect(body(notes)).toBe(changelog('### 🐛 Bug fixes', fix(4)));
|
||||
});
|
||||
|
||||
it('should list each original once in merge order', async () => {
|
||||
setup([{ number: 6, original: 1 }, { number: 5 }, { number: 1 }]);
|
||||
|
||||
const notes = await run();
|
||||
|
||||
expect(body(notes)).toBe(changelog('### 🐛 Bug fixes', fix(1), fix(5)));
|
||||
});
|
||||
|
||||
it('should ignore commits without a known pull request', async () => {
|
||||
setup([{ number: 1 }, { number: 99, known: false }]);
|
||||
|
||||
const notes = await run();
|
||||
|
||||
expect(body(notes)).toBe(changelog('### 🐛 Bug fixes', fix(1)));
|
||||
});
|
||||
|
||||
it('should group by changelog label and omit unlisted labels', async () => {
|
||||
setup([
|
||||
{ number: 3, labels: ['🗄️server', 'changelog:bugfix'] },
|
||||
{ number: 2, labels: ['changelog:skip'] },
|
||||
{ number: 1, labels: ['changelog:feature'] },
|
||||
]);
|
||||
|
||||
const notes = await run();
|
||||
|
||||
expect(body(notes)).toBe(
|
||||
changelog('### 🚀 Features', fix(1), '### 🐛 Bug fixes', fix(3)),
|
||||
);
|
||||
});
|
||||
|
||||
it('should look up first contributions using the earliest change per human author', async () => {
|
||||
setup([
|
||||
{ number: 4, author: bob },
|
||||
{ number: 3 },
|
||||
{ number: 2 },
|
||||
{ number: 1, author: bot },
|
||||
]);
|
||||
|
||||
await run();
|
||||
|
||||
expect(github.getFirstTimers).toHaveBeenCalledWith([
|
||||
{ author: alice, number: 2, mergedAt: '2026-01-02T00:00:00Z' },
|
||||
{ author: bob, number: 4, mergedAt: '2026-01-04T00:00:00Z' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should render new contributors', async () => {
|
||||
setup(
|
||||
[{ number: 1 }],
|
||||
[{ author: alice, number: 1, mergedAt: '2026-01-01T00:00:00Z' }],
|
||||
);
|
||||
|
||||
const notes = await run();
|
||||
|
||||
expect(body(notes)).toMatchInlineSnapshot(`
|
||||
"## What's Changed
|
||||
### 🐛 Bug fixes
|
||||
* fix: change 1 by @alice in https://github.com/immich-app/immich/pull/1
|
||||
|
||||
## New Contributors
|
||||
* @alice made their first contribution in https://github.com/immich-app/immich/pull/1
|
||||
|
||||
**Full Changelog**: https://github.com/immich-app/immich/compare/v3.2.4...v3.3.0
|
||||
"
|
||||
`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,326 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { Public } from 'src/types.js';
|
||||
import { parse } from 'yaml';
|
||||
|
||||
const findRoot = (dir: string): string =>
|
||||
existsSync(join(dir, 'pnpm-workspace.yaml')) ? dir : findRoot(dirname(dir));
|
||||
|
||||
const root = findRoot(dirname(fileURLToPath(import.meta.url)));
|
||||
|
||||
const REPO = 'immich-app/immich';
|
||||
|
||||
const Files = {
|
||||
Config: join(root, '.github/release.yml'),
|
||||
Template: join(root, 'misc/release/notes.tmpl'),
|
||||
};
|
||||
|
||||
export type NotesOptions = {
|
||||
tag: string;
|
||||
previous: string;
|
||||
};
|
||||
|
||||
type Side = 'left' | 'right';
|
||||
type Commit = { side: Side; number: number };
|
||||
|
||||
type Author = { login: string; bot: boolean };
|
||||
export type PullRequest = {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
mergedAt: string;
|
||||
author: Author;
|
||||
labels: string[];
|
||||
};
|
||||
|
||||
type Category = { title: string; labels: string[] };
|
||||
export type Contributor = { author: Author; number: number; mergedAt: string };
|
||||
|
||||
type GraphQlResponse<T> = {
|
||||
data?: T;
|
||||
errors?: Array<{ message: string }>;
|
||||
};
|
||||
|
||||
type PullRequestNode = {
|
||||
number: number;
|
||||
title: string;
|
||||
body: string | null;
|
||||
mergedAt: string;
|
||||
author: { __typename: string; login: string } | null;
|
||||
labels: { nodes: Array<{ name: string }> };
|
||||
};
|
||||
|
||||
const PULL_REQUEST_BATCH = 50;
|
||||
const SEARCH_BATCH = 20;
|
||||
|
||||
export class GitClient {
|
||||
log(previous: string, tag: string) {
|
||||
return execFileSync(
|
||||
'git',
|
||||
[
|
||||
'log',
|
||||
'--left-right',
|
||||
'--no-merges',
|
||||
'--format=%m %s',
|
||||
`${previous}...${tag}`,
|
||||
],
|
||||
{ cwd: root, encoding: 'utf8' },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class GitHubClient {
|
||||
constructor(private token: string) {}
|
||||
|
||||
async getPullRequests(numbers: number[]) {
|
||||
const pulls = new Map<number, PullRequest>();
|
||||
const [owner, name] = REPO.split('/');
|
||||
|
||||
for (const batch of this.chunk(numbers, PULL_REQUEST_BATCH)) {
|
||||
const fields = batch
|
||||
.map(
|
||||
(number) =>
|
||||
`pr_${number}: pullRequest(number: ${number}) { ...PullRequestFields }`,
|
||||
)
|
||||
.join('\n');
|
||||
|
||||
const query = `
|
||||
query($owner: String!, $name: String!) {
|
||||
repository(owner: $owner, name: $name) { ${fields} }
|
||||
}
|
||||
fragment PullRequestFields on PullRequest {
|
||||
number
|
||||
title
|
||||
body
|
||||
mergedAt
|
||||
author { __typename login }
|
||||
labels(first: 100) { nodes { name } }
|
||||
}`;
|
||||
|
||||
const data = await this.graphql<{
|
||||
repository: Record<string, PullRequestNode | null>;
|
||||
}>(query, { owner, name });
|
||||
|
||||
for (const node of Object.values(data.repository)) {
|
||||
if (!node?.mergedAt) {
|
||||
continue;
|
||||
}
|
||||
|
||||
pulls.set(node.number, {
|
||||
number: node.number,
|
||||
title: node.title,
|
||||
body: node.body,
|
||||
mergedAt: node.mergedAt,
|
||||
author: {
|
||||
login: node.author?.login ?? 'ghost',
|
||||
bot: node.author?.__typename === 'Bot',
|
||||
},
|
||||
labels: node.labels.nodes.map(({ name }) => name),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return pulls;
|
||||
}
|
||||
|
||||
async getFirstTimers(candidates: Contributor[]) {
|
||||
const firstTimers: Contributor[] = [];
|
||||
|
||||
for (const batch of this.chunk(candidates, SEARCH_BATCH)) {
|
||||
const fields = batch
|
||||
.map(({ author, mergedAt }, index) => {
|
||||
const search = JSON.stringify(
|
||||
`repo:${REPO} is:pr is:merged author:${author.login} merged:<${mergedAt}`,
|
||||
);
|
||||
return `c_${index}: search(query: ${search}, type: ISSUE, first: 1) { issueCount }`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const data = await this.graphql<Record<string, { issueCount: number }>>(
|
||||
`query { ${fields} }`,
|
||||
);
|
||||
|
||||
for (const [index, candidate] of batch.entries()) {
|
||||
if (data[`c_${index}`].issueCount === 0) {
|
||||
firstTimers.push(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return firstTimers;
|
||||
}
|
||||
|
||||
private chunk<T>(items: T[], size: number) {
|
||||
const chunks: T[][] = [];
|
||||
for (let i = 0; i < items.length; i += size) {
|
||||
chunks.push(items.slice(i, i + size));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
private async graphql<T>(query: string, variables?: Record<string, string>) {
|
||||
const response = await fetch('https://api.github.com/graphql', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ query, variables }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`GitHub GraphQL request failed: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { data, errors } = (await response.json()) as GraphQlResponse<T>;
|
||||
if (!data) {
|
||||
throw new Error(
|
||||
`GitHub GraphQL request failed: ${errors?.map(({ message }) => message).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
export class NotesService {
|
||||
constructor(
|
||||
private git: Public<GitClient>,
|
||||
private github: Public<GitHubClient>,
|
||||
) {}
|
||||
|
||||
async handleNotes({ tag, previous }: NotesOptions) {
|
||||
const commits = this.parseCommits(this.git.log(previous, tag));
|
||||
const pulls = await this.getPullRequests(commits);
|
||||
const changes = this.selectChanges(commits, pulls);
|
||||
const firstTimers = await this.getFirstTimers(changes);
|
||||
|
||||
return this.render({ tag, previous, changes, firstTimers });
|
||||
}
|
||||
|
||||
private parseCommits(log: string): Commit[] {
|
||||
return log.split('\n').flatMap((line) => {
|
||||
const match = line.match(/^([<>]) .*\(#(\d+)\)$/);
|
||||
return match
|
||||
? {
|
||||
side: match[1] === '<' ? 'left' : 'right',
|
||||
number: Number(match[2]),
|
||||
}
|
||||
: [];
|
||||
});
|
||||
}
|
||||
|
||||
private async getPullRequests(commits: Commit[]) {
|
||||
const pulls = await this.github.getPullRequests(
|
||||
this.unique(commits.map(({ number }) => number)),
|
||||
);
|
||||
|
||||
const originals = this.unique(
|
||||
[...pulls.values()].map((pull) => this.toOriginal(pull)),
|
||||
).filter((number) => !pulls.has(number));
|
||||
|
||||
for (const [number, pull] of await this.github.getPullRequests(originals)) {
|
||||
pulls.set(number, pull);
|
||||
}
|
||||
|
||||
return pulls;
|
||||
}
|
||||
|
||||
private toOriginal(pull: PullRequest) {
|
||||
const match = pull.body?.match(/^Backport of #(\d+) to `release\//);
|
||||
return match ? Number(match[1]) : pull.number;
|
||||
}
|
||||
|
||||
private selectChanges(commits: Commit[], pulls: Map<number, PullRequest>) {
|
||||
const originals = (side: Side) =>
|
||||
commits
|
||||
.filter((commit) => commit.side === side)
|
||||
.flatMap((commit) => pulls.get(commit.number) ?? [])
|
||||
.map((pull) => this.toOriginal(pull));
|
||||
|
||||
const published = new Set(originals('left'));
|
||||
|
||||
return this.unique(originals('right'))
|
||||
.filter((number) => !published.has(number))
|
||||
.flatMap((number) => pulls.get(number) ?? [])
|
||||
.sort((a, b) => a.mergedAt.localeCompare(b.mergedAt));
|
||||
}
|
||||
|
||||
private getFirstTimers(changes: PullRequest[]) {
|
||||
const candidates = new Map<string, Contributor>();
|
||||
|
||||
for (const { author, number, mergedAt } of changes) {
|
||||
if (!author.bot && !candidates.has(author.login)) {
|
||||
candidates.set(author.login, { author, number, mergedAt });
|
||||
}
|
||||
}
|
||||
|
||||
return this.github.getFirstTimers([...candidates.values()]);
|
||||
}
|
||||
|
||||
private render({
|
||||
tag,
|
||||
previous,
|
||||
changes,
|
||||
firstTimers,
|
||||
}: NotesOptions & { changes: PullRequest[]; firstTimers: Contributor[] }) {
|
||||
const { changelog } = parse(readFileSync(Files.Config, 'utf8')) as {
|
||||
changelog: { categories: Category[] };
|
||||
};
|
||||
|
||||
const sections = [
|
||||
readFileSync(Files.Template, 'utf8').trim(),
|
||||
[
|
||||
"## What's Changed",
|
||||
...changelog.categories.flatMap(({ title, labels }) => {
|
||||
const matches = changes.filter((pull) =>
|
||||
pull.labels.some((label) => labels.includes(label)),
|
||||
);
|
||||
return matches.length === 0
|
||||
? []
|
||||
: [
|
||||
`### ${title}`,
|
||||
...matches.map(
|
||||
({ title, author, number }) =>
|
||||
`* ${title} by ${this.mention(author)} in ${this.link(number)}`,
|
||||
),
|
||||
];
|
||||
}),
|
||||
].join('\n'),
|
||||
];
|
||||
|
||||
if (firstTimers.length > 0) {
|
||||
sections.push(
|
||||
[
|
||||
'## New Contributors',
|
||||
...firstTimers.map(
|
||||
({ author, number }) =>
|
||||
`* ${this.mention(author)} made their first contribution in ${this.link(number)}`,
|
||||
),
|
||||
].join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
sections.push(
|
||||
`**Full Changelog**: https://github.com/${REPO}/compare/${previous}...${tag}`,
|
||||
);
|
||||
|
||||
return sections.join('\n\n') + '\n';
|
||||
}
|
||||
|
||||
private link(number: number) {
|
||||
return `https://github.com/${REPO}/pull/${number}`;
|
||||
}
|
||||
|
||||
private mention({ login, bot }: Author) {
|
||||
return `@${login}${bot ? '[bot]' : ''}`;
|
||||
}
|
||||
|
||||
private unique<T>(items: T[]) {
|
||||
return [...new Set(items)];
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,8 @@ export class JsonFile<T extends object> {
|
||||
}
|
||||
}
|
||||
|
||||
export type Public<T extends object> = Pick<T, keyof T>;
|
||||
|
||||
export class ReleaseInputError extends Error {}
|
||||
export class ReleaseError extends Error {
|
||||
version: string;
|
||||
|
||||
Generated
+3
@@ -372,6 +372,9 @@ importers:
|
||||
semver:
|
||||
specifier: ^7.8.1
|
||||
version: 7.8.5
|
||||
yaml:
|
||||
specifier: ^2.9.1
|
||||
version: 2.9.1
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^24.13.6
|
||||
|
||||
Reference in New Issue
Block a user