- introduce proper approach to retrieve tag before a given tag

- add configuration options for
  - path
  - configuration
  - fromTag, toTag
  - token
- allow to specify transformers to adjust information to a specific form
- allow to specify different templates
- speed up by limiting information to pull
- add logic to automatically resolve current
- use github actions logger
This commit is contained in:
Mike Penz
2020-10-16 15:52:24 +02:00
parent 5ec7a2d86f
commit 0a66008df0
18 changed files with 14515 additions and 158 deletions
+3 -4
View File
@@ -1,7 +1,6 @@
import { Octokit, RestEndpointMethodTypes } from "@octokit/rest"
import moment from 'moment';
import { Logger } from "./logger"
import * as core from '@actions/core';
export interface CommitInfo {
sha: string
@@ -13,7 +12,7 @@ export interface CommitInfo {
}
export class Commits {
constructor(private octokit: Octokit) {}
constructor(private octokit: Octokit) { }
async getDiff(owner: string, repo: string, base: string, head: string): Promise<CommitInfo[]> {
const commits: CommitInfo[] = await this.getDiffRemote(owner, repo, base, head)
@@ -34,7 +33,7 @@ export class Commits {
compareHead = `${commits[0].sha}^`
}
Logger.log(`Found ${commits.length} commits from the GitHub API for ${owner}/${repo}`)
core.info(`Found ${commits.length} commits from the GitHub API for ${owner}/${repo}`)
return commits.map(commit => ({
sha: commit.sha,
summary: commit.commit.message.split("\n")[0],
+27
View File
@@ -0,0 +1,27 @@
interface Configuration {
sort: string;
template: string;
pr_template: string;
empty_template: string;
categories: Array<Category>;
transformers: Array<Transformer>;
}
interface Category {
title: string;
labels: Array<string>;
}
interface Transformer {
pattern: string;
target: string;
}
const DefaultConfiguration: Configuration = {
sort: "ASC",
template: "${{CHANGELOG}}",
pr_template: "- ${{TITLE}}\n - PR: #${{NUMBER}}",
empty_template: "- no changes",
categories: [],
transformers: []
}
-29
View File
@@ -1,29 +0,0 @@
import { PullRequestInfo } from "./pullRequests"
const RELEASE_NOTES_LINE_PATTERN = /^\s*#{3}\s+Release\s+Notes\s*([\s\S]+?)\s*$/im
export function defaultPullRequestNotableFormatter(pullRequest?: PullRequestInfo): string {
if (!pullRequest) {
return ""
}
const matches = RELEASE_NOTES_LINE_PATTERN.exec(pullRequest.body || "")
if (matches && matches.length > 0) {
const message = matches[1].trim()
return message === "<!--" ? "" : `* ${message} ([#${pullRequest.number}](${pullRequest.htmlURL}))`
}
return ""
}
export function defaultNotableChangesFormatter(notableChanges?: string): string {
return `## Notable Changes\n${notableChanges || "**TODO**: Pull relevant changes here!"}`
}
export function defaultPullRequestTitleFormatter(pullRequest?: PullRequestInfo): string {
return pullRequest ? `* [#${pullRequest.number}](${pullRequest.htmlURL}) - ${pullRequest.title}` : ""
}
export function defaultAllChangesFormatter(allChanges?: string): string {
return allChanges ? `\n<details>\n<summary>All Changes</summary>\n\n${allChanges}\n</details>` : ""
}
+108
View File
@@ -0,0 +1,108 @@
import * as core from '@actions/core'
import * as exec from '@actions/exec'
import * as fs from 'fs'
import * as io from '@actions/io'
export async function createCommandManager(
workingDirectory: string
): Promise<GitCommandManager> {
return await GitCommandManager.createCommandManager(workingDirectory)
}
function directoryExistsSync(path: string, required?: boolean): boolean {
if (!path) {
throw new Error("Arg 'path' must not be empty")
}
let stats: fs.Stats
try {
stats = fs.statSync(path)
} catch (error) {
if (error.code === 'ENOENT') {
if (!required) {
return false
}
throw new Error(`Directory '${path}' does not exist`)
}
throw new Error(
`Encountered an error when checking whether path '${path}' exists: ${error.message}`
)
}
if (stats.isDirectory()) {
return true
} else if (!required) {
return false
}
throw new Error(`Directory '${path}' does not exist`)
}
class GitCommandManager {
private gitPath = ''
private workingDirectory = ''
// Private constructor; use createCommandManager()
private constructor() { }
getWorkingDirectory(): string {
return this.workingDirectory
}
async latestTag(): Promise<string> {
const revListOutput = await this.execGit(['rev-list', '--tags', '--skip=0', '--max-count=1'])
const output = await this.execGit(['describe', '--abbrev=0', '--tags', revListOutput.stdout.trim()])
return output.stdout.trim()
}
static async createCommandManager(
workingDirectory: string
): Promise<GitCommandManager> {
const result = new GitCommandManager()
await result.initializeCommandManager(workingDirectory)
return result
}
private async execGit(
args: string[],
allowAllExitCodes = false,
silent = false
): Promise<GitOutput> {
directoryExistsSync(this.workingDirectory, true)
const result = new GitOutput()
const stdout: string[] = []
const options = {
cwd: this.workingDirectory,
silent,
ignoreReturnCode: allowAllExitCodes,
listeners: {
stdout: (data: Buffer) => {
stdout.push(data.toString())
}
}
}
result.exitCode = await exec.exec(`"${this.gitPath}"`, args, options)
result.stdout = stdout.join('')
return result
}
private async initializeCommandManager(
workingDirectory: string
): Promise<void> {
this.workingDirectory = workingDirectory
this.gitPath = await io.which('git', true)
}
}
class GitOutput {
stdout = ''
exitCode = 0
}
-16
View File
@@ -1,16 +0,0 @@
// tslint:disable: no-any
import * as util from "util"
export class Logger {
static verbose = true
static log(format: any, ...args: any[]) {
if (this.verbose) {
console.log(util.format(format, ...args))
}
}
static warn(format: any, ...args: any[]) {
console.warn(util.format(format, ...args))
}
}
+80 -7
View File
@@ -1,16 +1,89 @@
import * as core from '@actions/core'
import {wait} from './wait'
import { wait } from './wait'
import { readConfiguration } from './utils';
import { ReleaseNotes } from './releaseNotes';
import { createCommandManager } from './git-helper';
import * as github from '@actions/github'
import * as path from 'path';
async function run(): Promise<void> {
try {
const ms: string = core.getInput('milliseconds')
core.debug(`Waiting ${ms} milliseconds ...`) // debug is only output if you set the secret `ACTIONS_RUNNER_DEBUG` to true
let githubWorkspacePath = process.env['GITHUB_WORKSPACE']
if (!githubWorkspacePath) {
throw new Error('GITHUB_WORKSPACE not defined')
}
githubWorkspacePath = path.resolve(githubWorkspacePath)
core.debug(`GITHUB_WORKSPACE = '${githubWorkspacePath}'`)
core.debug(new Date().toTimeString())
await wait(parseInt(ms, 10))
core.debug(new Date().toTimeString())
let repositoryPath = core.getInput('path') || '.'
repositoryPath = path.resolve(
githubWorkspacePath,
repositoryPath
)
core.debug(`repositoryPath = '${repositoryPath}'`)
core.setOutput('time', new Date().toTimeString())
const configurationFile: string = core.getInput('configuration')
const configurationPath = path.resolve(
githubWorkspacePath,
configurationFile
)
core.debug(`configurationPath = '${configurationPath}'`)
const configuration = readConfiguration(configurationPath)
let token = core.getInput('token')
let owner = core.getInput('owner')
let repo = core.getInput('repo')
let fromTag = core.getInput("fromTag")
let toTag = core.getInput("toTag")
if (!toTag) {
// if not specified try to retrieve tag from git
const gitHelper = await createCommandManager(repositoryPath)
const latestTag = await gitHelper.latestTag()
toTag = latestTag
core.debug(`toTag = '${latestTag}'`)
}
if (!owner || !repo) {
// Qualified repository
const qualifiedRepository = core.getInput('repository') || `${github.context.repo.owner}/${github.context.repo.repo}`
core.debug(`qualified repository = '${qualifiedRepository}'`)
const splitRepository = qualifiedRepository.split('/')
if (splitRepository.length !== 2 || !splitRepository[0] || !splitRepository[1]) {
throw new Error(
`Invalid repository '${qualifiedRepository}'. Expected format {owner}/{repo}.`
)
}
owner = splitRepository[0]
repo = splitRepository[1]
}
if (!owner) {
core.error(`Missing or couldn't resolve 'owner'`)
return
}
if (!repo) {
core.error(`Missing or couldn't resolve 'owner'`)
return
}
if (!toTag) {
core.error(`Missing or couldn't resolve 'toTag'`)
return
}
const releaseNotes = new ReleaseNotes({
owner: owner,
repo: repo,
fromTag: fromTag,
toTag: toTag,
configuration: configuration
})
core.setOutput('changelog', await releaseNotes.pull(token))
} catch (error) {
core.setFailed(error.message)
}
+18 -8
View File
@@ -2,7 +2,7 @@ import { Octokit, RestEndpointMethodTypes } from "@octokit/rest"
import moment from 'moment';
import { CommitInfo } from "./commits"
import { Logger } from "./logger"
import * as core from '@actions/core';
export interface PullRequestInfo {
number: number
@@ -33,7 +33,7 @@ export class PullRequests {
body: pr.data.body
}
} catch (e) {
Logger.warn("Cannot find PR", `${owner}/${repo}#${prNumber}`, e.code, e.message)
core.warning(`Cannot find PR ${owner}/${repo}#${prNumber} - ${e.message}`)
return null
}
}
@@ -60,7 +60,7 @@ export class PullRequests {
const firstPR = prs[0]
if(firstPR.merged_at && fromDate.isAfter(moment(firstPR.merged_at))) {
// bail out early to not keep iterating on PRs super old
return this.sortPullRequests(mergedPRs)
return sortPullRequests(mergedPRs, true)
}
prs.filter(
@@ -82,7 +82,7 @@ export class PullRequests {
})
}
return this.sortPullRequests(mergedPRs)
return sortPullRequests(mergedPRs, true)
}
filterCommits(commits: CommitInfo[]): CommitInfo[] {
@@ -100,8 +100,10 @@ export class PullRequests {
return filteredCommits
}
}
private sortPullRequests(pullRequests: PullRequestInfo[]): PullRequestInfo[] {
export function sortPullRequests(pullRequests: PullRequestInfo[], ascending: Boolean): PullRequestInfo[] {
if(ascending) {
pullRequests.sort((a, b) => {
if (a.mergedAt.isBefore(b.mergedAt)) {
return -1
@@ -110,7 +112,15 @@ export class PullRequests {
}
return 0
})
return pullRequests
} else {
pullRequests.sort((b, a) => {
if (a.mergedAt.isBefore(b.mergedAt)) {
return -1
} else if (b.mergedAt.isBefore(a.mergedAt)) {
return 1
}
return 0
})
}
}
return pullRequests
}
+34 -45
View File
@@ -1,38 +1,22 @@
import { Octokit } from "@octokit/rest"
import { Commits } from "./commits"
import * as formatters from "./formatters"
import { Logger } from "./logger"
import { PullRequestInfo, PullRequests } from "./pullRequests"
import { buildChangelog } from './transform';
import * as core from '@actions/core';
import { Tags } from './tags';
export interface ReleaseNotesOptions {
owner: string
repo: string
fromTag: string
fromTag: string | null
toTag: string
formatter: {
pullRequestTitle: (pullRequest?: PullRequestInfo) => string
pullRequestNotable: (pullRequest?: PullRequestInfo) => string
notableChanges: (notableChanges?: string) => string
allChanges: (allChanges?: string) => string
}
configuration: Configuration
}
export class ReleaseNotes {
static get defaultFormatter() {
return {
pullRequestTitle: formatters.defaultPullRequestTitleFormatter,
pullRequestNotable: formatters.defaultPullRequestNotableFormatter,
notableChanges: formatters.defaultNotableChangesFormatter,
allChanges: formatters.defaultAllChangesFormatter
}
}
constructor(private options: ReleaseNotesOptions) {
options.formatter = {
...ReleaseNotes.defaultFormatter,
...options.formatter
}
}
async pull(token?: string): Promise<string> {
@@ -40,20 +24,36 @@ export class ReleaseNotes {
auth: `token ${token || process.env.GITHUB_TOKEN}`
})
const mergedPullRequests = await this.getMergedPullRequests(octokit)
const notableChanges = this.getFormatedChanges(mergedPullRequests, this.options.formatter.pullRequestNotable)
const allChanges = this.getFormatedChanges(mergedPullRequests, this.options.formatter.pullRequestTitle)
const format = this.options.formatter
const { owner, repo, fromTag, toTag, configuration } = this.options
return `${format.notableChanges(notableChanges)}${format.allChanges(allChanges)}`
if(fromTag == null) {
const tagsApi = new Tags(octokit)
const previousTag = await tagsApi.findPredecessorTag(owner, repo, toTag)
if(previousTag == null) {
core.error(`Unable to retrieve previous tag given ${toTag}`)
return configuration.empty_template ? configuration.empty_template : DefaultConfiguration.empty_template
}
this.options.fromTag = previousTag.name
}
const mergedPullRequests = await this.getMergedPullRequests(octokit)
if (mergedPullRequests.length == 0) {
core.warning(`No pull requests found for between ${fromTag}...${toTag}`)
return configuration.empty_template ? configuration.empty_template : DefaultConfiguration.empty_template
}
return buildChangelog(mergedPullRequests, configuration)
}
private async getMergedPullRequests(octokit: Octokit): Promise<PullRequestInfo[]> {
const { owner, repo, fromTag, toTag } = this.options
Logger.log("Comparing", `${owner}/${repo}`, `${fromTag}...${toTag}`)
core.info(`Comparing ${owner}/${repo} ${fromTag}...${toTag}`)
const commitsApi = new Commits(octokit)
const commits = await commitsApi.getDiff(owner, repo, fromTag, toTag)
const commits = await commitsApi.getDiff(owner, repo, fromTag!!, toTag)
if (commits.length === 0) {
return []
@@ -64,12 +64,12 @@ export class ReleaseNotes {
const fromDate = firstCommit.date
const toDate = lastCommit.date
Logger.log(`Fetching PRs between dates ${fromDate.toISOString()} ${toDate.toISOString()} for ${owner}/${repo}`)
core.info(`Fetching PRs between dates ${fromDate.toISOString()} ${toDate.toISOString()} for ${owner}/${repo}`)
const pullRequestsApi = new PullRequests(octokit)
const pullRequests = await pullRequestsApi.getBetweenDates(owner, repo, fromDate, toDate)
Logger.log(`Found ${pullRequests.length} merged PRs for ${owner}/${repo}`)
core.info(`Found ${pullRequests.length} merged PRs for ${owner}/${repo}`)
const prCommits = pullRequestsApi.filterCommits(commits)
const filteredPullRequests = []
@@ -89,30 +89,19 @@ export class ReleaseNotes {
if (pullRequestsByNumber[commit.prNumber]) {
filteredPullRequests.push(pullRequestsByNumber[commit.prNumber])
} else if (fromDate.toISOString() === toDate.toISOString()) {
Logger.log(`${prRef} not in date range, fetching explicitly`)
core.info(`${prRef} not in date range, fetching explicitly`)
const pullRequest = await pullRequestsApi.getSingle(owner, repo, commit.prNumber)
if (pullRequest) {
filteredPullRequests.push(pullRequest)
} else {
Logger.warn(`${prRef} not found! Commit text: ${commit.summary}`)
core.warning(`${prRef} not found! Commit text: ${commit.summary}`)
}
} else {
Logger.log(`${prRef} not in date range, likely a merge commit from a fork-to-fork PR`)
core.info(`${prRef} not in date range, likely a merge commit from a fork-to-fork PR`)
}
}
return filteredPullRequests
}
private getFormatedChanges(pullRequests: PullRequestInfo[], formatter: (pr?: PullRequestInfo) => string): string {
if (pullRequests.length) {
return pullRequests.reduce((result, pr) => {
let formated = formatter(pr)
formated = !!formated ? `${formated}\n` : ""
return `${result}${formated}`
}, "")
}
return ""
}
}
Executable
+96
View File
@@ -0,0 +1,96 @@
import { Octokit, RestEndpointMethodTypes } from '@octokit/rest';
import * as core from '@actions/core';
import { PullRequestInfo } from './pullRequests';
export interface TagInfo {
name: string,
commit: string
}
export class Tags {
constructor(private octokit: Octokit) { }
async getTags(owner: string, repo: string): Promise<TagInfo[]> {
const tagsInfo: TagInfo[] = []
const options = this.octokit.repos.listTags.endpoint.merge({
owner,
repo,
direction: "desc",
per_page: 100
})
const max: number = 200
for await (const response of this.octokit.paginate.iterator(options)) {
type TagsListData = RestEndpointMethodTypes["repos"]["listTags"]["response"]["data"]
const tags: TagsListData = response.data as TagsListData
tags.forEach(tag => {
tagsInfo.push({
name: tag.name,
commit: tag.commit.sha
})
})
// for performance only fetch newest 200 tags!!
if (tagsInfo.length >= max) {
break
}
}
core.info(`Found ${tagsInfo.length} (fetching max: ${max}) tags from the GitHub API for ${owner}/${repo}`)
return tagsInfo
}
async findPredecessorTag(owner: string, repo: string, tag: string): Promise<TagInfo | null> {
const tags = this.sortTags(await this.getTags(owner, repo))
var length = tags.length;
for (var i = 0; i < length; i++) {
if (tags[i].name.toLowerCase() === tag.toLowerCase()) {
return tags[i + 1]
}
}
// not found, throw exception?
return tags[0]
}
private sortTags(commits: TagInfo[]): TagInfo[] {
commits.sort((b, a) => {
const partsA = a.name.replace(/^v/, '').split('-')
const partsB = b.name.replace(/^v/, '').split('-')
const versionCompare = partsA[0].localeCompare(partsB[0])
if(versionCompare != 0) {
return versionCompare
} else {
if(partsA.length == 1) {
return 0
} else if(partsB.length == 1) {
return 1
} else {
return partsA[1].localeCompare(partsB[1])
}
}
})
return commits
}
}
/*
2020.3.2 ( should resolve 2020.3.1 )
2020.4.0
2020.4.0-rc02
2020.3.1
2020.3.1-rc03
2020.3.1-rc02
2020.3.1-rc01
2020.3.1-b01
2020.3.1-a01
2020.3.0
*/
+114
View File
@@ -0,0 +1,114 @@
import { PullRequestInfo, sortPullRequests } from './pullRequests';
import * as core from '@actions/core';
export function buildChangelog(prs: PullRequestInfo[], config: Configuration): string {
// sort to target order
prs = sortPullRequests(prs, config.sort.toUpperCase() === "ASC")
const validatedTransformers = validateTransfomers(config.transformers)
let transformedMap = new Map<PullRequestInfo, string>();
// convert PRs to their text representation
prs.forEach(pr => {
transformedMap.set(pr, transform(fillTemplate(pr, config.pr_template), validatedTransformers))
})
// bring PRs into the order of categories
let categorized = new Map<Category, string[]>();
config.categories.forEach(category => {
categorized.set(category, [])
})
let uncategorized: Array<string> = [];
// bring elements in order
transformedMap.forEach((body, pr) => {
let matched = false
categorized.forEach((prs, category) => {
if (findCommonElements3(category.labels, pr.labels)) {
prs.push(body)
matched = true
}
})
if (!matched) {
uncategorized.push(body)
}
})
// construct final changelog
let changelog = ""
categorized.forEach((prs, category) => {
if (prs.length > 0) {
changelog = changelog + category.title + "\n\n"
prs.forEach(pr => {
changelog = changelog + pr + "\n"
})
// add space between
changelog = changelog + "\n"
}
})
let changelogUncategorized = ""
uncategorized.forEach(pr => {
changelogUncategorized = changelogUncategorized + pr + "\n"
})
// fill template
let transformedChangelog = config.template
transformedChangelog = transformedChangelog.replace("${{CHANGELOG}}", changelog)
transformedChangelog = transformedChangelog.replace("${{UNCATEGORIZED}}", changelogUncategorized)
return transformedChangelog;
}
function findCommonElements3(arr1: string[], arr2: string[]) {
return arr1.some(item => arr2.includes(item))
}
function fillTemplate(pr: PullRequestInfo, template: string): string {
let transformed = template
transformed = transformed.replace("${{NUMBER}}", pr.number.toString())
transformed = transformed.replace("${{TITLE}}", pr.title)
transformed = transformed.replace("${{URL}}", pr.htmlURL)
transformed = transformed.replace("${{MERGED_AT}}", pr.mergedAt.toString)
transformed = transformed.replace("${{AUTHOR}}", pr.author)
transformed = transformed.replace("${{BODY}}", pr.body)
return transformed
}
function transform(filled: string, transformers: RegexTransformer[]): string {
if (transformers.length == 0) {
return filled
}
let transformed = filled
transformers.forEach(({ pattern, target }) => {
transformed = transformed.replace(pattern!!, target)
})
return transformed
}
function validateTransfomers(transformers: Transformer[]): RegexTransformer[] {
return transformers
.map((transformer) => {
try {
return {
pattern: new RegExp(transformer.pattern.replace("\\\\", '\\'), "g"),
target: transformer.target
}
} catch (e) {
core.warning(`Bad replacer regex: ${transformer.pattern}`)
return {
pattern: null,
target: ""
}
}
})
.filter(transformer => transformer.pattern != null)
}
interface RegexTransformer {
pattern: RegExp | null;
target: string;
}
+7
View File
@@ -0,0 +1,7 @@
const fs = require("fs");
export function readConfiguration(filename: string) {
const rawdata = fs.readFileSync(filename);
const configurationJSON: Configuration = JSON.parse(rawdata);
return configurationJSON;
}