- initial import of original source from https://github.com/nblagoev/pull-release-notes
- adjust action specifications
This commit is contained in:
Executable
+71
@@ -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
|
||||
}
|
||||
}
|
||||
Executable
+29
@@ -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
@@ -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))
|
||||
}
|
||||
}
|
||||
Executable
+106
@@ -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
|
||||
}
|
||||
}
|
||||
Executable
+118
@@ -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 ""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user