- initial import of original source from https://github.com/nblagoev/pull-release-notes

- adjust action specifications
This commit is contained in:
Mike Penz
2020-10-16 09:51:24 +02:00
parent 8fe22768b5
commit 8944338c36
13 changed files with 2171 additions and 1455 deletions
Executable
+71
View File
@@ -0,0 +1,71 @@
import { Octokit, RestEndpointMethodTypes } from "@octokit/rest"
import * as moment from "moment"
import { Logger } from "./logger"
export interface CommitInfo {
sha: string
summary: string
message: string
author: string
date: moment.Moment
prNumber: number | undefined
}
export class Commits {
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)
return this.sortCommits(commits)
}
private async getDiffRemote(owner: string, repo: string, base: string, head: string): Promise<CommitInfo[]> {
// Fetch comparisons recursively until we don't find any commits
// This is because the GitHub API limits the number of commits returned in a single response.
let commits: RestEndpointMethodTypes["repos"]["compareCommits"]["response"]["data"]["commits"] = []
let compareHead = head
while (true) {
const compareResult = await this.octokit.repos.compareCommits({ owner, repo, base, head: compareHead })
if (compareResult.data.total_commits === 0) {
break
}
commits = compareResult.data.commits.concat(commits)
compareHead = `${commits[0].sha}^`
}
Logger.log(`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],
message: commit.commit.message,
date: moment(commit.commit.committer.date),
author: commit.commit.author.name,
prNumber: undefined
}))
}
private sortCommits(commits: CommitInfo[]): CommitInfo[] {
const commitsResult = []
const shas: { [key: string]: boolean } = {}
for (const commit of commits) {
if (shas[commit.sha]) {
continue
}
shas[commit.sha] = true
commitsResult.push(commit)
}
commitsResult.sort((a, b) => {
if (a.date.isBefore(b.date)) {
return -1
} else if (b.date.isBefore(a.date)) {
return 1
}
return 0
})
return commitsResult
}
}
+29
View File
@@ -0,0 +1,29 @@
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>` : ""
}
Executable
+16
View File
@@ -0,0 +1,16 @@
// 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))
}
}
+106
View File
@@ -0,0 +1,106 @@
import { Octokit, RestEndpointMethodTypes } from "@octokit/rest"
import * as moment from "moment"
import { CommitInfo } from "./commits"
import { Logger } from "./logger"
export interface PullRequestInfo {
number: number
title: string
htmlURL: string
mergedAt: moment.Moment
author: string
repoName: string
body: string
}
export class PullRequests {
constructor(private octokit: Octokit) {}
async getSingle(owner: string, repo: string, prNumber: number): Promise<PullRequestInfo | null> {
try {
const pr = await this.octokit.pulls.get({ owner, repo, pull_number: prNumber })
return {
number: pr.data.number,
title: pr.data.title,
htmlURL: pr.data.html_url,
mergedAt: moment(pr.data.merged_at),
author: pr.data.user.login,
repoName: pr.data.base.repo.full_name,
body: pr.data.body
}
} catch (e) {
Logger.warn("Cannot find PR", `${owner}/${repo}#${prNumber}`, e.code, e.message)
return null
}
}
async getBetweenDates(
owner: string,
repo: string,
fromDate: moment.Moment,
toDate: moment.Moment
): Promise<PullRequestInfo[]> {
const mergedPRs: PullRequestInfo[] = []
const options = this.octokit.pulls.list.endpoint.merge({
owner,
repo,
state: "closed",
sort: "updated",
direction: "desc"
})
for await (const response of this.octokit.paginate.iterator(options)) {
type PullsListData = RestEndpointMethodTypes["pulls"]["list"]["response"]["data"]
const prs: PullsListData = response.data as PullsListData
prs.filter(
pr =>
!!pr.merged_at &&
fromDate.isBefore(moment(pr.merged_at)) &&
toDate.isSameOrAfter(moment(pr.merged_at))
).forEach(pr => {
mergedPRs.push({
number: pr.number,
title: pr.title,
htmlURL: pr.html_url,
mergedAt: moment(pr.merged_at),
author: pr.user.login,
repoName: pr.base.repo.full_name,
body: pr.body
})
})
}
return this.sortPullRequests(mergedPRs)
}
filterCommits(commits: CommitInfo[]): CommitInfo[] {
const prRegex = /Merge pull request #(\d+)/
const filteredCommits = []
for (const commit of commits) {
const match = commit.summary.match(prRegex)
if (!match) {
continue
}
commit.prNumber = Number.parseInt(match[1], 10)
filteredCommits.push(commit)
}
return filteredCommits
}
private sortPullRequests(pullRequests: PullRequestInfo[]): PullRequestInfo[] {
pullRequests.sort((a, b) => {
if (a.mergedAt.isBefore(b.mergedAt)) {
return -1
} else if (b.mergedAt.isBefore(a.mergedAt)) {
return 1
}
return 0
})
return pullRequests
}
}
+118
View File
@@ -0,0 +1,118 @@
import { Octokit } from "@octokit/rest"
import { Commits } from "./commits"
import * as formatters from "./formatters"
import { Logger } from "./logger"
import { PullRequestInfo, PullRequests } from "./pullRequests"
export interface ReleaseNotesOptions {
owner: string
repo: string
fromTag: string
toTag: string
formatter: {
pullRequestTitle: (pullRequest?: PullRequestInfo) => string
pullRequestNotable: (pullRequest?: PullRequestInfo) => string
notableChanges: (notableChanges?: string) => string
allChanges: (allChanges?: string) => string
}
}
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> {
const octokit = new Octokit({
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
return `${format.notableChanges(notableChanges)}${format.allChanges(allChanges)}`
}
private async getMergedPullRequests(octokit: Octokit): Promise<PullRequestInfo[]> {
const { owner, repo, fromTag, toTag } = this.options
Logger.log("Comparing", `${owner}/${repo}`, `${fromTag}...${toTag}`)
const commitsApi = new Commits(octokit)
const commits = await commitsApi.getDiff(owner, repo, fromTag, toTag)
if (commits.length === 0) {
return []
}
const firstCommit = commits[0]
const lastCommit = commits[commits.length - 1]
const fromDate = firstCommit.date
const toDate = lastCommit.date
Logger.log(`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}`)
const prCommits = pullRequestsApi.filterCommits(commits)
const filteredPullRequests = []
const pullRequestsByNumber: { [key: number]: PullRequestInfo } = {}
for (const pr of pullRequests) {
pullRequestsByNumber[pr.number] = pr
}
for (const commit of prCommits) {
if (!commit.prNumber) {
continue
}
const prRef = `${owner}/${repo}#${commit.prNumber}`
if (pullRequestsByNumber[commit.prNumber]) {
filteredPullRequests.push(pullRequestsByNumber[commit.prNumber])
} else if (fromDate.toISOString() === toDate.toISOString()) {
Logger.log(`${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}`)
}
} else {
Logger.log(`${prRef} not in date range, likely a merge commit from a fork-to-fork PR`)
}
}
return pullRequests
}
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 ""
}
}