Skip to content

Commit

Permalink
build: Add a release notes generator (#5)
Browse files Browse the repository at this point in the history
Fixes podman-desktop#1458

Signed-off-by: Jeff MAURY <[email protected]>
  • Loading branch information
jeffmaury authored May 30, 2023
1 parent eb5f998 commit 20e10c8
Show file tree
Hide file tree
Showing 6 changed files with 640 additions and 1 deletion.
72 changes: 72 additions & 0 deletions .github/workflows/release-notes.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#
# Copyright (C) 2023 Red Hat, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0

name: release-notes

on:
workflow_dispatch:
inputs:
milestone:
description: 'Milestone to generate release notes from'
required: true
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}

jobs:

build:
name: Generate release notes
runs-on: ubuntu-latest
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 16

- name: Get yarn cache directory path (mac/Linux)
id: yarn-cache-dir-path-unix
run: echo "dir=$(yarn cache dir)" >> ${GITHUB_OUTPUT}

- uses: actions/cache@v3
id: yarn-cache-unix
with:
path: ${{ steps.yarn-cache-dir-path-unix.outputs.dir }}
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
- name: yarn
run: |
yarn --frozen-lockfile --network-timeout 180000
- name: Build
run: |
cd tools
yarn build
- name: Generate the release notes document
run: |
node tools/dist/release-notes-generator.js --milestone ${{ github.event.inputs.version }} --user jeffmaury --repo test-release-notes >rn.md
- name: Archive the generated release notes
uses: actions/upload-artifact@v2
with:
name: release-notes
path: rn.md

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@
"test:watch": "vitest watch",
"watch": "node scripts/watch.cjs",
"format:check": "prettier --check \"{extensions,packages,tests,types}/**/*.{ts,svelte}\" \"extensions/*/scripts/build.js\" \"website/**/*.{md,js}\" \"website/src/**/*.{css,tsx}\"",
"format:fix": "prettier --write \"{extensions,packages,tests,types}/**/*.{ts,svelte}\" \"extensions/*/scripts/build.js\" \"website/**/*.{md,js}\" \"website/src/**/*.{css,tsx}\"",
"format:fix": "prettier --write \"{extensions,packages,tests,types,tools}/**/*.{ts,svelte}\" \"extensions/*/scripts/build.js\" \"website/**/*.{md,js}\" \"website/src/**/*.{css,tsx}\"",
"markdownlint:check": "markdownlint-cli2 \"website/**/*.md\" \"#website/node_modules\"",
"markdownlint:fix": "markdownlint-cli2-fix \"website/**/*.md\" \"#website/node_modules\"",
"lint:check": "eslint . --ext js,ts,tsx",
Expand Down
18 changes: 18 additions & 0 deletions tools/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "release-notes-generator",
"version": "1.0.0",
"description": "Generates release notes",
"main": "index.js",
"author": "The Podman Desktop team",
"license": "MIT",
"scripts": {
"build": "tsc"
},
"dependencies": {
"@octokit/graphql": "^5.0.6",
"@types/node": "^20.2.4"
},
"devDependencies": {
"typescript": "^5.0.4"
}
}
218 changes: 218 additions & 0 deletions tools/release-notes-generator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
import { graphql } from '@octokit/graphql';

const RELEASE_NOTES_SECTION_TAG = '// release-notes';

const DEFAULT_MAPPINGS = new Map<string, string>([
['feat', 'Features'],
['fix', 'Bug Fixes'],
['docs', 'Documentation'],
['chore', 'Other'],
]);

const DEFAULT_CATEGORY = 'Other';

interface PullRequestInfo {
title: string;
link: string;
number: number;
sections: string[];
}
class Generator {
private client;
constructor(
private token: string,
private organization: string,
private repo: string,
private isUser: boolean,
private milestone: string | undefined,
) {
this.client = graphql.defaults({
headers: {
authorization: `token ${token}`,
},
});
}

private getMilestoneQuery(): string {
return `query {
${this.isUser ? 'user' : 'organization'}(login: "${this.organization}") {
repository(name: "${this.repo}") {
milestones(last:1, states: OPEN${this.milestone ? ', query: "' + `${this.milestone}` + '"' : ''}) {
nodes {
id
title
}
}
}
}
}`;
}

private getPullRequestsQuery(milestone: string, latestPR?: string): string {
return `query {
${this.isUser ? 'user' : 'organization'}(login: "${this.organization}") {
repository(name: "${this.repo}") {
milestones(last:1, states: OPEN, query: "${milestone}") {
nodes {
pullRequests(first: 100, states: MERGED${latestPR ? ', after: "' + `${latestPR}` + '"' : ''}) {
pageInfo {
endCursor
startCursor
}
nodes {
title
state
id
number
body
permalink
}
}
}
}
}
}
}`;
}

async getMilestone() {
const query = this.getMilestoneQuery();
const result = await this.client(query);
if (this.isUser) {
return (result as any).user?.repository?.milestones?.nodes[0];
} else {
return (result as any).organization?.repository?.milestones?.nodes[0];
}
}

async getPullRequests(milestone: string, latestPr?: string) {
const query = this.getPullRequestsQuery(milestone, latestPr);
const result = await this.client(query);
if (this.isUser) {
return (result as any).user?.repository?.milestones?.nodes[0].pullRequests;
} else {
(result as any).organization?.repository?.milestones?.nodes[0].pullRequests;
}
}

public getReleaseNotesSections(input: string): string[] {
const sections = [];

let index = 0;
while ((index = input.indexOf(RELEASE_NOTES_SECTION_TAG, index)) != -1) {
const endIndex = input.indexOf(RELEASE_NOTES_SECTION_TAG, index + RELEASE_NOTES_SECTION_TAG.length);
if (endIndex != -1) {
sections.push(input.substring(index + RELEASE_NOTES_SECTION_TAG.length, endIndex));
index = endIndex + RELEASE_NOTES_SECTION_TAG.length;
} else {
sections.push(input.substring(index + RELEASE_NOTES_SECTION_TAG.length));
break;
}
}
return sections;
}

private processPullRequestTitle(title: string): { category: string; title: string } {
const index = title.indexOf(':');
if (index > 0) {
let category: string | undefined = title.substring(0, index);
const subIndex = category.indexOf('(');
if (subIndex > 0) {
category = category.substring(0, subIndex);
}
title = title.substring(index + 1).trim();
category = DEFAULT_MAPPINGS.get(category);
if (!category) {
category = DEFAULT_CATEGORY;
}
return { category, title };
} else {
return { category: DEFAULT_CATEGORY, title };
}
}

async generate(): Promise<string> {
const milestone = await this.getMilestone();
if (milestone) {
const prInfos = new Map<string, PullRequestInfo[]>();
let latestPR = undefined;
let done = false;
while (!done) {
const pullRequests = await this.getPullRequests(milestone.title, latestPR);
latestPR = pullRequests?.pageInfo?.endCursor;
if (pullRequests?.nodes?.length) {
for (const pr of pullRequests?.nodes) {
if (pr.body && pr.title) {
const sections = this.getReleaseNotesSections(pr.body);
if (sections.length > 0) {
const { category, title } = this.processPullRequestTitle(pr.title);
let categoryInfos = prInfos.get(category);
if (!categoryInfos) {
categoryInfos = [];
prInfos.set(category, categoryInfos);
}
categoryInfos.push({ title, link: pr.permalink, number: pr.number, sections });
}
}
}
} else {
done = true;
}
}
let content = '';
prInfos.forEach((prInfos1, category) => {
content += `### ${category}\n`;
prInfos1.forEach(prInfo => {
content += ` - ${prInfo.title} [(#${prInfo.number})](${prInfo.link})\n`;
content += prInfo.sections.join('');
content += '\n';
});
});
return content;
} else {
throw new Error(`${this.milestone ? `Milestone ${this.milestone} not found` : `no milestone found`}`);
}
}
}

async function run() {
let token = process.env.GITHUB_TOKEN;
if (!token) {
token = process.env.GH_TOKEN;
}
const args = process.argv.slice(2);
let organization = 'containers';
let repo = 'podman-desktop';
let isUser = false;
let milestone = undefined;
for (var i = 0; i < args.length; i++) {
if (args[i] === '--token') {
token = args[++i];
} else if (args[i] === '--org') {
organization = args[++i];
} else if (args[i] === '--user') {
organization = args[++i];
isUser = true;
} else if (args[i] === '--repo') {
repo = args[++i];
} else if (args[i] === '--milestone') {
milestone = args[++i];
}
}
if (token) {
const rn = await new Generator(token, organization, repo, isUser, milestone).generate();

console.log(`${rn}`);
} else {
console.log('No token found use either GITHUB_TOKEN or pass it as an argument');
}
}

run()
.then(() => {
process.exit(0);
})
.catch(err => {
console.error(err);
process.exit(1);
});
Loading

0 comments on commit 20e10c8

Please sign in to comment.